Repository navigation
fix(mesh): a subscriber's own release is not announced back; a same-silo grain call shares the immutable delivery envelope - #5635
Conversation
…ilo grain call shares the immutable delivery envelope - The owner's StreamEndedEvent no longer answers an end the subscriber asked for (UnsubscribeRequest). Its sync hub is already gone, so the announcement only crossed the subscriber's mesh hub to be dropped; a bulk release (a cache hub releasing thousands of mirrors) put thousands on one router turn loop and tripped the aggregate watermark (#5532). Owner-side ends (idle release, EvictClientSubscriptions) are still announced. - Orleans deep-copied every same-silo grain call's IMessageDelivery through JsonCodec (serialise + parse, arguments and result). The envelope is immutable and its crossing payload is RawJson, so MessageDeliveryCopier hands the same instance across; JsonCodec no longer claims copying for delivery types. Cross-silo serialisation is unchanged (#4824). Refs #5532 Refs #4824 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Restrict the copier scope and correct the eviction disposal path and related documentation.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (2)
What changed in this PR
This PR suppresses redundant stream-end notifications and avoids same-silo JSON deep copies of delivery envelopes.
Changes:
- Tracks subscriber-requested stream termination.
- Adds and registers a delivery-envelope copier.
- Adds regression tests and documentation.
| File | Summary |
|---|---|
test/MeshWeaver.Hosting.Orleans.Test/LocalGrainCallSharesTheEnvelopeTest.cs |
Tests same-silo envelope sharing. |
test/MeshWeaver.Data.Test/SubscriberReleaseIsNotAnnouncedBackTest.cs |
Tests release announcement behavior. |
src/MeshWeaver.Documentation/Data/Architecture/OversizedDeliveryRefusal.md |
Documents local-call copying behavior. |
src/MeshWeaver.Documentation/Data/Architecture/LiveMirrorsAndTheChangeFeed.md |
Documents stream-release behavior; causal wording requires correction. |
src/MeshWeaver.Data/Serialization/SynchronizationStream.cs |
Tracks subscriber-requested termination; eviction disposal remains unresolved. |
src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs |
Suppresses redundant end announcements. |
src/MeshWeaver.Connection.Orleans/OrleansConnectionExtensions.cs |
Registers the custom copier and excludes envelopes from JSON copying. |
src/MeshWeaver.Connection.Orleans/MessageDeliveryCopier.cs |
Adds envelope sharing, but its broad scope can violate deep-copy isolation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// <summary>Whether <paramref name="type"/> is a delivery envelope this copier owns — also the | ||
| /// predicate that takes those types away from <c>JsonCodec</c>'s copying, so the two can never | ||
| /// both claim one type.</summary> | ||
| internal static bool Covers(Type type) => typeof(IMessageDelivery).IsAssignableFrom(type); |
There was a problem hiding this comment.
Agreed, and fixed in eb1bdb3. Covers is now exactly type == typeof(MessageDelivery<RawJson>), the packaged shape the immutability argument covers. A delivery still carrying a typed CLR message keeps JsonCodec's isolating copy. The router only hands packaged deliveries to grains, so the hot path and every large payload still take the reference. New control An_unpackaged_delivery_is_still_copied asserts the narrowing through the silo's own DeepCopier. Orleans suite: 336/336.
| /// <summary> | ||
| /// 🚨 Set when this server-side stream ends because its SUBSCRIBER is gone — it asked for the | ||
| /// end itself (an <see cref="UnsubscribeRequest"/> reached this stream's sync hub), or the | ||
| /// router proved its address unserved (<c>Workspace.EvictClientSubscriptions</c>). | ||
| /// |
There was a problem hiding this comment.
Correct: nothing on the eviction path sets it, and that is intended. The summary now names its one setter, the UnsubscribeRequest handler, and says explicitly that owner-side ends (idle release, EvictClientSubscriptions) are still announced. Fixed in eb1bdb3.
Test Results (shard 0) 1 files 1 suites 2m 57s ⏱️ Results for commit eb1bdb3. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)547 tests 547 ✅ 1m 8s ⏱️ Results for commit eb1bdb3. ♻️ This comment has been updated with latest results. |
Test Results (shard 3)429 tests 429 ✅ 59s ⏱️ Results for commit eb1bdb3. ♻️ This comment has been updated with latest results. |
…y<RawJson>; SubscriberEnded doc names its one setter Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Test Results (shard 4) 2 files 2 suites 6m 12s ⏱️ Results for commit eb1bdb3. |
Test Results (shard 2)1 602 tests 1 602 ✅ 8m 59s ⏱️ Results for commit eb1bdb3. |
Test Results (shard 5) 5 files 5 suites 15m 1s ⏱️ Results for commit eb1bdb3. |
Test Results 13 files 13 suites 35m 18s ⏱️ Results for commit eb1bdb3. |


What was wrong
1. The owner announced a subscriber's own release back to it (#5532)
JsonSynchronizationStreampostsStreamEndedEventfrom the owner-side stream's disposal (#2191). That event is meant for an end the subscriber did not ask for. The most common disposal is the subscriber's ownUnsubscribeRequest, and that one was announced too. The subscriber'ssync/hub had already disposed itself before the request left, so each announcement crossed the subscriber's mesh hub and was dropped there ([CanBeIgnored]).One wasted message per release was invisible. When a subscriber releases N mirrors at once, for example a cache hub going down, the owners send N
StreamEndedEvents back through one router turn loop. That is the shape of #5532: 454 aggregate-watermark trips in 36 s onmesh/YxuIePH430S3wXXs-HRa8A, sheddingStreamEndedEvent. There was a second problem too. A stream id is reused across a re-subscribe, so a late echo could reach the next incarnation and read as an owner-side end.Fix: the owner's
sync/hub records that anUnsubscribeRequestended it (SynchronizationStream.MarkSubscriberEnded, set beforehub.Dispose()). The announcement skips that end. Owner-side ends are still announced: an idle release, orEvictClientSubscriptions.2. Every same-silo grain call copied the delivery through JSON (#4824)
ConfigureMeshWeaverregisteredJsonCodecas Orleans' copier for every type (AddJsonSerializer(_ => true, _ => true, …), where the second predicate isisCopyable). So every localRouteMessage/DeliverMessage/Deliverserialised its delivery and parsed it back, for the arguments and again for the result. That copy is where the production OOM stacks sit:ObjectPolymorphicConverter.Writeunder the Orleans deep-copy (#4824), andPooledResponseCopier → JsonCodec.DeepCopy(#3045).The copy isolates nothing:
MessageDelivery<T>is a record with onlyinitmembers, anImmutableDictionaryof properties and anImmutableListrouting path.RawJson, because the router is only reached throughdelivery.Package(…).Fix:
MessageDeliveryCopier(anIGeneralizedCopier) claims exactly the packaged shape,MessageDelivery<RawJson>, and returns the input.JsonCodec'sisCopyableexcludes that type. An un-packaged delivery, whose typed payload may be mutable, keeps the isolating copy. Cross-silo calls still serialise throughJsonCodec; that path is unchanged.This answers the #4824 question "is DeepCopy of large payloads avoidable?" for the local hop: yes, and it is now gone. The
$type-first question for the wire path stays open. The doc states what I did and did not establish there.Evidence
SubscriberReleaseIsNotAnnouncedBackTest, counting on the owner's post pipeline:false &&): fails with "expected 0 … but found 1".LocalGrainCallSharesTheEnvelopeTest, through the silo's ownDeepCopier:JsonObjectare still copied.MeshWeaver.Data.Test: 547/547MeshWeaver.Layout.Test: 521/521MeshWeaver.Graph.Test: 1979/1979MeshWeaver.Hosting.Orleans.Test: 336/336dotnet build -c Release -warnaserrorgives 0 Warning(s) / 0 Error(s) for:Not established
Workspace.AnnounceRecycleToClientSubscriptions, sends one event per LIVE subscriber of a recycled hub, and those events are needed. This change removes the emitter that is pure waste by construction. It needs aLogsread of that window, withSHED_AGGREGATEfate trails, to tell which emitter it was.Docs:
LiveMirrorsAndTheChangeFeed(step 5 note) andOversizedDeliveryRefusal(new section "A same-silo call does not copy the envelope at all").Deploy / recycle
Framework code. It takes effect with the image roll. No address needs a manual recycle.
Refs #5532
Refs #4824
Pairs-with: none — nothing public removed (internal members and one internal type added)
🤖 Generated with Claude Code