Repository navigation
fix(#1910): a Conflict retry must rebase on state the owner has not refused - #1961
Merged
Merged
Conversation
…efused
ActivityTracking dropped a user's navigation record:
MeshNode Conflict at 'rbuergi/_UserActivity/rbuergi': cross-hub write
refused: 3 field(s) changed on the owner since the writer's base and
nothing was applied — re-read and re-apply
The issue proposed "adding a retry-with-refresh loop, or switching to an
append-only pattern". A retry-with-refresh loop was already there, and it
already re-ran the update lambda rather than re-posting the patch — the
#1814 defect, fixed. What it did NOT do is guarantee the re-run sees
anything different.
UpdateRemote rebuilt each attempt from `mirror.Take(1)`: whatever this
hub's mirror holds at that instant. Immediately after a Conflict NACK that
is very often still the version the owner just refused. The lambda then
recomputes the same values from the same base, ExtractBaseValues emits the
same base, and the owner refuses identically — three attempts, three
identical patches, then a terminal MeshNodeStreamException and the
caller's write is gone. A retry whose input cannot change is not a retry;
it is #1814 one level down, with the base re-read from a source that had
not moved instead of the patch being re-sent.
Waiting for the newer state is sound rather than hopeful: a Conflict is
the owner stating it is AHEAD of the writer's base, because it committed
the write that beat us BEFORE it answered us. The state that makes the
re-attempt converge already exists and is in flight to this mirror. So the
re-attempt waits for exactly that fact — a version strictly greater than
the one refused — bounded at 5s, and falls back to the previous behaviour
(with a STALE_MIRROR warning) if it never arrives. It converges where the
old shape could not and degrades to the old shape where it cannot.
Only Conflict carries a stale base. OwnerDisposing / OwnerNotReady mean
the patch never reached a merge, so no newer version was minted and
waiting for one would burn the bound before every one of those retries;
they pass 0 and are untouched, as is every first attempt.
This also relieves the second failure mode without touching the retry
budget: a loser no longer re-issues at the same instant as every other
loser, it issues after the state that beat it, so a burst of concurrent
writers serialises into a chain instead of re-stampeding.
Closed in the same change: the filter introduces a completion the
unfiltered shape could not produce. A mirror that ENDS while holding only
the refused version would complete the source empty, the write's observer
would never fire, and the caller would wait on a pipeline that had already
finished. That now errors, naming the write as not landed.
Measured, and worth recording: 40 concurrent TrackActivityRequests for one
path land all 40 increments in a monolith test both with and without this
change. The end-to-end failure needs the production interleaving (the
cache queue advancing on UpdateRemote's optimistic 2s emit, slower mirror
propagation), which is why the incident reads "1 occurrence on 1 pod" —
so the regression is pinned deterministically at the seam, with the old
shape kept beside it as a negative control.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens cross-hub MeshNodeStreamHandle.UpdateRemote optimistic-concurrency retries so a Conflict re-attempt rebases on mirror state the owner has not already refused, preventing “retry that can’t change its input” write drops (issue #1910).
Changes:
- Add
MeshNodeStreamHandle.RebaseSource(...)and thread the refused base version throughUpdateRemote(...)soConflictretries wait (bounded) for a strictly newer mirror version before re-running the update lambda. - Add deterministic tests (
TestScheduler+ReplaySubjectmirror seam) covering both the new rebase behavior and the pre-fix negative control. - Add a WhatsNew entry and add
Microsoft.Reactive.Testingto the Query test project.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| test/MeshWeaver.Query.Test/MeshWeaver.Query.Test.csproj | Adds Microsoft.Reactive.Testing dependency used by the new deterministic conflict-rebase tests. |
| test/MeshWeaver.Query.Test/ActivityWriteSurvivesConflictTest.cs | New deterministic test suite validating conflict retry rebasing and edge cases (stale mirror bound, mirror completion). |
| src/MeshWeaver.Mesh.Contract/MeshNodeStreamExtensions.cs | Implements bounded rebase-on-newer-mirror logic for Conflict retries and threads refused base version through UpdateRemote. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-21-activity-survives-a-write-collision.md | Adds release note describing the user-visible effect of the conflict retry behavior change. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
This was referenced Sep 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1910.
The issue's remedy was already implemented — that is the finding
#1910 suggests "adding a retry-with-refresh loop for concurrency conflicts, or switching to an append-only/event-sourced pattern". The loop exists.
UpdateRemotere-enqueues onConflict(bounded at 2), and it re-runs the update lambda rather than re-posting the precomputed patch — the #1814 defect, already fixed.What it did not do is guarantee the re-run sees anything different.
The mirror is read at the instant the NACK arrives, which is exactly when it is most likely to still hold the version that was just refused. A retry whose input cannot change is not a retry — it is #1814 one level down: there the patch was re-sent, here the base is re-read from a source that had not moved.
(The "3 field(s)" in the incident is the shape's fingerprint: the diff of a
UserActivityfold iscontent.accessCount,content.lastAccessedAt, and thelastModifiedstampUpdateRemoteadds — three leaves, all refused.)The fix
A
Conflictis the owner stating it is ahead of the writer's base: it minted a version for the write that beat us before it answered us. So the state that makes the re-attempt converge already exists and is in flight to this mirror. The re-attempt now waits for exactly that fact — a version strictly greater than the one refused — and rebases on it. That IS the "re-read and re-apply" the refusal asks for.MeshNodeStreamHandle.RebaseSource— static, with the mirror and the scheduler as seams:OwnerDisposing/OwnerNotReadymean the patch never reached a merge, so no newer version was minted; they pass0and are untouched, as is every first attempt (no filter, no timer, no second subscription on the ordinary write path).STALE_MIRROR. It converges where the old shape could not and degrades to the old shape where it cannot, so it cannot be worse.It also relieves the other way that budget gets spent: a loser no longer re-issues at the same instant as every other loser, it issues after the state that beat it, so a burst of concurrent writers serialises into a chain instead of re-stampeding.
Closed in the same change: filtering introduces a completion the unfiltered shape could not produce. A mirror that ends (its hub torn down) while holding only the refused version would complete the source empty — the write's observer never fires and the caller waits on a pipeline that has already finished. That now errors, naming the write as not landed.
Tests
6 deterministic tests in
ActivityWriteSurvivesConflictTest—TestScheduler+ aReplaySubjectmirror, no hub, no cluster, no sleep:mirror.Take(1)shape, written out — it yields the very version the owner refused, so the regression above cannot go vacuous;Non-vacuity: with
RebaseSourcereverted tomirror.Take(1), 2 fail —Expected collection to be empty because re-running the lambda against the very version the owner refused …, but found 1 item(s)andExpected collection to be empty because still inside the bound, but found 1 item(s).What I could not reproduce, and why that matters
40 concurrent
TrackActivityRequests for one path land all 40 increments in a monolith test, both with and without this change (3 runs each). In-process the cache's per-path serial queue plus sub-millisecond mirror propagation means the retry's re-read is fresh anyway. The end-to-end loss needs the production interleaving — the write queue advancing onUpdateRemote's optimistic 2s emit while a patch is still in flight, and a mirror that has not caught up — which is why the incident reads "1 occurrence on 1 pod for 1 user" rather than as a reproducible failure.So the regression is pinned at the seam, with the old shape kept beside it as a control, rather than with a concurrency test that would be green on
mainby luck and flaky forever after.🤖 Generated with Claude Code