Skip to content

[Tsavorite] Fix checkpoint abort abandoning in-flight flushes and stale checkpoint selection on recovery - #2172

Merged
Badrish Chandramouli (badrishc) merged 3 commits into
mainfrom
badrishc/checkpoint-abort-inflight-flush
Sep 25, 2026
Merged

Badrish Chandramouli (badrishc) merged 3 commits into
mainfrom
badrishc/checkpoint-abort-inflight-flush

Conversation

@badrishc

@badrishc Badrish Chandramouli (badrishc) commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Two independent defects in Tsavorite's checkpoint/recovery paths, both found while triaging intermittent CI failures. They are unrelated in mechanism but adjacent in code, and both are silent — each produces a wrong result rather than an error.

  1. An aborted checkpoint releases the devices its flushes are still writing to, so orphaned completions land on the next checkpoint's state.
  2. Recovering to "the latest checkpoint" could silently recover an older one, losing every update made after it.

Part 1 — Aborted checkpoints abandon their in-flight flushes

Root Cause

An aborted checkpoint state machine releases the devices its flushes are still writing to without waiting for those flushes to complete. The completion callbacks then report through flush state that is shared across checkpoints — mainIndexCheckpointCallbackCount, mainIndexCheckpointError and mainIndexCheckpointTcs in TsavoriteBase, and the checkpointCallbackCount / checkpointError / checkpointTcs trio in MallocFixedPageSize. Both BeginMainIndexCheckpoint and BeginCheckpoint reset that state at the start of every checkpoint, so a completion that outlives its own checkpoint lands on the next one's state.

Three windows leave a flush unawaited:

  1. Index flush. IndexCheckpointSMTask issues TakeIndexFuzzyCheckpoint() at Phase.PREPARE but only registers the resulting tasks on the driver's waiting list at Phase.WAIT_INDEX_CHECKPOINT. An abort in between reaches OnAbort, whose store._indexCheckpoint.Reset() disposes main_ht_device under the live writes.

  2. Snapshot / foldover flush. StateMachineDriver.ProcessWaitingListAsync rethrows waitForTransitionInException before it iterates the waiting list, so an abort around Phase.WAIT_FLUSH reaches HybridLogCheckpointSMTask.OnAbort with the snapshot flush still running, and store._hybridLogCheckpoint.Dispose() releases the snapshot devices and object-log flush buffers it is writing through.

  3. Issuance failure. BeginMainIndexCheckpoint completed its task with TrySetException when a chunk could not be submitted, while the chunks it had already issued were still in flight — unlike MallocFixedPageSize.BeginCheckpoint, which correctly retires only the levels that were never issued. This also meant the waits added for (1) and (2) could return early.

Depending on when the orphaned completion lands it either records an error into the next checkpoint's state (the ERR checkpoint failed, check server logs seen on CI), completes that checkpoint's flush before its data is on disk, or drives the outstanding count negative so the flush task never completes at all. Only the first is loud.

Making the flush task mean "every write this checkpoint issued has called back" exposed two further ordering defects in the issue loops themselves, both of which had to be fixed for the waits above to be trustworthy:

  • A device may invoke a write's completion callback synchronously and then throw out of the same WriteAsync. If that write was the last one outstanding, the callback drove the count to zero and resolved the task with success before the catch recorded the error — a failed index checkpoint reporting success.
  • In MallocFixedPageSize.BeginCheckpoint, the error path advanced the loop variable (for (i++; ...)) before retiring the remaining levels. A throw from the sector-aligned staging code, which runs outside the inner try, therefore skipped the failing level's own retire, the count never reached zero, and the checkpoint hung.

Description of Change

Both OnAbort paths now await the flush they left outstanding before releasing what it is writing to, discarding its outcome — the checkpoint has already failed, and the exception that aborted it is the actionable one. The two index-checkpoint issue loops are restructured so the flush task always means "every write this checkpoint issued has called back", and can never be resolved while issuance is still in progress.

Key changes:

  • IndexCheckpointSMTask.OnAbort calls the new TsavoriteBase.WaitForIndexCheckpointFlushCompletion() before _indexCheckpoint.Reset().
  • HybridLogCheckpointSMTask.OnAbort calls the new TsavoriteBase.WaitForCheckpointFlush(...) on _hybridLogCheckpoint.flushedTask before _hybridLogCheckpoint.Dispose().
  • Both BeginMainIndexCheckpoint and MallocFixedPageSize.BeginCheckpoint hold an issuance sentinel: the outstanding count starts at numChunks + 1 (numLevels + 1 for the overflow buckets) and the sentinel is retired in a finally, after issuance has ended and any error has been recorded. A synchronous callback can no longer resolve the task ahead of the catch that records the failure.
  • BeginMainIndexCheckpoint tracks how many chunks are accounted for (a callback is pending, or the submit failed and the catch retired it in place), records the error before retiring, and retires only the chunks that were counted but never issued — letting the last real completion finish the task.
  • MallocFixedPageSize.BeginCheckpoint tracks accounted levels the same way instead of skipping the failing level with for (i++; ...), so a throw from the staging code outside the inner try still retires its own level.
  • New RetireMainIndexCheckpointChunk() extracts the decrement-and-complete logic previously inline in AsyncPageFlushCallback, so issuance and completion retire through one path.
  • MallocFixedPageSize.GetCheckpointTask() returns checkpointTcs?.Task; it previously threw NullReferenceException before the first checkpoint, which the new wait would hit.

Blocking in OnAbort is safe: it runs on the state machine's own thread with no epoch held — the same thread that would have awaited those tasks on the normal path — and IStateMachineTask.OnAbort is a default interface method, so it stays synchronous and the public API is unchanged.

Affected types:

  • TsavoriteBase (Index/Recovery/IndexCheckpoint.cs) — owns the shared index-flush state; gains WaitForIndexCheckpointFlushCompletion(), static WaitForCheckpointFlush(Task), GetMainIndexCheckpointTask() and RetireMainIndexCheckpointChunk().
  • IndexCheckpointSMTask, HybridLogCheckpointSMTask — the two OnAbort implementations.
  • MallocFixedPageSize<T> — overflow-bucket allocator with the same shared-state pattern.

Edge Cases

Scenario Risk Mitigation
Abort before any flush was started Low WaitForCheckpointFlush no-ops on a null task; GetCheckpointTask() is now null-safe.
Flush faults while the abort waits on it Low The fault is retrieved and discarded, which also marks it observed, so it cannot resurface as an unobserved task exception.
Device invokes the completion synchronously, then throws out of the submit Medium The claim guards decide whether the callback or the catch retires the chunk; exactly one does. The issuance sentinel keeps the task unresolved until the catch has recorded the error.
Issuance throws before any chunk is counted Low countedChunks == 0, so the task is failed directly rather than waiting for a completion that will never run.
Issuance throws from staging code outside the inner try Medium The failing index is retired along with the rest of the unissued range, so the count still reaches zero.

Behavior change

A submission rejected synchronously by the device now surfaces as TsavoriteIOException wrapping the rejection, rather than the raw exception — consistent with how the identical failure already arrives from a flush completion, and the cause is preserved as the inner exception. RecoveryTestFailOnSectorSize used Assert.ThrowsAsync (exact type match) and is updated to Assert.CatchAsync<TsavoriteException>, which keeps its intent and is robust to which path reports the failure.


Part 2 — Recovery could silently restore a stale checkpoint

Root Cause

RecoverAsync() with no version argument means "recover to the latest checkpoint", and reaches FindRecoveryInfo(-1) → GetClosestHybridLogCheckpointInfo. That scan is written to examine every token and keep the highest version, precisely because — as its own comment says — the "file system is not guaranteed to return tokens in order of freshness".

But the loop also carried an early exit for the case where an exact target version has been found:

if (current.info.nextVersion > requestedVersion)
    break;   // no closer version can exist

nextVersion is always >= 1, so with requestedVersion == -1 that test is true for every checkpoint. The scan broke on the first token and returned whatever the checkpoint manager enumerated first.

That enumeration is not ordered by checkpoint age. LocalStorageNamedDeviceFactory.ListContents sorts the checkpoint directories by LastWriteTime, which comes from the system clock; its file-time granularity is coarse (~15.6 ms on Windows), so two checkpoints taken in quick succession can carry the same timestamp. OrderByDescending is a stable sort, so ties fall back to the order the filesystem returned — on NTFS, by name, effectively the GUID, which is unrelated to when the checkpoint was taken. The older checkpoint then wins roughly half the time, and every update made after it is silently lost.

This is what made NamespaceTests.RecoveryAsync(*, MLSD) fail intermittently on Windows CI with a post-recovery value mismatch.

Description of Change

Restrict the early exit to a real requested version, so the -1 case runs the full scan. No new selection logic was needed: distanceToTarget is computed as long.MaxValue - version when requestedVersion == -1, and closestVersion starts at long.MaxValue, so the existing accumulate logic below already keeps the smallest distance and therefore the highest version.

if (requestedVersion != -1 && current.info.nextVersion > requestedVersion)

The same branch also overwrote closest without disposing it, leaking the snapshot file device of any candidate the scan had already accepted — the accumulate branch below it disposes correctly. closest.Dispose() is added there; HybridLogCheckpointInfo.Dispose() is null-safe and ends with this = default, so it is safe on a not-yet-assigned candidate.

Blast radius. GetLatestCheckpointTokens and GetLatestCheckpointVersion route through the same helper, so both were affected. The cluster replication path in CheckpointStore.GetLatestCheckpointEntryFromDisk — documented as returning "the latest checkpoint entry from disk by scanning all available tokens" — could likewise hand a replica a stale checkpoint.

Not a bug: GetClosestIndexCheckpointInfo picks the first compatible index checkpoint (IsCompatible: indexInfo.finalLogicalAddress <= recoveryInfo.finalLogicalAddress). An older compatible index checkpoint is safe by design, because recovery replays the log forward from it.

What NOT to Do (for future agents)

  • ❌ Don't complete a checkpoint's flush task to "unblock" a failure while writes it issued are still in flight. That is precisely the bug: it releases the waiters that dispose the device those writes target, and the resulting completions are charged to whatever checkpoint owns the shared state by then.
  • ❌ Don't assume the driver's waiting list covers every flush. The index flush is issued one phase before it is registered, and ProcessWaitingListAsync rethrows the transition exception before it ever reaches the list.
  • ❌ Don't treat a synchronous throw out of WriteAsync as "the write never happened." A device may invoke the completion callback and then throw; the TryClaimIoUnitRelease / TryClaimRetirement guards are what distinguish the two, and retiring without them double-counts.
  • ❌ Don't let the outstanding count reach zero while the issue loop is still running. Without the issuance sentinel, a synchronous completion resolves the task before the catch records the error, and the checkpoint reports success for a write that failed.
  • ❌ Don't make OnAbort async. It is a default interface method on a public interface; changing its signature is a breaking API change, and blocking there is already correct.
  • ❌ Don't rely on checkpoint enumeration order to mean "newest first." ListContents sorts by a coarse filesystem timestamp with a stable sort, so equal timestamps fall back to name order. Checkpoint version is the only ordering that means anything.
  • ❌ Don't "fix" this in ListContents by sorting on something else. The selection scan already knows the versions; ordering the directory listing would only make the defect harder to hit, not impossible.

Tests

Seven new tests, each verified to fail when its corresponding product change is reverted:

Part 1

  • CheckpointFailureTests.AbortedCheckpointWaitsForTheIndexCheckpointFlushItIssued — holds back ht.dat write completions, aborts at Phase.IN_PROGRESS, and asserts nothing is in flight when SAVE reports the failure. Without the fix: 2 writes outstanding.
  • CheckpointFailureTests.AbortedCheckpointWaitsForTheSnapshotFlushItIssued — same for snapshot.dat, aborting at Phase.WAIT_FLUSH. Without the fix: 1 write outstanding.
  • IndexCheckpointIoTests.IndexCheckpointDoesNotCompleteWhileAnIssuedChunkIsStillWriting — fails chunk 1's submission with chunk 0 still writing, and asserts the checkpoint neither completes early nor hangs on the chunks that were never issued.
  • IndexCheckpointIoTests.IndexCheckpointFailsWhenTheFinalChunkCompletesThenThrows — the last chunk's write calls back and then throws; asserts the checkpoint fails. Without the sentinel it reports success.
  • IndexCheckpointIoTests.OverflowBucketCheckpointFailsWhenTheFinalLevelCompletesThenThrows — the same for MallocFixedPageSize.BeginCheckpoint.

Both Garnet tests synchronize on observed state rather than a timer: they spin until the abort has fired with a write verifiably in flight, then assert SAVE stays blocked for a grace period before the deferred completions are released. A timer started before anything is deferred could drain the held writes ahead of the abort on a slow run and pass against unfixed code.

New harness DeferringCheckpointDeviceFactoryCreator withholds DeviceIOCompletionCallback invocations for a named checkpoint file, so a test can abort with writes verifiably in flight. ThrowOnNthWriteDevice gains the equivalent DeferWriteCompletions and CompleteBeforeThrowing knobs.

Part 2 — new fixture RecoverLatestVersionTests, which removes the timing dependence entirely by backdating the newer checkpoint's cpr-checkpoints and index-checkpoints directories, so enumeration is guaranteed to hand the older checkpoint over first:

  • RecoverLatestIgnoresDirTimeOrder(Snapshot) and RecoverLatestIgnoresDirTimeOrder(FoldOver) — take two checkpoints writing different values, backdate the second, recover with -1, and assert the second value is read back. Without the fix both fail with Expected: 222, But was: 111 — on Linux, deterministically.
  • RecoverToVersionPicksThatVersion — asserts the targeted-version path is unchanged, covering the early exit that is still reachable for a real requestedVersion.

Validation

  • CheckpointFailureTests 10/10 across 10 consecutive runs.
  • Full Garnet.test: 1116 passed, 0 failed.
  • Tsavorite: recovery 242, core 290, hlog 580 — all green.
  • Garnet.test.cluster.replication: 110 passed, 0 failed — covers CheckpointStore.GetLatestCheckpointEntryFromDisk, the cluster consumer of Part 2.
  • NamespaceTests (the Part 2 CI failure) 12× locally plus 6× under taskset -c 0,1 — all pass.
  • Both solutions build on net8.0 and net10.0; dotnet format --verify-no-changes clean on both.

The Part 1 CI flake did not reproduce locally on Linux across 1600 abort+retry iterations, consistent with the report; that fix rests on the deterministic tests above rather than on reproducing the original timing. Part 2 does reproduce deterministically once the enumeration order is pinned.

Issues Fixed

Fixes #2171

…leases their devices

An aborted checkpoint released the devices its flushes were still writing to without
waiting for those flushes. The completion callbacks then reported through flush state
that is shared across checkpoints (mainIndexCheckpointCallbackCount, its error and task,
and the overflow-bucket equivalents), so a failed checkpoint's error was charged to the
next one: the retry after an aborted SAVE intermittently failed with "ERR checkpoint
failed, check server logs". The same race can instead complete the next checkpoint's
flush before its data is on disk, or drive the outstanding count negative so the flush
task never completes.

Three windows left a flush unawaited:

- The fuzzy index flush is issued at PREPARE but only put on the driver's waiting list at
  WAIT_INDEX_CHECKPOINT. Aborting in between reached IndexCheckpointSMTask.OnAbort, whose
  Reset disposes main_ht_device under the live writes.

- ProcessWaitingListAsync rethrows the transition exception before it iterates the waiting
  list, so an abort around WAIT_FLUSH reached HybridLogCheckpointSMTask.OnAbort with the
  snapshot flush still running, and Dispose released the devices and flush buffers it was
  writing through.

- BeginMainIndexCheckpoint completed its task with TrySetException when a chunk could not
  be submitted, while the chunks it had already issued were still in flight. It now
  mirrors MallocFixedPageSize.BeginCheckpoint: record the error, retire only the chunks
  that were counted but never issued, and let the last real completion finish the task.

Both OnAbort paths now await the flush they left outstanding, discarding its outcome - the
checkpoint has already failed, and the exception that aborted it is the actionable one.

Tests: CheckpointFailureTests gains two tests that hold back the checkpoint file's write
completions, abort at each injection point, and assert nothing is still in flight when
SAVE reports the failure. IndexCheckpointIoTests covers the issuance-failure path. All
three fail without the corresponding fix.

RecoveryTestFailOnSectorSize now uses CatchAsync: a submission rejected synchronously by
the device surfaces as TsavoriteIOException wrapping the rejection, consistent with how
the same failure arrives from a flush completion.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e3e8cad9-ef95-433b-ae6d-a1ff5d6d6a18
Copilot AI balanced review requested due to automatic review settings September 24, 2026 20:36

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

Final synchronous completion can still report success before issuance records an exception, and the new timing-based tests can miss the regression.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Fixes #2171 by ensuring aborted Tsavorite checkpoints await outstanding flush callbacks before releasing shared devices and state.

Changes:

  • Waits for index and hybrid-log flushes during abort cleanup.
  • Revises index-chunk retirement and failure handling.
  • Adds deterministic-device harnesses and regression tests.
File Description
DeferringCheckpointDeviceFactoryCreator.cs Adds deferred checkpoint completion test device.
CheckpointFailureTests.cs Tests index and snapshot abort cleanup.
RecoveryTests.cs Accepts derived checkpoint I/O exceptions.
IndexCheckpointIoTests.cs Tests partial issuance with outstanding writes.
SimulatedFlakyDevice.cs Supports deferred write completions.
IndexCheckpoint.cs Adds flush waits and chunk retirement handling.
IndexCheckpointSMTask.cs Waits for index flushes during abort.
HybridLogCheckpointSMTask.cs Waits for log flushes during abort.
MallocFixedPageSize.cs Makes checkpoint-task retrieval null-safe.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread test/standalone/Garnet.test/CheckpointFailureTests.cs Outdated
…ack-then-throw

A device may invoke a write's completion callback synchronously and then
throw out of the same WriteAsync call. If that write was the last one
outstanding, the callback drove the outstanding count to zero and completed
the checkpoint task with success before the catch block had a chance to
record the error, so a failed index checkpoint reported success.

Both index-checkpoint issue loops now hold an issuance sentinel: the
outstanding count starts at numChunks + 1 (numLevels + 1 for the overflow
buckets) and the sentinel is retired in a finally, after issuance has ended
and any error has been recorded. The completion task therefore cannot be
resolved while issuance is still running.

MallocFixedPageSize.BeginCheckpoint also loses a `for (i++; ...)` skip in its
error path: a throw from the sector-aligned staging code, which runs outside
the inner try, skipped the failing level's own retire, so the count never
reached zero and the checkpoint hung.

The two new Garnet regression tests no longer release deferred writes on a
timer started before anything was deferred; on a slow run that could drain the
held writes before the abort fired and pass against unfixed code. They now
spin until the abort has fired with a write actually in flight, then assert
that SAVE stays blocked for a grace period before releasing.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e3e8cad9-ef95-433b-ae6d-a1ff5d6d6a18
…tory timestamp order

RecoverAsync() could silently recover an older checkpoint, losing every
update made after it.

FindRecoveryInfo(-1) means "recover to the latest checkpoint". Its scan is
written to examine every token and keep the highest version, precisely because,
as its own comment says, "file system is not guaranteed to return tokens in
order of freshness". But the loop also carries an early exit for the case where
an exact target version has been found: nextVersion > requestedVersion. With
requestedVersion == -1 that test is true for every checkpoint, so the scan broke
on the first token and returned whatever the checkpoint manager enumerated
first.

That enumeration is not ordered by checkpoint age.
LocalStorageNamedDeviceFactory.ListContents sorts the checkpoint directories by
LastWriteTime, which comes from the system clock; its file-time granularity is
coarse (~15.6ms on Windows), so two checkpoints taken in quick succession can
carry the same timestamp. OrderByDescending is a stable sort, so ties fall back
to the order the filesystem returned, which on NTFS is by name - effectively the
GUID, which is unrelated to when the checkpoint was taken. The older checkpoint
then wins roughly half the time.

Restricting the early exit to a real requested version lets the existing scan
run for -1, which already keeps the smallest distanceToTarget and therefore the
highest version.

GetLatestCheckpointTokens and GetLatestCheckpointVersion route through the same
helper, so both were affected. The cluster replication path in
CheckpointStore.GetLatestCheckpointEntryFromDisk, documented as returning "the
latest checkpoint entry from disk by scanning all available tokens", could
likewise hand a replica a stale checkpoint.

The early-exit branch also overwrote closest without disposing it, leaking the
snapshot file device of any candidate the scan had already accepted; the
accumulate branch below it disposes correctly. Fixed while restricting the
branch.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e3e8cad9-ef95-433b-ae6d-a1ff5d6d6a18
@badrishc Badrish Chandramouli (badrishc) changed the title [Tsavorite] Wait for in-flight checkpoint flushes before an aborted checkpoint releases their devices [Tsavorite] Fix checkpoint abort abandoning in-flight flushes and stale checkpoint selection on recovery Sep 24, 2026
@badrishc
Badrish Chandramouli (badrishc) merged commit 917e0e1 into main Sep 25, 2026
227 checks passed
@badrishc
Badrish Chandramouli (badrishc) deleted the badrishc/checkpoint-abort-inflight-flush branch September 25, 2026 00:55
Ted Hart (TedHartMS) added a commit that referenced this pull request Sep 27, 2026
Merges origin/main d2d71f6 (8 commits). Git reported zero textual
conflicts; all 211 files auto-merged.

One semantic break required a fix. Main's #2162 and #2173 moved TestBase
from Garnet.test to Tsavorite.test (libs/storage/Tsavorite/cs/test/TestBase.cs)
and removed the Garnet ProjectReference from the Tsavorite test projects.
Seven test files added by this branch imported TestBase via
"using Garnet.test;" and stopped compiling with CS0246. That line is removed
from each; no replacement using is needed because all seven declare
namespaces nested under Tsavorite.test, so TestBase now resolves by
enclosing-namespace lookup.

  test.recovery/V7DownlevelRecoveryTests.cs
  test.recovery/LogGeometryVerificationTests.cs
  test.recovery/ObjectRecoveryUndoReflushTests.cs
  test.recovery/ObjectSizeBoundaryDiskIOTests.cs
  test.recovery/ObjectSizeBoundaryRecoveryTests.cs
  test.recovery/SnapshotBoundarySectorTests.cs
  test.recordops/ObjectReadOnlyFlushTraceTests.cs

Note that dotnet build Garnet.slnx reports 0 warnings and 0 errors while
this break is present, because Garnet.slnx does not contain the Tsavorite
test projects. Verifying a merge requires building both solutions.

This merge also brings in 917e0e1 (#2172), which fixes checkpoint abort
abandoning in-flight flushes.

Verified: build 0 warnings / 0 errors on both solutions; dotnet format
--verify-no-changes clean on both; Tsavorite.test.recovery 575 passed;
Tsavorite.test.recordops 379 passed; CheckpointFailureTests 10 passed.
x@01 (x-at-01) added a commit to webc-fork/garnet that referenced this pull request Oct 4, 2026
…oint abort in-flight flushes + stale selection on recovery (microsoft#2172), checkpoint metadata I/O scoping (microsoft#2154)
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.

[Tsavorite] Aborted checkpoint releases devices with flushes still in flight, failing the next checkpoint

3 participants