Fix spurious NOTFOUND from ContinuePendingRead after a successful disk read#1952
Fix spurious NOTFOUND from ContinuePendingRead after a successful disk read#1952hexonal wants to merge 2 commits into
Conversation
…k read Root-causes and partially fixes microsoft#1950 (data loss on recovery: SeSaveRecoverMultipleKeysTest("25k","18k") intermittently loses a key). Investigation found the original hypothesis in the issue (an interaction between microsoft#1932's synchronous eviction path and the snapshot-checkpoint recovery state machine) does not hold: extensive tracing showed the record is always correctly present and durable on disk at recovery time, and disk reads for it correctly retrieve the value via ContinuePendingRead's Reader() call in every observed instance. The real, confirmed bug is in ContinuePendingRead (Index/Tsavorite/Implementation/ContinuePending.cs). After a pending (disk) read's Reader() call succeeds, the function decides whether to copy the record to the log tail or read cache based on operationState.readCopyOptions: if (readCopyOptions.CopyFrom != ReadCopyFrom.None && !memoryRecord.IsSet) { if (CopyTo == ReadCopyTo.MainLog) status = ConditionalCopyToTail(...); else if (CopyTo == ReadCopyTo.ReadCache && ...) status |= COPIED_RECORD_TO_READ_CACHE; } else return OperationStatus.SUCCESS; When CopyFrom is Device/AllImmutable (the default whenever ReadCopyFrom.Inherit resolves at the store level - see Tsavorite.cs's constructor) but CopyTo is None - a valid, common configuration meaning "track that a read came from disk for stats, but don't promote it" - neither inner branch runs, and `status` is left holding whatever FindTagAndTryEphemeralSLock's out-parameter set earlier in the loop. That out-parameter is only meaningful when the call returns false; on success it is not a valid completion status. In practice it is left initialized to OperationStatus.NOTFOUND, so a fully successful pending read - Reader() already populated the caller's output - was reported as NOTFOUND, discarding the correct value and causing the client to see an empty reply instead of the stored value. This reproduces whenever a plain GET has to go to disk (i.e. the key was evicted below HeadAddress), which is rare in normal operation but happens reliably right after recovering into a tighter memory budget than the data was written under - exactly what SeSaveRecoverMultipleKeysTest("25k","18k") does. It is not specific to recovery or to microsoft#1932's eviction path; any store using the library default ReadCopyOptions (CopyFrom=Device, CopyTo=None) that returns a disk-resident key hits it. Fix: explicitly set status = OperationStatus.SUCCESS before the CopyFrom-gated block, matching the equivalent and already-correct synchronous path in InternalRead.cs's CopyFromImmutable, which always explicitly returns a status on every branch. This also fixes the same latent bug on the ReadCopyTo.ReadCache branch, which previously OR'd COPIED_RECORD_TO_READ_CACHE onto the same stale placeholder instead of a SUCCESS base. Verification: - Baseline on upstream/main (unpatched): 6/20 local runs of SeSaveRecoverMultipleKeysTest failed (~30%), always case ("25k","18k"), always key SeSaveRecoverTestKey0693 - matching the issue exactly. - With this fix: 6/150 failed (~4%) across several batches - a ~6-7x reduction, but not full elimination. Investigation traced at least one remaining failure to a second, separate issue: the completing AsyncIOContext's logicalAddress was observed as 0 instead of the correct address at ContinuePendingRead entry, consistent with a spurious key-mismatch during on-disk hash-chain verification (TryVerifyOrReissuePendingRead) walking to end-of-chain, or a pending-IO-context pooling/lifetime issue. This second mechanism is NOT fixed here - it needs further investigation and is called out explicitly rather than guessed at, per the risk of a wrong fix in this code path. - Full RespAdminCommandsTests (56 tests), RespConfigTests + AofShardedTxnRecoveryTests (18 tests), Tsavorite.test.recovery (204 tests), CompletePendingTests/LowMemoryTests/StateMachineDriverTests (33 tests), and ReadAddressTests/ReadCacheTests (171 tests) all pass with this change - no regressions observed. No new regression test added: the existing SeSaveRecoverMultipleKeysTest("25k","18k") already reliably exercises this exact mechanism (a plain GET forced to disk after eviction, under default ReadCopyOptions) and continues to serve that purpose, now at a much lower failure rate; a lower-level, deterministic Tsavorite unit test targeting CopyFrom=Device/CopyTo=None directly would be a good follow-up but was not added here for time reasons.
There was a problem hiding this comment.
Pull request overview
This PR fixes an incorrect OperationStatus being returned from Tsavorite’s async/pending read completion path (ContinuePendingRead) after a successful Reader() call, preventing a successfully read value from being reported upstream as NOTFOUND under specific ReadCopyOptions configurations.
Changes:
- Set
status = OperationStatus.SUCCESSimmediately after a successfulReader()call inContinuePendingRead, before anyCopyTo-gated copy logic runs. - Add an explanatory comment documenting why this is required and the scenario it addresses.
|
CI finished: 209/211 checks green. Two cluster jobs failed:
I looked into whether these could be caused by this PR's change before assuming otherwise, since it touches core read-path status handling. My assessment, with the caveats noted:
Flagging this transparently rather than asserting certainty, given this PR touches core Tsavorite read logic. Let me know if you'd like me to dig further, or if this pattern (gossip-timeout flakiness in cluster CI jobs) is already familiar. |
|
Follow-up on the two cluster failures above — dug further rather than leaving it at "probably unrelated":
Net: high confidence the first failure is unrelated (direct root-cause trace + 320-run local parity), moderate-high confidence the second is too (same noise signature, and the one theoretically-reachable path is impractical given the test's tiny data size — the local block on confirming that statistically is an unrelated native-library gap, not something this PR affects). Standing by the PR as-is. |
|
Thanks for the report and investigation. #1957 supersedes this; the change here is not needed, as comments in that PR at this point indicate: // status is already OperationStatus.SUCCESS here: FindTagAndTryEphemeralSLock (at the top of the loop) returns true |
|
Thanks for the correction, and confirming here rather than letting it stand unverified: you're right, and I traced through why.
I also went back and re-tested the "30% → 4%" improvement I cited as evidence, since a no-op code change obviously can't have caused a real effect, and the numbers deserved a harder look rather than being left dangling. Built One thing worth flagging while I have the harness set up: |
|
Yes--turns out there was another issue. Try the current 1957 (it's draft now as I'm investigating something related to the earlier fix). Thanks! |
Addresses part of #1950 (see that issue for the full reproduction and analysis — this PR fixes one of at least two distinct causes of the flakiness described there).
Root cause
In
ContinuePendingRead(libs/storage/Tsavorite/cs/src/core/Index/Tsavorite/Implementation/ContinuePending.cs), after a pending (disk) read'sReader()call succeeds,statusis only explicitly set toSUCCESSinside the two branches gated byoperationState.readCopyOptions.CopyTo(MainLogorReadCache). WhenCopyFrom != ReadCopyFrom.NonebutCopyTo == ReadCopyTo.None— a valid configuration reachable viaReadCopyFrom.Inheritresolution — neither branch runs, andstatusis left holding whateverFindTagAndTryEphemeralSLock's out-parameter set earlier in the call, which is only meaningful on a failed lock/find and effectively behaves likeNOTFOUNDhere. A fully successful disk read, with the correct value already retrieved intooutput, was therefore reported back to the caller asNOTFOUND, discarding the correct value.This is reachable whenever a plain read is forced to disk (record evicted below
HeadAddress) under that specificCopyFrom/CopyTocombination — uncommon in normal steady-state operation, but reliably triggered right after recovering into a tighter memory budget than the data was originally written under (exactly whatRespAdminCommandsTests.SeSaveRecoverMultipleKeysTest("25k","18k")does, which is how this was found).Fix
One line: explicitly set
status = OperationStatus.SUCCESS;immediately after a successfulReader()call, before theCopyTo-gated branching — matching the already-correct pattern in the synchronous read path (InternalRead.cs, wherestatus = OperationStatus.SUCCESSis set unconditionally right afterReader()succeeds, before anyCopyFrom-gated logic runs). The async/pending path was simply missing the equivalent line.Testing
SeSaveRecoverMultipleKeysTest("25k","18k"): on unpatchedmain, 6/20 (~30%) local runs failed. With this fix, 6/150 (~4%) failed — a ~7x reduction, confirming this addresses a real, distinct cause.RespAdminCommandsTests(56 tests),RespConfigTests+AofShardedTxnRecoveryTests(18),Tsavorite.test.recovery(204),CompletePendingTests/LowMemoryTests/StateMachineDriverTests(33),ReadAddressTests/ReadCacheTests(171). All pass, no regressions observed.This does not fully close #1950
The residual ~4% failure rate is a second, distinct issue — in a captured failure,
ContinuePendingRead'srequest.logicalAddresswas0instead of the correct address at completion time. I was not able to pin that down with confidence within a reasonable investigation budget and did not want to guess at a fix for it in this sensitive async-completion path. Full details and a working diagnostic-instrumentation approach are in the issue; happy to keep digging into the second cause separately.