Bound memory on the spool backfill-dedupe read (partial #280 hardening) - #282
Conversation
…issue #280) `readSpooledRows` read each whole spool file with `fs.readFile(name, 'utf8')` then `split('\n')`, holding roughly 2x the file (the file as one V8 string plus the split-line array) resident before yielding a single row. A spool file can reach DEFAULT_SPOOL_BYTES_THRESHOLD (512 MB) of content-heavy `ai_gateway_messages` envelopes before it flushes, so the backfill dedupe scan (`scanSpooledPartIds` -> `createBackfillDedupe`) that seeds its seen-set from the spool was an unbounded whole-file async UTF-8 decode - the exact `FSReqCallback -> StringDecoder -> NewStringFromUtf8` allocation signature in the issue #280 crash, and a real OOM risk on the fresh-enrollment 116k-row claude backfill path. Stream the file in bounded 64 KB chunks with a tail-buffered line loop, mirroring `streamFlushFile` right next to it. Row output and the envelope-validity contract (version === 1, columns array, rows array; a trailing no-newline segment still parsed) are unchanged - the existing readSpooledRows parity tests stay green. Regression test asserts heap growth up to the first yielded row stays far below a ~48 MB spool file (the old whole-file reader needed the entire file resident by then); it fails against the pre-fix code and passes after. Scope note: this hardens the backfill-subprocess dedupe path. The reported daemon process OOM is a separate daemon-side accumulation (the central-push path has no matching async-utf8 whole-file read) still under investigation - see PR body. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CLAUDE.md forbids the em dash character anywhere (code, comments, JSDoc, strings, docs). Two were introduced by this branch: a JSDoc line in readSpooledRows' rowsFromSpoolLine helper and a comment in the new cache-storage regression test. Replace with parentheses and a colon. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
neutral review round - PR #282 (streamed readSpooledRows in bounded chunks)Reviewed head: What I checkedCorrectness of the new chunked/streamed reader (
Regression test - does it prove the bound?Yes, and I verified it independently. The test writes ~48 MB (1024 x ~48 KB envelopes) and asserts first-row Findings (both fixed and pushed)
Both fixes are comment-only, no behavior change. Verified: zero U+2014 remain in either file in the committed tree at CodexRan, but failed as expected in this repo ( Advisory only; no merge attempted. Held for human merge gate. |
Round 2 review — CLEANHead reviewed: Round 2 confirms the two em-dash fixes pushed after round 1 (which reviewed Em-dash fixes: verified comment-only, zero U+2014The delta
No executable line changed. Correctness still sound at this head
CodexDid not run. The dual-review skill checks the PR head out into the main working tree (which this worker must not perturb, and which currently holds unrelated uncommitted tracked changes), and hypaware intercepts Codex Verdict: clean — no blocker/major/minor actionable findings. Nothing changed; holding for the human merge gate. |
What this lands
Streams
readSpooledRows(src/core/cache/spool.js) so the backfill dedupescan reads the spool in bounded 64 KB chunks instead of
fs.readFile(name, 'utf8') + split('\n'), which held ~2x the whole file (one V8 string + thesplit-line array) resident before yielding a single row.
DEFAULT_SPOOL_BYTES_THRESHOLD(512 MB) ofcontent-heavy
ai_gateway_messagesenvelopes before they flush.scanSpooledPartIds->createBackfillDedupeseeds its seen-set from thespool on a
hyp backfillrun, so a large-backfill dedupe scan was anunbounded whole-file async UTF-8 decode - the exact
node::fs::FSReqCallback::Resolve -> StringDecoder::DecodeData -> NewStringFromUtf8allocation signature in the Daemon OOM (4GB heap) on first central push after fleet enrollment; crash strands pending 'attach claude' action #280 crash dump.streamFlushFile's bounded line loop that already lives rightnext to it. Row output and the envelope-validity contract are unchanged; the
existing
readSpooledRowsparity tests stay green.New regression test (
test/core/cache-storage.test.js) writes a ~48 MB spoolfile and asserts heap growth up to the first yielded row stays far below the
file size. It fails against the pre-fix whole-file read and passes after
(verified by temporarily reverting only the reader).
npm test: 2105 pass, 0 fail, 1 skipped.Honest scope - this is a partial hardening, not the full #280 fix
I diagnosed both reported sub-bugs. Neither's stated hypothesis held up, and
the true root causes need design-level work, so I am using
Refs #280(notFixes) to avoid auto-closing the issue.Bug A - daemon OOM. The issue's hypothesis (the
@hypaware/centralrequest-sink push materializes the whole payload/files as strings) is already
handled: the sink streams rows via
storage.readRowsSince()and POSTs inbounded 5000-row / 4 MB chunks (LLP 0040), proven by
test/plugins/central-forward-chunking.test.js(49 green). The one code pathmatching the fatal async-utf8 whole-file-decode stack is
readSpooledRows(fixed here) - but it runs in the
hyp backfillsubprocess, and in theincident that backfill completed successfully (116,541 rows), so it did not
fire this time. The process that OOM'd (pid 85942, ~11.7 min uptime) is the
daemon, whose central-push surface has no async-utf8 whole-file read. So
the reported daemon OOM is a separate daemon-side accumulation (prime
suspects: the local IO resolver's
readFileSyncof the whole ~242 MB parquetper open in
src/core/cache/iceberg/resolver.js, compounding with icebird rowmaterialization / live-capture buffers). That is an architectural memory audit,
not a bounded bugfix - it needs a request/design LLP. This PR removes one
real latent OOM on the same enrollment+large-backfill path and matching the
crash signature, but does not by itself prove the daemon OOM is gone.
Bug B - stranded
attach claude. The hypothesis ("the reconcile core skipsa requested action with no completion record") is false: the reconciler is
level-triggered and re-drives any non-
doneaction wheneverdesired()namesit - already covered by
test/core/action-reconciler.test.js("a missed pass(no marker yet) runs on the next reconcile call"). The real gap is two-layer:
(1)
attach.desired()gates onctx.clients.getClient(name)+ a bound endpointwhile
hyp status'sclient_attach_missingonly checks (plugin enabled +attach_probe + disk-not-marked), so when they diverge status shows
[pending]forever but the reconciler never attaches; and (2) the daemon schedules reconcile
at only two one-shot edges (confirm-edge, boot-already-confirmed) over a
once-resolved client seam, with no ongoing/tick retry, so a gap that could
not be closed at boot is stranded until the next restart. A robust fix
(seam re-resolution + ongoing reconcile trigger) overlaps directly with the
active PR #278 (
action_attach.js/action_reconciler.js/gateway_endpoint.js), so it needs design coordination, not a collidingbounded patch.
Refs #280