Skip to content

Flush pending own-node saves on hub dispose (acked-write loss; TwoSiloRecycleConvergence flake) - #627

Merged
2 commits merged into
mainfrom
fix/flush-pending-own-save-on-dispose
Jul 24, 2026
Merged

2 commits merged into
mainfrom
fix/flush-pending-own-save-on-dispose

Conversation

@rbuergi

@rbuergi rbuergi commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Root cause (product data-loss bug — the flaky test was RIGHT)

CI run 30068597014 shard 2: TwoSiloRecycleConvergenceTest failed at WaitForPersistedBeyond, not at convergence — the post-recycle PatchDataRequest was ACKed Success and then durably lost. A patch landing in the owner hub's Quiescing window (after DisposeRequest, before the RunLevel where #609's ShuttingDown NACK gates) commits in-RAM and ACKs, but the per-node hub's update-persistence path is a Sample(200ms) debounce that teardown destroyed two ways: DisposeImpl disposed the sampler with the save pending, and the sampler's RunLevel > Started branch silently discarded sampled values. Creates/deletes already had a final-flush dispose action (FlushPendingWrites); updates had none — that asymmetry is the defect. In prod this loses the last ≤200ms of acknowledged writes on any recycle/shutdown.

Fix (MeshDataSource.cs)

  • PendingOwnSave tracker: stamps every save-worthy state ahead of the Sample buffer; a state counts as dispatched only when its SaveMeshNodeRequest post was accepted (not the teardown-guard Failed return). Holds only MeshNode refs — nothing in the timer chain pins the hub (preserves abandoned-hub collectability).
  • Sampler teardown branch: stops posting but keeps tracking (no dispose-and-discard); DisposeImpl bounds its lifetime.
  • FlushPendingOwnSave: reactive dispose action persisting the latest undispatched state via IStorageAdapter.Write (same guards as HandleSaveMeshNode: delete-wins tombstone, PersistedSnapshot identity, Version ≥ 1); the write leaf runs on the mesh IO pool (outlives the hub); faults surface via [DISPOSE-ACTION] logging.

Verification

  • Deterministic repro DisposalPendingSaveFlushTest: own-node stream.Update, dispose the per-node hub inside the 200ms window, assert durability — RED pre-fix (same TimeoutException signature as CI), GREEN post-fix.
  • TwoSiloRecycleConvergenceTest class: 1/1 passed.
  • CI-exact build (--no-restore -c Release -p:CIRun=true -warnaserror): 0 errors.

No What's New entry — internal durability fix (though "no more lost writes on recycle" arguably deserves one; say the word).

🤖 Generated with Claude Code

rbuergi and others added 2 commits July 24, 2026 08:16
…istence sample on dispose

Root cause of the TwoSiloRecycleConvergenceTest flake (main run 30068597014,
shard 2): a PatchDataRequest that lands in the owner hub's Quiescing window
(after Dispose() posted the DisposeRequest, before RunLevel reaches
DisposeHostedHubs - where the #609 ShuttingDown NACK gate starts) is processed
normally: the patch commits in-RAM and the owner ACKs with PatchDataResponse
Success. The own-node persistence sampler (MeshDataSource.SubscribeToOwnDeletion)
debounces updates with Sample(200 ms), and the ShutDown phase's DisposeImpl
disposed that subscription with the save still pending - and its
RunLevel > Started branch silently DISCARDED a sampled value during teardown.
The acknowledged write was durably lost; the reactivated grain loaded the stale
persisted version, so the test's WaitForPersistedBeyond timed out at the
pre-recycle version (trx: resilient write "succeeded" instantly, store never
advanced past v12). Creates/deletes were already covered by MeshNodeTypeSource's
FlushPendingWrites reactive dispose action; updates were not.

Fix (update-path twin of that existing flush):
- PendingOwnSave tracker: stamps every save-worthy own-node state ahead of the
  Sample buffer (Do), and marks a state dispatched only when its
  SaveMeshNodeRequest was ACCEPTED by the hub (Post did not return the
  teardown-guard Failed delivery). Holds only MeshNode references - nothing in
  the timer chain can pin the hub (preserves the abandoned-hub collectability
  the static-local-function sampler shape exists for).
- Sampler teardown branch (RunLevel > Started): stop posting but keep tracking
  instead of self-disposing-and-discarding, so quiesce-window writes reach the
  tracker; DisposeImpl bounds the chain's lifetime via RegisterForDisposal.
- FlushPendingOwnSave: reactive dispose action that persists the latest
  undispatched state straight through IStorageAdapter.Write (same guards as
  HandleSaveMeshNode: recently-deleted tombstone, PersistedSnapshot identity,
  Version >= 1). The write leaf runs on the mesh IO pool, which outlives the
  hub; faults surface via DisposeImpl's [DISPOSE-ACTION] logging.

Deterministic repro: DisposalPendingSaveFlushTest forces the exact interleaving
without Orleans - own-node stream.Update, observe the own stream carry the
write, dispose the per-node hub inside the 200 ms sample window, assert the
write reaches storage. RED on unfixed code (same TimeoutException signature as
CI), GREEN with the flush. TwoSiloRecycleConvergenceTest green; full-solution
Release -p:CIRun=true -warnaserror build (CI-exact restore/build split) clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wP3c7DLHutUczcBWgs4h8
Copilot AI review requested due to automatic review settings July 24, 2026 06:17

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 was unable to review this pull request because the user who requested the review has reached their quota limit.

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