Skip to content

fix(data): a lease and a mirror reclaim are one atomic decision (#5087) - #5640

Merged
meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/5087-reclaim-lease-atomic
Sep 24, 2026
Merged

meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/5087-reclaim-lease-atomic

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Refs #5087

The defect

Workspace.ReclaimIfUnheld READ the lease count, then removed the lease entry unconditionally and disposed the evicted mirror. A writer that resolved the mirror before its eviction could take its lease between the read and the dispose: its count went 0 → 1 after the reclaim had read 0, its post-lease IsUsable re-check still passed, and the reclaim then dropped its entry and disposed the mirror under it. Its base read completed empty — RequireBaseState's "Update aborted: this hub's mirror ended without ever carrying the node's state", the terminal on #5087.

The fix

Lease and reclaim now decide over the same dictionary slot by compare-exchange:

  • the reclaim claims the stream with TryUpdate(stream, claim, 0), where claim is a fresh negative token per reclaim — only an evicted stream, only at zero holders;
  • a lease is taken only by compare-exchange from a non-negative count, and is refused against the sentinel → AcquireRemoteStreamUnchecked resolves again and builds a fresh mirror (the reclaimed one is out of the cache);
  • after taking the parking entry the reclaim re-reads the slot: its own token → dispose; anything else → a DetachRemoteStreams + ParkRemoteStreams handover superseded the claim (review finding), so it restores the parking entry and leaves the stream alone;
  • the token is removed only after Dispose; a claim that loses _evictedRemoteStreams.TryRemove to another owner hands the slot back.

No lock, no gate, no bound changed. Unleased streams keep today's conservative parking (the claim fails on a missing entry), so #3432's behaviour is unchanged.

Evidence

test/MeshWeaver.Data.Test/ReclaimLeaseAtomicityTest.cs — deterministic, via the ReclaimDecided seam (runs when the reclaim has decided "unheld"; writer B leases there, same thread):

  • negative control on the pre-fix logic: failed — DIAG leaseBGranted=True mirrorUsable=False
  • handover arm (ADetachAndRePark_InsideTheClaim_SupersedesTheReclaim, from the review): failed on the shared-sentinel version (re-parked mirror disposed), passes with the token
  • after the fix: 3/3 pass (race, handover, and a control arm proving a lease taken BEFORE the last release still keeps the mirror alive until released)
  • related classes still green locally: Evicted* 3/3, *Mirror* 17/17, AnnouncedFloor* 2/2 (Data.Test), WriteBaseStateTotality* 3/3 (Graph.Test)
  • dotnet build -c Release -warnaserror: MeshWeaver.Data, MeshWeaver.Data.Test, MeshWeaver.Documentation — 0 warnings, 0 errors

Not established

The same message has two other producers (a ReplaySubject that never carried a value; the write path's Where(Value is not null)), byte-identical in the log. This closes the lease/reclaim producer only, so it stays Refs, not Fixes: #5087's two production samples cannot be attributed to this producer from the log alone.

Docs: Doc/Architecture/EvictedStreamRetention §10 (new), WriteVerdictTotality table row.

Pairs-with: none — only internal members of MeshWeaver.Data change (LeaseRemoteStream → TryLeaseRemoteStream, new internal TryLeaseResolved/ReclaimClaimed); no public surface.

Recycle: none needed — the fix is in the portal binary; takes effect on the next roll.

🤖 Generated with Claude Code

ReclaimIfUnheld read the lease count, then removed the entry and disposed.
A writer that resolved the mirror before its eviction could lease in that
window, pass its IsUsable re-check, and have the mirror disposed under it —
its base read completed empty ("this hub's mirror ended without ever
carrying the node's state").

The reclaim now claims the stream by compare-exchanging its lease count
0 -> -1 (ReclaimingSentinel); a lease is taken only by compare-exchange from
a non-negative count and is refused against the sentinel, so the acquirer
resolves a fresh mirror. The sentinel is dropped only after Dispose.

ReclaimLeaseAtomicityTest drives the window deterministically through the
ReclaimDecided seam: on the old code B's lease was granted on a disposed
mirror (leaseBGranted=True mirrorUsable=False); now it is refused.

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

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.

Copilot review overview

🟡 Changes recommended

Unresolved moderate and critical lifecycle/concurrency findings remain in Workspace.cs.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity

Open (1)
What changed in this PR

Fixes a remote-stream lease/reclaim race using atomic sentinel-based coordination.

Changes:

  • Updates lease and reclaim handling in Workspace.
  • Adds deterministic race and control tests.
  • Documents lifecycle behavior and remaining failure producers.
File Summary
test/​MeshWeaver.Data.Test/​ReclaimLeaseAtomicityTest.cs Adds race and control tests.
src/​MeshWeaver.Documentation/​Data/​Architecture/​WriteVerdictTotality.md Updates write-verdict documentation.
src/​MeshWeaver.Documentation/​Data/​Architecture/​EvictedStreamRetention.md Documents the reclaim protocol.
src/​MeshWeaver.Data/​Workspace.cs Implements atomic lease/reclaim coordination.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/MeshWeaver.Data/Workspace.cs Outdated
Comment on lines +654 to +660
if (!_remoteStreamLeases.TryUpdate(stream, ReclaimingSentinel, 0))
return;
if (!_evictedRemoteStreams.TryRemove(stream, out _))
{
// Another edge (DetachRemoteStreams, Dispose) took ownership after the claim; give the
// slot back so the bookkeeping does not keep refusing a stream we no longer reclaim.
_remoteStreamLeases.TryUpdate(stream, 0, ReclaimingSentinel);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and reproduced. Fixed in 1f90603cb3.

With one shared -1 the reclaim could not tell the re-parked ownership from its own claim: DetachRemoteStreams dropped the sentinel, ParkRemoteStreams re-added the parking entry, and the reclaim's TryRemove then took that new entry and disposed the stream.

The fix:

  • Per-claim token. Each claim is now a fresh negative token (-Interlocked.Increment(ref _reclaimClaims)).
  • Re-read after taking the entry. After it takes the parking entry, the reclaim re-reads the lease slot. Its own token means no handover happened, so it disposes. Any other value (absent, 0, a count, another token) means the claim was superseded, so it restores _evictedRemoteStreams[stream] and returns.
  • No gap before the dispose. Once the re-read passes, the stream is out of _evictedRemoteStreams. DetachRemoteStreams can no longer reach it before the dispose.

Test. I added ReclaimLeaseAtomicityTest.ADetachAndRePark_InsideTheClaim_SupersedesTheReclaim for exactly this interleaving. The seam (ReclaimClaimed) now runs right after the claim, and the test runs DetachRemoteStreams + ParkRemoteStreams there. It failed on the sentinel version: the re-parked mirror was disposed. It passes with the token. The other arms (the lease race and the control) and the related Evicted*/*Mirror* classes are still green locally. EvictedStreamRetention §10 now documents the token.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

429 tests   - 14   429 ✅  - 14   1m 4s ⏱️ -6s
  2 suites  -  1     0 💤 ± 0 
  2 files    -  1     0 ❌ ± 0 

Results for commit 1f90603. ± Comparison against base commit bf5ac85.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

550 tests   - 1 094   550 ✅  - 1 094   1m 11s ⏱️ - 1m 55s
  1 suites  -     1     0 💤 ±    0 
  1 files    -     1     0 ❌ ±    0 

Results for commit 1f90603. ± Comparison against base commit bf5ac85.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

  1 files  ±0    1 suites  ±0   3m 8s ⏱️ +5s
332 tests ±0  332 ✅ ±0  0 💤 ±0  0 ❌ ±0 
336 runs  ±0  336 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 1f90603. ± Comparison against base commit bf5ac85.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

    2 files   -   1      2 suites   - 1   6m 23s ⏱️ +54s
2 604 tests +603  2 604 ✅ +603  0 💤 ±0  0 ❌ ±0 
2 605 runs  +603  2 605 ✅ +603  0 💤 ±0  0 ❌ ±0 

Results for commit 1f90603. ± Comparison against base commit bf5ac85.

♻️ This comment has been updated with latest results.

…ark supersedes it (#5087)

Review finding on #5640: with one shared -1 sentinel, DetachRemoteStreams
(which drops the lease entry) followed by ParkRemoteStreams inside the claim
left the reclaim unable to tell the re-parked ownership from its own claim,
so it took the new parking entry and disposed a stream a consumer had just
re-attached to.

The claim is now a fresh negative token per reclaim. After taking the
parking entry the reclaim re-reads the slot: its own token means no
handover; anything else means the claim was superseded, and it restores the
parking entry and leaves the stream alone. The seam moves to right after
the claim (ReclaimClaimed) and a new arm drives the handover; it failed on
the sentinel version and passes now.

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

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 602 tests  +870   1 602 ✅ +1 062   8m 55s ⏱️ + 1m 50s
    2 suites  -   1       0 💤  -   192 
    2 files    -   1       0 ❌ ±    0 

Results for commit 1f90603. ± Comparison against base commit bf5ac85.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    5 files  ±  0      5 suites  ±0   14m 51s ⏱️ +7s
3 414 tests  - 645  3 414 ✅  - 643  0 💤  - 2  0 ❌ ±0 
3 418 runs   - 645  3 418 ✅  - 643  0 💤  - 2  0 ❌ ±0 

Results for commit 1f90603. ± Comparison against base commit bf5ac85.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   13 files   -   4     13 suites   - 4   35m 34s ⏱️ +55s
8 931 tests  - 280  8 931 ✅  - 86  0 💤  - 194  0 ❌ ±0 
8 940 runs   - 280  8 940 ✅  - 86  0 💤  - 194  0 ❌ ±0 

Results for commit 1f90603. ± Comparison against base commit bf5ac85.

@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit b215e1f Sep 24, 2026
43 checks passed
@rbuergi
rbuergi deleted the fix/5087-reclaim-lease-atomic branch October 10, 2026 13:10
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.

2 participants