Repository navigation
fix(telemetry): read a transcript a block at a time - #1047
Merged
Merged
Conversation
The R1 telemetry audit (M3) found ingest reads a transcript's unread tail whole. Contract, plan and phases for reading it a block at a time with the same lines and positions, byte for byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Ingest read a transcript's unread tail into one buffer and one string, then one string per line, all alive at once: memory grew with the tail, and a tail over 512 MB cannot be one string (inferred, not run). The port now hands each complete line on as it is found, and the adapter reads 1 MiB at a time from the handle it stat'd, up to the size stat'd. Lines, positions and ledger bytes are the same as the whole-tail reader's, which the tests keep as an oracle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Two mutants on the lines the stream touched survived the telemetry mutation run: `restarted: true` for a file that vanished and for one skipped as old. Both are now asserted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Red runs, the hand mutation behind each guard, peak memory before and after on generated transcripts, the mutation score and every gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A hand mutation for the empty-line, offset-past-size and empty-file guards, and counts showing the ingest property stores calls and holds positions. The audit's transcript sizes are attributed to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…tream Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A read now spans many block reads, so a transcript truncated or rewritten in place between two of them could hand over a line spliced from two versions and save an offset inside the new one. After the last block the adapter stats the open handle and the path: shorter than when the read began, or another file at the path, and it returns no position. The reading then keeps none of that file's records or lines and ingest keeps its position, so the next ingest reads it again. A line read whole from one block is now decoded in place, without the copy Buffer.concat made. The docs state the memory bound in full. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The open handle still holds a deleted transcript whole, so its lines are consistent and keep their position; a torn read is not skipped. Both were mutants alive after the torn-read change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The bench that measured peak memory is kept beside the evidence so it can be rerun, and was: 371 MB before, 62 MB after at 151 MB, 67 MB at 402 MB. Red run, hand mutations, mutation score and gates of round 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…tream Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The open handle reads a file replaced at its path whole and consistently, so discarding that read lost the old file's lines, and a path rotated at every read would never be counted. Only a file that shrank under the read is discarded now; a replaced one keeps its lines and identity, and the next read restarts on the new file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
… replace an open file Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🎯 What & why
Ingest used to read the whole unread tail of a Claude Code transcript into memory at once: one buffer, then one string, then one string per line, all alive together. The audit reports transcripts of 371 MB and 417 MB. Memory therefore grew with the tail, and a tail over 512 MB (V8's longest string) cannot be read at all (inferred, not run). Ingest now reads a transcript 1 MiB at a time and produces the same lines, positions and ledger bytes as before.
🛠️ How it works
TranscriptSource.readnow takes aneach(line)callback and returns only{position, restarted, skipped}(transcript-source.ts). Streaming in the adapter alone would not have been enough, because an array of lines still holds the whole tail.ReadUsageUseCasemoved its loop body into the callback. Ingest, the hold-back from fix(telemetry): nothing measured is lost silently #1046 and everything downstream are untouched.eachLinein claude-transcript-source-adapter.ts:41 reads 1 MiB blocks from the handle it already stat'd, and stops at the size it stat'd.0x0aand keeps a line that spans blocks as pieces, joined once at its newline.0x0anever occurs inside a UTF-8 sequence, so this decodes exactly like the whole tail did, invalid bytes and BOM included.ReadUsageUseCasethen keeps none of that file's records or lines, so ingest keeps the old position and the next ingest reads the same bytes again.Buffer.concatcopy.readline: it breaks on a lone\rand gives no byte offsets.createReadStream(path): it reopens the path, which may by then be another file.TextDecoder: it strips a BOM on every call.🧪 How to verify
cd cli && pnpm exec vitest run tests/contexts/telemetry. The relevant tests:offsets.jsonandleft-out.txtbytes and the same ingest result, across ingests of transcripts cut mid-line, with a held clone.Peak memory on generated transcripts, as max RSS (bare
node: 44 MB):origin/nextRerun it with memory-bench.mjs:
node memory-bench.mjs <MB> [<cli src dir>].Gates, red runs and hand mutations are in evidence.md. Telemetry mutation score is 96.6. Bundle is 696.0 KB of a 709 KB budget, with no raise.
🔗 Linked issue
None. Found by the R1 telemetry audit (M3).
✅ I certify
🤖 Generated with Claude Code
https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp