Repository navigation
fix(#2776): a write no longer ends its own streams — the change feed leaves a LIVE mirror alone - #3013
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The new test awaits IObservable directly instead of using ObservableAwait.Await()/.Await(), which violates the repo’s sanctioned observable→Task bridge rule and can resume inline on the signalling thread.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR addresses #2776 by preventing the workspace’s change-feed eviction (Workspace.EvictForPath) from tearing down live remote synchronization streams (“mirrors”) on the writer’s own writes, and improves diagnosability of stream-message drops.
Changes:
- Update
Workspace.EvictForPathto evict only unusable cached remote streams by gating onStreamLiveness.IsUsable. - Enhance
DataExtensionsstream-drop logging to include how long a stream message was held before being dropped. - Add an integration/regression test plus documentation/What’s New entries capturing the RCA and user-visible impact.
File summaries
| File | Description |
|---|---|
| test/MeshWeaver.Graph.Test/MirrorSurvivesItsOwnWriteTest.cs | New regression test counting sync/ hub churn during repeated writes. |
| src/MeshWeaver.Data/Workspace.cs | Change-feed eviction now preserves live mirrors and only removes unusable ones. |
| src/MeshWeaver.Data/DataExtensions.cs | Stream-drop diagnostics now report hold time (“age”) to avoid misreading timestamps. |
| src/MeshWeaver.Documentation/Data/Architecture/LiveMirrorsAndTheChangeFeed.md | New architecture/RCA page documenting the defect, traps, and the fix. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-02-a-live-view-is-no-longer-resubscribed-every-time-its-page-is-written.md | New What’s New entry describing the observable user impact. |
| src/MeshWeaver.Documentation/Data/Architecture.md | Adds the new architecture page to the “Reading & writing nodes” section. |
Review details
Suppressed comments (3)
test/MeshWeaver.Graph.Test/MirrorSurvivesItsOwnWriteTest.cs:116
- This awaits an IObservable directly (via Rx’s awaiter), which is equivalent to a ToTask-style bridge and can resume continuations inline on the signalling thread. Use the repo’s sanctioned bridge ObservableAwait.Await() instead (via the .Await() extension) to ensure continuations run asynchronously.
.FirstAsync().Timeout(TestTimeouts.Convergence);
test/MeshWeaver.Graph.Test/MirrorSurvivesItsOwnWriteTest.cs:139
- This awaits an IObservable directly (via Rx’s awaiter), which is equivalent to a ToTask-style bridge and can resume continuations inline on the signalling thread. Use the repo’s sanctioned bridge ObservableAwait.Await() instead (via the .Await() extension) to ensure continuations run asynchronously.
.FirstAsync().Timeout(TestTimeouts.Convergence);
test/MeshWeaver.Graph.Test/MirrorSurvivesItsOwnWriteTest.cs:145
- This awaits an IObservable directly (via Rx’s awaiter), which is equivalent to a ToTask-style bridge and can resume continuations inline on the signalling thread. Use the repo’s sanctioned bridge ObservableAwait.Await() instead (via the .Await() extension) to ensure continuations run asynchronously.
.FirstAsync().Timeout(TestTimeouts.Convergence);
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 3)586 tests 586 ✅ 53s ⏱️ Results for commit 4c92048. ♻️ This comment has been updated with latest results. |
Test Results (shard 0)216 tests 216 ✅ 2m 9s ⏱️ Results for commit 4c92048. ♻️ This comment has been updated with latest results. |
Test Results (shard 5) 3 files 3 suites 1m 15s ⏱️ Results for commit 4c92048. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)420 tests 420 ✅ 1m 40s ⏱️ Results for commit 4c92048. ♻️ This comment has been updated with latest results. |
Test Results (shard 4)1 735 tests 1 735 ✅ 1m 50s ⏱️ Results for commit 4c92048. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)534 tests 532 ✅ 1m 33s ⏱️ Results for commit 4c92048. ♻️ This comment has been updated with latest results. |
Test Results 14 files 14 suites 9m 22s ⏱️ Results for commit 4c92048. ♻️ This comment has been updated with latest results. |
…vious fix loses writes #2776 asked one question: what ended the activity node's two sync streams while the activity was still running. This answers it, records the measurement that shows the obvious cure is worse than the disease, and makes the diagnostic that caused the mis-triage state its own age. It changes NO behaviour. ── the answer ── Its own previous progress write did, at +0.12 s. IMeshChangeFeed is published after EVERY create/update/delete, and Workspace.EvictForPath drops every cached remote stream whose owner matches the changed path — including the mirror the writer itself is using. A write leases that mirror only for its Observable.Create subscription, so the lease is released moments after its own change event lands; the evicted stream then has no declared holder and ReclaimIfUnheld DISPOSES it — UnsubscribeRequest to the owner, both sync/{id} hubs gone — and the owner then announces StreamEndedEvent to a subscriber that no longer exists. Measured: six progress writes to one activity node with one live reader mint 7 client-side and 11 owner-side sync/ hubs, with 5 mirrors disposed mid-run. ── the two arithmetic traps that made it read as a hub teardown ── 1. The drop line is written FIVE SECONDS after the event it describes: RouteStreamMessage holds a StreamMessage whose sync/{id} hub is missing for SyncStreamOptions.SyncHubRegistrationGrace (5 s) and logs only on that Timeout. In #2776 the streams ended at +0.12 s — confirmed independently by the failure's own arithmetic (FAILED seq=3 elapsedMs=31015.7 at 13:28:58.952 ⇒ EnteredAt 13:28:27.936). The ADVANCE_WITHOUT_HANDOFF beside it is a DIFFERENT 5 s bound (QueueAdvanceBound) counting from the same instant. Two unrelated five-second bounds produced a coincidence that looked like the hosted-hub drain cap. 2. The SENDER rules out a recycle. The recycle announcement is posted by a carrier (a dying hub must never speak for itself); the healthy-owner announcement is posted by the owner and refuses to fire once RunLevel > Started. Both #2776 lines name the activity node — so the owner was Started and NOT disposing. #2763/#2582 are eliminated by the trace, not by argument. ── why the obvious fix is NOT taken ── Giving EvictForPath the liveness gate its two sibling listeners already have (MeshNodeStreamCache.ResetFailureState: "A healthy live entry is left untouched"; JsonSynchronizationStream's version-gated Resubscribe: "resubscribing on it is pure churn") was implemented and measured, and it LOSES WRITES. The eviction is incidentally what keeps a cross-hub writer's BASE current with respect to writes the OWNER makes for itself: _pendingSelfWrites carries only this cache's writes forward, while an owner-side write (an activity's messageCount, a sealed segment) reaches the mirror only through the async fan-out. Controlled, same box, DOTNET_PROCESSOR_COUNT=4, on StaticRepoImportActivityWriteCountTest.AppendCost_DoesNotGrowWithTheLengthOfTheActivity: gate ON FAILED — 2000 messages appended, 1975 recorded (a 25-message batch lost) gate OFF PASSED and on CI the gated build turned 80 append calls into 99 writes with 44 OWNER_NACK_REENQUEUE / "MergeGuard: refused stale/reordered cross-hub write to 'messageCount'". The O(1) append pin (6a48f9f, d1a239a) was right and is not touched. EvictForPath now carries a comment saying the unconditional eviction is deliberate, and why. ── what DOES change ── LogStreamMessageDrop states how long the message was HELD, so its timestamp can never again be read as the time the stream ended. That is the whole behavioural diff; the rest is a doc page and comments. ── the real fix, for whoever takes it ── Make the writer's base version-aware (MeshChangeEvent already carries Version) so a write waits for the mirror to reach the announced version instead of throwing the mirror away to force a fresh snapshot. That is a change to the write path, not to EvictForPath. ── not claimed ── #2776's visible failure was a write with no verdict at 31 s. That silence has a separate, already-fixed cause (#2882 / #2890, 4d23117 — the write registered its response subject after posting; the trail could only say REGISTERED_AFTER_POST, which is exactly what that trace says). Verified: Release -warnaserror 0/0 for Graph.Test and Documentation.Test (which transitively build MeshWeaver.Data, Hosting, Mesh.Contract, Graph, Layout, Messaging.Hub, Documentation). Graph.Test 1086/1086 and Documentation.Test 166/166 at DOTNET_PROCESSOR_COUNT=4; earlier full runs on this tree also had Data.Test 419/419 and Hosting.Test 274/274. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa
c2715b9 to
f755a05
Compare
…press From the automatic review on #3013. Rx's own awaiter can resume the continuation inline on the signalling thread; `.Await()` is the repo's bridge that does not (297 sites across 88 test files already use it). That matters more than usual in this file, whose subject IS when a stream ends and on which thread its terminal lands. Applying it surfaced a nullability the Rx awaiter had inferred away — the read result feeds a `with` expression. Fixed with `Assert.NotNull` rather than `!`: the node was created immediately above, so a null there is a real failure (the read gave up, or the create never landed) and should say so rather than NRE on the next line. Full project, Release -warnaserror: 0 warnings / 0 errors; 1086/1086. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa
# Conflicts: # src/MeshWeaver.Documentation/Data/Architecture.md
Fixes #2776.
What ended the activity node's streams
Its own previous progress write did — at +0.12 s, not +5 s, and with no recycle and no teardown anywhere in it.
IMeshChangeFeedis published by the persistence layer after every create/update/delete.Workspace.EvictForPathlistened to it and dropped every cached remote stream whose owner matched the changed path — including the mirror the writer itself was using, on the writer's own write. A cross-hub write leases that mirror only for the duration of itsObservable.Createsubscription, so:P, posts its patch, the owner commits, persistence publishesUpdatedforP;EvictForPath("P")removes that very mirror from_remoteStreamCache;ReclaimIfUnhelddisposes it →UnsubscribeRequestto the owner → the clientsync/{id}hub and its owner-side twin both die;StreamEndedEventto a subscriber whose sync hub step 4 just destroyed → held 5 s → dropped with the diagnostic Flake: ActivityLiveProgressTest.EveryProgressLine_LandsWhileTheCommandIsStillRunning — the activity node's owner ends its streams ~5 s in, the next progress write gets no verdict (OwnerUnreachable after 31 s) #2776 is titled after.One full subscribe / initial-state / unsubscribe round trip per write, for as long as anything is watching the node.
The two arithmetic traps that made this read as a mid-run hub teardown
1. The drop line is written five seconds after the event it describes.
RouteStreamMessagedoes not drop aStreamMessagewhosesync/{id}sub-hub is missing — it holds it forSyncStreamOptions.SyncHubRegistrationGrace(5 s, present at the failing commit2abb6a60) and logs only when thatTimeoutfires. Re-reading the #2776 trace with that in hand puts everything in one 10 ms window at +0.12 s:Dropping StreamEndedEvent … uZzjE4MWi0KBrSXTSLNN9gADVANCE_WITHOUT_HANDOFF seq=3 bound=5000msDropping StreamEndedEvent … dPb18nIfvUCsJEhA8RgoLQConfirmed independently by the failure's own arithmetic:
FAILED … seq=3 elapsedMs=31015.7at 13:28:58.952 ⇒EnteredAt= 13:28:27.936. Two unrelated five-second bounds (SyncHubRegistrationGrace,QueueAdvanceBound) counting from one instant produced a coincidence that looked like a mechanism — "+5 s is the hosted-hub drain cap". The second witness (InstanceSyncRemoteDeletionTest, +5.1/+5.2 s) is the same artifact, not a corroboration.2. The sender rules out a recycle. The recycle announcement (
AnnounceRecycleToClientSubscriptions) is deliberately posted by a carrier — a dying hub must never speak for itself. The healthy-owner announcement (JsonSynchronizationStream's disposal registration) is posted by the owner and refuses to fire onceRunLevel > StartedorIsDisposing. Both #2776 lines name the activity node itself, so at that instant the owner wasStartedand not disposing. #2763 / #2582 and the drain cap are eliminated by the trace, not by argument.The fix
EvictForPathnow probesStreamLiveness.IsUsable— the one shared liveness predicate — and leaves a live mirror where it is. A dead one (disposed hub, terminally faulted store) is evicted and reclaimed exactly as before. The probe runs before the removal and readsLazy.Valueonly when already materialised, so the handler can never open theSubscribeRequestit is meant to police.This is the rule the other two listeners on the same broadcast already state:
MeshNodeStreamCache.ResetFailureState— "A healthy live entry is left untouched: the owner's sync stream already delivers routine updates, and tearing the shared handle down on every post-commit broadcast would sever live GUI subscribers."JsonSynchronizationStream's coalesced, version-gated change-feedResubscribe, throttled after exactly this defect one layer over (ChangeFeedResubscribeCoalesceTest) — "a HEALTHY subscriber receives that same write through its own subscription, so resubscribing on it is pure churn — at scale it is the storm that starved prod's hubs."EvictForPathwas the third and the only one still acting unconditionally.Nothing is lost. The guarantee the eviction claimed — "the next
GetRemoteStreamcaller spins up a fresh stream" — was never the eviction's to give:GetExternalClientSynchronizationStreamre-checksStreamLiveness.IsUsableon every cache hit and rebuilds on a false verdict. Recycled-owner recovery belongs to the stream's own change-feedResubscribe("the sole recycled-grain detector"), the recycle re-arm, and the heartbeat — never to this eviction. And a recycle publishes itsUpdatedevent before the owner is disposed, so the eager eviction never had a timing advantage either.Diagnosability, so the next occurrence cannot be misdated
LogStreamMessageDropnow states how long the message was held, so its timestamp can never again be read as the time the stream ended.(The other diagnosability item #2776 asks for — switching the write off the
Observe(delivery)overload so the trail is notREGISTERED_AFTER_POST— already landed on 2026-08-31 in4d231170a/ #2890.)Measured
🚨 Counts, not timings. This host was at load average 100–220 on 18 cores throughout, and both bounds in the original trace (
QueueAdvanceBound5 s,WriteVerdictBound31 s) are ones a starved machine trips on its own. Counting hub creations is load-independent: a slow box mints the same number of hubs, just later.Monolith harness (
test/MeshWeaver.Graph.Test), one activity node, one live reader, six progress writes:sync/hubs mintedsync/hubs mintedUnsubscribeRequest+StreamEndedEvent)Regression guard —
MirrorSurvivesItsOwnWriteTestcounts thesync/hubs the owner mints while the writes run, for two write counts, and requires them equal:What this does not claim
#2776's visible failure was a write that reached no verdict in 31 s. That silence has its own, already-fixed cause — #2882 / #2890: the write registered its response subject after posting, so a warm owner's sub-millisecond ack could land before anything was listening, and the trail could only ever say
REGISTERED_AFTER_POST— which is exactly what that trace says. This PR fixes the stream-ending the issue is titled after. Removing the churn also removes a per-write subscribe/unsubscribe race from the write path; that is a consequence, not a claim that it caused that particular timeout.Work products conserved
Doc/Architecture/LiveMirrorsAndTheChangeFeed— the full RCA, linked from the Architecture index.Category: Fix): A live view is no longer resubscribed every time its page is written.Verification
dotnet build -c Release -warnaserror, one project per invocation:test/MeshWeaver.Graph.Test,test/MeshWeaver.Data.Test,test/MeshWeaver.Documentation.Test,test/MeshWeaver.Hosting.Test— 0 Warning(s), 0 Error(s) each (these transitively buildMeshWeaver.Data,MeshWeaver.Hosting,MeshWeaver.Mesh.Contract,MeshWeaver.Graph,MeshWeaver.Layout,MeshWeaver.Messaging.Hub,MeshWeaver.Documentation).DocumentationLinkIntegrityTest— passed.🤖 Generated with Claude Code
https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa