perf: cut allocations in firehose, archive and Spaces decoding #205

Merged
Grandiras merged 1 commit from issue-195-streaming-perf into main 2026-10-05 12:44:10 +00:00
Owner

Closes #195.

What changed

  • Reused transcoder buffer (DagCborJson, DagCborDecoder, EventStreamFrame). One ArrayBufferWriter per thread, taken and returned synchronously around each decode and dropped if it grows past 1 MB. It replaces a fresh buffer of 2× the frame per frame: a 2 MB commit used to allocate 4 MB every time. Reads are bounded by WrittenSpan, and a test covers decoding after a failed decode.
  • No string per key or value. Map keys, text values and byte strings are written straight from their UTF-8 bytes. Invalid UTF-8 is replaced exactly as before (new test; a 20,000-case randomized comparison against the old path found 0 differences).
  • Frame headers compare keys as bytes and reuse a shared string for the known #commit/#sync/… types.
  • Spaces. Record validation reads definite-length byte spans; the repo CAR compares binary CIDs directly.
  • RecordProof builds the node CID string only for an error message.
  • Debloat. DagCborEncoder gets one $link/$bytes helper. AtProtoCrypto drops its padded big-endian comparer in favour of SequenceCompareTo: every caller checks the 64-byte P1363 length first, and the order constants are fixed 32-byte arrays, so the comparison is numeric. LtHash gets one lane combiner.

Behaviour note

The transcoder now rejects indefinite-length text and byte strings in frames. DAG-CBOR forbids them, and the stricter readers (ReadSeq, TryScan, MstNodeData) already did, so such a frame is dropped as malformed instead of accepted in lax mode.

Measurements (BenchmarkDotNet, in-process, baseline origin/main)

Scenario Baseline Candidate
Firehose consume, 100 commit frames 515 µs, 1839 KB 420 µs, 675 KB
Same, with Sync 1.1 verification 25.1 ms, 4725 KB 25.1 ms, 3561 KB
DAG-CBOR decode of a post ×100 160 µs, 316 KB 140 µs, 179 KB
Spaces record validation ×100 65 µs, 170 KB 56 µs, 87 KB

Verification time is dominated by native ECDSA (~206 of ~240 µs per commit).

Net src: −25 lines. Build has 0 warnings; 4966 tests pass.

🤖 Generated with Claude Code

Closes #195. ## What changed - **Reused transcoder buffer** (`DagCborJson`, `DagCborDecoder`, `EventStreamFrame`). One `ArrayBufferWriter` per thread, taken and returned synchronously around each decode and dropped if it grows past 1 MB. It replaces a fresh buffer of 2× the frame per frame: a 2 MB commit used to allocate 4 MB every time. Reads are bounded by `WrittenSpan`, and a test covers decoding after a failed decode. - **No string per key or value.** Map keys, text values and byte strings are written straight from their UTF-8 bytes. Invalid UTF-8 is replaced exactly as before (new test; a 20,000-case randomized comparison against the old path found 0 differences). - **Frame headers** compare keys as bytes and reuse a shared string for the known `#commit`/`#sync`/… types. - **Spaces.** Record validation reads definite-length byte spans; the repo CAR compares binary CIDs directly. - **`RecordProof`** builds the node CID string only for an error message. - **Debloat.** `DagCborEncoder` gets one `$link`/`$bytes` helper. `AtProtoCrypto` drops its padded big-endian comparer in favour of `SequenceCompareTo`: every caller checks the 64-byte P1363 length first, and the order constants are fixed 32-byte arrays, so the comparison is numeric. `LtHash` gets one lane combiner. ## Behaviour note The transcoder now rejects indefinite-length text and byte strings in frames. DAG-CBOR forbids them, and the stricter readers (`ReadSeq`, `TryScan`, `MstNodeData`) already did, so such a frame is dropped as malformed instead of accepted in lax mode. ## Measurements (BenchmarkDotNet, in-process, baseline `origin/main`) | Scenario | Baseline | Candidate | |---|---|---| | Firehose consume, 100 commit frames | 515 µs, 1839 KB | 420 µs, 675 KB | | Same, with Sync 1.1 verification | 25.1 ms, 4725 KB | 25.1 ms, 3561 KB | | DAG-CBOR decode of a post ×100 | 160 µs, 316 KB | 140 µs, 179 KB | | Spaces record validation ×100 | 65 µs, 170 KB | 56 µs, 87 KB | Verification time is dominated by native ECDSA (~206 of ~240 µs per commit). Net `src`: −25 lines. Build has 0 warnings; 4966 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
perf: cut allocations in firehose, archive and Spaces decoding (closes #195)
All checks were successful
CI / pds-integration (pull_request) Successful in 31s
CI / build-and-test (pull_request) Successful in 1m16s
32fb60b1d2
The DAG-CBOR to JSON transcoder reuses a per-thread buffer and writes keys,
text and byte strings from their UTF-8 instead of through a string or array
each, so a #commit frame allocates about a third of what it did. The frame
header shares its type strings, Spaces record validation reads without
copying, and the Spaces repo CAR no longer round-trips CIDs through strings.
Small debloat: one lane combiner in LtHash, no padded big-endian compare in
the signature range checks, and a shared helper for the $link/$bytes forms
in the DAG-CBOR encoder.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-205/run-2026-10-05T02-48-21-629Z.md) — updating every ~5 minutes while the review runs.
Collaborator

🤖 Resolving the merge conflicts — the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the fix runs.

🤖 Resolving the merge conflicts — the reply will link the full session transcript. <!-- opencode-run:conflict --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-205/run-2026-10-05T02-57-11-766Z.md) — updating every ~5 minutes while the fix runs.
Author
Owner

Independent adversarial review (read-only Sonnet reviewer): no defects found. A differential probe of DagCborDecoder.Decode against main gave byte-identical output on 9 cases: invalid UTF-8 variants, indefinite lengths, escapes, duplicate keys and key order. The reused buffer is synchronous-only, nested-safe and returned in finally, and nothing references it after return. Every SequenceCompareTo caller is gated on the 64-byte length, and the order constants are 32 bytes. LtHash.Combine matches the old add/subtract lanes. Unit suite: 4966 passed.

Independent adversarial review (read-only Sonnet reviewer): **no defects found**. A differential probe of `DagCborDecoder.Decode` against `main` gave byte-identical output on 9 cases: invalid UTF-8 variants, indefinite lengths, escapes, duplicate keys and key order. The reused buffer is synchronous-only, nested-safe and returned in `finally`, and nothing references it after return. Every `SequenceCompareTo` caller is gated on the 64-byte length, and the order constants are 32 bytes. `LtHash.Combine` matches the old add/subtract lanes. Unit suite: 4966 passed.
Grandiras force-pushed issue-195-streaming-perf from 32fb60b1d2
All checks were successful
CI / pds-integration (pull_request) Successful in 31s
CI / build-and-test (pull_request) Successful in 1m16s
to 33ae74e1a6
All checks were successful
CI / pds-integration (pull_request) Successful in 5m13s
CI / build-and-test (pull_request) Successful in 11m18s
2026-10-05 02:58:18 +00:00
Compare
Collaborator

⚠️ The conflict run failed: git -c http.extraheader=Authorization: Basic [REDACTED] push origin HEAD:refs/heads/issue-195-streaming-perf failed (1): To http://forgejo:3000/Grandiras/ATProto.NET.git
! [rejected] HEAD -> issue-195-streaming-perf (fetch first)
error: failed to push some refs to 'http://forgejo:3000/Grandiras/ATProto.NET.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. T

⚠️ The conflict run failed: git -c http.extraheader=Authorization: Basic [REDACTED] push origin HEAD:refs/heads/issue-195-streaming-perf failed (1): To http://forgejo:3000/Grandiras/ATProto.NET.git ! [rejected] HEAD -> issue-195-streaming-perf (fetch first) error: failed to push some refs to 'http://forgejo:3000/Grandiras/ATProto.NET.git' hint: Updates were rejected because the remote contains work that you do hint: not have locally. T
Author
Owner

@claude /review

@claude /review
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/732); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-205/run-732.md) — updating every ~5 minutes while the task runs.
claude-reviewer approved these changes 2026-10-05 11:50:26 +00:00
Dismissed
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

I found no blocking problems. I read the diff and the surrounding code; I did not build the PR or run the tests.

  • Thread-local buffer: Rent/return is safe. Every decode is synchronous and the output is consumed before the finally runs. The Utf8JsonWriter is disposed before the buffer is reset. A failed decode leaves nothing behind because ResetWrittenCount runs in finally, and there is a test for that. A nested call gets a fresh buffer because the field is nulled on rent. The 1 MB cap bounds memory held per thread.
  • Definite-length reads: ReadDefiniteLengthTextStringBytes and ReadDefiniteLengthByteString raise InvalidOperationException or CborContentException on indefinite-length strings. Every call site (DagCborDecoder, TryReadHeader, Deserialize) already catches those through IsMalformed. The stricter handling is documented in the PR description.
  • ReadUtf8: The span stays valid for the writer call. Invalid UTF-8 goes through a string round trip, which reproduces the old replacement behaviour, and a test covers it.
  • Crypto: SequenceCompareTo is numerically correct for equal-length big-endian inputs, and NormalizeS now returns early on the same conditions as before.
  • LtHash.Combine: Behaviour is equivalent to the two removed helpers.

Minor: a short comment in AtProtoCrypto stating that the SequenceCompareTo calls rely on the callers' 64-byte length check would stop a future caller from quietly breaking the numeric ordering.


🧠 claude-sonnet-5-5 · ⏱ 0m 16s · 3 turns · tokens: 27.2k in / 1.8k out (+49.4k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. I found no blocking problems. I read the diff and the surrounding code; I did not build the PR or run the tests. - **Thread-local buffer:** Rent/return is safe. Every decode is synchronous and the output is consumed before the `finally` runs. The `Utf8JsonWriter` is disposed before the buffer is reset. A failed decode leaves nothing behind because `ResetWrittenCount` runs in `finally`, and there is a test for that. A nested call gets a fresh buffer because the field is nulled on rent. The 1 MB cap bounds memory held per thread. - **Definite-length reads:** `ReadDefiniteLengthTextStringBytes` and `ReadDefiniteLengthByteString` raise `InvalidOperationException` or `CborContentException` on indefinite-length strings. Every call site (`DagCborDecoder`, `TryReadHeader`, `Deserialize`) already catches those through `IsMalformed`. The stricter handling is documented in the PR description. - **`ReadUtf8`:** The span stays valid for the writer call. Invalid UTF-8 goes through a string round trip, which reproduces the old replacement behaviour, and a test covers it. - **Crypto:** `SequenceCompareTo` is numerically correct for equal-length big-endian inputs, and `NormalizeS` now returns early on the same conditions as before. - **`LtHash.Combine`:** Behaviour is equivalent to the two removed helpers. Minor: a short comment in `AtProtoCrypto` stating that the `SequenceCompareTo` calls rely on the callers' 64-byte length check would stop a future caller from quietly breaking the numeric ordering. --- 🧠 claude-sonnet-5-5 · ⏱ 0m 16s · 3 turns · tokens: 27.2k in / 1.8k out (+49.4k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-205/run-732.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:33ae74e1a623a63ba43d8525cfbee98f3685cb86 -->
Grandiras force-pushed issue-195-streaming-perf from 33ae74e1a6
All checks were successful
CI / pds-integration (pull_request) Successful in 5m13s
CI / build-and-test (pull_request) Successful in 11m18s
to 9533157f8c
All checks were successful
CI / pds-integration (pull_request) Successful in 39s
CI / build-and-test (pull_request) Successful in 1m19s
Sync Closures to GitHub / sync-closure (pull_request) Successful in 7s
2026-10-05 12:31:23 +00:00
Compare
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-205/run-2026-10-05T12-32-25-007Z.md) — updating every ~5 minutes while the review runs.
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/735); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-205/run-735.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

I found no blocking problems. I read the diff and did not build the PR or run the tests.

My earlier pass was an approval with one minor, non-blocking note: a comment in AtProtoCrypto saying that the SequenceCompareTo calls depend on the callers' 64-byte P1363 length check. The diff doesn't add it. It would be a cheap guard against a future caller breaking the numeric ordering, but it doesn't block merging.

Re-checked and still fine:

  • Buffer reuse: the thread-local buffer is taken on rent and reset in finally. The writer is disposed before the reset, and a nested call gets a fresh buffer. The test for decoding after a failed decode covers the reset.
  • Strict decoding: the ReadDefinite* reads reject indefinite-length strings, and every call site already catches that through its malformed-input handling. The PR description documents the stricter behaviour.
  • Invalid UTF-8: the replacement path goes through a string round trip, which keeps the old behaviour, and a new test covers it.
  • LtHash.Combine and NormalizeS: both are equivalent to the code they replace.

🧠 claude-sonnet-5-5 · ⏱ 0m 6s · 2 turns · tokens: 26.0k in / 759 out (+12.2k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. I found no blocking problems. I read the diff and did not build the PR or run the tests. My earlier pass was an approval with one minor, non-blocking note: a comment in `AtProtoCrypto` saying that the `SequenceCompareTo` calls depend on the callers' 64-byte P1363 length check. The diff doesn't add it. It would be a cheap guard against a future caller breaking the numeric ordering, but it doesn't block merging. Re-checked and still fine: - **Buffer reuse:** the thread-local buffer is taken on rent and reset in `finally`. The writer is disposed before the reset, and a nested call gets a fresh buffer. The test for decoding after a failed decode covers the reset. - **Strict decoding:** the `ReadDefinite*` reads reject indefinite-length strings, and every call site already catches that through its malformed-input handling. The PR description documents the stricter behaviour. - **Invalid UTF-8:** the replacement path goes through a string round trip, which keeps the old behaviour, and a new test covers it. - **`LtHash.Combine` and `NormalizeS`:** both are equivalent to the code they replace. --- 🧠 claude-sonnet-5-5 · ⏱ 0m 6s · 2 turns · tokens: 26.0k in / 759 out (+12.2k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-205/run-735.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:9533157f8c880e6111110c976f67a91b49f98aff -->
opencode-reviewer left a comment

💬 Verdict: comment — the reviewer did not submit a structured review; the raw output follows.

Let me check the crypto context for the SequenceCompareTo change, since length mismatches matter there.


⏱ 6m 42s · 3 steps · $0.0057 · tokens: 21.0k in / 126 out (+9.0k cache-read) · Go: 5h 0% · wk 0% · mo 91%
📜 Full session transcript

💬 **Verdict: comment** — the reviewer did not submit a structured review; the raw output follows. Let me check the crypto context for the `SequenceCompareTo` change, since length mismatches matter there. --- ⏱ 6m 42s · 3 steps · $0.0057 · tokens: 21.0k in / 126 out (+9.0k cache-read) · Go: 5h 0% · wk 0% · mo 91% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-205/run-2026-10-05T12-32-25-007Z.md)
Author
Owner

@opencode-bot /review

@opencode-bot /review
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-205/run-2026-10-05T12-39-49-095Z.md) — updating every ~5 minutes while the review runs.
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

No blocking problems found. This is a fresh pass (no prior opencode-reviewer findings to resolve); the earlier approvals from claude-reviewer stand under my own checks.

Verified points:

  • Thread-local buffer reuse (DagCborJson.RentBuffer/ReturnBuffer): rent/return is synchronous around each decode, the Utf8JsonWriter is disposed before ResetWrittenCount runs in finally, and the output (WrittenSpan → JsonElement.ParseValue / JsonSerializer.Deserialize) is consumed inside the try, so nothing references the reused buffer afterwards. A nested call rents a fresh buffer because the field is nulled on rent. The 1 MB cap bounds what a thread retains, and the new Decode_AfterAFailedDecode_ReturnsOnlyItsOwnValue test covers a failed decode leaving no residue.
  • SequenceCompareTo in AtProtoCrypto: every reachable caller checks the 64-byte P1363 signature length first — AtProtoKey.Verify (via HasSignatureLength), DidKeyCache.VerifyJws, and AtProtoKey.Sign via the fixed-field signature format — and OrderBytes/HalfOrder are fixed 32-byte big-endian arrays, so byte-wise lexicographic order equals numeric order. NormalizeLowSSignature's restructure preserves the old behaviour for the reachable inputs. The minor suggestion to add a comment stating the 64-byte length precondition (raised in previous passes) is still worth a line, but it remains non-blocking.
  • Strict definite-length reads (ReadDefiniteLengthTextStringBytes/ReadDefiniteLengthByteString): indefinite-length strings now throw, and every call site (DagCborDecoder.Decode, EventStreamFrame.TryReadHeader/Deserialize, SpaceRecordCbor) already filters malformed input through IsMalformed or its equivalent, so an indefinite-length frame is dropped as malformed rather than crashing. The behaviour change is documented in the PR description.
  • ReadUtf8 invalid-UTF-8 fallback: goes through a string round trip so replacement matches what the lax CborReader produced before, and the returned span is written immediately. The randomized comparison plus the new test give good coverage.
  • LtHash.Combine, RecordProof.Name (error-path-only string), SpaceRepoCar binary CID comparison, and the DagCborEncoder.TryGetOnlyString helper: all behaviour-equivalent to what they replace.

⏱ 0m 53s · 10 steps · $0.0129 · tokens: 30.6k in / 1.1k out (+140.2k cache-read) · Go: 5h 0% · wk 0% · mo 91%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. No blocking problems found. This is a fresh pass (no prior opencode-reviewer findings to resolve); the earlier approvals from claude-reviewer stand under my own checks. Verified points: - **Thread-local buffer reuse** (`DagCborJson.RentBuffer`/`ReturnBuffer`): rent/return is synchronous around each decode, the `Utf8JsonWriter` is disposed before `ResetWrittenCount` runs in `finally`, and the output (`WrittenSpan` → `JsonElement.ParseValue` / `JsonSerializer.Deserialize`) is consumed inside the `try`, so nothing references the reused buffer afterwards. A nested call rents a fresh buffer because the field is nulled on rent. The 1 MB cap bounds what a thread retains, and the new `Decode_AfterAFailedDecode_ReturnsOnlyItsOwnValue` test covers a failed decode leaving no residue. - **`SequenceCompareTo` in `AtProtoCrypto`**: every reachable caller checks the 64-byte P1363 signature length first — `AtProtoKey.Verify` (via `HasSignatureLength`), `DidKeyCache.VerifyJws`, and `AtProtoKey.Sign` via the fixed-field signature format — and `OrderBytes`/`HalfOrder` are fixed 32-byte big-endian arrays, so byte-wise lexicographic order equals numeric order. `NormalizeLowSSignature`'s restructure preserves the old behaviour for the reachable inputs. The minor suggestion to add a comment stating the 64-byte length precondition (raised in previous passes) is still worth a line, but it remains non-blocking. - **Strict definite-length reads** (`ReadDefiniteLengthTextStringBytes`/`ReadDefiniteLengthByteString`): indefinite-length strings now throw, and every call site (`DagCborDecoder.Decode`, `EventStreamFrame.TryReadHeader`/`Deserialize`, `SpaceRecordCbor`) already filters malformed input through `IsMalformed` or its equivalent, so an indefinite-length frame is dropped as malformed rather than crashing. The behaviour change is documented in the PR description. - **`ReadUtf8` invalid-UTF-8 fallback**: goes through a string round trip so replacement matches what the lax `CborReader` produced before, and the returned span is written immediately. The randomized comparison plus the new test give good coverage. - **`LtHash.Combine`**, **`RecordProof.Name`** (error-path-only string), **`SpaceRepoCar`** binary CID comparison, and the **`DagCborEncoder.TryGetOnlyString`** helper: all behaviour-equivalent to what they replace. --- ⏱ 0m 53s · 10 steps · $0.0129 · tokens: 30.6k in / 1.1k out (+140.2k cache-read) · Go: 5h 0% · wk 0% · mo 91% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-205/run-2026-10-05T12-39-49-095Z.md) <!-- opencode-reviewed-head:9533157f8c880e6111110c976f67a91b49f98aff --> <!-- opencode-verdict:approve -->
Grandiras deleted branch issue-195-streaming-perf 2026-10-05 12:44:10 +00:00
Sign in to join this conversation.
No description provided.