Live Mirrors and the Change Feed
A mirror is the cross-hub half of GetMeshNodeStream(path): when hub A reads or writes a node
owned by hub B, A opens a SynchronizationStream to B. That costs a SubscribeRequest, an
initial-state round trip, a sync/{streamId} sub-hub on both ends, and a registry entry on the
owner. Once open it is live β the owner fans every change out to it.
IMeshChangeFeed is the other invalidation signal. The persistence layer publishes one event per
Created/Updated/Deleted, and three components listen. This page is about what the third one
did, why it looked like a defect, why it was load-bearing anyway β and how
#1174 replaced it.
π¨ FIXED 2026-09-15 β the fix is the last section of this page. A versioned Updated no longer evicts: the workspace records the version it
announced and keeps the mirror, and the next write's base must reach that version. The sections
between here and there describe the behaviour before the fix and are kept because the measurement
that rules out the liveness gate is still the reason the fix has the shape it has.
Every cross-hub write ended its own streams (before #1174)
Workspace.EvictForPath dropped every cached remote stream whose owner matches the changed path β
including the mirror the writer itself is using, on the writer's own write. A write leases that
mirror only for the duration of its Observable.Create subscription, so:
- write N leases the mirror for
P, posts its patch; the owner commits and persistence publishesUpdatedforP; EvictForPath("P")removes that very mirror from_remoteStreamCacheand parks it;- write N settles and releases its lease;
- the parked stream now has no declared holder, so
ReclaimIfUnhelddisposes it β which postsUnsubscribeRequestto the owner, kills the clientsync/{id}hub, and makes the owner dispose its own twin; - the owner's healthy-owner announcement then posts
StreamEndedEventback to a subscriber whosesync/hub step 4 has just destroyed.
The next read or write resolves a fresh mirror: another SubscribeRequest, another initial-state
round trip, another pair of sync/ hubs. Per write.
Measured on the monolith harness β one activity node, one live reader, six progress writes:
| count | |
|---|---|
client-side sync/ hubs minted |
7 |
owner-side sync/ hubs minted |
11 |
| change-feed mirror evictions | 7 |
mirrors disposed mid-run (β UnsubscribeRequest + StreamEndedEvent) |
5 |
Those step-5 announcements are the Dropping StreamEndedEvent β¦ the target stream is gone lines
that #2776 was filed on.
The two arithmetic traps
#2776 was filed as "the activity node's owner ends its streams ~5 s in", and two hypotheses β a hub recycle and the hosted-hub drain cap, which is also 5 s β were built on that reading. Both are wrong, and each is wrong for a reason worth keeping.
1. The drop diagnostic is written five seconds after the event it describes. A StreamMessage
whose sync/{id} sub-hub is not registered is not dropped on arrival: RouteStreamMessage holds it
for SyncStreamOptions.SyncHubRegistrationGrace β 5 s β waiting for the sub-hub to appear, and
only then logs the drop. So a line at T describes an event at T β 5 s. In #2776 the streams
ended at +0.12 s; the ADVANCE_WITHOUT_HANDOFF beside it is a different 5 s bound
(QueueAdvanceBound) measured from the write's own start, which was also +0.12 s. Two unrelated
five-second bounds counting from one instant produced a coincidence that looked like a mechanism.
The drop line now states its own age.
2. The sender rules out a recycle. There are two emitters of StreamEndedEvent. The recycle
announcement (Workspace.AnnounceRecycleToClientSubscriptions) is deliberately posted by a
carrier β the parent hub or its spokesman β because 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. #2776's two events name the
activity node itself as sender, which is provably the healthy-owner path: at that instant the owner
was Started and not disposing. No recycle, no teardown, no drain cap.
Why the obvious fix is wrong
Three components listen to this one broadcast, and two of them already refuse to act on a healthy subscriber:
| Listener | Its rule |
|---|---|
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." Evicts only a faulted entry. |
JsonSynchronizationStream's Resubscribe |
Coalesced and version-gated: "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." |
Workspace.EvictForPath (before #1174) |
Evicts unconditionally. |
So the obvious change is to give EvictForPath the same rule β skip a mirror that is still
StreamLiveness.IsUsable. It was implemented and measured, and it is wrong.
The eviction is, incidentally, what keeps a cross-hub writer's BASE current with respect to writes
the OWNER makes for itself. The per-path update queue hands a predecessor's locally-computed node
to its successor (_pendingSelfWrites), but that only carries this cache's writes forward. An
owner-side write β an activity's messageCount, a sealed log segment β reaches the mirror only
through the asynchronous fan-out. Evicting on the change event forces the next write to resolve a
fresh stream and therefore to diff against a freshly-fetched authoritative snapshot.
Controlled measurement, StaticRepoImportActivityWriteCountTest.AppendCost_DoesNotGrowWithTheLengthOfTheActivity,
same machine, DOTNET_PROCESSOR_COUNT=4 (the CI-race repro):
| arm | outcome |
|---|---|
| liveness gate ON | FAILED β 2000 messages appended, 1975 recorded: one whole 25-message batch lost |
| liveness gate OFF (as shipped) | PASSED |
And on CI with the gate on, the same test turned 80 append calls into 99 writes with 44
OWNER_NACK_REENQUEUE and MergeGuard: refused stale/reordered cross-hub write to 'messageCount' (changed since the writer's base) β refusing every other version. Both shapes are one mechanism: a
stale base.
Note what this means for the two rules quoted above. They are right for their layer: the shared
handle and the subscription itself must not be rebuilt on every write. EvictForPath sits below
them and is doing a different job than its own comment claims β it is not (only) cache hygiene, it is
the writer's freshness barrier. That is why removing it broke writes rather than merely changing
their cost.
The fix: a write base held to the announced version (#1174)
MeshChangeEvent already carries Version, so a write can wait for the mirror to reach the
announced version instead of throwing the mirror away to force a fresh snapshot. That is what
landed, in two halves.
The workspace (Workspace.OnOwnerNodeChanged) answers a commit on a mirrored owner in one of
two ways, decided by what the commit announced, never by how the mirror looks:
| Event | Answer |
|---|---|
Updated with a version β one per write, the hot path |
Keep the mirror and raise its floor to that version β a field of the mirror instance itself (IAnnouncedVersionFloor) |
Deleted (announces 0) |
Evict, as before β the node is gone and a recreate restarts its version counter |
Created on a path already mirrored |
Evict β a recreate is a new incarnation, whose versions say nothing about the old mirror |
Updated with version 0 (the operator recycle broadcast) |
Evict β nothing a mirror could be held to |
| any shape without a version or a kind | Evict β the pre-#1174 behaviour, the only safe one |
π¨ The floor is a field of the mirror, not an entry beside the cache. The handler raises it on
each instance it finds in the cache for the owner, and the write reads it off the very stream it
acquired (Workspace.AnnouncedVersionFor(stream)). So a floor cannot outlive its mirror and cannot
reach the next one: whatever removes a mirror β eviction, the mesh-node cache's idle release, a
fault, a recycle β removes its floor with it, and a new mirror starts with none, which is right
because it hydrates from the owner's current state, at or past every version announced before it.
The first cut kept a per-owner map instead, and review found the window that shape cannot close: a
removal landing between "the cache still mirrors this owner" and "record the floor" left a floor with
no mirror behind it, which the next mirror inherited (AnnouncedFloorLifetimeTest reproduces it
deterministically through a seam in exactly that window, and the same inheritance through the
faulted-stream path β both red against the map, both green now).
The write path (MeshNodeStreamHandle.RebaseSource, the same seam the #1910 conflict rebase
uses) reads the mark once per attempt and requires the base to satisfy
Version > refused && Version >= announced. A mirror that already carries the version passes at
once; one that is a moment behind is waited for (the owner's own fan-out delivers the version β no
round trip); the wait is bounded by ConflictRebaseBound, and it never parks β at the bound the
write proceeds on the state it has, exactly as before. With no mark (a cold path, or a feed that
never reaches the workspace) the read is byte-for-byte the old mirror.Take(1).
π¨ The fallback evicts only on PROOF. When the bound elapses, the writer evicts the mirror β the old freshness barrier, paid once β only if the mirror carried a node below the floor and nothing at or above it. A mirror that carried nothing at all is still hydrating, not behind; evicting it would throw away the one subscription about to deliver and make the next write hydrate yet another, which is the churn this fix removes, re-entered at exactly the moment the owner is slowest.
π¨ This is the opposite of the liveness gate above. Liveness asks how the mirror looks and lets a healthy-but-behind one through, which is what lost the 25-message batch. The version floor asks the mirror to prove it carries the announced commit β and the announcement is the same event that used to trigger the eviction, so every commit the old barrier covered is still covered.
Measured
| before | after | |
|---|---|---|
Five versioned commits on one owner, leased acquire per commit (HotPathMirrorChurnTest) |
6 distinct mirrors | 1 mirror, 0 SubscribeRequest, 0 UnsubscribeRequest |
Six sequential cross-hub writes to one node on a real mesh (HotPathWritesKeepTheirMirrorTest) |
5 mirrors opened after the first write | 0 opened, 0 MIRROR_BEHIND, every write landed |
StaticRepoImportActivityWriteCountTest at DOTNET_PROCESSOR_COUNT=4 (the liveness-gate repro) |
β | passes: 2000 messages in 80 appends β version 84 (80 + 4 sealed segments), no refused re-writes |
A removal between locating a mirror and recording its floor, then a new mirror; and a faulted mirror rebuilt (AnnouncedFloorLifetimeTest) |
per-owner map: the new mirror inherited floor 7, the rebuilt one 5 | floor on the instance: both start at 0 |
The real-mesh arm also asserts that the floor was in force (the workspace's "Mirror β¦ kept" line
fired for the later commits) and that the owner's fanned-out version met the announced one every time
(no MIRROR_BEHIND) β the second is the property the whole design rests on, and only a real owner
can show it. Negative controls: with the source change set aside both measurements go red as shown
in the "before" column, and with either refinement removed (Created evicting; proof-only
eviction) its own arm goes red.
What it means for #1174 and #2776
Each of those per-write hydrations had to complete inside the writer's 30 s
base-state bound while the mirror it replaced was being torn down on the
same owner address, and a node like {user}/_UserActivity/{user} β written on every cold page load,
at version 6498 in production β paid that thousands of times. That is the only mechanism in the
code that predicts #1174's concentration on hot paths. The census from #4342 is what will confirm it
on a rolled replica: an occurrence after this fix would have to come from a mirror that was NOT
rebuilt per write. The Dropping StreamEndedEvent β¦ the target stream is gone lines of #2776 were the
same churn seen from the owner, and stop with it for every write-driven eviction.
The #3432 retention (The Evicted-Stream Retention) is untouched as a mechanism, but it is now reached only by the events in the table that still evict β rare ones β rather than once per write.
What #2776's other half was
Its visible failure was a write that reached no verdict in 31 s. That silence has a separate,
already-fixed cause β #2882: the write
registered its response subject after posting, so a warm owner's sub-millisecond ack could land
before anything was listening for it, and the trail could only ever say REGISTERED_AFTER_POST
(exactly what that trace says). Fixed in 4d231170a.
See also
- The MeshNode Stream Cache β the layer above, and the write path whose base freshness this page is really about.
- Debugging Message Flow β how to read a stalled hub trace without mistaking a bound for a mechanism.