Repository navigation
Description
Controlled experiment settles it
Ran the same instrumented image (3.0.0-ci.1469, core #685) against a fresh disposable mesh, with a NodeType whose sources have the same cross-type shared= shape as Store/Coupon:
Probe/Main sources: ["namespace:Source scope:subtree", "shared=@Probe/Lib/Source"]
Result:
⏱ NodeType definition resolved in 0ms (ok); source-freshness scan 0ms.
⏱ Source discovery took 0ms (synced query) — everything below this line is Roslyn.
Source query: namespace:Probe/Main/Source scope:subtree nodeType:Code
Source query: path:Probe/Lib/Source nodeType:Code
Source query: namespace:Probe/Lib/Source scope:subtree nodeType:Code
Source query: namespace:Probe/Main/Test scope:subtree nodeType:Code
Source discovery matched 2 Code node(s)
TOTAL: 109ms
109 ms total, 0 ms discovery — versus 45.20 s of discovery for Store/Coupon on memex (and 90.19 s during the 2026-07-27 outage), against ~2.8 s of Roslyn in both cases.
What this rules out
- ❌ The compile path itself. Identical code, identical
shared=cross-type shape → 0 ms. - ❌
CodeQueryResolverexpansion (pinned byRecursiveCompilationTest, and visible above: all four queries expand correctly). - ❌ The heartbeat theory (Sync: start the heartbeat early — the 45s stall behind the 2026-07-27 outage #684) — already disproved in production: 48 s before, 57 s after.
- ❌
15s GetMeshNode + 30s SnapshotSources = 45s— Loki shows those 15 s stalls only onLocalSyncDemo/_Sync/party-1, never on the compile path.
What it points at
The difference between the two environments is scale and partition topology, not code. On memex those four-to-nine discovery queries are cross-partition (path:Store/Plugin/Source, namespace:Store/Order/Source scope:subtree, …) and must be answered by per-node/partition hubs that may be cold. A cold hub activation on memex is exactly the multi-second-to-minute event seen all night in MessageHubGrain timeouts and SubscribeRequest 60 s failures.
On the disposable mesh everything is one small process with warm hubs → 0 ms. That is consistent with every observation, including why a second compile of the same type is 165 ms (cache hit, no discovery) and why the stall reappears after a deploy (all hubs cold again).
Suggested next step
Instrument inside discovery rather than around it — per-query elapsed, and whether the answering partition hub was already active. That distinguishes "the query is slow" from "the hub had to activate", which are different fixes:
- hub activation → pre-warm the source partitions before compiling (or keep them warm), which is also what a deploy-time precompile would need;
- query slow → the cross-schema search path at ~90 schemas.
Do not gather this by triggering repeated compiles on memex: doing so on 2026-07-28 04:05 put six roots (Claims, Edu, Underwriting, Chess, Publish, Training) onto the "did not settle" overlay and required a scale-to-zero to recover. A compile is a 45 s+ mesh-wide event at that scale.
Reproduction
cd education/e2e && az acr login -n meshweaver
MW_PORTAL_IMAGE=meshweaver.azurecr.io/memex-portal-ai:3.0.0-ci.1469 \
MW_MIGRATION_IMAGE=meshweaver.azurecr.io/memex-migration:3.0.0-ci.1469 \
MW_PLUGINS_REPO=~/code/MeshWeaver.Plugins mesh/up.sh
# create Probe/Lib + Probe/Main as above, then compile @Probe/Main and read
# @Probe/Main/_Activity/compile-… IMMEDIATELY (activity logs are in-memory only and 404 seconds later)
Activity
rbuergi commentedon Jul 28, 2026
Enabling the pre-warm on memex: tried, degraded the site, rolled back
Sequence, 2026-07-28 ~05:25–05:40Z:
3.0.0-ci.1471(=f359385, containing Pre-warm one NodeType at a time — 4-at-once is the storm that keeps boot warming disabled #687's concurrency-1 + 2s-stagger pre-warm). Verified 16/16 roots healthy on that image with the flag still OFF.PreWarm__DynamicTypes=true(confirmed present in the pod spec).AgenticEngineering,Claims,Eduunreachable;Underwriting,Publish,Training,Pricingon the fallback overlay.What this means
Sequencing the warm-up is not sufficient. #687 was a real improvement — 4-at-once is measurably worse — but even one-type-at-a-time with a 2 s stagger is too much additional load on a mesh this size while it is also serving its own cold-start traffic. The warm sweep competes with exactly the activations it is trying to pay off.
So the class summary's "ships disabled" is right for reasons beyond concurrency, and #687 should be understood as making the option less dangerous, not as making it safe to enable here.
Caveat on the evidence
I could not confirm the sweep was actually running: no
DynamicTypePreWarmerlog lines appeared on either pod, not even thedisabled (…!= true)line that logs when the flag is off. The category appears to be filtered belowInformationin this deployment. So strictly, what is established is:Not established: that the warmer itself did the damage, versus the pod restart the
set envtriggered landing badly. Both restarts tonight that didn't have the flag recovered to 16/16 on their own, which points at the flag — but I want the weaker claim on record rather than the confident one.Recommended before anyone retries
DynamicTypePreWarmerlogging so the sweep is observable (it is currently invisible in production, which is why this attempt yields correlation rather than causation).The underlying defect in this issue — cold cross-partition hub activation costing 45.20 s on memex versus 0 ms on a fresh mesh — remains the thing to fix. Pre-warming is a mitigation that this mesh currently cannot absorb.
rbuergi commentedon Jul 28, 2026
The variable my "fresh mesh" comparison never controlled: SILO COUNT
The failing read says it in its own words:
"or is owned by another silo." My comparison was:
Orleans__Clustering: "Localhost")memex-portal-deployment, 2 replicas)On one silo every hub is local, so a read can never miss. On two, a read for a node owned by the other silo has to be routed there — and if that routing does not happen for
GetDataRequest, the reader waits its full budget and reports exactly the message above. That would explain, in one mechanism:TwoSiloRecycleConvergenceTestis one of the four main flakes, and why the flakes are allTimeoutException;S10.244.4.102 → S10.244.2.97) while these reads still died: the forwarding path that works for hub messages may not cover this read.Why I am not testing it tonight
Reproducing two silos locally needs a shared clustering provider rather than
Localhost, and I am not going to guess at that config or run another experiment against memex — I have already caused two degradations there tonight (a compile storm while collecting diagnostics, and the pre-warm flag), both recovered, and my hypotheses about this system have been wrong five times.The experiment to run
shared=sources; read the phase split (core Compile: record the phase split in the activity log — stop guessing where 45s goes #685 prints it) — discovery should jump from ~0 ms to tens of seconds if this is right.If it does NOT reproduce, this hypothesis is dead too and the remaining difference is scale (node count, ~90 schemas in the cross-schema search) rather than topology.
rbuergi commentedon Jul 28, 2026
Attempted the two-silo repro locally — blocked on harness setup, not on the hypothesis
Tried to reproduce the stall on the disposable mesh with two silos (the variable my earlier 0 ms measurement never controlled for). Zero production risk, and it got far enough to be worth handing over rather than repeating:
What worked
Features__Orleans__Clustering: "AdoNet"is the multi-silo provider (Localhost= one in-process silo —Doc/Architecture/FeatureFlags), and the disposable mesh already runs Postgres, so no extra infrastructure is needed.docker compose --scale portal=2is the right shape.What blocked it
docker-compose.ymlpins the portal to host port 8323, so two replicas collide (Bind for 0.0.0.0:8323 failed). Fixed with a port range:ports: !override ["8323-8324:8080"].MW_PLUGINS_REPOandMW_EDUCATION_REPOeven forlogs/ps;up.shsets them, directdocker composecalls do not.Upafter 10 minutes; it normally completes and lets the portal start viaservice_completed_successfully). The portals therefore never came up. I did not chase this further.So the harness is single-silo by construction and needs a small amount of work — most likely Orleans AdoNet membership tables that the migration image does not create — before the experiment can run. That is a contained task for someone fresh, not a blocker on the idea.
Why the experiment is still the right next step: on one silo every hub is local and a read cannot miss, which is exactly the 0 ms result. On two, a read for a node owned by the other silo must be routed there, and the failing production read says
NO LOCAL HUB … it never activated here (or is owned by another silo). If discovery jumps from ~0 ms to tens of seconds when the second silo joins, the defect is in the read path for remotely-owned nodes — not the compile path (#685 instrumentation proves that part is 0 ms), and not something pre-warming can absorb (that was tried on memex and degraded 7 of 16 roots).Everything torn down; no stray containers or files.
rbuergi commentedon Jul 28, 2026
Two more hypotheses dead, and the strongest lead yet — measured on memex itself
1. Silo count is NOT the cause
Built the two-silo repro (the blocker was that Orleans membership lives in a separate
orleansdatabase andOrleansClusteringSetupskips itself whenConnectionStrings:orleansis unset — the migration wasn't hanging, it was no-opping). With both silos Active in one cluster and a NodeType using cross-typeshared=sources:Same as one silo. Topology is not the difference.
2. Scale/query cost is NOT the cause either
Timed the exact discovery queries a
Store/Couponcompile issues, against memex, read-only:namespace:Store/Coupon/Source scope:subtree nodeType:Codepath:Store/Plugin/Source nodeType:Codenamespace:Store/Plugin/Source scope:subtree nodeType:Codenamespace:Store/Order/Source scope:subtree nodeType:CodeThe data is reachable in a quarter of a second, with the right counts, on the production mesh. ~90 schemas and thousands of nodes are not the problem.
So the remaining difference is the MECHANISM
Compile discovery goes through the cached synced query (
NodeSources.GetSources→workspace.GetQuery(id, queries)), not the direct read path that just answered in 0.25 s. Everything now points there, and nowhere else:Why my earlier attempt at exactly this failed
PR #682 tried to race the cached synced query against a direct
IMeshService.Queryprobe and never fired once in production. Given the above, that is now the most interesting fact in this issue: either the probe never ran (anAmb/gating mistake in that PR — it also required a non-empty result and a 3 s subscription delay), orIMeshService.Queryshares whatever the cached path is waiting on, while the MCPsearchroute does not.Next step: instrument
NodeSources.GetSources/SnapshotSourcesto log subscribe→first-emission latency and which route answered, then compare the three routes on the same mesh in one run. That is a logging-only change, and it would finally separate "the cache is slow to deliver" from "the query is slow" — the two have been conflated in every attempt so far, including mine.Compile discovery: read sources directly instead of through the live …
Flakes: give hub tests a budget the CI fixture can actually meet
Compile discovery: read sources directly instead of through the live …
Flakes: give hub tests a budget the CI fixture can actually meet (#688)
rbuergi commentedon Jul 29, 2026
Follow-up with measurements from unbreaking
mainafter #690 landed.#690 fixed the production stall, but its ordering regressed every healthy mesh, because
Concatis sequential rather than a fallback: the synced query is not even subscribed until the direct probe completes. So every compile paid the probe's full latency up front — and the probe cannot answer faster thanSourceProbeQuietWindow(1s), since each leg must watch its chunked Initial go quiet before it may emit. That is a ~1s floor per compile the previous code never paid (the probe wasDelaySubscription'd and on a healthy mesh never ran).Measured, one box,
CompileLeafStabilityTest:Concat→Merge(true race)Full
MeshWeaver.Hosting.Monolith.Test: 7m50s → 3m13s (CI's cap is 6m — this is whymainwentexit=124on every run fromde86b033aonward). Fix is in #708.The part worth keeping open this issue for. That 1s floor cost minutes, not seconds, because it lands on far more calls than actual compiles:
MeshNodeCompilationService.cs:326→DiscoverSourceMaxLastModified→SnapshotSources, and the disk cache is keyed byeffectiveLastModified— which that scan produces. So source discovery runs BEFORE the cache can be consulted, and a cache HIT pays it in full. The cache saves Roslyn; it never saves discovery. In any workload that touches the same NodeTypes repeatedly, the cache-hit path is the common case.Concrete optimisation available:
DiscoverSourceMaxLastModifiedonly needsMAX(LastModified), butSnapshotSourcesfetches every source node's full content, accumulates it into a path→node map, waits out the 1s quiet window, and then discards the bodies on a cache hit. A cheap max-last-modified aggregate per query would make cache hits near-free and put the compile path well below the pre-#690 baseline. Not attempted here — it is a design change to code measured in production, and out of scope for unbreakingmain.One thing explicitly NOT done: shrinking
SourceProbeQuietWindowto 50ms, which also takes 52s → 7s. That window exists so a chunked Initial can settle, and the code's own comment warns a partial source set "compiles WRONG (missing files → phantom errors)".rbuergi commentedon Aug 8, 2026
The remaining half, counted rather than reasoned about — PR #969
Picking up the last comment's "Concrete optimisation available … Not attempted here". First, to be
explicit about what is already closed: the 45.20 s headline is not what this addresses. That was
the cached synced query missing its
Initialand idling oneSyncStreamOptions.HeartbeatInterval(45 s, to within 200 ms), fixed by #690 + #708, both on
main. The title's cold-hub-activationhypothesis was disproved earlier in this thread (two silos = the same 0 ms as one; the queries answer
in ~0.25 s on memex).
What I measured
Instrumented the source-set materializations and counted them on
CompileActivityLogTest, instead ofinferring them:
IMeshService.Querydiscovery readsThe three:
DiscoverSourceMaxLastModifiedMAX(LastModified)for the disk-cache key, then discard the bodiesCompileCoreDiscoverSourceVersionSnapshotpath → LastModified.TicksintoCompiledSourcesEach an independent
RaceSourceSnapshotover a live collection, each issuing the full expandedquery set.
Nis 2 in that test; the memex NodeType shapes in this issue expand to 4–9cross-partition queries, so 12–27 reads where 4–9 do. Each read is ~0 ms on a fresh mesh — which is
precisely why the fresh-mesh control in this issue could never have shown it.
Raw before/after, same three tests, per NodeType path:
The fix
Take the snapshot once in
GetAssemblyLocationWithLogand thread it — into the cache-key fold,into
CompileCoreas itssourcesOverride, and out on a newCompileAttempt.Sourcesfor theversion-snapshot / failure-diagnostics stages.
RaceSourceSnapshotis untouched: #612'sempty-by-consensus rule and #690/#708's
Mergesemantics are unchanged. One race instead of three,not a different race.
It is also a correctness fix. Three independent observations of a live collection can disagree, so
the cache key could be folded from set A while set B compiled and set C was recorded as
CompiledSources— the very set the recompile-needed check compares against.Not done, and why
The other half of the suggestion — a content-free
MAX(LastModified)aggregate so cache hits arenear-free — needs a second
select:-projected query shape. That adds a round trip on the cache-MISSpath and breaks the invariant
NodeSourcesdocuments at length (compile andIsDirtywatcher shareONE
GetQuerycache id, so "what the compile sees" === "what the watcher computes IsDirtyagainst") — the bug class
CodeEditRecompileTestexists to catch.NodeSourcesalso carries anexplicit
NO select: — deliberatelywarning that omittingcontentmakes the adapter emitNULL::jsonb AS contentand the type compiles against empty sources with no error anywhere.Not verified
Nothing was run against memex — this thread already records that triggering compiles there put 6 of
16 roots onto the "did not settle" overlay. So the ~0.25 s-per-read figure that converts the
read-count win into wall clock is this issue's own measurement, not a fresh one. Locally the mesh
is in-memory, so the wall-clock saving is milliseconds; the win is the read count, which is what
scales with the mesh.
rbuergi commentedon Aug 9, 2026
Closing — but the reason is not the one the title implies, so it is worth recording precisely.
The headline 45 s was already fixed before this PR
The
45.20 swas not cold-hub cost. It was a cached synced query missing itsInitialand idling oneSyncStreamOptions.HeartbeatInterval(that constant is 45 s —src/MeshWeaver.Data/Serialization/SyncStreamOptions.cs:18, which is what makes the number suspiciously round). #690 + #708 fixed that, and both are onmain.The title's hypothesis — that this is cold hub — was already dead in this thread: two silos measured the same 0 ms as one, and the discovery queries answer in ~0.25 s on memex. Claiming PR #969 fixed the 45 s would have been a fabricated win.
What #969 actually fixed
The part this thread explicitly left open ("Concrete optimisation available … Not attempted here"): discovery was paid more than once per compile. Counted on
CompileActivityLogTestrather than argued:IMeshService.QueryreadsThe three were
DiscoverSourceMaxLastModified(foldsMAX(LastModified)for the cache key, then discards the bodies),CompileCore(the real inputs), andDiscoverSourceVersionSnapshot(recordsCompiledSources) — each an independent race over a live collection. N is 2 in that test; the memex NodeType shapes here expand to 4–9 cross-partition queries.It is also a correctness fix, not only a cost one: three independent observations of a live collection can disagree, so the cache key could be folded from set A while set B compiled and set C was recorded as
CompiledSources— the very set the recompile-needed check compares against.Honest limits on the claim
MAX(LastModified)aggregate — was deliberately not done: it needs a secondselect:-projected query, adding a round trip on the cache-miss path and breaking the invariant that compile and theIsDirtywatcher share oneGetQuerycache id (the bug classCodeEditRecompileTestexists to catch). That one genuinely is riskier than the win.Nothing here explains #618 or #890; both sit downstream of Roslyn/assembly-load, and this change touches only source discovery.