Skip to content

fix(#2776): a write no longer ends its own streams — the change feed leaves a LIVE mirror alone - #3013

Merged
rbuergi merged 3 commits into
mainfrom
fix/2776-activity-streams-end-midrun
Sep 1, 2026
Merged

rbuergi merged 3 commits into
mainfrom
fix/2776-activity-streams-end-midrun

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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.

IMeshChangeFeed is published by the persistence layer after every create/update/delete. Workspace.EvictForPath listened 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 its Observable.Create subscription, so:

  1. write N leases the mirror for P, posts its patch, the owner commits, persistence publishes Updated for P;
  2. EvictForPath("P") removes that very mirror from _remoteStreamCache;
  3. write N settles and releases its lease;
  4. the evicted stream now has no declared holder → ReclaimIfUnheld disposes it → UnsubscribeRequest to the owner → the client sync/{id} hub and its owner-side twin both die;
  5. the owner announces StreamEndedEvent to 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. RouteStreamMessage does not drop a StreamMessage whose sync/{id} sub-hub is missing — it holds it for SyncStreamOptions.SyncHubRegistrationGrace (5 s, present at the failing commit 2abb6a60) and logs only when that Timeout fires. Re-reading the #2776 trace with that in hand puts everything in one 10 ms window at +0.12 s:

log line logged at actual event
Dropping StreamEndedEvent … uZzjE4MWi0KBrSXTSLNN9g 13:28:32.936 arrived 13:28:27.936 (+121 ms)
ADVANCE_WITHOUT_HANDOFF seq=3 bound=5000ms 13:28:32.940 write started 13:28:27.940 (+125 ms)
Dropping StreamEndedEvent … dPb18nIfvUCsJEhA8RgoLQ 13:28:32.946 arrived 13:28:27.946 (+131 ms)

Confirmed independently by the failure's own arithmetic: FAILED … seq=3 elapsedMs=31015.7 at 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 once RunLevel > Started or IsDisposing. Both #2776 lines name the activity node itself, so at that instant the owner was Started and not disposing. #2763 / #2582 and the drain cap are eliminated by the trace, not by argument.

The fix

EvictForPath now probes StreamLiveness.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 reads Lazy.Value only when already materialised, so the handler can never open the SubscribeRequest it 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-feed Resubscribe, 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."

EvictForPath was the third and the only one still acting unconditionally.

Nothing is lost. The guarantee the eviction claimed — "the next GetRemoteStream caller spins up a fresh stream" — was never the eviction's to give: GetExternalClientSynchronizationStream re-checks StreamLiveness.IsUsable on every cache hit and rebuilds on a false verdict. Recycled-owner recovery belongs to the stream's own change-feed Resubscribe ("the sole recycled-grain detector"), the recycle re-arm, and the heartbeat — never to this eviction. And a recycle publishes its Updated event before the owner is disposed, so the eager eviction never had a timing advantage either.

Diagnosability, so the next occurrence cannot be misdated

LogStreamMessageDrop now 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 not REGISTERED_AFTER_POST — already landed on 2026-08-31 in 4d231170a / #2890.)

Measured

🚨 Counts, not timings. This host was at load average 100–220 on 18 cores throughout, and both bounds in the original trace (QueueAdvanceBound 5 s, WriteVerdictBound 31 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:

before after
client-side sync/ hubs minted 7 2
owner-side sync/ hubs minted 11 6
change-feed mirror evictions 7 0
mirrors disposed mid-run (⇒ UnsubscribeRequest + StreamEndedEvent) 5 0
reader emissions (versions 1…7) 7 7

Regression guard — MirrorSurvivesItsOwnWriteTest counts the sync/ hubs the owner mints while the writes run, for two write counts, and requires them equal:

  • RED without the fix: 2 writes → 4, 8 writes → 16 (exactly two per write).
  • GREEN with it: 2 writes → 2, 8 writes → 2.

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.
  • What's New (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 build MeshWeaver.Data, MeshWeaver.Hosting, MeshWeaver.Mesh.Contract, MeshWeaver.Graph, MeshWeaver.Layout, MeshWeaver.Messaging.Hub, MeshWeaver.Documentation).
  • DocumentationLinkIntegrityTest — passed.
  • Full local suites: see the comment below. CI is the authority here — the local box was 12× oversubscribed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa

Copilot AI lite review requested due to automatic review settings September 1, 2026 19:56

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.

🟡 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.EvictForPath to evict only unusable cached remote streams by gating on StreamLiveness.IsUsable.
  • Enhance DataExtensions stream-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.

@github-actions

github-actions Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

586 tests   586 ✅  53s ⏱️
  2 suites    0 💤
  2 files      0 ❌

Results for commit 4c92048.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

216 tests   216 ✅  2m 9s ⏱️
  1 suites    0 💤
  1 files      0 ❌

Results for commit 4c92048.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    3 files      3 suites   1m 15s ⏱️
1 220 tests 1 028 ✅ 192 💤 0 ❌
1 221 runs  1 029 ✅ 192 💤 0 ❌

Results for commit 4c92048.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

420 tests   420 ✅  1m 40s ⏱️
  1 suites    0 💤
  1 files      0 ❌

Results for commit 4c92048.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 735 tests   1 735 ✅  1m 50s ⏱️
    3 suites      0 💤
    3 files        0 ❌

Results for commit 4c92048.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

534 tests   532 ✅  1m 33s ⏱️
  4 suites    2 💤
  4 files      0 ❌

Results for commit 4c92048.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   14 files     14 suites   9m 22s ⏱️
4 711 tests 4 517 ✅ 194 💤 0 ❌
4 712 runs  4 518 ✅ 194 💤 0 ❌

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
@rbuergi
rbuergi force-pushed the fix/2776-activity-streams-end-midrun branch from c2715b9 to f755a05 Compare September 1, 2026 20:18
Roland Bürgi and others added 2 commits September 2, 2026 01:37
…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
@rbuergi
rbuergi merged commit 72ea6ae into main Sep 1, 2026
25 of 26 checks passed
@rbuergi
rbuergi deleted the fix/2776-activity-streams-end-midrun branch September 13, 2026 10:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants