Skip to content

Coalesce client loader/store rebuilds during incremental event bursts#5604

Open
habdelra wants to merge 3 commits into
cs-12288-client-perf-telemetryfrom
cs-12289-coalesce-client-loaderstore-rebuilds-during-incremental
Open

Coalesce client loader/store rebuilds during incremental event bursts#5604
habdelra wants to merge 3 commits into
cs-12288-client-perf-telemetryfrom
cs-12289-coalesce-client-loaderstore-rebuilds-during-incremental

Conversation

@habdelra

Copy link
Copy Markdown
Contributor

Every incremental index event whose invalidations include an already-loaded executable module triggers a full client rebuild in the host: resetLoader() + store.reset() + re-establishing every live card reference. The full reset is load-bearing and stays — targeted loader resets are unsafe against nested instance graphs, live Glimmer bindings, and @field closures over card-api. What was missing is scheduling. With no coalescing, N events in a burst cost N complete teardowns and N full re-fetch/re-render passes of the whole open card graph.

That is exactly the shape a rapid write session produces: sustained writes every few seconds. A rebuild of a large open card can take several seconds, so during a burst the rebuild work arrives at or above the rate it completes, and the open card stays pinned in "Loading card…" for the length of the burst.

What this changes

The rebuild moves into a keepLatest task. At most one rebuild runs in flight and one stays pending; events arriving mid-rebuild collapse into the single pending slot. A burst of N rapid executable invalidations therefore costs at most 2 rebuilds regardless of length. The final rebuild re-fetches current server state, so the end state reflects the latest generation — this is scheduling only, no reactivity semantics change, and an isolated change still resets and re-renders as a single rebuild.

Two details worth calling out:

  • Once a rebuild is in flight, any further executable invalidation folds into it even when the freshly reset loader reports its module as not-loaded. The isModuleLoaded probe alone would otherwise drop a mid-burst code change.
  • The per-invalidation reload loop is skipped when a rebuild is scheduled: the rebuild's store.reset + reestablishReferences subsumes it (the synchronous store.reset already emptied the store before that loop ran, leaving it a no-op there). It is extracted into #reloadInvalidatedInstances for the non-rebuild path.

The rebuild telemetry now fires once per actual rebuild and carries a coalesced_events count, so the dashboard reflects how many events collapsed into each rebuild.

Scope

The local editor file-write rebuild path (refreshReferencesForCodeChange) is a single-write path, not a burst, and is left unchanged.

Test plan

New integration tests in store-test:

  • A burst of 6 rapid executable invalidations coalesces to at most 2 rebuilds, with the open card re-established against current server state and reference counts balanced.
  • An isolated executable invalidation still triggers exactly one rebuild.

The full Integration | Store module is otherwise green locally. One save-timing test (can skip waiting for the save when patching an instance) fails on a slow local stack because its waitUntil outlasts the save round-trip; it fails identically on the base branch, so it is independent of this change.

🤖 Generated with Claude Code

Every incremental index event touching an already-loaded executable
module tore down and rebuilt the whole client — resetLoader + store.reset
+ re-establishing every live card reference — once per event. A write
burst therefore cost N full rebuilds and N full-graph re-fetches, arriving
at or above the rate they complete.

Move the rebuild into a keepLatest task so at most one runs in flight and
one stays pending: intermediate events collapse into the pending slot, so
N rapid executable invalidations cost at most 2 rebuilds. The final
rebuild re-fetches current server state, so the end state still reflects
the latest generation. Reactivity is unchanged — an isolated change still
resets and re-renders exactly as before, and the full reset stays
load-bearing.

Once a rebuild is in flight, further executable invalidations fold into it
regardless of the isModuleLoaded probe, which reads false against the
freshly reset loader and would otherwise drop a mid-burst code change. The
per-invalidation reload loop is skipped when a rebuild is scheduled, since
the rebuild subsumes it (as the synchronous store.reset already did).

The rebuild telemetry now fires once per actual rebuild and carries a
coalesced_events count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

  1 files    1 suites   4m 52s ⏱️
108 tests 107 ✅ 0 💤 0 ❌ 1 🔥
108 runs  106 ✅ 0 💤 1 ❌ 1 🔥

Results for commit 32f16f2.

For more details on these errors, see this check.

Realm Server Test Results

    1 files      1 suites   10m 32s ⏱️
1 943 tests 1 943 ✅ 0 💤 0 ❌
2 022 runs  2 022 ✅ 0 💤 0 ❌

Results for commit 32f16f2.

`can skip waiting for the save when patching an instance` waited on the
fire-and-forget save with a bare `waitUntil(() => didSave)`, i.e. the 1s
default. That save completes a write + incremental-index round-trip before
`onSave` fires — measured at ~1.8s under load — so the assertion timed out
even though the save always succeeds. Its sibling `can skip waiting for the
save when adding to the store` already waits with `{ timeout: 10000 }`;
match it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

This PR reduces redundant host-side client rebuild work during bursts of incremental index events that invalidate already-loaded executable modules. It coalesces those rebuilds so rapid sequences of invalidations don’t repeatedly tear down and reestablish the entire live card graph, while also adding telemetry to quantify the coalescing and tests to pin the new scheduling behavior.

Changes:

  • Coalesce executable-invalidation-triggered rebuilds in StoreService using a keepLatestTask, and skip the per-invalidation reload loop when a full rebuild is scheduled.
  • Extend rebuild telemetry to include coalesced_events and adjust rebuild reporting to occur once per actual rebuild.
  • Add integration tests covering burst coalescing (≤2 rebuilds) and the isolated invalidation case, plus a timeout tweak for an existing save-timing test.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
packages/host/app/services/store.ts Coalesces rebuild scheduling for executable invalidations; extracts non-rebuild reload loop; emits rebuild telemetry per rebuild with coalescing metadata.
packages/host/app/services/client-telemetry.ts Adds coalesced_events to the RebuildEvent telemetry schema.
packages/host/tests/integration/store-test.gts Adds integration tests for burst coalescing and isolated rebuild behavior; increases waitUntil timeout for a persist timing test.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/host/tests/integration/store-test.gts
The rebuild-count spy restored `loaderService.resetLoader` to a bound copy
of the method, leaving the service with a different method identity than it
started with. Capture the original reference, invoke it via `.call` for the
correct `this`, and restore that exact reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants