Repository navigation
[Tsavorite] Fix checkpoint abort abandoning in-flight flushes and stale checkpoint selection on recovery - #2172
Merged
Badrish Chandramouli (badrishc) merged 3 commits intoSep 25, 2026
Conversation
…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 started reviewing on behalf of
Badrish Chandramouli (badrishc)
September 24, 2026 20:37
View session
Contributor
There was a problem hiding this comment.
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
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.
…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
Ted Hart (TedHartMS)
approved these changes
Sep 24, 2026
…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
Ted Hart (TedHartMS)
approved these changes
Sep 25, 2026
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)
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.


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.
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,mainIndexCheckpointErrorandmainIndexCheckpointTcsinTsavoriteBase, and thecheckpointCallbackCount/checkpointError/checkpointTcstrio inMallocFixedPageSize. BothBeginMainIndexCheckpointandBeginCheckpointreset 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:
Index flush.
IndexCheckpointSMTaskissuesTakeIndexFuzzyCheckpoint()atPhase.PREPAREbut only registers the resulting tasks on the driver's waiting list atPhase.WAIT_INDEX_CHECKPOINT. An abort in between reachesOnAbort, whosestore._indexCheckpoint.Reset()disposesmain_ht_deviceunder the live writes.Snapshot / foldover flush.
StateMachineDriver.ProcessWaitingListAsyncrethrowswaitForTransitionInExceptionbefore it iterates the waiting list, so an abort aroundPhase.WAIT_FLUSHreachesHybridLogCheckpointSMTask.OnAbortwith the snapshot flush still running, andstore._hybridLogCheckpoint.Dispose()releases the snapshot devices and object-log flush buffers it is writing through.Issuance failure.
BeginMainIndexCheckpointcompleted its task withTrySetExceptionwhen a chunk could not be submitted, while the chunks it had already issued were still in flight — unlikeMallocFixedPageSize.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 logsseen 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:
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.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 innertry, therefore skipped the failing level's own retire, the count never reached zero, and the checkpoint hung.Description of Change
Both
OnAbortpaths 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.OnAbortcalls the newTsavoriteBase.WaitForIndexCheckpointFlushCompletion()before_indexCheckpoint.Reset().HybridLogCheckpointSMTask.OnAbortcalls the newTsavoriteBase.WaitForCheckpointFlush(...)on_hybridLogCheckpoint.flushedTaskbefore_hybridLogCheckpoint.Dispose().BeginMainIndexCheckpointandMallocFixedPageSize.BeginCheckpointhold an issuance sentinel: the outstanding count starts atnumChunks + 1(numLevels + 1for the overflow buckets) and the sentinel is retired in afinally, 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.BeginMainIndexCheckpointtracks 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.BeginCheckpointtracks accounted levels the same way instead of skipping the failing level withfor (i++; ...), so a throw from the staging code outside the innertrystill retires its own level.RetireMainIndexCheckpointChunk()extracts the decrement-and-complete logic previously inline inAsyncPageFlushCallback, so issuance and completion retire through one path.MallocFixedPageSize.GetCheckpointTask()returnscheckpointTcs?.Task; it previously threwNullReferenceExceptionbefore the first checkpoint, which the new wait would hit.Blocking in
OnAbortis 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 — andIStateMachineTask.OnAbortis 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; gainsWaitForIndexCheckpointFlushCompletion(),static WaitForCheckpointFlush(Task),GetMainIndexCheckpointTask()andRetireMainIndexCheckpointChunk().IndexCheckpointSMTask,HybridLogCheckpointSMTask— the twoOnAbortimplementations.MallocFixedPageSize<T>— overflow-bucket allocator with the same shared-state pattern.Edge Cases
WaitForCheckpointFlushno-ops on anulltask;GetCheckpointTask()is now null-safe.countedChunks == 0, so the task is failed directly rather than waiting for a completion that will never run.tryBehavior change
A submission rejected synchronously by the device now surfaces as
TsavoriteIOExceptionwrapping 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.RecoveryTestFailOnSectorSizeusedAssert.ThrowsAsync(exact type match) and is updated toAssert.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 reachesFindRecoveryInfo(-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:
nextVersionis always>= 1, so withrequestedVersion == -1that 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.ListContentssorts the checkpoint directories byLastWriteTime, 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.OrderByDescendingis 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
-1case runs the full scan. No new selection logic was needed:distanceToTargetis computed aslong.MaxValue - versionwhenrequestedVersion == -1, andclosestVersionstarts atlong.MaxValue, so the existing accumulate logic below already keeps the smallest distance and therefore the highest version.The same branch also overwrote
closestwithout 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 withthis = default, so it is safe on a not-yet-assigned candidate.Blast radius.
GetLatestCheckpointTokensandGetLatestCheckpointVersionroute through the same helper, so both were affected. The cluster replication path inCheckpointStore.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:
GetClosestIndexCheckpointInfopicks 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)
ProcessWaitingListAsyncrethrows the transition exception before it ever reaches the list.WriteAsyncas "the write never happened." A device may invoke the completion callback and then throw; theTryClaimIoUnitRelease/TryClaimRetirementguards are what distinguish the two, and retiring without them double-counts.OnAbortasync. It is a default interface method on a public interface; changing its signature is a breaking API change, and blocking there is already correct.ListContentssorts 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.ListContentsby 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 backht.datwrite completions, aborts atPhase.IN_PROGRESS, and asserts nothing is in flight whenSAVEreports the failure. Without the fix: 2 writes outstanding.CheckpointFailureTests.AbortedCheckpointWaitsForTheSnapshotFlushItIssued— same forsnapshot.dat, aborting atPhase.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 forMallocFixedPageSize.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
SAVEstays 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
DeferringCheckpointDeviceFactoryCreatorwithholdsDeviceIOCompletionCallbackinvocations for a named checkpoint file, so a test can abort with writes verifiably in flight.ThrowOnNthWriteDevicegains the equivalentDeferWriteCompletionsandCompleteBeforeThrowingknobs.Part 2 — new fixture
RecoverLatestVersionTests, which removes the timing dependence entirely by backdating the newer checkpoint'scpr-checkpointsandindex-checkpointsdirectories, so enumeration is guaranteed to hand the older checkpoint over first:RecoverLatestIgnoresDirTimeOrder(Snapshot)andRecoverLatestIgnoresDirTimeOrder(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 withExpected: 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 realrequestedVersion.Validation
CheckpointFailureTests10/10 across 10 consecutive runs.Garnet.test: 1116 passed, 0 failed.Garnet.test.cluster.replication: 110 passed, 0 failed — coversCheckpointStore.GetLatestCheckpointEntryFromDisk, the cluster consumer of Part 2.NamespaceTests(the Part 2 CI failure) 12× locally plus 6× undertaskset -c 0,1— all pass.dotnet format --verify-no-changesclean 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