Skip to content

fix(data): announce an owner recycle once per subscriber, not once per stream - #6539

Merged
rbuergi merged 6 commits into
mainfrom
bugfix/systemorph-meshweaver-5532
Oct 13, 2026
Merged

rbuergi merged 6 commits into
mainfrom
bugfix/systemorph-meshweaver-5532

Conversation

@systemorph-com

Copy link
Copy Markdown
Contributor

A recycled owner told its subscribers one StreamEndedEvent per live client subscription, all through one carrier. A subscriber that declares the new SubscribeRequest.AcceptsCoalescedStreamEnd (every C# subscriber, through MintSubscribeRequest) now gets ONE StreamEndedEvent per (owner, subscriber), whose new AlsoEndedStreamIds names its other streams; the subscriber's RouteStreamMessage splits it back into one end per stream before anything reaches a sync/{id} sub-hub, so every mirror still re-asks exactly once. Every other subscriber (JS/Python clients, pods on the previous image) keeps the per-stream ends. No timeout, retry, pacing, shed policy or watermark changes.

Root cause

Workspace.AnnounceRecycleToClientSubscriptions (src/MeshWeaver.Data/Workspace.cs) ran carrier.Post(new StreamEndedEvent(streamId), o => o.WithTarget(subscriber)) once per (subscriber, stream) pair. For a hub under the mesh router the carrier is the router's RouterCarrier spokesman, portal/nodeops-{meshId}, which is also the hub that issues a recycle cascade. The owner keys each subscription on the subscribing workspace hub (Subscriber = delivery.Sender in JsonSynchronizationStream.CreateSynchronizationStream), so a circuit or cache hub holding many streams of one owner was told once per stream, and a cascade over many owners put the sum of all their streams on that one single-threaded queue.

Evidence, memex-cloud, pod memex-portal-deployment-b79b66b68-w488f, 2026-10-11:

  • 19:21:35.04Z–19:21:35.2Z: [DISPOSE-DISCARD] lines show a RecycleCascade requested by portal/nodeops-ZjzXQ4RCv0K2l430tkdI5A (ModuleLiveUpdate of MeshWeaver.OgCard) recycling LinkedIn, RemoteControl, Edu, Reinsurance, Hosting and more (Ops/Logs/memex-cloud-1791746495042810736-b66b68-w488f, Ops/Logs/memex-cloud-1791746495108529539-b66b68-w488f, landed by Ops/Actions/logs-memex-cloud-20261011-5718-getdata-discards).
  • 19:21:35.706Z: ACTION-BLOCK OVERLOAD in portal/nodeops-ZjzXQ4RCv0K2l430tkdI5A at inbound depth 30907, shedding StreamEndedEvent; 19:21:36.553Z the same in mesh/ZjzXQ4RCv0K2l430tkdI5A at depth 44118 (Ops/Logs/memex-cloud-1791746495706784813-b66b68-w488f, Ops/Logs/memex-cloud-1791746496553186738-b66b68-w488f).
  • Ruled out as the producer: the unserved-subscriber eviction (0 lines, Ops/Actions/logs-memex-cloud-20261011-5532-p2-unserved-eviction), the healthy-owner end (silent while its owner tears down), a pod stop (Ops/Actions/logs-memex-cloud-20261011-5532-pod-stops).

Negative control: commit 0a9e866 is the test alone. Against the unfixed per-stream loop quoted above, the carrier posts one StreamEndedEvent per stream, 3 owners x 20 streams = 60 posts to the one subscriber, which violates the test's bound perTarget.Max(kv => kv.Value) <= Owners (3).

How it was tested

RecycleCascadeAnnouncementFanOutTest.ARecycleTellsEachSubscriberOncePerOwner_NotOncePerStream (test/MeshWeaver.Data.Test): one subscriber holds 20 streams on each of 3 router-hosted owners, and all 3 are recycled with routed DisposeRequests. It counts what the router's carrier posts per target (bound: 3) and, as its control, requires all 60 mirrors to re-ask their owner. This pull request's CI is its first run, together with the existing recycle tests (RecycleAnnouncementTest, OwnerDeactivationTellsItsLiveSubscribersTest, SubscriberReleaseIsNotAnnouncedBackTest).

Outside this change: burst A of the same day (19:16:10Z, pod fcnw2, mesh/QkJnM_x6wUmSDasRo6cINQ, shedding HeartBeatEvent) and the 2026-09-23 window, whose producers have not been read; and the re-asks themselves, which still cross the router one per stream. The issue stays open until a bug.verify on memex-cloud. The finding is conserved in src/MeshWeaver.Documentation/Data/Architecture/LiveMirrorsAndTheChangeFeed.md.


Refs #5532
Bug-Thread: Hosting/Triage/_Thread/bug-systemorph-meshweaver-5532

Opened by Dispatch's control plane on behalf of Essentials/Agent/bug-triage (auto · Provider/OpenRouterEU/anthropic/claude-opus-5.5), from the bug's own branch bugfix/systemorph-meshweaver-5532. It is never merged by Dispatch: the merge is the governed activity dev.merge, signed by a person once the checks and the internal review are green.

RecycleCascadeAnnouncementFanOutTest: one subscriber holds 20 streams on each of 3 owners, and all 3 owners are recycled. The test counts what the router's carrier posts to the subscriber and asserts at most one message per owner. On main the carrier posts one StreamEndedEvent per stream (60), from Workspace.AnnounceRecycleToClientSubscriptions. Control: every mirror still re-asks its owner once.

Test only, pushed alone so that its red run is the negative control.

Refs #5532
Workspace.AnnounceRecycleToClientSubscriptions posted one StreamEndedEvent per client subscription through one carrier (portal/nodeops-{meshId} on a mesh); a recycle cascade put every stream on that queue (memex-cloud 2026-10-11T19:21:35Z, depth 30907). Subscribers declaring AcceptsCoalescedStreamEnd now get one end per (owner, subscriber) naming the rest in AlsoEndedStreamIds; RouteStreamMessage splits it per stream. Others unchanged.

Refs #5532
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    20 files   -   5      20 suites   - 5   55m 21s ⏱️ - 1m 33s
12 058 tests  - 310  12 058 ✅  - 117  0 💤  - 193  0 ❌ ±0 
12 073 runs   - 310  12 073 ✅  - 117  0 💤  - 193  0 ❌ ±0 

Results for commit 1658104. ± Comparison against base commit 3182d81.

This pull request removes 356 and adds 20 tests. Note that renamed tests count towards both.

   --- End of inner exception stack trace ---
   --- End of inner exception stack trace ---)
   --- End of inner exception stack trace ---, expected: True)
   --- End of inner exception stack trace ---, isDenial: True)
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---
 ---> (Inner Exception #1) MeshWeaver.Messaging.Hub.Test.InfrastructureFaultTest+ProviderException (0x80004005): Failed to connect to 10.42.18.4:5432<---
 ---> (Inner Exception #1) System.ArgumentException: Value does not fall within the expected range.<---
 ---> (Inner Exception #1) System.InvalidOperationException: boom<---
 ---> (Inner Exception #1) System.InvalidOperationException: source B is misconfigured<---
…
Memex.Portal.Shared.Test.InstanceIdRulesMatchTheRegistryTest ‑ TheSetupHostAgreesWithTheRegistry(candidate: "4611500c-6ca2-4dd9-89c1-346fe26ce2f4")
Memex.Portal.Shared.Test.PublishStorageFaultIsNotAConflictTest ‑ AStorageFault_IsA500_NamingTheError(fault: System.AggregateException: One or more errors occurred. (Access to the path '/data/modules/activation.d/MeshWeaver.Markdown.Export.json' is denied.)
 ---> System.UnauthorizedAccessException: Access to the path '/data/modules/activation.d/MeshWeaver.Markdown.Export.json' is denied.
   --- End of inner exception stack trace ---)
Memex.Portal.Shared.Test.PublishStorageFaultIsNotAConflictTest ‑ AStorageFault_IsA500_NamingTheError(fault: System.InvalidOperationException: landing faulted
 ---> System.UnauthorizedAccessException: Access to the path '/data/modules/activation.d/MeshWeaver.Markdown.Export.json' is denied.
   --- End of inner exception stack trace ---)
Memex.Portal.Shared.Test.SessionDenialIsAnAnswerTest ‑ OnlyAVerdictReadsAsADenial(shape: "the same verdict nested, as a late denial dispatch"···, failure: System.InvalidOperationException: write failed
 ---> System.UnauthorizedAccessException: Access denied
   --- End of inner exception stack trace ---, isDenial: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall as an aggregate's FIRST member", fault: System.AggregateException: One or more errors occurred. (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.
   --- End of inner exception stack trace ---, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall as an aggregate's SECOND member", fault: System.AggregateException: One or more errors occurred. (The operation has timed out.) (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> System.TimeoutException: The operation has timed out.
   --- End of inner exception stack trace ---
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---
, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall nested in an aggregate inside a wrapper", fault: System.InvalidOperationException: outer
 ---> System.AggregateException: One or more errors occurred. (Value does not fall within the expected range.) (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> System.ArgumentException: Value does not fall within the expected range.
   --- End of inner exception stack trace ---
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---

   --- End of inner exception stack trace ---, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall wrapped as InnerException", fault: System.InvalidOperationException: outer
 ---> MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.
   --- End of inner exception stack trace ---, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "an aggregate of unrelated faults", fault: System.AggregateException: One or more errors occurred. (The operation has timed out.) (Value does not fall within the expected range.)
 ---> System.TimeoutException: The operation has timed out.
   --- End of inner exception stack trace ---
 ---> (Inner Exception #1) System.ArgumentException: Value does not fall within the expected range.<---
, expected: False)
MeshWeaver.Data.Test.RecycleCascadeAnnouncementFanOutTest ‑ ARecycleTellsEachSubscriberOncePerOwner_NotOncePerStream
…

♻️ This comment has been updated with latest results.

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 regression test can pass on duplicate re-asks and does not cover legacy subscribers that reject coalescing.

3 open findings
What changed in this PR

Coalesces recycle notifications per capable subscriber while preserving per-stream mirror behavior and legacy-client compatibility.

Changes:

  • Adds negotiated coalesced stream-end messages.
  • Splits coalesced messages at subscribers.
  • Adds regression coverage and architecture documentation.
File Description
src/​MeshWeaver.Data.Contract/​Messages.cs Adds capability flag and additional stream IDs.
src/​MeshWeaver.Data/​DataExtensions.cs Splits coalesced notifications before sync-hub routing.
src/​MeshWeaver.Data/​Serialization/​JsonSynchronizationStream.cs Advertises and propagates capability support.
src/​MeshWeaver.Data/​Workspace.cs Coalesces recycle announcements per subscriber.
src/​MeshWeaver.Documentation/​Data/​Architecture/​LiveMirrorsAndTheChangeFeed.md Documents the revised recycle flow.
test/​MeshWeaver.Data.Test/​RecycleCascadeAnnouncementFanOutTest.cs Exercises fan-out reduction and resubscription.

🧠 Review effort: Balanced


💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/MeshWeaver.Data/Workspace.cs Outdated
Comment on lines +288 to +292
if (subscription.AcceptsCoalescedEnd)
coalesced.Add(key.StreamId);
else
ends.Add((subscriber, new StreamEndedEvent(key.StreamId)));
}

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.

Agreed, the end-to-end test only mints declaring subscribers, so it could not see this branch. Fixed in 1658104: CoalesceRecycleEnds now takes plain (subscriber, stream id, declared) rows and is internal. The new unit test AnUndeclaredSubscriberKeepsOneEndPerStream_ADeclaredOneGetsOne pins both branches through the one seam. An undeclared subscriber gets one plain end per stream with no AlsoEndedStreamIds, and a declared one gets a single end naming all its streams. Negative control: with the declaration ignored (if (acceptsCoalescedEnd || true)), the test fails with Expected collection {"l1"} to equal {"l1", "l2", "l3"}.

Comment on lines +171 to +175
.Select(_ => Volatile.Read(ref reAsksAfterRecycle))
.Should().Within(TestTimeouts.Convergence)
.Match(n => n >= Owners * StreamsPerOwner,
"every mirror of a recycled owner must still re-ask it once -- an announcement that "
+ "reaches fewer mirrors strands the rest on a dead activation (#2533, #3986)");

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.

Right, a total can be met by a subset looping. Fixed in 1658104: the route records each re-asking mirror as (owner address, stream id), and the control waits for Owners x StreamsPerOwner distinct mirrors. It also checks that the re-asks reach all Owners owners. Local run: re-asks that crossed the router after the recycle: 60 from 60 distinct mirrors, carrier posts client/…=3.

Comment on lines +307 to +311
/// <summary>
/// The second negotiated capability: this subscriber splits a <see cref="StreamEndedEvent"/>
/// carrying <see cref="StreamEndedEvent.AlsoEndedStreamIds"/> into one end per stream, so a
/// recycled owner may tell it ONCE about every stream it held there instead of once per stream
/// (Systemorph/MeshWeaver#5532). Default <c>false</c>: a subscriber that does not say so (a JS or

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.

Fixed in 1658104: AcceptsStringSplice now calls itself "THE FIRST NEGOTIATED WIRE CAPABILITY", so the two adjacent contracts no longer contradict each other.

StreamEndedEvent now carries both AlsoEndedStreamIds (this branch, #5532) and Activation (main,
#6602). The coalesced announcement is stamped with the owner activation's token, and the
subscriber's split copies it onto every per-stream end, so the re-arm budget still counts one
teardown once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@meshweaver-cloud
meshweaver-cloud Bot disabled auto-merge October 13, 2026 08:49
rbuergi and others added 2 commits October 13, 2026 17:06
…the recycle coalescing (#5532)

Answers the three review findings on #6539:
- the end-to-end control counts DISTINCT re-asking mirrors (owner, stream id), so a retry loop on
  a subset of mirrors can no longer satisfy it while the rest stay stranded;
- CoalesceRecycleEnds takes plain (subscriber, stream id, declared) rows and is internal, and a
  unit test pins the rolling-deploy contract: an undeclared subscriber keeps one plain end per
  stream with no AlsoEndedStreamIds, a declared one gets one end naming all its streams.
  Negative control: with the declaration ignored, it fails with {"l1"} vs {"l1","l2","l3"};
- AcceptsStringSplice's doc calls itself the FIRST negotiated capability, not THE ONE.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi enabled auto-merge October 13, 2026 15:08
@rbuergi
rbuergi merged commit bd72dab into main Oct 13, 2026
80 of 82 checks passed
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