Skip to content

Scope checkpoint metadata I/O completions to their own operation - #2154

Merged
Ted Hart (TedHartMS) merged 8 commits into
mainfrom
tedhar-fix-devicelogcommit-shared-io-completion
Sep 25, 2026
Merged

Ted Hart (TedHartMS) merged 8 commits into
mainfrom
tedhar-fix-devicelogcommit-shared-io-completion

Conversation

@TedHartMS

@TedHartMS Ted Hart (TedHartMS) commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2149

Problem

DeviceLogCommitCheckpointManager kept its completion semaphore and its error code on the instance, and passed context: null to every device I/O:

private readonly SemaphoreSlim semaphore;   // one per manager
private uint metadataIoErrorCode;           // one per manager
...
metadataIoErrorCode = 0;
device.ReadAsync(..., IOCallback, null);    // nothing identifies the operation
semaphore.Wait();

Nothing tied a completion to the operation that issued it. With two metadata operations in flight:

  • Completion theft — B's completion releases A's wait. ReadInto A wakes while its own read is still outstanding and copies its still-zeroed buffer, so GetIndexCheckpointMetadata reads a length prefix of 0 and rejects a valid checkpoint as truncated.
  • Buffer recycled under in-flight I/O — A's finally { pbuffer.Return(); } then runs while A's read is still writing into that buffer. SectorAlignedBufferPool.Return zeroes the buffer and can hand it to another caller, so this also corrupts whichever operation was still running. This is a live use-after-return, not merely a stale read.
  • Error stealing / clobbering — metadataIoErrorCode = 0 resets an error another operation has not observed yet, and the post-wait read can surface an error belonging to a different operation.

Symptom from the issue: concurrent INFO ALL intermittently logs Skipping unreadable index checkpoint / Invalid metadata length 0 and returns disk_checkpoint_entry:(empty) while the on-disk metadata is intact.

Fix

Each ReadInto/WriteInto allocates its own MetadataIoCompletion (its own SemaphoreSlim plus its own ErrorCode) and passes it as the device I/O context; IOCallback records the error on, and releases, only that instance. This mirrors the per-call semaphore Garnet.cluster.ClusterUtils already passes as its I/O context.

Because the wait can now only be satisfied by the operation's own callback, the existing finally { pbuffer.Return(); } becomes a correct buffer-lifetime guarantee instead of a race.

Metadata operations stay concurrent — INFO ALL is a normal concurrent request path, so serializing the whole metadata sequence behind one lock (the issue's other suggested option) was rejected; it would also not fix the buffer-lifetime hazard as directly.

Two details worth noting:

  • The SemaphoreSlim is deliberately not disposed. It never allocates a wait handle (AvailableWaitHandle is untouched), and on a synchronous device throw — where no completion is delivered — disposing it would risk a late Release faulting a device I/O thread.
  • Release/Wait supplies the happens-before edge that publishes ErrorCode to the waiting thread, which is the same guarantee the previous code already relied on.

Also in this PR:

  • The lazy bufferPool creation was an unsynchronized check-then-act; it is now published with Interlocked.CompareExchange, and a candidate that loses the publish race is freed rather than dropped — its constructor reserves a process-wide pool slot that sizes every thread's shard array, and only Free returns that slot.
  • GarnetClusterCheckpointManager.GetLogCheckpointMetadata sliced by the metadata length prefix without the ThrowIfInvalidMetadataSize check every base-class reader applies, so a truncated file was silently parsed as empty metadata (or threw ArgumentOutOfRangeException from Slice on a negative size). It now applies the same guard — ThrowIfInvalidMetadataSize becomes protected static — and disposes its device with using so the added throw cannot leak it. The bound is unchanged from the one the base class already applies to this identical file format.
  • TestBase keys its running-test set by TestContext.Test.FullName rather than Name. Name carries a parameterized test's arguments but not its fixture, so same-named tests in different fixtures shared a key — which both blurred the unhandled-exception dump and let one test's TearDown remove an entry belonging to another.

Tests

New GatedCompletionDevice test double defers the underlying I/O, not just its callback, so a caller woken by a foreign completion observes its own buffer exactly as it left it. It releases pending operations in a chosen order, can complete one with an injected error code, and stamps a shared monotonic counter when each callback fires so "did this caller return before its own completion?" is assertable without timing.

Three tests, covering the four scenarios the issue asks for:

Test Covers
ConcurrentMetadataReadsDoNotConsumeEachOthersCompletions reordered concurrent reads (1) + buffer not returned before its own callback (3)
MetadataWriteDoesNotConsumeAConcurrentReadsCompletion read concurrent with write (2) + write lands intact
MetadataOperationDoesNotConsumeAnotherOperationsError error not stolen and not reset by a concurrent operation (4)

All three were verified to fail against the pre-fix code with the intended diagnostics (read 0 returned a buffer that its own read had not filled, the metadata write returned before its own completion fired..., a healthy read reported another operation's error), not merely to pass after it.

Detection is structural rather than timing-based: the gate holds the second caller inside the device call once its operation is captured, so it never reaches its wait. When the first completion is delivered there is exactly one waiter, and SemaphoreSlim — which specifies no wake order — has no ordering choice to make. An earlier revision relied on FIFO wake order and could pass against the broken code by scheduling luck.

ClusterCheckpointMetadataTests covers the cluster override that gained the length check: it asserts the named exception, that a failing read does not leak its device, and that valid metadata still round-trips. The internal manager is reached through the public ClusterFactory.CreateCheckpointManager rather than by widening its visibility.

Validation

  • dotnet build Garnet.slnx and Tsavorite.slnx — 0 warnings, 0 errors
  • dotnet format clean on Garnet.slnx and Tsavorite.slnx
  • Tsavorite.test.hlog FlakyDeviceTests — 22/22 pass
  • Garnet.test.cluster ClusterCheckpointMetadataTests — 3/3 pass
  • Garnet.test CheckpointFailureTests + PortSlotTests — 34/34 pass
  • Tsavorite recovery (CheckpointManagerTests, SimpleRecoveryTest, LogResumeTests) — 19 passed, 5 skipped (Azure)
  • Standalone Garnet save/recover + checkpoint — 21 passed, 2 skipped
  • Cluster replication checkpoint tests (ClusterSRPrimaryCheckpointAsync, ClusterSRPrimaryCheckpointRetrieve) fail identically on a pristine baseline with no changes applied — a pre-existing Timed out DeleteDirectory teardown flake on this Windows machine, reported as primary failure — test itself passed. Parity confirmed, not a regression from this PR.

DeviceLogCommitCheckpointManager held its completion semaphore and its
error code on the instance and passed a null device I/O context, so
nothing tied a completion to the operation that issued it. With two
metadata operations in flight, one operation's completion released the
other's wait: the woken operation copied a buffer the device had not
filled yet, and returned that buffer to the pool while its own I/O was
still writing into it. SectorAlignedBufferPool zeroes a returned buffer
and can hand it to another caller, so this also corrupted the operation
that was still running. The shared error field had the same ownership
problem in both directions: an operation could observe an error raised
by another, or reset one another had not observed yet.

Concurrent INFO ALL requests hit this and intermittently reported a
valid checkpoint as "Invalid metadata length 0 ... truncated or
corrupt", logged "Skipping unreadable index checkpoint", and returned
disk_checkpoint_entry:(empty).

Give each read and write its own MetadataIoCompletion, holding that
operation's semaphore and error code, and pass it as the device I/O
context. A completion now releases only its own issuer, which also makes
the existing buffer return in the finally block a correct lifetime
guarantee rather than a race. Publish the lazily created buffer pool
with a compare-exchange so concurrent operations cannot each build one.

Also validate the metadata length prefix in the cluster-mode override of
GetLogCheckpointMetadata, which sliced by it without the check every
base-class reader applies, and dispose its device with using so the
added throw cannot leak it.

Regression coverage adds a GatedCompletionDevice that defers the
underlying I/O, not just its callback, so a test can deliver completions
in a chosen order and assert that no operation returns before its own
completion fires. The three new tests fail against the previous code.

Fixes #2149
Copilot AI balanced review requested due to automatic review settings September 17, 2026 22:13

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.

🟡 Changes recommended

Buffer-pool publication leaks losing candidates, two tests rely on unspecified semaphore ordering, and cluster validation lacks regression coverage.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Scopes checkpoint metadata completions and errors per I/O operation to prevent concurrent reads and writes from corrupting results or recycling active buffers.

Changes:

  • Adds operation-specific completion state and synchronized buffer-pool publication.
  • Validates cluster checkpoint metadata sizes and ensures device disposal.
  • Adds gated-device concurrency and error-isolation tests.
File summaries
File Description
DeviceLogCommitCheckpointManager.cs Isolates metadata I/O completion state.
GarnetClusterCheckpointManager.cs Validates metadata lengths and disposes devices.
SimulatedFlakyDevice.cs Adds a gated test device.
FlakyDeviceTests.cs Adds concurrent metadata I/O tests.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 4
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.


💡 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/test/test.hlog/FlakyDeviceTests.cs Outdated
Comment thread libs/storage/Tsavorite/cs/test/test.hlog/FlakyDeviceTests.cs
Comment thread libs/cluster/Server/Replication/GarnetClusterCheckpointManager.cs
Resolves a conflict in SimulatedFlakyDevice.cs where both sides appended new
test devices after ErrorCodeOnReadDevice. Git aligned them on the shared
StorageDeviceBase boilerplate (constructor, Initialize, RemoveSegmentAsync,
Dispose) and reported four hunks; both additions are kept in full.

Main's DeferredCompletionDevice and this branch's GatedCompletionDevice are
not redundant. The former holds a single read, selected by index, while writes
pass through, and records whether Dispose ran with IO outstanding. The latter
gates reads and writes, releases pending operations in an order the test
chooses, can complete one with an injected error code, and stamps a shared
counter so a test can assert that no caller returned before its own completion.
The checkpoint metadata tests need reordered release across two concurrent
operations and write gating, which the former does not provide.
Free the losing buffer pool candidate. Its constructor reserves a
process-wide slot that sizes every thread's shard array, and only Free
returns that slot, so a candidate dropped after losing the publish race
raised the slot high-water mark permanently.

Make the concurrency regression tests independent of SemaphoreSlim's
wake order, which is unspecified. The gate now holds the second caller
inside the device call after its operation is captured, so it never
reaches its wait; when the first operation's completion is delivered
there is exactly one waiter and no ordering choice exists. Previously
the tests detected completion theft only if the runtime happened to
wake waiters first-in-first-out.

Cover the cluster override of GetLogCheckpointMetadata, which gained
the metadata length check. The manager is reached through the public
ClusterFactory.CreateCheckpointManager rather than by widening the
internal type's visibility. The tests assert the named exception, that
a failing read does not leak its device, and that valid metadata still
round-trips.
TestContext.Test.Name carries a parameterized test's arguments but not
its fixture, so two same-named tests in different fixtures share a key
in RunningTests. That loses the disambiguation the unhandled-exception
dump exists to provide, and lets one test's TearDown remove an entry
belonging to another.

Key on FullName in both the SetUp that adds the entry and the TearDown
that removes it; the two must agree or the TryRemove assertion fires.
@TedHartMS
Ted Hart (TedHartMS) merged commit 0e5fa38 into main Sep 25, 2026
226 of 227 checks passed
@TedHartMS
Ted Hart (TedHartMS) deleted the tedhar-fix-devicelogcommit-shared-io-completion branch September 25, 2026 01:21
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.

DeviceLogCommitCheckpointManager shares I/O completions across concurrent reads, causing valid checkpoints to be skipped

3 participants