Skip to content

Sync: start the heartbeat early — the 45s stall behind the 2026-07-27 outage - #684

Merged
rbuergi merged 7 commits into
mainfrom
fix/resubscribe-on-missing-initial
Jul 28, 2026
Merged

rbuergi merged 7 commits into
mainfrom
fix/resubscribe-on-missing-initial

Conversation

@rbuergi

@rbuergi rbuergi commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

This is the actual root cause

My two earlier fixes (#681, #682) treated it one level too high, and neither worked in production: after deploying ci.1459 the uncached probe never fired (zero log lines) and the self-heal never healed — four plugin roots went to the fallback overlay again and only a scale-to-zero restored the site. Both sit on top of the layer that is actually broken.

The gap

Every recovery path in CreateExternalClient is event-driven: the change feed announces an owner restart, the latch re-subscribes. Nothing recovers a stream whose owner ACKED the SubscribeRequest and then never delivered the first DataChangedEvent. No announce ever comes, so the latch never trips and the read just waits — in practice until the 45 s heartbeat shakes something loose.

The existing comment says so outright:

"This is not a retry/watchdog: nothing re-posts on a timer — recovery stays event-driven."

The measurement

From the compile activity log's microsecond timestamps on memex:

source discovery Roslyn
healthy mesh 45.20 s 2.83 s
during the outage 90.19 s 2.6 s

45.20 s is one HeartbeatInterval to within 200 ms. 97% of every compile was a lost Initial waiting for a heartbeat. Types then crossed the 60 s settle window and every plugin root served the "did not settle" fallback.

The same silence keeps an instance's NodeType stream from ever seeing a fresh build — which is precisely why the overlay self-heal had nothing to fire on, and why only a process restart cleared it. One bug, three symptoms.

The fix

If nothing has arrived shortly after subscribing, re-send the SubscribeRequest through the same proven Resubscribe path. Bounded: 3 attempts, 4 s apart. A missed-wakeup nudge, not a retry loop —

  • an owner that genuinely cannot answer already faults the stream through the subscribe's own error path;
  • one that is merely slow satisfies the probe with its first delivery;
  • Take() bounds the timer, so it can never become the process-global TimerQueue root that the heartbeat comment in this same file documents (the MeshHub_IsCollected leak).

The load-bearing test is the negative one

HealthyStream_ReceivesItsData_AndNeverResubscribes — a stream that gets its snapshot must never re-subscribe. A watchdog that fires anyway re-creates a sync/{ClientId} hub per stream on the owner's single-threaded action block: the exact storm ChangeFeedResubscribeCoalesceTest exists to prevent, and considerably worse than the bug being fixed. Plus a budget test pinning that the probe sits far below the heartbeat and the retry count stays bounded.

289/289 Data tests pass.

Honest scope

This removes the 45 s stall. It does not remove the need for deploy-time precompile: a new image invalidates every assembly, so types still recompile on first activation — just in ~3 s instead of ~48 s, which is the difference between invisible and an outage.

🤖 Generated with Claude Code

… snapshot

This is the actual root cause behind today's memex outage. My two earlier fixes
(#681, #682) treated it one level too high and neither worked in production — the
uncached probe never fired and the self-heal never healed, because both sit on top
of the layer that is actually broken.

Every recovery path in CreateExternalClient is event-driven: the change feed
announces an owner restart and the latch re-subscribes. NOTHING recovered a stream
whose owner ACKED the SubscribeRequest and then never delivered the first
DataChangedEvent. No announce ever comes, so the latch never trips and the read
just waits — in practice until the 45s heartbeat shakes something loose. The
comment above the subscribe says so outright: "This is not a retry/watchdog:
nothing re-posts on a timer."

Measured on memex, from the compile activity log's microsecond timestamps:

  source discovery   45.20s  (healthy mesh)   90.19s (outage — two misses)
  Roslyn              2.83s                    2.6s

45.20s is one HeartbeatInterval to within 200ms. 97% of every compile was a lost
Initial waiting for a heartbeat. Types then crossed the 60s settle window and every
plugin root served the "did not settle" fallback. The same silence keeps an
instance's NodeType stream from ever seeing a fresh build, which is exactly why the
overlay self-heal had nothing to fire on and only a process restart cleared it —
one bug, three symptoms.

So: if nothing has arrived shortly after subscribing, re-send the SubscribeRequest
through the SAME proven Resubscribe path. Bounded (3 attempts, 4s apart) — a
missed-wakeup nudge, not a retry loop. An owner that genuinely cannot answer already
faults the stream through the subscribe's own error path; one that is merely slow
satisfies the probe with its first delivery. Take() bounds the timer so it can never
become the process-global TimerQueue root the heartbeat comment documents.

The load-bearing test is the NEGATIVE one: a healthy stream must never re-subscribe.
A watchdog that fires anyway re-creates a sync/{ClientId} hub per stream on the
owner's single-threaded action block — the very storm ChangeFeedResubscribeCoalesceTest
exists to prevent, and worse than the bug.

289/289 Data tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 23:01

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

Adds a bounded “missing initial snapshot” resubscribe probe to JsonSynchronizationStream.CreateExternalClient to recover from the case where the owner ACKs a subscribe but never delivers the first DataChangedEvent, and pins the behavior with a new integration test (including the critical negative “healthy stream never resubscribes” guard).

Changes:

  • Add a bounded timer-based probe (MissingInitialProbe, MaxMissingInitialResubscribes) that triggers Resubscribe(...) if no initial delivery is observed shortly after subscribing.
  • Add MissingInitialResubscribeTest to ensure healthy streams never trigger the watchdog and to pin watchdog budget/timing constraints.

Reviewed changes

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

File Description
src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs Introduces the missing-initial watchdog and its configuration constants inside the external-client sync stream path.
test/MeshWeaver.Data.Test/MissingInitialResubscribeTest.cs Adds coverage to prevent resubscribe storms on healthy streams and to pin watchdog bounds.

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

Comment on lines +496 to +508
var initialArrived = 0;
keepAlive.Add(reduced.Subscribe(
ci =>
{
// Same shape as the version tracker below: TReduced is unconstrained, so read
// the value as object before null-testing it.
object? value = ci is null ? null : ci.Value;
if (value is not null)
Interlocked.Exchange(ref initialArrived, 1);
},
// Passive tracker (see the version tracker below): never rethrow on the emission
// thread — the stream's real subscribers own error propagation.
_ => { }));
@github-actions

github-actions Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

959 tests  ±0   953 ✅ ±0   4m 56s ⏱️ -2s
  5 suites ±0     6 💤 ±0 
  5 files   ±0     0 ❌ ±0 

Results for commit c94c9a5. ± Comparison against base commit ee5f2a0.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   12 files  ±0     12 suites  ±0   4m 26s ⏱️ +7s
  974 tests ±0    970 ✅ ±0  4 💤 ±0  0 ❌ ±0 
1 037 runs  ±0  1 033 ✅ ±0  4 💤 ±0  0 ❌ ±0 

Results for commit c94c9a5. ± Comparison against base commit ee5f2a0.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 129 tests  ±0   1 026 ✅ ±0   4m 43s ⏱️ -41s
   10 suites ±0     103 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit c94c9a5. ± Comparison against base commit ee5f2a0.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 142 tests  +3   1 141 ✅ +3   6m 12s ⏱️ -15s
   12 suites ±0       1 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit c94c9a5. ± Comparison against base commit ee5f2a0.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 997 tests  ±0   1 809 ✅ ±0   4m 10s ⏱️ -48s
   12 suites ±0     188 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit c94c9a5. ± Comparison against base commit ee5f2a0.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 183 tests  ±0   1 182 ✅ ±0   7m 25s ⏱️ +6s
   10 suites ±0       1 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit c94c9a5. ± Comparison against base commit ee5f2a0.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   61 files  ±0     61 suites  ±0   31m 54s ⏱️ - 1m 33s
7 384 tests +3  7 081 ✅ +3  303 💤 ±0  0 ❌ ±0 
7 447 runs  +3  7 144 ✅ +3  303 💤 ±0  0 ❌ ±0 

Results for commit c94c9a5. ± Comparison against base commit ee5f2a0.

♻️ This comment has been updated with latest results.

rbuergi and others added 2 commits July 28, 2026 00:24
The new test went RED on CI while passing locally, and it was right: on a loaded
runner a genuine cold read outruns a 4s probe, so the watchdog re-subscribed a
HEALTHY stream. That is the dangerous direction, under exactly the condition that
matters — a mass cold start is every deploy, and each re-subscribe creates a
sync/{ClientId} hub on the owner's single-threaded action block. A stall that costs
15s is a slow page; a storm is an outage. When in doubt, be late.

Two changes:

- The probe moves to 15s: still a third of the 45s heartbeat it exists to beat, but
  comfortably above the slowest legitimate cold read.
- The test stops measuring machine speed. It asserted an absolute "exactly one
  subscribe" BEFORE the idle wait, which is a statement about how fast the runner
  served the first payload. The real invariant — and what it asserts now — is that
  once data is flowing the count never grows again. An extra subscribe during a slow
  start is the watchdog working, not the regression.

289/289 Data tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ream behaviour

CI failed across THREE shards, and only one failure was my own test:

  shard 0  ExportImportAccessControlTest.Export_PartialAccess_ExportsOnlyPermittedSubset
  shard 4  TodoGraphIntegrationTest.ACME_Project_NodeType_CanBeInitialized
  shard 5  MissingInitialResubscribeTest.HealthyStream_...

Those first two were green on this same code an hour earlier, so the watchdog was
not just failing to work — it was perturbing unrelated streams. Cause: it rode its
OWN reduced.Subscribe to notice the first delivery. An additional early subscriber
changes WHEN a ref-counted stream starts, so a stream behaved differently merely by
being watched, and my own arrival flag was reading a different activation than the
one serving data.

The arrival flag now piggybacks the EXISTING passive tracker (the one recording
receivedVersion): one subscription, two observations. Nothing else about the
watchdog changes.

Verified against the exact tests CI broke, not just my own:
  Data.Test                      289/289
  ExportImportAccessControlTest    5/5
  TodoGraphIntegrationTest         7/7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi marked this pull request as draft July 28, 2026 00:13
@rbuergi

rbuergi commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Draft — stopping here deliberately, with the diagnosis intact

Fourth attempt at this tonight. The site is healthy (ci.1459, 16/16 roots verified) and nothing from this branch is deployed. A fifth speculative change to code every sync stream in the mesh depends on, at 01:00, after three wrong fixes, is worse than a documented open bug. Handing over instead.

What is PROVEN (measured, not inferred)

From the compile activity node's microsecond timestamps on memex:

source discovery Roslyn
healthy mesh 45.20 s 2.83 s
during the outage 90.19 s 2.6 s

45.20 s = one SyncStreamOptions.HeartbeatInterval to within 200 ms; 90.19 s = two. 97% of every compile is a lost Initial waiting for a heartbeat. Types then cross the 60 s settle window → every plugin root serves the "did not settle" fallback. The same silence stops an instance's NodeType stream from seeing a fresh build, which is why the overlay self-heal has nothing to fire on and only a process restart clears it. One bug, three symptoms.

Also proven: RecursiveCompilationTest (merged in #682) passes on main — the shared=@Other/Source expansion is correct. The recursion is not mis-specified; its execution stalls.

What this branch does, and what it got wrong

Re-sends the SubscribeRequest through the existing Resubscribe path when nothing arrives after a probe. Two mistakes found by CI, both fixed here, both worth recording:

  1. Probe at 4 s fired on healthy streams on loaded runners — a genuine cold read outran it. Moved to 15 s. Wrong direction is dangerous: under mass cold start (every deploy) an eager probe re-subscribes healthy streams and each re-subscribe creates a sync/{ClientId} hub on the owner's single action block — the storm ChangeFeedResubscribeCoalesceTest exists to prevent.
  2. It added its own reduced.Subscribe to notice the first delivery. An extra early subscriber changes when a ref-counted stream starts, so unrelated tests broke across shards 0 and 4 (ExportImportAccessControlTest, TodoGraphIntegrationTest — both green on the same code an hour earlier). The flag now piggybacks the existing passive tracker. Those two shards are green again.

The remaining failure, and why it may be a design problem

MissingInitialResubscribeTest.HealthyStream_ReceivesItsData_AndNeverResubscribes still fails on CI while passing locally (Data 289/289, plus the two formerly-broken classes 5/5 and 7/7).

The uncomfortable reading: if a healthy CI stream still hasn't delivered after 15 s, then elapsed time is a poor discriminator between "lost wakeup" and "slow owner" — and any purely time-based watchdog will either fire on healthy streams or arrive too late to matter. If so, the right fix is at the protocol level (an explicit ack/empty-snapshot response, so silence is unambiguous) rather than a timer.

That is the question the next person should answer before tuning this number again.

Left open

  • main is red independently of this: shard-3/shard-1 host crashes (a test host exited non-zero), which is why CD skipped and ci.1451 sat on the cluster for hours. That blocks all delivery and needs its own fix.
  • Deploy-time precompile is still the structural answer (a new image invalidates every assembly); it's cheap only once this stall is gone.

…e actual fix

Replaces the re-subscribe watchdog with the thing the evidence was pointing at all
along. Net effect on the file: -34 lines, and the functional change is one line.

  Observable.Interval(heartbeatInterval)  ->  Observable.Timer(FirstHeartbeat, heartbeatInterval)

The tell was always WHERE the data arrives: exactly on a heartbeat. 45.20s of
source discovery on a healthy mesh, 90.19s during the outage, against 2.83s of
Roslyn — 45.20s is one HeartbeatInterval to within 200ms. The owner is not slow; it
holds the delivery until something pokes it, and every other recovery path here is
event-driven, so a stream whose owner acked and then went quiet produces no event at
all and nothing runs until the first heartbeat 45s later.

So poke sooner. The first tick moves to 5s; the cadence is unchanged. It is the same
fire-and-forget [SystemMessage] the stream already sends forever — one extra cheap
message per stream, no new subscription, no new protocol, no state machine.

Deliberately NOT the re-subscribe probe I tried first. Each SubscribeRequest creates
a sync/{ClientId} hub on the owner's single-threaded action block, so a probe becomes
a storm under mass cold start — every deploy — which is the failure
ChangeFeedResubscribeCoalesceTest exists to prevent. CI proved that twice: at a 4s
probe it fired on healthy streams under load, and its extra reduced.Subscribe changed
when ref-counted streams start, breaking ExportImportAccessControlTest and
TodoGraphIntegrationTest in unrelated shards. A heartbeat creates nothing.

The test is a deterministic RED on the old schedule, not a timing race: with the
production 45s cadence configured, a heartbeat inside the test window can ONLY come
from the early first tick. Verified by reverting the one line — the test fails —
then restoring it.

  Data.Test                       290/290
  ExportImportAccessControlTest     5/5   (broken by the previous attempt)
  TodoGraphIntegrationTest          7/7   (broken by the previous attempt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi marked this pull request as ready for review July 28, 2026 00:21
@rbuergi rbuergi changed the title Sync: re-subscribe when the owner acks but never delivers the initial snapshot Sync: start the heartbeat early — the 45s stall behind the 2026-07-27 outage Jul 28, 2026
@rbuergi

rbuergi commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Rewritten: one line, and it is the actual fix

The re-subscribe watchdog is gone. Net effect on the file is -34 lines, and the functional change is one line:

- Observable.Interval(heartbeatInterval)
+ Observable.Timer(FirstHeartbeat, heartbeatInterval)

Why this, and not the probe

The tell was always where the data arrives: exactly on a heartbeat. 45.20 s of source discovery on a healthy mesh, 90.19 s during the outage, against 2.83 s of Roslyn — 45.20 s is one HeartbeatInterval to within 200 ms. The owner isn't slow; it holds the delivery until something pokes it, and every other recovery path here is event-driven, so a stream whose owner acked and then went quiet produces no event at all and nothing runs until the first heartbeat 45 s later.

So poke sooner. First tick at 5 s, cadence unchanged. Same fire-and-forget [SystemMessage] the stream already sends forever — one extra cheap message per stream, no new subscription, no new protocol, no state machine.

The probe was the wrong shape and CI proved it twice: at 4 s it fired on healthy streams under load, and its extra reduced.Subscribe changed when ref-counted streams start, breaking ExportImportAccessControlTest and TodoGraphIntegrationTest in unrelated shards. Each SubscribeRequest creates a sync/{ClientId} hub on the owner's single-threaded action block, so a probe becomes a storm under mass cold start — i.e. every deploy — which is exactly what ChangeFeedResubscribeCoalesceTest guards. A heartbeat creates nothing.

The test is a deterministic red, not a timing race

With the production 45 s cadence configured, a heartbeat inside the test window can only come from the early first tick. Verified by reverting the one line — FirstHeartbeat_ArrivesLongBeforeAFullInterval fails — then restoring it.

Data.Test 290/290
ExportImportAccessControlTest 5/5 (broken by the previous attempt)
TodoGraphIntegrationTest 7/7 (broken by the previous attempt)

Plus a storm guard (subscribe count never grows) and a bounds check on the constant.

rbuergi and others added 3 commits July 28, 2026 01:49
CI caught a real regression I introduced: HeartbeatFireAndForgetTest configures a
200ms heartbeat, and an unconditional 5s first tick pushed its first beat out to 5s,
so the beats it counts never arrived. The early tick is meant to LOWER the floor for
long intervals, never to delay a short one.

    var firstTick = heartbeatInterval < FirstHeartbeat ? heartbeatInterval : FirstHeartbeat;

200ms cadence keeps its 200ms first beat; the 45s production cadence gets its first
beat at 5s, which is the whole fix. Pinned by EarlyTick_NeverDelaysAShorterConfiguredInterval
so nobody re-inverts it.

Also widened the CI headroom in the new test from 5s to 15s — still far below the
45s cadence, so a heartbeat inside the window can only come from the early tick and
the assertion stays a detector rather than a stopwatch on the runner.

  Data.Test                    291/291
  HeartbeatFireAndForgetTest     green (the test this commit repairs)
  NonExistentOwnerNoHeartbeat…   green

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the runner

Only my own two tests were still failing CI; every existing test, including the
HeartbeatFireAndForgetTest this branch repaired, was green. The cause was the test,
not the fix: both slept a fixed Task.Delay and then asserted, which measures how
loaded the runner is rather than what the schedule does. That is the same mistake
twice — it is why the first version passed locally and failed on CI.

Now it polls to a deadline and exits the moment the beat arrives: 5s locally instead
of a fixed 20s, and tolerant of a slow runner. The deadline (30s) stays far below the
45s cadence, so a heartbeat seen inside it can still ONLY be the early first tick —
the detector is unchanged in strength.

The two integration tests collapse into one: the storm assertion (subscribe count
never grows) belongs in the same run as the beat it is guarding, not in a second
fixture paying its own startup.

Re-verified after the rewrite by reverting the one-line change: the test FAILS on
Observable.Interval and passes on Observable.Timer(firstTick, …).

  Data.Test  290/290

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ve passed

The CI failure was never the fix and never runner load: "Test execution timed out
after 5000 milliseconds". This repo enforces a 5s per-test budget, and my test waited
for the production 5s beat. It could not pass on any machine, including mine — it
only "passed" locally because the local runner does not enforce that budget.

The correction improves the product rather than weakening the test:
FirstHeartbeat moves onto SyncStreamOptions (default 5s, taken as the sooner of it
and HeartbeatInterval). Operators get a knob, and the test configures a 300ms first
beat against the production 45s cadence — so the only thing that can deliver a beat
inside a 2s deadline is the early tick under test.

Runtime: 846ms, from 20s of sleeping. Still a deterministic detector — reverted the
one-line change and the test FAILS, restored it and it passes.

  Data.Test  290/290  (incl. HeartbeatFireAndForgetTest, NonExistentOwnerNoHeartbeatStorm)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi merged commit aeb2bf0 into main Jul 28, 2026
15 checks passed
@rbuergi

rbuergi commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

⚠️ Post-deploy measurement: this did NOT fix the stall

Deployed as 3.0.0-ci.1467 (commit aeb2bf0) and measured the same compile, same method as the baseline in this PR.

total compile
pre-fix baseline (warm system, 21:35Z) 48 s
post-fix (settled system, 02:50Z) 57 s

No improvement. A compile attempted a few minutes after the rollout also failed outright with the original symptom — No response received in hub cache/… within 00:01:00 for request SubscribeRequest → target Store/Coupon.

So the hypothesis this PR implements — "the owner withholds delivery until a heartbeat pokes it, so poke sooner" — is not supported by production. Starting the first heartbeat at 5 s instead of 45 s changed nothing measurable. Either the delivery is not actually triggered by the heartbeat post, or something else dominates the 45 s.

What IS still solid

The measurement that started this remains reproducible and unexplained: 45.20 s of source discovery on a healthy mesh, 90.19 s during the outage, against 2.6–2.83 s of Roslyn, with 45.20 s matching HeartbeatInterval to within 200 ms. That correlation is real; my causal story about it was wrong.

What this PR still legitimately contributes

  • SyncStreamOptions.FirstHeartbeat as a documented, configurable knob (default 5 s, taken as the sooner of it and the cadence).
  • EarlyFirstHeartbeatTest, including EarlyTick_NeverDelaysAShorterConfiguredInterval — which caught a real regression where an unconditional early tick delayed short cadences and broke HeartbeatFireAndForgetTest.
  • No regression: 15/15 CI green, and the ci.1467 rollout did not need the manual scale-to-zero that the previous deploy required (16/16 roots verified rendering).

Recommendation for whoever picks this up

Do not treat the stall as fixed. The next step is to instrument where those 45 s actually go — per-phase timing inside SnapshotSources/GetSources, and a log line when a delivery is satisfied by a heartbeat versus an Initial — rather than another causal guess. I made three of those tonight and each was wrong; the measurement is cheap and would have settled it hours ago.

Reverting is also reasonable if the knob isn't wanted: the behaviour change is one line.

rbuergi added a commit that referenced this pull request Jul 29, 2026
…synced query

A compile needs a POINT-IN-TIME snapshot of its sources. It was taking that snapshot
from the cached SYNCED query, which exists to deliver LIVE updates — a guarantee a
compile does not need and, on a large mesh, pays 45 seconds for.

Measured on memex, same box, same data, same four queries a Store/Coupon compile
issues:

    via IMeshService.Query (what MCP `search` uses)    ~0.25s   counts 16 / 4 / 1
    via the cached synced query (what compiles used)    45.20s

45.20s is one SyncStreamOptions.HeartbeatInterval to within 200ms: the synced
subscription misses its Initial and idles until the periodic heartbeat re-delivers.
Roslyn itself is 2.83s, so 97% of every compile was that wait — and types then
crossed the 60s settle window, which is how every plugin root came to serve the
"did not settle" fallback on 2026-07-27.

Everything else was eliminated by experiment BEFORE writing this, which is the part
the earlier attempts skipped:

  - the compile path itself     0ms discovery on a fresh mesh (issue #686)
  - topology                    0ms with ONE silo and with TWO (real AdoNet cluster,
                                both silos Active) — silo count is not the cause
  - scale / ~90 schemas         the same queries answer in 0.25s ON memex
  - the heartbeat schedule      #684 shipped and measured no change (48s → 57s)
  - pre-warming                 enabled on memex, degraded 7 of 16 roots, rolled back

So: direct read FIRST, synced query kept only as a fallback when the direct read
finds nothing. Never the other way round. #682 kept the cache primary and raced a
delayed probe against it — that probe never fired once in production, and this
ordering is exactly what removes the dependency on winning a race.

647/647 Graph, 8/8 Hosting.Monolith compile/pre-warm integration tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rbuergi added a commit that referenced this pull request Jul 29, 2026
…synced query (#690)

A compile needs a POINT-IN-TIME snapshot of its sources. It was taking that snapshot
from the cached SYNCED query, which exists to deliver LIVE updates — a guarantee a
compile does not need and, on a large mesh, pays 45 seconds for.

Measured on memex, same box, same data, same four queries a Store/Coupon compile
issues:

    via IMeshService.Query (what MCP `search` uses)    ~0.25s   counts 16 / 4 / 1
    via the cached synced query (what compiles used)    45.20s

45.20s is one SyncStreamOptions.HeartbeatInterval to within 200ms: the synced
subscription misses its Initial and idles until the periodic heartbeat re-delivers.
Roslyn itself is 2.83s, so 97% of every compile was that wait — and types then
crossed the 60s settle window, which is how every plugin root came to serve the
"did not settle" fallback on 2026-07-27.

Everything else was eliminated by experiment BEFORE writing this, which is the part
the earlier attempts skipped:

  - the compile path itself     0ms discovery on a fresh mesh (issue #686)
  - topology                    0ms with ONE silo and with TWO (real AdoNet cluster,
                                both silos Active) — silo count is not the cause
  - scale / ~90 schemas         the same queries answer in 0.25s ON memex
  - the heartbeat schedule      #684 shipped and measured no change (48s → 57s)
  - pre-warming                 enabled on memex, degraded 7 of 16 roots, rolled back

So: direct read FIRST, synced query kept only as a fallback when the direct read
finds nothing. Never the other way round. #682 kept the cache primary and raced a
delayed probe against it — that probe never fired once in production, and this
ordering is exactly what removes the dependency on winning a race.

647/647 Graph, 8/8 Hosting.Monolith compile/pre-warm integration tests.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi deleted the fix/resubscribe-on-missing-initial branch August 5, 2026 10:45
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