Skip to content

Harden TsavoriteLog commit-failure handling and fix fast-commit recovery poisoning - #2065

Merged
Ted Hart (TedHartMS) merged 6 commits into
mainfrom
tedhar/ci-commit-failure
Aug 18, 2026
Merged

Harden TsavoriteLog commit-failure handling and fix fast-commit recovery poisoning#2065
Ted Hart (TedHartMS) merged 6 commits into
mainfrom
tedhar/ci-commit-failure

Conversation

@TedHartMS

Copy link
Copy Markdown
Contributor

Summary

Fixes a host-crashing CI failure in CommitRecordBoundedGrowthTest (LogFastCommitTests) and the two underlying Tsavorite defects it exposed.

The original crash was a CommitFailureException with device error 0xFFFFFFFF thrown from an unguarded log.Commit() on a background thread, which terminated the whole test host. Investigating that surfaced a separate, production-relevant defect: a CalculateReadOnlyAddress address overflow caused by fast-commit recovery leaving the log poisoned after a swallowed exception.

Changes

1. Test + device-error hardening (commit 94ba1f96)

  • Parameterized CommitRecordBoundedGrowthTest with [Values(1, -1)] int numThreads (-1ProcessorCount / 2, else 1).
  • Background commit threads now capture the first exception, stop the others, and fail the test assertively instead of throwing unhandled on a raw Thread and killing the test host.
  • Plumbed the real device exception through StorageDeviceBase.LastErrorCommitInfo.ExceptionCommitFailureException.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 sets HeadAddress / CommittedUntilAddress = long.MaxValue as "shut up safe guards" sentinels before scanning the log tail. If RestoreHybridLogAsync then throws (device/metadata mismatch), the TsavoriteLog sync constructor swallowed it with a bare catch { }, leaving the log poisoned with a sentinel HeadAddress. The first Enqueue routed through CalculateReadOnlyAddress(tail, long.MaxValue), whose page arithmetic overflowed to a negative address:

  • Debug: tripped the ReadOnlyAddress ... must not be less than HeadAddress assert.
  • 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 (with unbounded memory growth) — worse than the Debug assert.

Fixes:

  • TsavoriteLog: added ResetRecoveryState(), 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 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 had succeeded (silent data loss). This matches the async CreateAsync path, which already propagates recovery failures.
  • AllocatorBase: CalculateReadOnlyAddress now clamps headAddress >= tailAddress to return tailAddress, so a sentinel/out-of-range headAddress can never overflow the page arithmetic regardless of source (defense-in-depth).

Why the fail-fast constructor change is safe for Garnet

  • The sync recovery path only runs when TryRecoverLatest = true.
  • Garnet AOF sets TryRecoverLatest = false, so the durability path never runs it and is unaffected.
  • PubSub uses the default but on fresh/NullDevice logs, so it only fail-fasts on genuine corruption.
  • First startup (no commit) hits info.UntilAddress == 0 → resets to empty and never throws.

Testing

  • New 48-thread fast-commit stress reproduction failed on iteration 1 before the fix; 400/400 iterations pass after (harness kept out of the committed test).
  • Full test.hlog suite: 550 passed, 0 failed (2 Azure cases skipped).
  • Tolerate path (FlakyLogTestTolerateFailure) still recovers surviving entries best-effort; non-tolerate paths (FlakyLogTestCleanFailure, FlakyLogTestConcurrentWriteFailure) still surface CommitFailureException.
  • Release build clean (net8.0 + net10.0, 0 warnings); dotnet format clean.

Ted Hart (TedHartMS) and others added 2 commits August 12, 2026 16:22
…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

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.

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 that inflightWord is 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.

Comment thread libs/storage/Tsavorite/cs/src/core/Allocator/AllocatorBase.cs Outdated
Comment thread libs/storage/Tsavorite/cs/src/core/TsavoriteLog/TsavoriteLog.cs Outdated
Comment thread libs/storage/Tsavorite/cs/test/test.hlog/LogFastCommitTests.cs
Ted Hart (TedHartMS) and others added 4 commits August 13, 2026 20:30
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
@TedHartMS
Ted Hart (TedHartMS) merged commit e5a4f13 into main Aug 18, 2026
668 of 671 checks passed
@TedHartMS
Ted Hart (TedHartMS) deleted the tedhar/ci-commit-failure branch August 18, 2026 02:22
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.

3 participants