perf: stream wire.jsonl reads to reduce peak memory consumption#2137
Open
bowenliang123 wants to merge 1 commit into
Open
perf: stream wire.jsonl reads to reduce peak memory consumption#2137bowenliang123 wants to merge 1 commit into
bowenliang123 wants to merge 1 commit into
Conversation
Convert the three remaining whole-file JSONL readers to streaming reads: - kap-server SnapshotReader readWireRecords: chunk-buffered line framing over createReadStream, preserving the exact torn-final-line / corruption error contract and ENOENT propagation - agent-core transcript readWireRecords: delegate to the canonical FileSystemAgentRecordPersistence.read() async generator (same crash tolerance), dropping the raw string + per-line array - agent-core session-export scanSessionWire: readline over createReadStream, keeping the catch-all-to-empty degradation for missing/unreadable logs Peak heap on a 240 MB wire.jsonl drops ~57% for readWireRecords (779 -> 331 MB, the remainder being the returned records array itself) and ~98% for scanSessionWire (556 -> 8.5 MB), at wall-time parity.
🦋 Changeset detectedLatest commit: 27f97ef The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
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.
Related Issue
No linked issue — the problem is explained below.
Problem
As the current situation, the
wire.jsonlfile is:So it's a key to improve the reading performance, as it's never compacted and repeatedly read.
Three
wire.jsonlreaders still load the whole log into memory before parsing a single record:SnapshotReader.readWireRecords(kap-server) — backsGET /sessions/{sid}/snapshotand the cold transcript rebuild; session wire logs routinely reach tens to hundreds of MB.readWireRecords(agent-coreservices/message/transcript.ts) — backs the daemon message-history view.scanSessionWire(agent-coresession/export/wire-scan.ts) — backs debug-bundle export activity scans.Each one did
readFile+raw.split('\n'), so a read transiently held the raw file string plus a per-line string array plus the parsed records — roughly 3× the file size in peak heap on a server that holds many sessions.What changed
Convert all three to streaming line-by-line reads, reusing existing components where semantics allow:
SnapshotReader.readWireRecords: chunk-buffered line framing overcreateReadStream(the same pattern already used byFileSystemAgentRecordPersistence, v2AppendLogStore, andsessionEventJournal). The exact error contract is preserved: a torn final line is dropped, corruption anywhere else throwswire.jsonl: corrupted line N in <path>with identical line numbering, and a missing file still rejects withENOENT(the cold-snapshot route depends on it). A hand-rolled splitter is used instead ofnode:readlinedeliberately: readline cannot distinguish "last line without a trailing newline" (torn, must be dropped) from "last line with one" (corrupt, must throw).readWireRecords: delegates to the canonicalFileSystemAgentRecordPersistence.read()async generator it always claimed to mirror — identical crash tolerance, no duplicated framing logic. One deliberate semantic alignment: a missing file now yields zero records (same as the persistence reader) instead of rejecting; every caller pre-stats the file and treats unreadable as "fall back to live view", so behavior is unchanged in practice.scanSessionWire:node:readlineovercreateReadStream(this scan tolerates all malformed lines, so readline's final-line blindness is a non-issue). Missing/unreadable file still degrades to an empty scan.No behavior change otherwise: same records returned, same per-line tolerance, same reducer inputs. Existing unit tests cover the contract (
message-transcript.test.ts,snapshotReader.unit.test.ts— torn-line drop, mid-file corruption throw, blobref rehydration, snapshot cache); fullagent-core(3996) andkap-server(737) suites pass.Benchmark
Fixtures: synthetic
wire.jsonlat 1 / 10 / 50 / 240 / 500 MB with realistic record shapes (context.append_message,context.append_loop_event,turn_begin; ~1,340 records per MB). Each implementation runs in a fresh process, 5 runs per cell, median reported; heap sampled in-process. macOS, Node 24.readWireRecords(oldreadFile+split vs new streaming):scanSessionWire(old vs new):Reading the tables:
(size, mtime)-cached, so this cost is paid once per file change, not per request). Memory win is modest because the file is small anyway.readWireRecordsand ~65× forscanSessionWire; the scan's peak stays ~flat (1.2 → 15.8 MB across 1 → 500 MB) instead of growing linearly with the log.JSON.parse-bound and both implementations parse every line identically.readWireRecords(327 MB at 240 MB) is the returned records array itself — eliminating it requires reducer-level streaming (see below).Follow-ups deliberately left out of this PR:
reduceWireRecordsalready acceptsIterable; widening it and v2'sreduceContextTranscripttoAsyncIterablewould letreadWireTranscript/ the snapshot cache fold records as they stream, removing the retained records array entirely (up to another ~2× on the numbers above). That touches public reducer signatures in two packages (apiSurface snapshot), so it deserves its own PR.Checklist
gen-changesetsskill, or this PR needs no changeset.gen-docsskill, or this PR needs no doc update. (Internal perf/memory change, no user-facing behavior or docs impact.)