Harden TsavoriteLog commit-failure handling and fix fast-commit recovery poisoning - #2065
Merged
Merged
Conversation
…ions CommitRecordBoundedGrowthTest ran log.Commit() in a tight loop on raw background threads with no exception handling. A device write failing under concurrent commit surfaces as a CommitFailureException (error code 0xFFFFFFFF), which was an unhandled throw on a background thread and terminated the whole test host. Test changes: - Parameterize CommitRecordBoundedGrowthTest with [Values(1, -1)] numThreads (-1 => ProcessorCount/2, else 1) to control commit-thread concurrency. - Capture the first background-thread exception and fail the test assertively instead of letting an unhandled throw kill the process. Production changes (surface the real fault for diagnosis): - StorageDeviceBase records the underlying write exception (RecordError / LastError); the device-to-allocator completion channel only carries a numeric error code (a non-IOException collapses to uint.MaxValue), which discarded the original type and stack. - All local device implementations record the exception in their WriteAsync catch blocks. - Thread the captured exception through CommitInfo.Exception and out as the InnerException of CommitFailureException, so an opaque 0xFFFFFFFF now carries the real device exception. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 573eaee0-3ed4-4f9d-96fc-07ea3fac9afd
…Address overflow
When fast-commit recovery fails after setting the `HeadAddress` / `CommittedUntilAddress`
= long.MaxValue "shut up safe guards" sentinels, the TsavoriteLog sync constructor swallowed
the exception with a bare `catch { }`, leaving the log poisoned with a sentinel HeadAddress.
The first Enqueue then routed through CalculateReadOnlyAddress(tail, long.MaxValue), whose page
arithmetic overflowed to a negative address: Debug asserted; Release silently returned
long.MinValue, which the `> ReadOnlyAddress` guard filtered out so ReadOnlyAddress never
advanced, pages never flushed, and the next page-turning enqueue blocked forever.
Root-cause fix (TsavoriteLog):
- Add ResetRecoveryState(), which rolls the allocator back to a clean empty state (mirroring the
existing "unable to recover using any available commit" reset), and call it on the
non-tolerated failure path of RestoreLatestAsync/RestoreSpecificCommitAsync before rethrowing.
- The sync constructor now logs and rethrows recovery failures instead of swallowing them, so a
failed recovery fails fast rather than presenting an empty log as if recovery succeeded (data
loss). This mirrors the async CreateAsync path. Garnet AOF sets TryRecoverLatest=false and so
never runs this path.
Defense-in-depth fix (AllocatorBase):
- CalculateReadOnlyAddress clamps `headAddress >= tailAddress` to return tailAddress, so a
sentinel/out-of-range headAddress can never overflow the page arithmetic regardless of source.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 573eaee0-3ed4-4f9d-96fc-07ea3fac9afd
Ted Hart (TedHartMS)
requested review from
Badrish Chandramouli (badrishc)
and
a balanced review from Copilot
August 13, 2026 05:08
Contributor
There was a problem hiding this comment.
Pull request overview
Hardens TsavoriteLog commit failures and fast-commit recovery state handling.
Changes:
- Captures background commit failures safely in tests.
- Propagates device exceptions through commit failures.
- Resets failed recovery state and guards address calculations.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
LogFastCommitTests.cs |
Adds threaded commit coverage. |
TsavoriteLog.cs |
Fails fast and resets recovery state. |
CommitInfo.cs |
Carries device exceptions. |
CommitFailureException.cs |
Supports inner exceptions. |
StorageDeviceBase.cs |
Tracks device errors. |
RandomAccessLocalStorageDevice.cs |
Records write exceptions. |
NativeStorageDevice.cs |
Records submission exceptions. |
ManagedLocalStorageDevice.cs |
Records write exceptions. |
LocalStorageDevice.cs |
Records write exceptions. |
AllocatorBase.cs |
Clamps addresses and propagates errors. |
Suppressed comments (2)
libs/storage/Tsavorite/cs/src/core/TsavoriteLog/TsavoriteLog.cs:267
- Rethrowing here leaves all resources initialized earlier in the constructor undisposed: the owned
LightEpoch, allocator, commit queue, and default commit manager cannot be disposed because the object never escapes. Repeated recovery failures can therefore leak epoch slots, memory, and handles. Add partial-construction cleanup before rethrowing (noting thatinflightWordis allocated only after this block), or move recovery to a factory that disposes the log on failure.
throw;
libs/storage/Tsavorite/cs/src/core/TsavoriteLog/TsavoriteLog.cs:2890
- The root fast-commit recovery failure still has no committed regression test. Add a deterministic device/metadata mismatch test that throws after the sentinel addresses are installed, verifies the recovery exception propagates, and then verifies retry/enqueue cannot observe a poisoned address state. The bounded-growth test does not execute this recovery path.
// Recovery failed after the fast-commit scan set the "shut up safe guards" sentinels
// (CommittedUntilAddress / HeadAddress = long.MaxValue). Roll the allocator back to a clean, empty
// state so a failed recovery never retains a log poisoned with HeadAddress == long.MaxValue, which
// overflows CalculateReadOnlyAddress on the next enqueue.
ResetRecoveryState();
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Follow-up to the earlier commit-failure hardening. That change stashed the
failing write's exception on the device (StorageDeviceBase.LastError) and
recovered it out-of-band, which is racy under concurrent flushes and only
covers StorageDeviceBase-derived devices. Carry the typed exception on the
completion channel itself instead, and log it for diagnosis.
Production changes:
- Add an `Exception ioException` parameter to DeviceIOCompletionCallback. The
numeric errorCode stays the authoritative success/failure signal; the new
argument only carries the typed exception for diagnosis (the code collapses
to uint.MaxValue for a non-IOException, so it is a failure sentinel, not the
device's real error code).
- Forward the caught exception from device read/write completion callbacks
(local, managed, native, random-access, sharded, tiered, memory, null,
Azure); sites with no exception pass null.
- Thread it through the buffered multi-write countdown (RecordError retains the
first exception alongside the first error code and forwards both on final
completion) and through PageAsyncFlushResult.Release.
- Carry the plumbed exception straight into CommitInfo.Exception, and drop the
StorageDeviceBase.LastError/RecordError slot the earlier commit added as an
out-of-band workaround (now redundant since the exception rides the channel).
- Log completion errors under a distinct "{exception}" placeholder via new
Utility.GetCallbackExceptionDetail, which flattens AggregateException from
sharded/tiered fan-out; the plain numeric "{errorCode}" placeholder is
reserved for the no-exception case.
Recovery-failure leak fix:
- The synchronous TsavoriteLog constructor now disposes resources it already
allocated (allocator, commit queue, owned epoch, default commit manager)
when recovery throws, so repeated construction failures cannot leak a
LightEpoch and its epoch-table slots. Cleanup is best-effort and never masks
the original recovery failure.
Test changes:
- New LogCommitFailureTests.CommitFailureExceptionCarriesDeviceError asserts a
failing write surfaces its exception through the new channel.
- Add CalculateReadOnlyAddressClampsOutOfRangeHeadAddress, a deterministic
regression for the fast-commit long.MaxValue "never evict" HeadAddress clamp,
via a new internal AllocatorCalculateReadOnlyAddress test accessor.
- Mark FastCommitRecoveryFailureFailsFastAndDoesNotPoisonLog [Explicit]: it is
flaky under test-suite ordering because process-global recovery state can
leak between fixtures. The deterministic clamp test above covers the same
invariant.
- Update SimulatedFlakyDevice and the device tests for the new callback
signature.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 573eaee0-3ed4-4f9d-96fc-07ea3fac9afd
…d async commit-metadata race SimulatedFlakyDevice fired its injected-error callback and then fell through to the underlying device, delivering a second completion for the same IO. The stray success completion re-entered AsyncGetFromDiskCallback after the error path had already nulled ctx.record, causing a background NullReferenceException (and a double semaphore release). Return after each injected-error callback so every IO completes exactly once, matching the existing already-failed-range fast path. FastCommitRecoveryFailureFailsFastAndDoesNotPoisonLog also raced fast commit's asynchronous commit-metadata write: spinWait guarantees the in-memory commit and the inline log commit record, but not that the manager's metadata file is yet visible. Phase 2a recovers with a device whose reads all fail, so that file is its only usable recovery source; if it had not landed yet, recovery found no commit, recovered an empty log, and never issued the read that must throw. Wait until the seed commit metadata is actually loadable before Phase 2a, and drop [Explicit] now that the test is deterministic. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 573eaee0-3ed4-4f9d-96fc-07ea3fac9afd
Resolve device-callback conflicts in AllocatorBase.AsyncFlushPageForSnapshotCallback and IndexCheckpoint.AsyncPageFlushCallback: keep main's native-allocator IO-unit release (the outer try/finally with TryClaim*UnitRelease) and the completion-callback signature, folding in this branch's ioException-aware error logging via Utility.GetCallbackExceptionDetail. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 573eaee0-3ed4-4f9d-96fc-07ea3fac9afd
Badrish Chandramouli (badrishc)
approved these changes
Aug 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.
Summary
Fixes a host-crashing CI failure in
CommitRecordBoundedGrowthTest(LogFastCommitTests) and the two underlying Tsavorite defects it exposed.The original crash was a
CommitFailureExceptionwith device error0xFFFFFFFFthrown from an unguardedlog.Commit()on a background thread, which terminated the whole test host. Investigating that surfaced a separate, production-relevant defect: aCalculateReadOnlyAddressaddress overflow caused by fast-commit recovery leaving the log poisoned after a swallowed exception.Changes
1. Test + device-error hardening (commit
94ba1f96)CommitRecordBoundedGrowthTestwith[Values(1, -1)] int numThreads(-1⇒ProcessorCount / 2, else1).Threadand killing the test host.StorageDeviceBase.LastError→CommitInfo.Exception→CommitFailureException.InnerException, so a commit failure surfaces the underlying device error instead of an opaque error code.2. Fast-commit recovery poisoning + address overflow (commit
a4f03ae3)Root cause. In
FastCommitMode, recovery setsHeadAddress/CommittedUntilAddress = long.MaxValueas "shut up safe guards" sentinels before scanning the log tail. IfRestoreHybridLogAsyncthen throws (device/metadata mismatch), theTsavoriteLogsync constructor swallowed it with a barecatch { }, leaving the log poisoned with a sentinelHeadAddress. The firstEnqueuerouted throughCalculateReadOnlyAddress(tail, long.MaxValue), whose page arithmetic overflowed to a negative address:ReadOnlyAddress ... must not be less than HeadAddressassert.long.MinValue, which the> ReadOnlyAddressguard filtered out, soReadOnlyAddressnever advanced, pages never flushed, and the next page-turning enqueue blocked forever (with unbounded memory growth) — worse than the Debug assert.Fixes:
TsavoriteLog: addedResetRecoveryState(), which rolls the allocator back to a clean, empty state (mirroring the existing "unable to recover using any available commit" reset) on the non-tolerated failure path ofRestoreLatestAsync/RestoreSpecificCommitAsyncbefore rethrowing. The sync constructor now logs and rethrows recovery failures instead of swallowing them, so a failed recovery fails fast rather than presenting an empty log as if recovery had succeeded (silent data loss). This matches the asyncCreateAsyncpath, which already propagates recovery failures.AllocatorBase:CalculateReadOnlyAddressnow clampsheadAddress >= tailAddressto returntailAddress, so a sentinel/out-of-rangeheadAddresscan never overflow the page arithmetic regardless of source (defense-in-depth).Why the fail-fast constructor change is safe for Garnet
TryRecoverLatest = true.TryRecoverLatest = false, so the durability path never runs it and is unaffected.NullDevicelogs, so it only fail-fasts on genuine corruption.info.UntilAddress == 0→ resets to empty and never throws.Testing
test.hlogsuite: 550 passed, 0 failed (2 Azure cases skipped).FlakyLogTestTolerateFailure) still recovers surviving entries best-effort; non-tolerate paths (FlakyLogTestCleanFailure,FlakyLogTestConcurrentWriteFailure) still surfaceCommitFailureException.dotnet formatclean.