Reduce CPU and memory use when reading large spool envelopes - #1361
Conversation
The fragment-retaining line reader is a behavior-preserving refactor of a data-integrity path, but the two cases that can silently corrupt a row were not pinned: a newline landing exactly on the 64 KiB read boundary (nothing to retain) and a 4-byte character split across it (the stream decoder must hold the partial sequence back). Add a test covering both, at each byte phase, asserting payload round-trip and resume offsets. It passes on the pre-refactor reader too, which is the point: it pins preserved behavior. benchmarks/ holds runnable .mjs scripts and no reports; the repo has no dated documents at all. performance-2026-09-04.md was a one-time validation transcript, an open-PR survey, and a description of changes in a different repository (hypaware-server ingest/*), none of which this repo can keep true. Keep the durable part: the measurements and their conditions now sit in the benchmark script's header, next to the command that reproduces them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scan-work regression counts characters through a String.prototype.indexOf interception, so it measures the splitter only while the splitter uses String#indexOf. A later reader built on Buffer#indexOf, readline, or split would score zero and pass the upper bound vacuously, retiring the guard with no signal. Assert a lower bound too: every input character is examined for a newline at least once, so a zero score now means the counter has stopped watching, not that the work went away. The benchmark's global.gc?.() no-ops without --expose-gc, leaving the fixture-build garbage resident and peak_rss_mib incomparable to the recorded figures, with a valid-looking JSON line either way. Emit which it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Verdict: approve with fixes applied. The refactor is correct. I could not find a behavioural difference between the old and new readers on any input I could construct, and I looked hard, because this is a performance rewrite of a data-integrity path. Findings are all low and all in the test/benchmark surface, not in Behaviour preservation: verified, not assumedDifferential harness running the pre-PR
On each of the four points the PR claims:
"No spool-format change" holds. The diff touches only reader loops. The envelope shape, The regression test is real. Reverting Perf reproduced and exceeded on Linux / Node v22.23.1, old vs new on the same fixture: flush 1366 → 165 ms CPU and 247 → 138 MiB peak RSS; provisional 1299 → 76 ms CPU and 253 → 129 MiB. The reported pre-existing failure did not reproduce. Findings1. Low - 2. Low - 3. Low - 4. Low - the two chunk-boundary cases were not pinned. The differential proved them, but nothing in the committed tests did: the added envelope is padded with a 4-byte character whose run happens to align to the 65536-byte read, and no test places a newline on the boundary. Those are exactly the two ways a fragment-retaining reader corrupts a row. Fixed: added Style and repo rulesNo new runtime dependencies, no new columns/config keys/schema fields, no em dashes, no stray semicolons, no NUL bytes. Verification of my own changes
New head: Reviewed at |
Round 1 added `// @ts-check` to benchmarks/spool-performance.mjs, matching the two benchmarks already in the tree. But `benchmarks/` is not in tsconfig.json's `include`, so `npm run typecheck` never compiles it, and under the repo's own compilerOptions the file is red: two TS7036s, because `await import(pathToFileURL(...))` hands a URL where a string is required. A pragma nothing checks, on a file that does not check, is a false clean signal. Pass `.href` so the specifier is the string the pragma expects; dynamic import already stringified it, so nothing changes at runtime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Verdict: findings (round 2 of 2). 2 low, both fixed and pushed. Nothing in Findings1. Low. Runtime is unaffected (dynamic import stringifies a 2. Low. PR description pointed at a file round 1 deleted. Independent re-verification of round 1's correctness claimsRound 1's fixes touched no 241 cases, 0 divergences. Coverage, case by case, against the specific risks:
So yes: chunk-boundary, split-UTF-8, no-newline-chunk, trailing-no-newline, hash equivalence, and the truncated final envelope are all independently confirmed byte-identical to base. The durability semantics are preserved in the direction that matters: flush refuses the unterminated tail (it may still be being written) while the provisional reader parses it best-effort, exactly as before. Also checked directly: no fd leak. 200 early- The
|
Ship risk:
|
Large spool envelopes were repeatedly flattened and searched as each 64 KiB chunk arrived. Both the flush reader and provisional reader now retain fragments and join them once per completed line, keeping scan work linear while preserving hashes, UTF-8 replay offsets, and trailing-envelope behavior.
On a synthetic 16 MiB envelope (1,024 rows), medians of three fresh Node v24.2.0 processes on macOS arm64 showed:
These numbers, their conditions, and the commands that reproduce them live in the header of
benchmarks/spool-performance.mjs, which takes an optional baseline-checkout argument so a candidate and its baseline are measured on one fixture in one Node version. They are path-specific synthetic measurements, not whole-daemon savings. Open PR file lists were checked; this does not duplicate #1075's query-execution work.Validation:
npm test(6,065 pass, 1 skipped, 0 fail),npm run typecheck, the declaration build, and thequery_grep_roundtripsmoke pass. The new scan-work regression test fails before the fix, measuring 18,352,789 characters searched for 1,048,884 input characters. Chunk-boundary behavior is pinned separately: a newline landing on the 64 KiB read boundary, and a 4-byte character split across it at every byte phase.CPU/memory review: no new unbounded cache or busy loop. Each chunk is scanned once; memory remains proportional to the largest individual envelope, whose JSON must still be parsed intact. No dependency, schema, or spool-format changes: the diff touches the two readers only, leaving the envelope shape, the spool file layout, and the persisted progress/cursor format untouched, so the
durable_cache_upgradeacceptance procedure is not triggered.Companion server optimization: https://github.com/hyparam/hypaware-server/pull/438