The gateway daemon leaks until GC thrash: recorder retention, unbounded dedupe and seed scans (LLP 0204) - #683
Conversation
…ed dedupe and seed scans (LLP 0204)
Incident 2026-08-08: the daemon grew to ~4.8 GB RSS and wedged, after
~daily supervisor restarts for 9 days. Four leaks fixed:
- recorder.js: finished exchanges never left the active set, retaining
every proxied body and stream event for the listener's lifetime (the
daily-death driver); they now self-remove on the finished signal, and
finalize releases the raw chunk buffers.
- dataset.js: the flush-time settle dedupe materialized EVERY committed
part_id per fallback-carrying tick; the scan is now restricted to the
batch's keys and early-exits once they resolve (backfill keeps the
unrestricted scan its per-run memo needs).
- message_projector.js: every NEW session id paid a whole-table seed scan
(the session_id partition skip was dead code, directory partitioning is
source= only); a lazily-built committed-session-id index lets unseen
sessions skip the scan, with a TTL rebuild on miss for concurrent
backfill writers. Per-thread chain state drops the unbounded id array
with linear includes for { seen: Set, last }.
- otlp_exporters.js: pending export promises self-drain on settle instead
of accumulating until a forceFlush that the daemon never calls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
drain()'s force-finish timeout used an unref'd setTimeout so it wouldn't keep the process alive after normal completion. But an unref'd timer can be dropped by the event loop before it fires whenever nothing else is keeping the loop alive, leaving the Promise.race await pending forever instead of resolving to the timeout outcome. node:test on Node 22 detects this as a dangling promise (cancelledByParent) once the file's own event loop work is done; Node 24's runner tolerates it, which is why only the Node 22 CI job failed. Ref the timer (so the race always settles) and clearTimeout it in a finally block (so drain() doesn't hold the timer open past its own return). Fixes the two cancelled subtests in test/plugins/ai-gateway-recorder-retention.test.js on Node 22. Co-Authored-By: Claude <noreply@anthropic.com>
…honesty - mightHaveCommittedRows re-checks `built` after the await so only the first stale caller rebuilds the index; concurrent callers past the rebuild window now share one rebuild instead of each triggering their own whole-table session_id scan. - scanCommittedSessionIds signals a failed discoverCachePartitions with undefined (distinct from a successful empty scan), and a failed build is neither cached for the rebuild window nor treated as "definitely no committed rows" (mightHaveCommittedRows now errs toward scanning, per its own JSDoc). - Thread an injectable clock through createCommittedSessionIndex and add tests pinning the rebuild-after-window behavior and the single-rebuild-under-concurrency invariant (verified to fail against the pre-fix code: 7 scans instead of 2 for 6 concurrent misses). - Correct the index comment: in a fresh-session-heavy workload it is one whole-table scan per rebuild window, indefinitely, not a single build. - Record recorder.js's drain-timer ref/unref change and its event-loop consequence in LLP 0204's Fix section so the existing @ref stays honest. Co-Authored-By: Claude <noreply@anthropic.com>
Review round 1 —
|
Review round 2 —
|
| Command | Result |
|---|---|
npm test at bdec4a0 |
3857 pass / 0 fail / 6 skipped, exit 0 |
npm run typecheck |
clean, exit 0 |
npm test with fix #2 reverted |
3857 pass / 0 fail (proves the coverage gap) |
node --test with pre-fix mightHaveCommittedRows |
not ok 31 (proves the concurrency test is genuine) |
| 4-scenario concurrency probe (failing build; 6 concurrent callers on a failing build; committed-session dedupe past the window; clock crossing the window mid-build) | all correct, no unhandled rejections |
Verdict:
|
Residual review findings tracked;
|
| Issue | Finding | Delegated |
|---|---|---|
| #684 | Failed-build fallback (return undefined) has no regression coverage: reverting it leaves the suite green |
neutral:fix |
| #685 | Self-clearing guard has no rejection handler; a rejecting scan would take down the daemon (unreachable today) | neutral:fix |
| #686 | atMs stamped at scan start, so a scan longer than the rebuild window never serves a cache hit |
neutral:fix |
| #687 | LLP 0204's projector-state-eviction follow-up omits seedPromises |
doc-only, unlabelled |
#687 is deliberately left without neutral:fix: the autonomous fix path gates every PR on a regression test that fails before the fix and passes after, and a documentation edit cannot produce one, so labelling it would drive it straight to stuck.
Swapped neutral:changes-requested for neutral:approved. The verdict marker in the description is left as-is, since it is the record of what the review concluded at bdec4a0 and should not be rewritten after the fact. Nothing outstanding blocks this merge: zero blockers, CI green at bdec4a0, and the round-1 stampede fix is regression-pinned.
Incident
On 2026-08-08 ~23:30 UTC the
hyp daemongateway on hypebox-1 (proxying all Claude Code traffic for 3 autonomous 24/7 reconcile loops) grew to ~4.8 GB RSS and entered GC thrash: the port accepted but never answered, and every loop timed out for ~3.5 h. The daemon had been supervisor-restarted ~daily for the 9 days prior, and a fresh daemon reached ~870 MB RSS within 2 minutes of boot. Full diagnosis in LLP 0204.Fixes
recorder.jsadded every exchange to itsactiveset and never removed it, so every finished exchange (raw body buffers, decoded body string, SSE events, headers) stayed reachable for the listener's lifetime: gigabytes/day at loop volume. Finished exchanges now self-remove viafinishedSignal, andfinalize()releases the raw chunk buffers once the decoded bodies are on the row.part_idever written (millions of entries, hundreds of MB) on every fallback-carrying flush tick. The committed scan is now restricted to the batch's keys and stops reading once they are all resolved; backfill keeps the unrestricted scan its per-run memo legitimately needs.part.partition?.session_id, but directory partitioning is bysource=only, so the guard never fired and every NEW session id (which autonomous loops mint constantly) scanned the entire table to find nothing. A lazily-built committed-session-id index (onesession_id-column scan shared per listener) lets unseen sessions skip the scan; a miss older than 10 min rebuilds the index once to cover concurrent backfill writers, inside the seed scan's documented best-effort envelope.includesper message (O(n²) over a long thread); replaced with{ seen: Set, last }.pendingexport promises now self-drain on settle instead of accumulating until aforceFlush()the daemon never calls.Not fixed here (follow-ups listed in LLP 0204)
Projector state eviction (maps still grow with session count), the claude projector's per-exchange transcript re-parse, and a daemon self-guard for the supervisor's hung-but-alive blind spot.
Verification
npm test: the only failures are 2 pre-existing on master (verified via stash:leave-command,usage-policy-fold).npm run typecheckclean.ref-check: no new errors (the 2 in touched files are pre-existingLLP 0066#enforcementanchors).🤖 Generated with Claude Code