Repository navigation
fix: the resync give-up must not count an ack that overtook its own answer (#3058) - #3059
Conversation
…nswer Fixes #3058 SubscribeAck was posted from HandleSubscribeRequest the instant a re-subscribe arrived, while the re-assert answering it only QUEUES an UpdateStreamRequest on the stream's action block. So the acknowledgement raced ahead of its own answer, down a different path (a direct hub response) from the one the Full takes (the stream's outbound forwarding subscription). SynchronizationStream.RequestFreshSnapshot reads an ack as "the owner answered and nothing followed" and faults the mirror past MaxUnansweredResyncs of them. With the ack arriving first, that sentence was true of every ask the moment it was made, so the counter measured the owner's QUEUE DEPTH, not non-convergence: during a bulk install the re-assert waits behind the burst's own frames, each of those frames re-proves the mirror has no base and earns a fresh re-ask, and three acks landed inside 9 ms (05:14:07.039 -> .043 -> .048, run 33593426491) while the first, entirely healthy Full was still queued. Mirrors faulted mid-install and the samples content gate went red on roughly a third of all runs, on main. The ack now follows the frame it acknowledges. CreateSynchronizationStream owns it and, on the re-assert path, posts it from Update(..., applied:) — the callback that runs in the same turn right after SetCurrent, by which point Store.OnNext has already driven the forwarding subscription and handed the Full to hub.Post. Ack and frame leave through the same outbound queue, in that order, so an ack with no snapshot behind it means what the exception says it means. A fresh stream still acks immediately and the asymmetry is deliberate: that subscribe has no answer to overtake, its first Full comes out of the reduce's own hydration (IO-bound, unbounded), and waiting would hold the subscriber's pending callback across it — risking the very RequestTimeout the ack exists to prevent. It cannot produce the miscount either: RegisterClientSubscription makes the next re-ask a re-assert. Every path acks exactly once, failure arms included. Not a threshold bump: MaxUnansweredResyncs is unchanged at 3, and #1384's termination is intact — StreamResyncGivesUpTest passes UNCHANGED. Tests - StreamResyncAnswerInFlightTest (new): the answer is HELD, not destroyed — the re-assert Full and the ack the owner posts once that frame is on its way, together, because on a real leg they leave through the same queue. The burst's own patches keep flowing. The mirror must ask ONCE, never fault, and converge when the answer is released. On the pre-fix tree the ack escapes the hold and the mirror asks per frame: fails deterministically in 245 ms at "Expected value to be 1 ... but found 2", with "Resync has not converged ... attempt 2" in the log. - StreamResyncGivesUpTest / StreamResyncConvergenceTest: unchanged behaviour; their comments asserting the OLD ack ordering are corrected, since both tests' determinism arguments rest on it. Measured, both arms on this machine, MeshWeaver.PluginTester.Test (14 runs each, 6 plain + 8 under DOTNET_PROCESSOR_COUNT=4): before 1/14 failed (GateInstallsUpstreamPackagesTest, StreamNotConverging on Course/Card; 2 StreamNotConverging log lines) after 0/14 failed (0 StreamNotConverging log lines) Docs: DataSyncAndCrdt sections 6 and 9 carried the ordering claim that was wrong ("the owner ACKs before its re-assert reaches the wire") and the reasoning built on it. Corrected, with the general lesson recorded: a give-up counter must count outstanding FAILURES, never attempts — the two are the same number only when each attempt has had its answer's round trip, and a bound that can be incremented by something which has not yet had a chance to succeed measures LOAD, so it fires hardest exactly when the system is busiest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa
There was a problem hiding this comment.
🟡 Changes recommended
The re-assert failure path in JsonSynchronizationStream.CreateSynchronizationStream currently sends a SubscribeAck even when no answering Full was enqueued, which contradicts the new “ack implies snapshot sent” contract and can reintroduce miscounting.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes a synchronization resync give-up false positive where a SubscribeAck could overtake the Full snapshot it was implicitly acknowledging, causing mirrors to “give up” based on the owner’s queue depth rather than genuine non-convergence.
Changes:
- Move
SubscribeAckemission fromDataExtensions.HandleSubscribeRequestintoJsonSynchronizationStream.CreateSynchronizationStream, ensuring the ack is queued after the re-assertFull(for the re-subscribe path). - Add a deterministic regression test (
StreamResyncAnswerInFlightTest) covering the “answer is delayed/held but in-flight” case to ensure the mirror re-asks only once and does not fault. - Update documentation and test rationales to reflect the corrected ack/answer ordering and the “count failures, not attempts” principle.
File summaries
| File | Description |
|---|---|
| test/MeshWeaver.Data.Test/StreamResyncGivesUpTest.cs | Updates test commentary to match the new ack-after-frame ordering. |
| test/MeshWeaver.Data.Test/StreamResyncConvergenceTest.cs | Updates test commentary to match the new ack-after-frame ordering. |
| test/MeshWeaver.Data.Test/StreamResyncAnswerInFlightTest.cs | Adds regression test for “answer in flight” scenario to prevent false give-up under burst. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-02-a-busy-install-no-longer-breaks-live-views.md | Adds “What’s New” entry describing the user-visible fix. |
| src/MeshWeaver.Documentation/Data/Architecture/DataSyncAndCrdt.md | Documents the ordering rule and why the give-up counter must count failures, not attempts. |
| src/MeshWeaver.Data/Serialization/SynchronizationStream.cs | Updates comments in resync logic to reflect ack semantics as evidence post-fix. |
| src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs | Implements deferred ack on re-assert path and immediate ack on fresh stream path. |
| src/MeshWeaver.Data/DataExtensions.cs | Removes immediate ack posting from HandleSubscribeRequest, delegating it to the stream implementation. |
Review details
- Files reviewed: 8/8 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.
Review found the new invariant violated one branch over, and following it to the end found the branch that was still reddening the gate. 1. The failure arm of the re-assert now REFUSES instead of acking. It ran when nothing had been sent (a disposed stream, an apply error), so acking there fed an OWNER-side failure into the mirror's acknowledged-and-unanswered count — a channel built to measure LEG loss. It now posts a DeliveryFailure, which SynchronizationStream.ResyncRefused already classifies: ObjectDisposed (the stream or its hub tearing down, incl. HubDisposingException) is ShuttingDown and therefore TRANSIENT — ridden out per #2745, gate re-opened, NOT counted; anything else is terminal, so the mirror faults and re-establishes instead of waiting. ResyncRefused releases the gate unconditionally as its first statement, so the verdict is as prompt as the ack it replaces. Ack and Refuse now share ONE latch (ClaimTheAnswer), so exactly one verdict leaves per subscribe whichever arms are reached. 2. THE BRANCH THAT STILL REDDENED THE GATE. BuildReassertFrame returns null when the stream has nothing to assert, and that branch acked immediately — with nothing sent, and REPEATEDLY. During an install the owner is a NodeType hub that cannot emit until its source compiles, so it sits Current-less for the whole compile window and every re-ask in it was answered "acknowledged" with no Full behind it. Three of those trip MaxUnansweredResyncs on a stream whose owner is merely busy. That is what survived the ordering fix, and the measurement says so: the fault still fired, still named 'Course/Card' — a NodeType — and still fired in the [checks] phase, i.e. at NodeType activation. With the ack now provably ordered after any frame, an ack with no Full can only come from a path that sends none, and this is the only such path that can repeat (the fresh-stream path registers its subscription, so the next re-ask is a re-assert). Such a re-assert now defers its answer to the stream's FIRST emission — the moment a Full really does go out. Nothing polls and nothing retries: one Rx one-shot, disposed by Take(1) when it fires and by the stream when it does not. If the state never arrives, the subscriber's own request/response terminal fires and ResyncRefused re-opens the gate WITHOUT counting the ask, which is the correct reading — an owner with nothing to send has not failed to answer, it has nothing to answer with. Measured, MeshWeaver.PluginTester.Test, full project per run, this machine: origin/main 1 StreamNotConverging in 14 runs ordering fix only (1st commit) 2 StreamNotConverging in 14 runs both doors closed (this commit) 0 StreamNotConverging in 20 runs, 0 failures The first commit alone did NOT move the gate flake — its own arm re-measured at 2/14 — because the counter had a second, repeating source of false evidence. Only both together close it. StreamResyncAnswerInFlightTest additionally asserts the invariant as an ABSENCE: zero acks may go out ahead of their answer during a resync, not merely "some ack rode behind one". Full Data.Test 422/422 and Layout.Test 443/443 in Release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa
The hypothesis held — and there were two doors, not one
SubscribeAckwas posted fromDataExtensions.HandleSubscribeRequestthe instant a re-subscribe arrived. But thealreadyServingbranch that answers it only queues anUpdateStreamRequeston the stream's action block — so the acknowledgement raced ahead of its own answer, down a different path (a direct hub response) from the one the Full takes (the stream's outbound forwarding subscription). The two are unordered, and the ack always wins.SynchronizationStream.RequestFreshSnapshotreads an ack as "the owner answered and nothing followed" and faults the mirror pastMaxUnansweredResyncsof them. With the ack arriving first, that sentence was true of every ask the moment it was made, so the counter measured the owner's queue depth:Three asks, one outstanding request, one perfectly healthy Full still in the queue.
But fixing the ordering alone did not close the gate flake — its own control arm re-measured at 2/14, no better than main. Because once an ack means "I sent it", any path that acks without sending is the same defect, and there were two more:
BuildReassertFramereturnednullThe second one is what was actually reddening the gate. During an install the owner is a NodeType hub that cannot emit until its source compiles, so it sits
Current-less for the whole compile window and every re-ask inside it was answered "acknowledged" with no Full behind it. Three of those trip the bound on a stream whose owner is merely busy. The measurement names it: after the ordering fix the fault still fired, still namedCourse/Card— a NodeType — and still fired in the[checks]phase, i.e. at NodeType activation.The fix
An ack means the snapshot is on the wire, on every path — and every path still answers exactly once.
CreateSynchronizationStreamowns the ack and, on the re-assert path, posts it fromUpdate(…, applied:)— the callback that runs in the same turn right afterSetCurrent, by which pointStore.OnNexthas already driven the forwarding subscription and handed the Full tohub.Post.DeliveryFailure, whichResyncRefusedalready classifies one-policy-per-ErrorType:ObjectDisposedException(incl.HubDisposingException) ⇒ShuttingDown⇒ transient, ridden out per fix(routing): pod-hub claim gets a derived lifetime; PodHubNotHere is answered, not published #2745 and not counted; anything else ⇒ terminal, so the mirror faults and re-establishes.ResyncRefusedreleases the gate as its first, unconditional statement, so the verdict is as prompt as the ack it replaces.ResyncRefusedre-opens the gate without counting the ask — an owner with nothing to send has not failed to answer, it has nothing to answer with.AckandRefuseshare one compare-and-set, so exactly one verdict leaves per subscribe however the arms interleave.A fresh stream still acks immediately, deliberately: it has no answer to overtake, its first Full comes out of the reduce's own hydration (IO-bound, unbounded), waiting would hold the subscriber's callback across it — and it cannot repeat, because
RegisterClientSubscriptionmakes the next re-ask a re-assert.Nothing waits on a timer.
MaxUnansweredResyncsis unchanged at 3.#1384's termination is intact — the strongest available evidence
StreamResyncGivesUpTestpasses unchanged: same drive loop, same bound, sameStreamNotConvergingException. An owner that genuinely never answers still faults after three acknowledged-and-lost snapshots, because an ack now means "I sent it".Control arms — matched 20 runs each
MeshWeaver.PluginTester.Test, full project per run, same machine, alternating onlysrc/:StreamNotConvergingorigin/mainThe control's 25% matches the ~1-in-3 the issue measured. The middle row is the load-bearing one: the first commit alone did not move it — which is why the second exists.
Tests
StreamResyncAnswerInFlightTest(new)StreamResyncGivesUpTestThe new test fails deterministically on unmodified
src(full project: 421 pass, 1 fail) in 245 ms:StreamResyncConvergenceTestandStreamResyncGivesUpTestboth carried comments asserting the old ack ordering, and each test's determinism argument rests on it — those rationales are corrected in the same change.Also green locally, Release:
MeshWeaver.Data.Test422/422,MeshWeaver.Layout.Test443/443,MeshWeaver.Documentation.Test176/176 (link integrity + the timeout-literal ratchet), and the full 61-project solution at-c Release -warnaserrorwith0 Warning(s), 0 Error(s).Docs
DataSyncAndCrdt§6 and §9 carried the ordering claim that was wrong — "Because the owner ACKs before its re-assert reaches the wire, the common case costs at most one redundant round trip" — and the "Why 3" reasoning built on it. Corrected, plus the general lesson recorded:Plus a What's New entry (
Category: Fix).Scope
This is question 2 of the issue ("why does an acknowledged re-ask produce no base snapshot"). Question 1 — why a Patch frame is lost at all, 8–15 per samples-gate run on
main— is untouched and still open: the give-up was the messenger. That frame loss no longer reds the gate, but it is still there.Fixes #3058
🤖 Generated with Claude Code