Repository navigation
[Tsavorite] Fix silent data loss when the hash index checkpoint exceeds the OS single-request limit - #2159
Merged
Badrish Chandramouli (badrishc) merged 4 commits intoSep 21, 2026
Conversation
Copilot started reviewing on behalf of
Badrish Chandramouli (badrishc)
September 18, 2026 17:56
View session
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Transfer-count checks conflict with the existing IDevice contract, and recovery cleanup can race outstanding I/O or leak devices.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Prevents silent hash-index checkpoint corruption by limiting I/O request sizes and detecting partial transfers.
Changes:
- Splits index checkpoint/recovery I/O into ≤1 GiB chunks.
- Detects short main-index and overflow-bucket transfers.
- Adds fault-injection and regression tests.
File summaries
| File | Description |
|---|---|
IndexCheckpointIoTests.cs |
Tests chunking, truncation, and recovery. |
SimulatedFlakyDevice.cs |
Adds a truncating test device. |
Utility.cs |
Calculates power-of-two chunk counts. |
AsyncResultTypes.cs |
Tracks requested transfer sizes. |
Constants.cs |
Defines the 1 GiB request limit. |
IndexRecovery.cs |
Adds chunked reads and failure propagation. |
IndexCheckpoint.cs |
Adds chunked writes and short-write detection. |
MallocFixedPageSize.cs |
Validates overflow I/O and exact recovery lengths. |
Review details
Suppressed comments (1)
libs/storage/Tsavorite/cs/src/core/Allocator/MallocFixedPageSize.cs:466
- A zero byte count is documented as valid for a successful
IDevicecallback (IDevice.cs:12-15), so this makes overflow recovery fail on conforming/custom devices that do not populate the count. Only validate a nonzero count unless transfer counts become mandatory in the public contract.
else if (numBytes < result.numBytesToRead)
- Files reviewed: 8/8 changed files
- Comments generated: 7
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Badrish Chandramouli (badrishc)
force-pushed
the
badrishc/fix-2134-index-io-max-rw-count
branch
from
September 18, 2026 18:03
870862d to
ab52db7
Compare
Ted Hart (TedHartMS)
approved these changes
Sep 19, 2026
Badrish Chandramouli (badrishc)
force-pushed
the
badrishc/fix-2134-index-io-max-rw-count
branch
from
September 19, 2026 00:03
67b8d81 to
7d07126
Compare
The main hash-index checkpoint and recovery split the table only when it exceeded uint.MaxValue, so a 2GiB index was written and read as a single request. Linux truncates any single transfer at MAX_RW_COUNT (0x7ffff000 with 4KiB pages) and reports the truncated count as a successful completion, and the index IO callbacks ignored the transferred count. SAVE therefore returned OK while the last 4096 bytes of the hash table were never written, and recovery likewise left those buckets unread. Prevention: cap a single index request at Constants.kMaxIoBytesPerRequest (1GiB), which is below every MAX_RW_COUNT variant and below int.MaxValue (required by devices that transfer through Memory<byte>). Chunk counts now come from Utility.GetNumIoChunks, an integer computation that replaces the previous Math.Pow/Math.Log arithmetic. Detection: the main-index and overflow-bucket flush and read callbacks now compare the transferred count against the requested length and fail the checkpoint or recovery. Main-index recovery previously logged hard IO errors and continued; it now propagates them. Overflow-bucket recovery reads exactly what the checkpoint wrote for each level instead of always requesting a full page, which no longer reads past the end of the checkpoint region and makes the length check safe on all platforms. Verified against the reported repro: with a 2GiB index, SAVE previously left a 4096-byte hole at offset 2,147,479,552 in ht.dat; it no longer does, and a 4M-key store saves and recovers with all keys intact. Co-authored-by: Copilot <[email protected]> Copilot-Session: 75fbde67-59b5-405d-95b5-9fbbbba240ae
…very safe DeviceIOCompletionCallback documents that an implementation may complete successfully without reporting a transferred count, reporting 0 instead, and that the count must not be used to detect short reads. The short-transfer checks compared that count directly, so a conforming device that does not populate it would have failed every index checkpoint and recovery. They now validate only a nonzero count, which still catches the MAX_RW_COUNT truncation this fixes because Linux reports the truncated count. The callback's documentation now states the convention the checks rely on. RecoverFuzzyIndexAsync disposed the index checkpoint device from its catch block while reads could still be outstanding. CountdownWrapper.WaitAsync observed cancellation by completing its own completion source, so a cancelled wait both returned with reads in flight and stranded the counts those reads would later post, making the real completion unobservable. It now abandons only the wait, leaving the countdown usable, and exposes DrainAsync for failure paths that must wait for outstanding IO without observing cancellation. Recovery drains the main-index and overflow-bucket reads before closing the device, and the cleanup scope now covers initialization, which opens the device and issues the reads. Both issue loops retire the requests they could not submit so a drain cannot wait forever for IO that was never started. The XML docs for the maxIoBytesPerRequest parameters described the 1GiB default as the largest single transfer the OS performs; it is deliberately below that on every supported platform, and below int.MaxValue. Tests: a device reporting a zero count round-trips a checkpoint and recovery; a cancelled wait leaves a countdown drainable and the drain completes only after the last callback; a device that fails to submit one of several chunk reads fails recovery without leaving the countdown short. Each was confirmed to fail when its fix is reverted. Co-authored-by: Copilot <[email protected]> Copilot-Session: 75fbde67-59b5-405d-95b5-9fbbbba240ae
Findings from independent reviews of this PR, each fixed with a test that fails when the fix is reverted. Correctness - Recovery could retire a chunk twice. A device may invoke the completion callback synchronously and then throw out of the submit; the compensating loop assumed it had not. The countdown then reached zero while earlier reads were still writing into the table, and the caller closed the device under them. Both read loops now claim a one-shot retirement guard, matching the write side. - ShardedStorageDevice.WriteAsync reported the last shard's transferred byte count rather than the aggregate, so a complete checkpoint spanning shards looked short. A shard reporting zero now makes the aggregate unknown rather than short, on both the read and write paths. - Overflow-bucket recovery derived its level layout from a record count. The checkpoint rounds its final level up to a sector, so for a record size that does not divide the sector the read was both short and unaligned, which the O_DIRECT paths reject. The layout now comes from the persisted byte count. - MallocFixedPageSize.BeginCheckpoint had no failure path: a submit that threw left the outstanding count above zero and every waiter on the checkpoint task blocked forever. It now retires the levels it never issued, as the recovery loops do. - BeginRecovery and InitializeMainIndexRecovery now drop the previous recovery's countdown and error before allocating, so a stale error cannot fail a later recovery and a failure before the countdown exists cannot drain against stale state. Errors - Error channels carry the originating exception alongside the detail, so the failure surfaces as TsavoriteIOException with the device exception as its inner exception rather than as text. Lifecycle - Staging for the read cache runs under try/finally so a throw cannot leave a pooled thread epoch-protected, pinning the safe-to-reclaim boundary. - A staging buffer is released exactly once when a submit throws after the callback has already run. Removals - Deleted IsIndexFuzzyCheckpointCompleted and IsCheckpointCompleted: both polled the outstanding count, which a failed checkpoint also drives to zero, so they reported success for a checkpoint that failed. Neither had a caller. - Deleted the unobservable error recording in the issue-loop catches; the raw exception always propagates before the error field can be read. Co-authored-by: Copilot <[email protected]> Copilot-Session: 75fbde67-59b5-405d-95b5-9fbbbba240ae
PR #2151 added a Garnet.test.TestUtils.EnsurePortSlotResolved() call to the [SetUpFixture] in test/standalone/Garnet.test/TestBase.cs. That file is link-compiled into Tsavorite.test.csproj, which does not compile TestUtils.cs and does not bind Garnet ports, so Tsavorite.slnx fails to build on both net8.0 and net10.0 with: TestBase.cs(46,9): error CS0234: The type or namespace name 'TestUtils' does not exist in the namespace 'Garnet.test' CI did not catch this because the tsavorite path filter in ci.yml only watches 'libs/storage/Tsavorite/**', and #2151 touched only test/** and .github/**, so every Tsavorite job was skipped. Gate the call behind a GARNET_TEST_UTILS symbol defined by the two projects that actually compile TestUtils.cs (Garnet.test and Garnet.test.cluster). Tsavorite.test does not define it and no longer sees the call. The fail-fast behavior #2151 introduced is unchanged: with an invalid GARNET_TEST_PORT_SLOT, Garnet.test still errors in the GlobalUnhandledExceptionHandling OneTimeSetUp before any test runs. Co-authored-by: Copilot <[email protected]> Copilot-Session: 75fbde67-59b5-405d-95b5-9fbbbba240ae
Badrish Chandramouli (badrishc)
force-pushed
the
badrishc/fix-2134-index-io-max-rw-count
branch
from
September 19, 2026 16:35
dfa2b5a to
f399f4b
Compare
Ted Hart (TedHartMS)
approved these changes
Sep 21, 2026
Badrish Chandramouli (badrishc)
deleted the
badrishc/fix-2134-index-io-max-rw-count
branch
September 21, 2026 06:13
Ted Hart (TedHartMS)
added a commit
that referenced
this pull request
Sep 21, 2026
Main fixed the Tsavorite.test build break in #2159 by gating the EnsurePortSlotResolved() call behind a GARNET_TEST_UTILS symbol defined by the two projects that compile TestUtils.cs. This branch removes the call from shared TestBase.cs instead, so the guarded block goes away entirely. Keep the GARNET_TEST_UTILS define in both projects: it marks which projects compile TestUtils.cs, which stays useful for source shared with projects that do not. Its comment now describes that rather than pointing at an #if that no longer exists.
This was referenced Sep 21, 2026
x@01 (x-at-01)
added a commit
to webc-fork/garnet
that referenced
this pull request
Oct 4, 2026
…ingle-request limit data loss (microsoft#2159)
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.
Root Cause
SAVEon a server configured with a 2 GiB hash index (--index 2g --index-max-size 2g) reported success while the last 4096 bytes of the persisted hash table were never written. Two independent defects combined:1. The index was issued as a single oversized request.
IndexCheckpointTsavoriteKV.BeginMainIndexCheckpointandIndexRecoveryTsavoriteKV.BeginMainIndexRecoveryonly split the table into chunks whentotalSize > uint.MaxValue:A 2 GiB table is one 2 GiB request; an 8 GiB table is four 2 GiB requests. Linux caps any single
read/write/io_submittransfer atMAX_RW_COUNT—INT_MAXrounded down to a page boundary,0x7ffff000(2,147,479,552) with 4 KiB pages — and reports the truncated count as a successful completion. Every chunk at or above 2 GiB therefore silently dropped its last 4096 bytes.2. No completion callback checked how many bytes actually moved. All four index I/O callbacks (main-index flush/read, overflow-bucket flush/read) ignored the
numBytesparameter entirely, so the short transfer was indistinguishable from a full one. Main-index recovery was worse still — it only logged hard I/O errors and then continued, bringing up a hash table with unread buckets.The hybrid log is not affected: it is bounded by
LogSettings.kMaxPageSizeBits(128 MB). The index checkpoint is the only in-tree path that can issue a request above 2 GiB.Description of Change
Two layers: prevent the oversized request, and fail loudly if a short transfer ever happens anyway.
Prevention — cap a single index request at 1 GiB
Constants.kMaxIoBytesPerRequest(1L << 30). Chosen to sit below everyMAX_RW_COUNTvariant (0x7ffff000with 4 KiB pages,0x7fff0000with 64 KiB pages) and belowint.MaxValue—ManagedLocalStorageDevicecasts the length tointforMemory<byte>, so a 2 GiB request there would overflow negative.Utility.GetNumIoChunks(totalBytes, maxBytesPerChunk)replaces theMath.Pow/Math.Logarithmetic with an integer loop that doubles the chunk count until each chunk fits. It still returns a power of two, which matters:state[version].sizeis always a power of two andsizeof(HashBucket)is 64, so the table divides evenly into chunks that preserve the sector alignment devices require.BeginMainIndexCheckpointandBeginMainIndexRecoveryuse it. The read-cache path keeps its existing 32 MiB staging chunk (Math.Minof the two).Detection — a short transfer now fails the operation
numBytesToWrite/numBytesToRead) and the chunk or level index.numBytesagainst the requested length and record a failure.DeviceIOCompletionCallbackdocuments that an implementation may complete successfully without reporting a count, reporting 0 instead, so 0 is treated as "not reported" rather than as a truncation. TheMAX_RW_COUNTcase this fixes is still caught, because Linux reports the truncated count. The callback's documentation now states the convention these checks rely on.int errorCodefailure fields became first-failure-winsstringfields (Interlocked.CompareExchange(ref field, detail, null)) so the message can name the chunk or level and the byte counts.BeginMainIndexRecovery's callback now records hard I/O errors instead of only logging them, andRecoverFuzzyIndexAsyncthrowsTsavoriteExceptionrather than returning a partially populated table.Lifecycle — a failed recovery closes the device safely
RecoverFuzzyIndexAsyncmust close the index checkpoint device on failure, becauseFinalizeMainIndexRecovery(which closes it on the success path) is skipped. Closing it naively is unsafe:CountdownWrapper.WaitAsyncobserved cancellation by completing its ownTaskCompletionSource. A cancelled wait therefore returned while reads were still in flight, and stranded the counts those reads would later post — making the real completion permanently unobservable. It now abandons only the wait (Task.WaitAsync), leaving the countdown usable, and exposesDrainAsyncfor failure paths that must wait for outstanding I/O without observing cancellation.InitializeMainIndexRecovery, which is what opens the device and issues the reads.Prerequisite fix — overflow-bucket recovery no longer reads past EOF
MallocFixedPageSize.BeginRecoveryalways requested a full 4 MiB page per level, including the short final level at the end of the checkpoint region, so every recovery read past the end of the file. That is benign when the result is ignored but makes a strict length check unusable (Windows returnsERROR_HANDLE_EOF). It now reads exactly whatBeginCheckpointwrote for that level.This is safe because the recovery-side
lastLevelSizeis derived fromnum_ofb_bytes, which is the sum of the sector-aligned write sizes — so the computed length equals the aligned size actually written and is itself sector-aligned. Theout numBytesReadvalue is unchanged.Key Technical Details
Affected types:
ConstantskMaxIoBytesPerRequest(1 GiB)UtilityGetNumIoChunksDeviceIOCompletionCallbacknumBytesconvention the checks rely onCountdownWrapperDrainAsyncHashIndexPageAsyncFlushResult/HashIndexPageAsyncReadResultOverflowPagesFlushAsyncResult/OverflowPagesReadAsyncResultIndexCheckpointTsavoriteKVstringerror fieldIndexRecoveryTsavoriteKVMallocFixedPageSize<T>DrainRecoveryAsyncBehavioral changes:
TsavoriteException. This is the intended fix — a loud failure is strictly better than a corrupt index.CountdownWrapper.WaitAsyncstill throws, but no longer poisons the countdown. Nothing depended on the old behavior; it made the countdown unusable afterwards.TsavoriteExceptionmessage text changed ("...failed with error code N"→"...failed: chunk N wrote X of Y bytes"). A repo-wide grep confirmed no test asserts the old strings.BeginMainIndexCheckpoint,BeginMainIndexRecoveryand the test-onlyRecoverFuzzyIndexAsyncoverload gained a trailing optionalinternalmaxIoBytesPerRequestparameter, defaulted to the production value. It exists so the chunking is falsifiable in a test without allocating a multi-gigabyte table.Performance: none on any hot path. The extra work is one
uintcomparison per index I/O completion, on a path that runs once per checkpoint.What NOT to Do (for future agents)
AsyncGetFromDiskretries on them when a record straddles the end of a read. A blanket device-level check breaks the log path.numBytes == 0as a short transfer.DeviceIOCompletionCallbackpermits a successful completion to omit the count. Only a nonzero count below the requested length proves truncation.TaskCompletionSourceto implement cancellation. It strands the counts the outstanding I/O will post and makes the real completion unobservable. UseTask.WaitAsync(token).IDeviceAPI isuint-bounded anyway. Split at the caller, where the chunk count is already a concept.MAX_RW_COUNTitself. The value must also stay underint.MaxValueforMemory<byte>-based devices, and the constant varies with page size across platforms.Edge Cases
totalSize / 1 GiBchunksMath.MinTsavoriteException, checkpoint/recovery failsTesting
New fixture
libs/storage/Tsavorite/cs/test/test.recovery/IndexCheckpointIoTests.cs(17 cases) plus three device doubles inSimulatedFlakyDevice.cs:TruncatingIoDevice(reproduces kernel truncation and records every(offset, length)issued),ZeroCountReportingDevice, andThrowOnNthReadDevice.IndexIoChunksStayWithinOsRequestLimit— 7 cases from 1 MiB to 4 TiB (including 2 GiB, the reported size); asserts every chunk is underMAX_RW_COUNTandint.MaxValue, that chunks tile the table exactly, and that the split is no finer than necessary.IndexLargerThanRequestCapRoundTripsWithoutTruncation— end-to-end round trip with the cap lowered to 1 MiB on a 4 MiB table and the device capped at 1 MiB, so an oversized request would lose data. Asserts zero truncations on write and read plus bucket-for-bucket table equality.MainIndexCheckpointFailsOnShortWrite,OverflowBucketCheckpointFailsOnShortWrite,MainIndexRecoveryFailsOnShortRead,OverflowBucketRecoveryFailsOnShortRead— each assertsTsavoriteExceptionrather than silent success.ZeroReportedByteCountIsNotTreatedAsShortTransfer— a device that reports 0 round-trips a checkpoint and recovery.CancellingAWaitLeavesTheCountdownDrainable— a cancelled wait throws, and the drain completes only after the last callback has run.RecoveryFailsPromptlyWhenAChunkCannotBeIssued— a device that fails to submit one of four chunk reads fails recovery without leaving the countdown short.OverflowBucketRecoveryReadsOnlyWhatWasWritten— compares summed read bytes against written bytes.MultiChunkIndexCheckpointRoundTrips— the read-cache path, 64 MiB table → two 32 MiB chunks.Falsification — every fix was reverted independently to confirm the tests actually catch the bug: reverting the short-I/O detection fails exactly the 5 detection tests; reverting the chunking to the old
uint.MaxValuelogic failsIndexLargerThanRequestCapRoundTripsWithoutTruncation; reverting the nonzero guard failsZeroReportedByteCountIsNotTreatedAsShortTransfer; reverting theCountdownWrapperchange failsCancellingAWaitLeavesTheCountdownDrainable; reverting request retirement hangs and failsRecoveryFailsPromptlyWhenAChunkCannotBeIssued.End-to-end, against a live server with
--index 2g --index-max-size 2gunderstrace -e trace=io_submit:aio_nbytesSAVEresultht.dat.0SEEK_HOLE)DBSIZE4,000,000; 10,000/10,000 sampled keys presentRegression runs:
Tsavorite.test.recovery223 passed / 0 failed / 13 skipped (Azure);Tsavorite.test290 passed;Tsavorite.test.hlog580 passed;Garnet.testRespAdminCommandsTests+NativeAllocatorServerTests+ checkpoint-recover 66 passed / 2 skipped.dotnet build Garnet.slnxclean on net8.0 and net10.0 with 0 warnings;dotnet formatclean on both solutions.Follow-up: first-principles review
Three independent model reviews of the PR surfaced further problems in the same code paths. Each is fixed with a test that fails when only that fix is reverted.
Correctness
LocalMemoryDevicepropagates callback exceptions.ShardedStorageDevice.WriteAsyncreported the last shard's transferred count, not the aggregate. For a write spanning shards that is a nonzero value below the requested length, so short-transfer detection would reject a checkpoint that actually succeeded — the only false-positive source in the detection layer. A shard reporting 0 now makes the aggregate unknown rather than short, on both the read and write paths.MallocFixedPageSize.BeginCheckpointhad no failure path. A submit that threw left the outstanding count above zero, socheckpointTcsnever completed and every waiter on the checkpoint task blocked forever — the symmetric half of the hang already fixed on the recovery loops. It now retires the levels it never issued.BeginRecoveryandInitializeMainIndexRecoverynow drop the previous recovery's countdown and error before allocating, so a stale error cannot fail a later recovery and a failure before the countdown exists cannot drain against stale state.Errors
IoFailure, CAS'd as one object so both publish atomically). Failures surface asTsavoriteIOExceptionwith the device exception asInnerExceptionrather than as flattened text.Lifecycle
try/finally, so a throw cannot leave a pooled thread epoch-protected and pin the safe-to-reclaim boundary for the life of the process.Removals
IsIndexFuzzyCheckpointCompletedandIsCheckpointCompleted. Both polled the outstanding-IO count, which the new failure path also drives to zero, so they reported success for a checkpoint that failed — the same silent-success class this PR fixes. Neither had a caller.catchblocks: the raw exception always propagates before anything reads the error field, so those calls were unobservable.Tests added (all falsified individually)
CancelledRecoveryClosesTheIndexFileOnlyAfterItsReadsHaveCompletedDisposeon the production entry pointSynchronousCompletionThenThrowRetiresAChunkOnlyOnceOverflowBucketRecoveryReadsSectorAlignedFinalLevelShardedCheckpointIsNotReportedAsShortOverflowBucketCheckpointFailsRatherThanHangingWhenALevelCannotBeIssuedCheckpointPreservesTheDeviceExceptionAsInnerException,RecoveryPreservesTheDeviceExceptionAsInnerExceptionNew device doubles:
ThrowOnNthWriteDevice,CallbackThenThrowDevice,CallbackExceptionDevice,DeferredCompletionDevice, andSingleDeviceCheckpointManager.Regression runs:
Tsavorite.test.recovery230 passed / 0 failed,Tsavorite.test.hlog580 passed / 0 failed,Tsavorite.test290 passed / 0 failed.dotnet build Garnet.slnxclean on net8.0 and net10.0;dotnet formatclean on both solutions.Unrelated build fix carried by this PR
Rebasing onto
main(5c6e59536, #2151) surfaced a pre-existing build break that blocks this PR's own CI.#2151 added
Garnet.test.TestUtils.EnsurePortSlotResolved()to the[SetUpFixture]intest/standalone/Garnet.test/TestBase.cs. That file is link-compiled intoTsavorite.test.csproj, which does not compileTestUtils.cs, soTsavorite.slnxfails to build on both TFMs:Verified on a pristine
origin/mainworktree, so it is not introduced by this branch. CI missed it because thetsavoritepath filter inci.ymlwatches onlylibs/storage/Tsavorite/**, and #2151 touched onlytest/**and.github/**— so every Tsavorite job was skipped. This PR does touch Tsavorite, so those jobs run and would fail.Commit
f399f4b68gates the call behind aGARNET_TEST_UTILSsymbol defined by the two projects that actually compileTestUtils.cs(Garnet.test,Garnet.test.cluster). It is isolated in its own commit and can be reverted or split out independently.The fail-fast behavior #2151 added is unchanged — verified by running
Garnet.testwithGARNET_TEST_PORT_SLOT=999999, which still errors inGlobalUnhandledExceptionHandlingOneTimeSetUpbefore any test runs. #2151's own tests still pass:PortSlotTests18/18,ClusterPortBandTests4/4.Issues Fixed
Fixes #2134