fix(server): bound thread catch-up replay and stop full-DB snapshot hydration - #5147
Conversation
subscribeThread's stale-cursor catch-up read the entire global event log (readEvents with Number.MAX_SAFE_INTEGER) and filtered per-thread in JS. Reconnecting clients with cursors hundreds of thousands of events behind forced the server to page and JSON-decode every intervening event's payload, OOM-killing the backend on large databases. The replay is now bounded to the projection head with the same gap cap subscribeShell already uses; past the cap the client is reset with a fresh thread snapshot, which it already handles. GET /api/orchestration/snapshot and the offline project CLI hydrated every message and activity payload in the database via getSnapshot(). The route's only consumer (the project CLI) reads the project list, so both now use the lightweight getCommandReadModel() with the same wire shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ApprovabilityVerdict: Approved 191a386 Defensive bug fix that bounds thread catch-up replay and avoids full-database hydration to prevent OOM server crashes. Follows an existing pattern (SHELL_RESUME_MAX_GAP) with comprehensive test coverage. You can customize Macroscope's approvability policy. Learn more. |
## What's Changed * feat(web): add settings sidebar search by @shivamhwp in pingdotgg/t3code#4682 * fix: threads with open PRs no longer auto-settle by @t3dotgg in pingdotgg/t3code#5151 * fix(server): bound thread catch-up replay and stop full-DB snapshot hydration by @t3dotgg in pingdotgg/t3code#5147 **Full Changelog**: pingdotgg/t3code@v0.0.32-nightly.20260731.968...v0.0.32-nightly.20260801.969 Upstream release: https://github.com/pingdotgg/t3code/releases/tag/v0.0.32-nightly.20260801.969
…shot pingdotgg#5147 drains buffered live events and the completion marker from the same queue after a gap-cap snapshot, so their order is not guaranteed. Assert both appear rather than a fixed [synchronized, event] sequence.
…ESUME_MAX_GAP Upstream pingdotgg#5147 bounds thread resume via orchestrationEngine.latestSequence. Keep that path over the older pingdotgg#3510 subscriptionReplayLimit helper and update the candidate test mock accordingly.
…shot pingdotgg#5147 drains buffered live events and the completion marker from the same queue after a gap-cap snapshot, so their order is not guaranteed. Assert both appear rather than a fixed [synchronized, event] sequence.
…ydration (pingdotgg#5147) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit ca72e38)
Reconnecting to a large database can OOM-kill the backend. Two unbounded reads are responsible:
subscribeThread's stale-cursor catch-up read the entire global event log (readEvents(afterSequence, Number.MAX_SAFE_INTEGER)) and filtered per-thread in JS afterwards. A cached cursor a few hundred thousand events behind forces the server to page and JSON-decode every intervening event's tool payloads — for every thread — only to discard almost all of them. Field traces in App Crash on heavy thread #996 measured cursors 271k–591k events behind producing 15k+ SQL pages per reconnect, multiplied by the sidebar's concurrent thread subscriptions, ending inFATAL ERROR: JavaScript heap out of memorycrash-loops.GET /api/orchestration/snapshot(and the offlinet3 projectCLI path) hydrated every message and activity payload in the database viagetSnapshot(). App Crash on heavy thread #996 has a reproduction where a single authenticated request to this route deterministically OOMs the backend on a 1.5 GB database.Fixes:
subscribeThreadnow bounds catch-up replay to the projection head with the same gap capsubscribeShellhas used since perf(orchestration): speed up new-chat propagation and offline catch-up #4177 (THREAD_RESUME_MAX_GAP = 1000). Past the cap (or with a cursor ahead of the head) it falls through to the existing fresh-snapshot path instead of an unbounded replay. No client change needed: clients already consume snapshot frames on resume and dedupe overlapping events by sequence, so the only observable difference is that a long-stale reconnect gets a fresh snapshot instead of a multi-minute replay (or a dead server).getCommandReadModel()— sameOrchestrationReadModelwire shape, thread bodies empty. The route's only consumer in the monorepo is the project CLI, which readsprojectsalone; web/desktop/mobile load the shell + per-thread snapshots instead.This is the short-term containment for the OOM class in #996 / #1686 / #4597 / #2761 on the current orchestration layer, shaped to be trivially portable to V2 (#2829). Longer term, moving tool payloads out of the hot paths entirely is the real fix.
Verification:
Built by Claude Fable 5 in Claude Code.
🤖 Generated with Claude Code
Note
High Risk
Changes hot-path orchestration reads and WebSocket resume semantics on large event logs; incorrect bounds could drop events or change reconnect behavior, though clients already handle snapshot resume and sequence dedupe.
Overview
Addresses backend OOM on large databases by bounding unbounded orchestration reads in two places.
Snapshot hydration:
GET /api/orchestration/snapshotand the offlinet3 projectCLI now callgetCommandReadModel()instead ofgetSnapshot()— same wire shape but without hydrating every message/activity body. That route is only used for project-list reads; UIs already use shell and per-thread snapshots.Thread WebSocket resume:
subscribeThreadcatch-up no longer usesreadEvents(..., Number.MAX_SAFE_INTEGER)on the global log (which decoded all threads’ payloads then filtered in JS). It mirrorssubscribeShell:THREAD_RESUME_MAX_GAP = 1000, replay limited to the capturedlatestSequence, and when the gap is too large or the cursor is ahead of the head it falls through to a fresh thread detail snapshot instead of replaying.Reviewed by Cursor Bugbot for commit 191a386. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Bound
subscribeThreadcatch-up replay and replace full-DB snapshot hydration with lightweight command read modelsubscribeThreadin ws.ts now caps catch-up replay toTHREAD_RESUME_MAX_GAP(1,000 events) using the authoritative head at the time of the request, replacing the previous unboundedNumber.MAX_SAFE_INTEGERreplay.getThreadDetailSnapshot()instead of replaying.getCommandReadModel()instead ofgetSnapshot(), avoiding full-DB hydration.Macroscope summarized 191a386.