Skip to content

perf(orchestration): bound in-memory read model and client per-thread state#4176

Open
RusiruSadathana wants to merge 1 commit into
pingdotgg:mainfrom
RusiruSadathana:pr/read-model-and-store-cleanup
Open

perf(orchestration): bound in-memory read model and client per-thread state#4176
RusiruSadathana wants to merge 1 commit into
pingdotgg:mainfrom
RusiruSadathana:pr/read-model-and-store-cleanup

Conversation

@RusiruSadathana

@RusiruSadathana RusiruSadathana commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

What Changed

Server: replaced the array-backed in-memory orchestration command read model with a HashMap-keyed CommandReadModel. Thread and project lookups and updates are now O(1) instead of O(N) linear scans plus full-array copies on every event, and deleted threads are evicted from the in-memory model (archived threads are retained because unarchive needs them and the projection cannot rehydrate them). The wire OrchestrationReadModel contract is unchanged; it is served by the DB-backed projection, which is untouched.

Web: wired the existing but previously uncalled per-thread cleanup into the thread deletion flow (preview state, right panel, diff panel, ui state), switched previewStateAtom from keepAlive to an idle TTL so idle preview atoms are collected, and made a released browser surface delete its entry instead of leaving a tombstone.

VCS: the VcsStatusBroadcaster per-cwd status cache is now pruned when the last poller subscriber releases, instead of growing for the process lifetime.

Added unit and integration coverage for the read model, projector eviction, decider behavior against a deleted thread, and the client store cleanup.

Why

After long uptime the server RSS and CPU climbed and the web UI became laggy. The cause was unbounded in-memory state on the single orchestration worker fiber (every event did O(N) work over a collection that never shed deleted or archived threads) compounded by client stores that accumulated one entry per thread ever visited and were never pruned on deletion. Making the hot-path structures O(1), evicting dead threads, and pruning the client and VCS caches removes the growth without changing any external contract or the projection that serves reads.

UI Changes

No visible UI changes. The web portion only prunes per-thread client state on deletion and adjusts atom retention; sidebar and panel behavior are unchanged.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes (not applicable)
  • I included a video for animation/interaction changes (not applicable)

Related Issues

Closes #4178.


Note

Medium Risk
Orchestration command-path behavior changes (deleted-thread eviction and id retention) affect invariant checks and decider/projector semantics; web/VCS changes are localized leak fixes with tests.

Overview
Addresses unbounded memory growth on long-running server and web sessions by changing how hot-path state is stored and when it is dropped.

Server orchestration introduces an internal CommandReadModel (HashMap/HashSet) for the serial command worker, replacing array scans on every event. thread.deleted evicts thread bodies from memory while deletedThreadIds preserves the “cannot create twice” invariant across restarts; boot seeds via fromWireReadModel with dropDeletedThreads: true. The wire OrchestrationReadModel and DB projection are unchanged.

Web wires per-thread cleanup into thread delete (preview, right panel, diff panel, UI state in localStorage), switches previewStateAtom from keepAlive to a 5-minute idle TTL, and removes browser surface entries on release instead of tombstones.

VCS evicts per-cwd cache when the last remote poller releases, fixing a race where concurrent retain/release could leave stale entries forever.

Reviewed by Cursor Bugbot for commit 56b6615. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Bound in-memory orchestration read model and client per-thread state to prevent unbounded growth

  • Replaces array-backed OrchestrationReadModel with a CommandReadModel backed by HashMap for O(1) lookups of threads and projects in commandReadModel.ts.
  • Deleted threads are evicted from the in-memory projector state and their IDs tracked in a HashSet, preventing memory growth and blocking re-creation of deleted thread IDs.
  • The VcsStatusBroadcaster now atomically evicts cached VCS status when the last subscriber releases a poller, closing a race condition.
  • Deleting a thread in the client now clears its associated state across preview, right-panel, diff-panel, and UI stores in useThreadActions.ts.
  • Idle per-thread preview atoms are evicted after 5 minutes via Atom.setIdleTTL instead of Atom.keepAlive.
  • Behavioral Change: released browser surface store entries are deleted entirely rather than retained as tombstones with visible=false.

Macroscope summarized 56b6615.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jul 20, 2026
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 30f20953-b942-43f8-bcf8-3a2f69a5f75f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread apps/server/src/orchestration/projector.ts
Comment thread apps/server/src/vcs/VcsStatusBroadcaster.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. Cross-cutting performance refactor affecting memory management in orchestration engine and multiple client stores. Unresolved review comments include a high-severity issue where the preview index keys may still grow unbounded despite the eviction changes.

You can customize Macroscope's approvability policy. Learn more.

@RusiruSadathana
RusiruSadathana force-pushed the pr/read-model-and-store-cleanup branch from 7b31cf8 to e30b77f Compare July 20, 2026 07:45
… state (Issue pingdotgg#4178)

Replays PR pingdotgg#4176 on current main and preserves the connection catch-up path from pingdotgg#4177.

Replace the array-backed command model with HashMap/HashSet state, evict deleted thread bodies, prune released VCS and browser caches, and clear per-thread client state.

Adds focused coverage for read-model invariants, deletion behavior, client cleanup, and VCS cache eviction after the final subscriber releases.

Co-authored-by: codex <codex@users.noreply.github.com>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

presentContent: (tabId, content) =>
set((state) => {
const current = state.byTabId[tabId];
if (!current) {
return {
byTabId: {
...state.byTabId,
[tabId]: {
rect: null,
visible: false,
content,
updatedAt: Date.now(),
owner: null,
},
},
};
}

release deletes the entry from byTabId, but presentContent is owner-independent and recreates a { owner: null } entry whenever the entry is missing. If HostedBrowserWebview fires a scheduled layout or scroll update after the lease has released, presentContent resurrects the deleted entry, so released tabs still accumulate in byTabId — defeating the bounded-state goal of this change. Consider guarding presentContent so it no-ops (or requires a valid owner) when no entry exists, instead of creating a new owner: null entry.

Suggested change
presentContent: (tabId, content) =>
set((state) => {
const current = state.byTabId[tabId];
if (!current) return state;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/browser/browserSurfaceStore.ts around lines 104-120:

`release` deletes the entry from `byTabId`, but `presentContent` is owner-independent and recreates a `{ owner: null }` entry whenever the entry is missing. If `HostedBrowserWebview` fires a scheduled layout or scroll update after the lease has released, `presentContent` resurrects the deleted entry, so released tabs still accumulate in `byTabId` — defeating the bounded-state goal of this change. Consider guarding `presentContent` so it no-ops (or requires a valid owner) when no entry exists, instead of creating a new `owner: null` entry.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 56b6615. Configure here.

export const previewStateAtom = Atom.family((threadKey: string) =>
Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe(
Atom.keepAlive,
Atom.setIdleTTL(PREVIEW_STATE_IDLE_TTL_MS),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idle preview TTL drops tab suppressions

Medium Severity

Introducing Atom.setIdleTTL on previewStateAtom causes client-only suppressedTabIds to be lost when a thread's preview state is evicted. This results in preview tabs that a user previously closed reappearing from the server list.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 56b6615. Configure here.

export const previewStateAtom = Atom.family((threadKey: string) =>
Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe(
Atom.keepAlive,
Atom.setIdleTTL(PREVIEW_STATE_IDLE_TTL_MS),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preview index keys never pruned

High Severity

activePreviewThreadKeysAtom is keepAlive and only updated via syncActivePreviewThread on explicit preview writes. When previewStateAtom idle TTL evicts a thread that still had sessions, the index entry is never removed, so the key set grows without bound and activePreviewSessionsAtom keeps scanning stale keys.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 56b6615. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Orchestration read model and per-thread client/VCS state grow unbounded over uptime

1 participant