fix(server): paginate large thread history to stop the server running out of memory#3510
fix(server): paginate large thread history to stop the server running out of memory#3510olafura wants to merge 13 commits into
Conversation
|
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:
✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
Address review (PR pingdotgg#3510, Cursor Bugbot): - Reconnect / checkpoint revert can re-snapshot or filter the live activity window, but the prepended `olderActivities` weren't invalidated — leaving gaps or showing reverted history. Reset the lazy-load state when the live window's oldest activity id changes (it's stable while activities only append, so this doesn't fire during a normal turn), in addition to on thread switch. Web + mobile. - Web only deduped a new older page against already-loaded older pages, not the live window (mobile already did both). Dedup against both so a boundary overlap can't produce duplicate ids / React keys. Not changed — the "unsequenced cursor hides sequenced history" finding is a false positive: NULL-sequence (legacy) rows always sort oldest in the window/cursor ordering, so the oldest-loaded row is only unsequenced once every sequenced row is already loaded; the unsequenced cursor can never strand sequenced rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks Bugbot — addressed in cddc7e1:
|
ApprovabilityVerdict: Needs human review This PR introduces significant new pagination infrastructure across server, contracts, and both clients—including a new RPC endpoint, a shared lazy-loading hook, and UI affordances for loading older history. The scope and architectural nature of these changes warrant human review. You can customize Macroscope's approvability policy. Learn more. |
Address review (PR pingdotgg#3510, Cursor Bugbot): - Reconnect / checkpoint revert can re-snapshot or filter the live activity window, but the prepended `olderActivities` weren't invalidated — leaving gaps or showing reverted history. Reset the lazy-load state when the live window's oldest activity id changes (it's stable while activities only append, so this doesn't fire during a normal turn), in addition to on thread switch. Web + mobile. - Web only deduped a new older page against already-loaded older pages, not the live window (mobile already did both). Dedup against both so a boundary overlap can't produce duplicate ids / React keys. Not changed — the "unsequenced cursor hides sequenced history" finding is a false positive: NULL-sequence (legacy) rows always sort oldest in the window/cursor ordering, so the oldest-loaded row is only unsequenced once every sequenced row is already loaded; the unsequenced cursor can never strand sequenced rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cddc7e1 to
75d25e2
Compare
|
Addressed the latest review in
Server/web/mobile typecheck, lint, and the projection + timeline suites pass. |
Address review (PR pingdotgg#3510, Cursor Bugbot): - Reconnect / checkpoint revert can re-snapshot or filter the live activity window, but the prepended `olderActivities` weren't invalidated — leaving gaps or showing reverted history. Reset the lazy-load state when the live window's oldest activity id changes (it's stable while activities only append, so this doesn't fire during a normal turn), in addition to on thread switch. Web + mobile. - Web only deduped a new older page against already-loaded older pages, not the live window (mobile already did both). Dedup against both so a boundary overlap can't produce duplicate ids / React keys. Not changed — the "unsequenced cursor hides sequenced history" finding is a false positive: NULL-sequence (legacy) rows always sort oldest in the window/cursor ordering, so the oldest-loaded row is only unsequenced once every sequenced row is already loaded; the unsequenced cursor can never strand sequenced rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
75d25e2 to
618df70
Compare
|
Addressed the latest review round: Cursor — "Append clears lazy-loaded history" (Medium) — The reset effect used macroscope — mixed-thread flash (Low, The two earlier macroscope Mediums (
|
618df70 to
c8e6474
Compare
|
Latest round: Cursor — "Wrong pagination cursor after live sort" (High). Valid consistency gap I introduced last round. I'd made the reshape sentinel order-independent ( Cursor — "Stale hasMore after revert" (Medium). False positive.
|
c8e6474 to
848cff6
Compare
Self-review (10-angle, max effort) of
|
| Sev | Finding | Fix |
|---|---|---|
| Low (hardening) | The internal SQL-input schemas typed beforeSequence/limit as bare Schema.Number, looser than the contract's NonNegativeInt. The WHERE clause (sequence < beforeSequence OR sequence IS NULL) is only equivalent to the old COALESCE(sequence,-1) < beforeSequence for non-negative cursors — a negative one silently returns only unsequenced rows. |
Tightened both internal schemas to NonNegativeInt so any future non-RPC caller is validated, not just the RPC boundary. Typecheck + 11 pager tests green. |
Latent / by-design (NOT changed — flagging for a decision)
- Window vs reducer NULL-ordering disagreement (highest-severity-if-triggered): the server window orders
sequence DESC(SQLite → NULLs last/oldest) while the client reducer'sactivityOrderusessequence ?? MAX_SAFE_INTEGER(NULLs newest). They only agree today because every row is NULL. Ifsequenceis ever partially backfilled, a thread with mixed rows would (a) drop the newest unsequenced rows from the 500-window and (b) paginate in a different order than it displays. The robust fix is to order both sides bycreated_atconsistently — but that's an index-strategy change (the covering index leads withsequence) and a no-op today, so I've left it. Recommend either ordering bycreated_atend-to-end, or guaranteeingsequenceis all-or-nothing. - Speculative sequence machinery: given the above, the entire sequenced-cursor arm (second SQL query, union input arm,
"beforeSequence" in inputbranch, the sequence-leading index) is dead in practice. A single(created_at, activity_id)keyset would cover 100% of today's reality with ~half the surface. Kept as forward-looking, but worth a conscious call. - web/mobile duplication (AGENTS.md "Duplicate logic across multiple files … should be avoided"): the ~120-line lazy-load state machine (gen guard, in-flight ref, reshape
useLayoutEffect, dedup) is near-verbatim inChatView.tsxanduse-thread-composer-state.ts. Should become one shared hook — deferred because it's a cross-app React-architecture refactor, not a surgical fix. - Asymmetry: activities are windowed to 500 but
messagesin the samegetThreadDetailByIdare still unbounded, so the heap-OOM rationale is only half-applied. handleScroll/loadOlderidentity churn on every live append (perf, not correctness);THREAD_DETAIL_ACTIVITY_WINDOWoverloaded as window size + default page + max page.
Verified safe (no action)
getSnapshot→getCommandReadModel (only the CLI reads .projects); toReversed() (Node/lib supports it); mapThreadActivityRow typing; hasMoreActivities optional doesn't break fixtures/decoders; window slice/hasMore arithmetic; the empty-page stop guard; sequence===0 cursor; created_at tie-break cursor. No CLAUDE.md/AGENTS.md violations beyond the duplication note.
848cff6 to
ead248b
Compare
Addressed the latest review round (
|
Address review (PR pingdotgg#3510, Cursor Bugbot): - Reconnect / checkpoint revert can re-snapshot or filter the live activity window, but the prepended `olderActivities` weren't invalidated — leaving gaps or showing reverted history. Reset the lazy-load state when the live window's oldest activity id changes (it's stable while activities only append, so this doesn't fire during a normal turn), in addition to on thread switch. Web + mobile. - Web only deduped a new older page against already-loaded older pages, not the live window (mobile already did both). Dedup against both so a boundary overlap can't produce duplicate ids / React keys. Not changed — the "unsequenced cursor hides sequenced history" finding is a false positive: NULL-sequence (legacy) rows always sort oldest in the window/cursor ordering, so the oldest-loaded row is only unsequenced once every sequenced row is already loaded; the unsequenced cursor can never strand sequenced rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ead248b to
311b469
Compare
Address review (PR pingdotgg#3510, Cursor Bugbot): - Reconnect / checkpoint revert can re-snapshot or filter the live activity window, but the prepended `olderActivities` weren't invalidated — leaving gaps or showing reverted history. Reset the lazy-load state when the live window's oldest activity id changes (it's stable while activities only append, so this doesn't fire during a normal turn), in addition to on thread switch. Web + mobile. - Web only deduped a new older page against already-loaded older pages, not the live window (mobile already did both). Dedup against both so a boundary overlap can't produce duplicate ids / React keys. Not changed — the "unsequenced cursor hides sequenced history" finding is a false positive: NULL-sequence (legacy) rows always sort oldest in the window/cursor ordering, so the oldest-loaded row is only unsequenced once every sequenced row is already loaded; the unsequenced cursor can never strand sequenced rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fixes - OSC 10/11/12 rgb in output is the legitimate set-colour command — kept (stripFromOutput flip in the sequence grammar), reply form still stripped from input. - sanitizePersistedTerminalHistory bypasses overflow recovery: whole-buffer loads preserve an over-cap unterminated tail byte-for-byte. - CPR input strip is query-gated (lastCursorQueryRelayedAt + 5s grace): the byte-identical modified-F3 keystroke passes at a quiet prompt while the querying-prompt flood stays broken. - Mobile surfaces the NEWEST open approval/user-input request instead of the oldest, so lazy-loaded ancient requests can't hijack the current prompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Hi, i experienced the same issue:
This cursor was about 1,550 global events behind, so the 1,000-event cutoff introduced here would have selected the fresh-snapshot path and avoided the failure. This is good evidence that bounding shell/thread catch-up and falling back to a current snapshot is needed in practice, not only for memory protection. |
Address review (PR pingdotgg#3510, Cursor Bugbot): - Reconnect / checkpoint revert can re-snapshot or filter the live activity window, but the prepended `olderActivities` weren't invalidated — leaving gaps or showing reverted history. Reset the lazy-load state when the live window's oldest activity id changes (it's stable while activities only append, so this doesn't fire during a normal turn), in addition to on thread switch. Web + mobile. - Web only deduped a new older page against already-loaded older pages, not the live window (mobile already did both). Dedup against both so a boundary overlap can't produce duplicate ids / React keys. Not changed — the "unsequenced cursor hides sequenced history" finding is a false positive: NULL-sequence (legacy) rows always sort oldest in the window/cursor ordering, so the oldest-loaded row is only unsequenced once every sequenced row is already loaded; the unsequenced cursor can never strand sequenced rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
d88cbcf to
59e56d6
Compare
carlulsoe Thank your for testing it, glad it helps |
|
Real-world confirmation that this PR targets the right root cause: hit the exact failure mode on Full diagnostics posted in #996 (comment). Notable data point for sizing the page limits: in-memory expansion was roughly 5× the raw JSON payload, and single threads can reach 165k+ events, so per-thread pagination (not just per-snapshot) matters. Would love to see this merged — currently the only user-side fix is manual SQLite surgery. |
A busy SQLite database materialised hundreds of MB of activity payloads into the
Node heap and crashed the server. Bound the reads and add an on-demand
pagination path so deep history is still recoverable.
- /api/orchestration/snapshot and the `t3` CLI offline path called getSnapshot(),
loading every thread's full activity/message/checkpoint history even though
they only read `.projects`. Point both at getCommandReadModel() (same shape,
without the heavy per-thread tables).
- getThreadDetailById windows activities to the most recent 500 (it fetches
WINDOW+1 to detect truncation and sets `hasMoreActivities` so clients can
lazy-load). Live activities still stream in via the event subscription.
- New orchestration.getThreadActivities RPC pages older activities on demand:
cursor is {beforeSequence} for sequenced rows or {beforeCreatedAt,
beforeActivityId} for legacy unsequenced (NULL) rows, output {activities,
hasMore}. The sequenced query orders by `sequence DESC` with
`(sequence < ? OR sequence IS NULL)` so the (thread_id, sequence, created_at,
activity_id) index satisfies the ORDER BY instead of a filesort. Unsequenced
rows page by a (created_at, activity_id) cursor consistent with the window's
ordering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The server windows thread detail to the most recent 500 activities and reports `hasMoreActivities`. The web timeline now fetches older pages on demand via the getThreadActivities RPC: - client-runtime: loadThreadActivities command on orchestrationEnvironment. - ChatView keeps per-thread older pages in local state, prepends them ahead of the live window, and derives hasMore from the server flag (no client-side window-size constant). A thread-keyed in-flight ref coalesces the duplicate dispatches a fast scroll-to-top would otherwise fire, and a request-key guard discards results after a thread switch. - MessagesTimeline triggers a load on reaching the top (maintainVisibleContentPosition anchors the viewport on prepend) with a "Load older history" header and loading indicator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile consumed the windowed thread detail with no way to fetch older activities, silently truncating history. Mirror the web lazy-load: - useThreadComposerState: older-activity state + a thread-keyed in-flight ref guard, a reset effect on thread switch, and loadThreadActivities with the same union cursor. Older pages are deduped (against prior pages and the live window) and prepended into the feed; initial hasMore comes from the server's hasMoreActivities flag. - ThreadFeed: onStartReached triggers the load, maintainVisibleContentPosition anchors the viewport on prepend, and the header shows a spinner while loading. - Forward the values through ThreadRouteScreen → ThreadDetailScreen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address review (PR pingdotgg#3510, Cursor Bugbot): - Reconnect / checkpoint revert can re-snapshot or filter the live activity window, but the prepended `olderActivities` weren't invalidated — leaving gaps or showing reverted history. Reset the lazy-load state when the live window's oldest activity id changes (it's stable while activities only append, so this doesn't fire during a normal turn), in addition to on thread switch. Web + mobile. - Web only deduped a new older page against already-loaded older pages, not the live window (mobile already did both). Dedup against both so a boundary overlap can't produce duplicate ids / React keys. Not changed — the "unsequenced cursor hides sequenced history" finding is a false positive: NULL-sequence (legacy) rows always sort oldest in the window/cursor ordering, so the oldest-loaded row is only unsequenced once every sequenced row is already loaded; the unsequenced cursor can never strand sequenced rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Detect a live-window reshape from an order-independent oldest boundary (`liveWindowOldestActivityId`: min by createdAt/id) instead of `activities[0]`. The reducer sorts unsequenced rows to the end while the server snapshot lists legacy unsequenced rows first, so the first live append re-sorts the array and shifts index 0 — which previously looked like a reshape and wrongly cleared the user's scrolled-up history. Run the older-pages reset in useLayoutEffect so the cleared state commits before paint; otherwise a thread switch renders one frame with the previous thread's lazy-loaded pages still merged in, flashing stale work-log and approval rows. Applied to both web (ChatView) and mobile (use-thread-composer-state); the shared sentinel helper lives in client-runtime with unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lazy-load state machine (~160 lines each) was hand-copied in the web ChatView and the mobile composer, and every review-round fix had to be applied to both — half the review churn on this PR was the copies drifting. One shared hook, `useOlderThreadActivities` in client-runtime, now owns all of the hardened behavior: reshape-sentinel reset, generation guard, synchronous in-flight coalescing, the advancing cursor (all-overlap pages keep paging), dedup against the latest merged set, and hasMore-preserving failure handling. Clients pass a `loadPage` transport wrapper and keep only their platform scroll trigger and error surfacing. The decision kernel (reshape detection, cursor selection, page dedup) is exported as pure functions with unit tests; react becomes an optional peer dependency of client-runtime, scoped to the new subpath. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Address review (PR #3510, Cursor Bugbot): - Reconnect / checkpoint revert can re-snapshot or filter the live activity window, but the prepended `olderActivities` weren't invalidated — leaving gaps or showing reverted history. Reset the lazy-load state when the live window's oldest activity id changes (it's stable while activities only append, so this doesn't fire during a normal turn), in addition to on thread switch. Web + mobile. - Web only deduped a new older page against already-loaded older pages, not the live window (mobile already did both). Dedup against both so a boundary overlap can't produce duplicate ids / React keys. Not changed — the "unsequenced cursor hides sequenced history" finding is a false positive: NULL-sequence (legacy) rows always sort oldest in the window/cursor ordering, so the oldest-loaded row is only unsequenced once every sequenced row is already loaded; the unsequenced cursor can never strand sequenced rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
59e56d6 to
e94ab70
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e94ab70. Configure here.
Address review (PR pingdotgg#3510, Cursor Bugbot): - Reconnect / checkpoint revert can re-snapshot or filter the live activity window, but the prepended `olderActivities` weren't invalidated — leaving gaps or showing reverted history. Reset the lazy-load state when the live window's oldest activity id changes (it's stable while activities only append, so this doesn't fire during a normal turn), in addition to on thread switch. Web + mobile. - Web only deduped a new older page against already-loaded older pages, not the live window (mobile already did both). Dedup against both so a boundary overlap can't produce duplicate ids / React keys. Not changed — the "unsequenced cursor hides sequenced history" finding is a false positive: NULL-sequence (legacy) rows always sort oldest in the window/cursor ordering, so the oldest-loaded row is only unsequenced once every sequenced row is already loaded; the unsequenced cursor can never strand sequenced rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fixes - OSC 10/11/12 rgb in output is the legitimate set-colour command — kept (stripFromOutput flip in the sequence grammar), reply form still stripped from input. - sanitizePersistedTerminalHistory bypasses overflow recovery: whole-buffer loads preserve an over-cap unterminated tail byte-for-byte. - CPR input strip is query-gated (lastCursorQueryRelayedAt + 5s grace): the byte-identical modified-F3 keystroke passes at a quiet prompt while the querying-prompt flood stays broken. - Mobile surfaces the NEWEST open approval/user-input request instead of the oldest, so lazy-loaded ancient requests can't hijack the current prompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Real-world macOS desktop validation: this PR targets the backend OOM path we captured. The test profile had an untouched 4.43 GiB Tracing showed cached task cursors 271k–591k global events behind. I tested all commits from this PR with two companion changes:
I also used an 8 GiB packaged-backend heap default as a safety rail while preserving explicit With the same database and no pruning or vacuuming, observed backend RSS stayed around 295k–437k KiB instead of climbing to ~2.8M KiB, port 3773 remained available for 15+ minutes, and no new crash report appeared. This was a combined build, so the RSS result should not be attributed to #3510 alone. However, the SQLite trace directly confirms that its bounded replay, stale-snapshot fallback, and activity paging target the actual OOM path. I recommend merging after fixing the missing synchronization marker. Recovering the aggregate-indexed per-task replay from closed PR #3825 would be a strong follow-up. |
e94ab70 to
4b45cac
Compare

Fixes the server-side root cause behind #2761 (and likely #996).
What Changed
The server used to load a thread's entire history — every tool activity, message, and checkpoint — into memory at once. On a busy database that's hundreds of MB, which exhausted the Node heap and crashed the server (and made large threads slow to load / churn reconnects, as reported in #2761).
Now the server loads only the most recent slice of a thread's activity and tells the client whether older history exists; the web and mobile apps fetch older pages on demand as you scroll up. Deep history is still fully reachable — it's paged in instead of loaded all at once.
t3CLI offline path stop loading every thread's full history (they only read the project list). Thread detail is bounded to the most recent activities, with ahasMoreActivitiesflag and a cursor-paginatedgetThreadActivitiesRPC for older pages. The older-page query is shaped to use the existing index instead of a full sort.The commits remain separated by server, web, mobile, shared-client, reconnect-safety, and rebase-formatting concerns so each layer is independently reviewable.
Why
Programs in a thread emit a lot of tool activity over time. The server treated "open a thread" (and "load the snapshot") as "read all of it from SQLite into memory." For a long-lived thread or a busy database, that single read materialised more than the heap could hold, so the process ran out of memory and died — and even when it didn't crash, shipping one giant snapshot blocked the connection (#2761). Bounding the read + paging older history on demand keeps memory flat regardless of how much history a thread accumulates.
UI Changes
The web and mobile timelines gain a small "Load older history" affordance and a loading spinner when you scroll to the top of a thread longer than the window. It's a minor, additive interaction. I wasn't able to attach before/after screenshots or a scroll video here — happy to add them, or the server commit alone (no UI) carries the actual reliability fix if you'd rather review that first.
Checklist
🤖 Generated with Claude Code
Note
High Risk
Large cross-cutting change to projection queries, subscriptions, and client assumptions about complete activity lists; incorrect pagination or reshape detection could hide history or break prompts until paged in.
Overview
Bounds server memory by no longer loading full thread history on open: thread detail returns only the 500 most recent activities, sets
hasMoreActivities, and addsgetThreadActivitiesfor cursor-paged older rows (sequence or legacy(createdAt, activityId)cursors). The orchestration snapshot HTTP path andt3CLI usegetCommandReadModelinstead of the full snapshot so project-only callers avoid materializing huge per-thread tables.Reconnect safety:
subscribeThreadreplays at most 1,000 global events after the client cursor; stale cursors get a fresh thread snapshot instead of unbounded event replay.Web and mobile share
useOlderThreadActivitiesto merge lazy-loaded pages with the live window, drive scroll-up / “Load older history,” and derive pending approvals frommergedActivities(newest open prompt, not oldest). Sidebar thread pre-warming is removed.Contracts: optional
hasMoreActivitieson threads; new RPC and error types for activity paging.Reviewed by Cursor Bugbot for commit 034f493. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Paginate large thread history to prevent server memory exhaustion
getThreadDetailByIdnow returns at most 500 most-recent activities per thread with ahasMoreActivitiesflag; previously all activities were loaded into memory unconditionally.getThreadActivitiesWebSocket RPC for cursor-based paging of older activities, supporting both sequence-based and(createdAt, activityId)keyset cursors.subscribeThreadnow bounds catch-up replay to 1,000 events; when the client cursor is stale it substitutes a fresh thread snapshot instead of replaying an unbounded event range.useOlderThreadActivitieshook merges paged older activities with the live window; web and mobile UIs auto-trigger a load when scrolled to the top and show a 'Load older history' button or spinner.SidebarThreadDetailPrewarmer) is removed, stopping background thread-detail subscriptions for visible threads.Macroscope summarized 034f493.