Skip to content

A view catching up got handed a snapshot older than what it had already applied - #975

Merged
rbuergi merged 4 commits into
mainfrom
fix/945-resubscribe-reassert-rollback
Aug 8, 2026
Merged

rbuergi merged 4 commits into
mainfrom
fix/945-resubscribe-reassert-rollback

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #945.

This is a live data-loss bug, not a test flake. The failing test is the messenger. Under
concurrent writes a mirror can end up permanently short of the owner — which in the product
means a user's open, data-bound view silently drops entries (a chat inbox missing messages, a
list missing rows) and stays wrong until the view is reopened. It reproduces on demand off CI
entirely; the CI redness is a symptom, and fixing the test would have hidden a real defect.

CrossHubPatchAtomicityTest.ConcurrentCrossHubPatches_DoNotDropAQueuedMessage has been
reddening unrelated PRs (#927, #930, #935, #959, #968) with a handful of the 288 concurrent
cross-hub adds missing. The test's failure message blamed a non-atomic owner-side merge.
It is not that — the owner is correct in every failing run, and the loss is on the READ
side.

What was measured, not inferred

Reproduced locally 12+ times by running the test under CPU saturation (10 concurrent
DOTNET_PROCESSOR_COUNT=2 test processes — the CI shard condition). Three diagnostics were
added to the test itself, so no log configuration is involved and they survive to the trx:

Diagnostic Result in every failing run
Write errors 0 — all 288 writes completed successfully
Owner-truth probe (fresh client → owner's authoritative Current) version=289 present=288 missing=[] — the owner kept everything
Mirror emission sequence REGRESSIONS (1): [245@v246 -> 234@v235]

The shared-cache mirror was at 245 entries / node version 246, went backward to 234 /
v235, then resumed applying patches normally (+1 per frame) to the end — permanently 11
entries short. The test reported exactly 11 missing.

Root cause

A write burst makes the mesh change feed announce node versions faster than a loaded mirror
applies them, so the mirror's version-gated Resubscribe fires. The owner answers on the
alreadyServing path in JsonSynchronizationStream.CreateSynchronizationStream by
re-asserting its snapshot onto the stream it is already serving. That re-assert had two
defects in three lines:

  1. Stale capture. Current was bound outside the stream's update turn and written back
    blindly (_ => snapshot). Update posts an UpdateStreamRequest; the lambda runs later
    on the action block, so every frame that committed in between was overwritten — the stream
    moved backward. The owner's outbound JSON cursor is then re-based on the rolled-back state,
    so the erased entries are never re-sent. Later patches keep applying cleanly on top, so
    the mirror tracks the owner forever at a constant deficit with no error anywhere.
  2. Wrong clock. The frame was stamped alreadyServing.Hub.Version — the per-subscriber
    sync hub's message counter — while every content frame on that stream carries the
    owner's data-source clock (ApplyChanges, carried through the reduce). Two unrelated
    counters on one stream is the exact defect StandardReducers already documents ("CARRY the
    base version … NEVER stream.Hub.Version"). It is what lets a stale Full slip past the
    receive-side monotonicity guard that exists to drop precisely that; when the sync clock
    instead runs ahead, every later content patch is dropped as "stale" and the mirror freezes.

The fix

BuildReassertFrame reads the snapshot in-turn and carries the version that state already
has. A re-assert is still a full authoritative snapshot, so an orphaned mirror re-converges (a
Full always lands, and a mirror that resubscribed is by definition not ahead) — but it can no
longer erase newer state or poison the mirror's version.

What this is not

Not an array-replacement bug. Thread.PendingUserMessages is an ImmutableDictionary, so
RFC 7396 merges it key-by-key, and the owner-truth probe proves the merge kept all 288 entries
in every failing run. (MeshNodePatchMerge.MergeArray already three-way-merges genuine arrays
by element identity for the same reason.)

Evidence the fix works

Same saturation harness, same machine:

  • before: 7 of 40 runs failed, every failure a permanent deficit (11 / 18 / 30 / 54 / 70
    entries) with the mirror never converging.
  • after: 39 of 40 passed. The single failure has REGRESSIONS (0) and the mirror reaching
    288@v289 — the owner's exact state — just after the 30 s settle bound. That is late
    delivery on a box running ten meshes at once, not a lost write, and the test now says so
    itself via converged=YES at t…ms.

Tests

  • ResubscribeReassertFrameTest (new, deterministic) pins both halves: the frame carries
    the state read at build time, at that state's own version. Negative control — reverting the
    helper to stream.Hub.Version fails it with "Expected 999999 … but found 4".
  • CrossHubPatchAtomicityTest keeps its assertion unchanged and unweakened — no widened
    timeout, no retry, no reduced concurrency. Its failure message now reports write errors,
    owner truth, and the mirror's emission sequence, so a future failure names which layer lost
    the data instead of asserting a theory.

Scope note

This is the cross-hub-patch defect only. It does not speak to the separate shard-4 instability
on #968 (SourceDocumentDataLoadingTest leaked Observe subscriptions past the Quiescing
budget, and one byte-identical run went green) — that is a different failure mode and is not
addressed here.

🤖 Generated with Claude Code

…dy applied

Fixes #945. `CrossHubPatchAtomicityTest.ConcurrentCrossHubPatches_DoNotDropAQueuedMessage`
has been reddening unrelated PRs on loaded CI shards with a handful of the 288 concurrent
cross-hub adds missing. The test's own message blamed a non-atomic owner-side merge. It is
not that: the owner is correct, and the loss is on the READ side.

WHAT THE INSTRUMENTATION SHOWED (not inferred — measured on a local repro)

Reproduced 12+ times by running the test under CPU saturation (10 concurrent
2-core-emulated test processes). Three diagnostics, added to the test itself so no log
configuration is involved:

  * WRITE ERRORS (0) — every one of the 288 writes completed successfully. Nothing failed.
  * OWNER-TRUTH probe (a fresh client opening its own subscription, so it receives the
    owner's authoritative Current): `version=289 present=288 missing=[]`, in EVERY failing
    run. The owner-side merge kept all 288 entries. It is atomic and it is not the bug.
  * MIRROR SEQUENCE — every emission the shared-cache mirror delivered. The failing run
    shows the mirror going BACKWARD:

        REGRESSIONS (1): [245@v246 -> 234@v235]
        tail=[272@v284, 273@v285, ..., 277@v289]

    The mirror was at 245 entries / node version 246, then dropped to 234 / v235, then
    resumed applying patches normally (+1 per frame) to the end — permanently 11 entries
    short. The test reported exactly 11 missing.

ROOT CAUSE

A write burst makes the mesh change feed announce node versions faster than a loaded
mirror applies them, so the mirror's version-gated `Resubscribe` fires. The owner answers
on the `alreadyServing` path in `JsonSynchronizationStream.CreateSynchronizationStream` by
re-asserting its snapshot onto the stream it is already serving. That re-assert had two
defects in three lines:

  1. STALE CAPTURE. `Current` was bound OUTSIDE the stream's update turn and written back
     blindly (`_ => snapshot`). `Update` posts an UpdateStreamRequest; the lambda runs
     later on the action block, so every frame that committed in between was overwritten —
     the stream moved backward. Because the owner's outbound JSON cursor is then re-based
     on the rolled-back state, the erased entries are never re-sent. Later patches keep
     applying cleanly on top, so the mirror tracks the owner forever at a CONSTANT deficit
     with no error anywhere.
  2. WRONG CLOCK. The frame was stamped `alreadyServing.Hub.Version` — the per-subscriber
     SYNC hub's message counter — while every content frame on that stream carries the
     OWNER's data-source clock (`ApplyChanges`, carried through the reduce). Two unrelated
     counters on one stream is the exact defect `StandardReducers` already documents
     ("CARRY the base version ... NEVER stream.Hub.Version"). It is what lets a stale Full
     slip past the receive-side monotonicity guard that exists to drop precisely that; when
     the sync clock instead runs ahead, every later content patch is dropped as "stale" and
     the mirror freezes.

THE FIX

`BuildReassertFrame` reads the snapshot IN-TURN and carries the version that state already
has. A re-assert is still a full authoritative snapshot, so an orphaned mirror re-converges
(a Full always lands, and a mirror that resubscribed is by definition not ahead) — but it
can no longer erase newer state or poison the mirror's version.

WHAT THIS IS NOT

Not an array-replacement bug. `Thread.PendingUserMessages` is an `ImmutableDictionary`, so
RFC 7396 merges it key-by-key; and the owner-truth probe proves the merge kept all 288
entries in every failing run. (`MeshNodePatchMerge.MergeArray` already three-way-merges
genuine arrays by element identity for the same reason.)

EVIDENCE THE FIX WORKS

Same saturation harness, same machine:
  * before: 7 of 40 runs failed, every failure a permanent deficit (11/18/30/54/70 entries)
    with the mirror never converging.
  * after: 39 of 40 passed. The single failure has REGRESSIONS (0) and the mirror reaching
    288@v289 — the owner's exact state — just after the 30s settle bound. That is late
    delivery on a box running ten meshes at once, not a lost write; the test now says so
    itself via `converged=YES at t...ms`.

TESTS

  * `ResubscribeReassertFrameTest` (new, deterministic) pins both halves: the frame carries
    the state read at build time, at that state's own version. Negative control: reverting
    the helper to `stream.Hub.Version` fails it with "Expected 999999 ... but found 4".
  * `CrossHubPatchAtomicityTest` keeps its assertion unchanged and unweakened; its failure
    message now reports write errors, owner truth, and the mirror's emission sequence, so a
    future failure names which layer lost the data instead of asserting a theory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 8, 2026 22:44

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.

Pull request overview

Fixes a cross-hub synchronization defect where a resubscribing mirror could be served a “re-assert” frame built from a stale snapshot and/or stamped with the wrong version clock, allowing a mirror to regress and permanently fall behind under heavy write bursts.

Changes:

  • Build resubscribe re-assert frames in-turn from the stream’s current state and carry the state’s own version (not the per-subscriber sync-hub clock).
  • Add a deterministic unit test to pin the re-assert frame’s value + version semantics.
  • Enhance CrossHubPatchAtomicityTest failure diagnostics to distinguish write failures, owner truth, mirror regressions, and late convergence.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
test/MeshWeaver.Data.Test/ResubscribeReassertFrameTest.cs New deterministic test pinning resubscribe re-assert frame semantics (value freshness + version clock).
test/MeshWeaver.AI.Test/CrossHubPatchAtomicityTest.cs Adds instrumentation and richer failure diagnostics to localize loss to write/owner/mirror layers.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-08-live-views-no-longer-fall-behind.md What’s New entry describing the user-visible symptom and the fix at a high level.
src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs Implements BuildReassertFrame and uses it to re-assert state correctly for resubscribing subscribers.

💡 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 Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

685 tests  ±0   685 ✅ ±0   5m 12s ⏱️ +8s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 1d0d411. ± Comparison against base commit d058db2.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 710 tests  ±0   1 519 ✅ ±0   6m 7s ⏱️ -6s
   11 suites ±0     191 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 1d0d411. ± Comparison against base commit d058db2.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   5m 16s ⏱️ +17s
1 878 tests ±0  1 772 ✅ ±0  106 💤 ±0  0 ❌ ±0 
2 001 runs  +1  1 895 ✅ +1  106 💤 ±0  0 ❌ ±0 

Results for commit 1d0d411. ± Comparison against base commit d058db2.

♻️ This comment has been updated with latest results.

…h it exists for

Review finding on #975: the mirror-sequence watcher was disposed with a plain
`watch.Dispose()` after the try/catch, which `Assert.Fail(...)` throws straight past, and it
was subscribed with no OnError handler.

Both matter more than hygiene here, because the instrumentation IS this PR's deliverable —
the recorded sequence is what makes the regression legible, and it is read only when the
test fails:

  * `using var` — an undisposed subscription on the failure path leaves an Observe
    subscription pending into teardown. That is a live CI failure mode in this repo right
    now (SourceDocumentDataLoadingTest, "left Observe subscriptions pending past the
    Quiescing budget", shard 4); a diagnostic must not add to that pile.
  * explicit OnError — an unhandled stream fault would be rethrown on the producer's thread
    and could replace the diagnostics the failure message is built from, leaving the next
    reader exactly where the previous three investigations were. The fault is now captured
    and REPORTED ("MIRROR STREAM FAULTED: …") alongside the sequence recorded so far, so a
    faulted stream is itself a datum rather than a swallowed one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 877 tests  ±0   1 875 ✅ ±0   6m 1s ⏱️ +5s
   11 suites ±0       2 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 1d0d411. ± Comparison against base commit d058db2.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 194 tests  +1   1 194 ✅ +1   7m 40s ⏱️ -30s
   11 suites ±0       0 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 1d0d411. ± Comparison against base commit d058db2.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 216 tests  ±0   1 209 ✅ ±0   7m 2s ⏱️ -9s
   11 suites ±0       7 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 1d0d411. ± Comparison against base commit d058db2.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   65 files  ±0     65 suites  ±0   37m 21s ⏱️ -15s
8 560 tests +1  8 254 ✅ +1  306 💤 ±0  0 ❌ ±0 
8 683 runs  +2  8 377 ✅ +2  306 💤 ±0  0 ❌ ±0 

Results for commit 1d0d411. ± Comparison against base commit d058db2.

♻️ This comment has been updated with latest results.

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.

CI: CrossHubPatchAtomicityTest drops concurrent cross-hub adds on loaded shards — the ack path is bounded, the merge is not the suspect

2 participants