Skip to content

Data loss on recovery: SeSaveRecoverMultipleKeysTest("25k","18k") intermittently loses a key (~50% repro rate) #1950

Description

@hexonal

Describe the bug

RespAdminCommandsTests.SeSaveRecoverMultipleKeysTest("25k","18k") (test/standalone/Garnet.test/RespAdminCommandsTests.cs) intermittently fails after a server restart with tryRecover: true: one specific key, SeSaveRecoverTestKey0693, comes back as an empty string instead of its expected value after recovery, even though it was written and verified present before the restart. All 1999 other keys in the same test recover correctly.

This looks like a real (if narrow) data-durability bug in the recovery path, not simple test flakiness — see reproduction data below.

Steps to reproduce the bug

dotnet test test/standalone/Garnet.test/Garnet.test.csproj -c Release --framework net10.0 --filter "FullyQualifiedName~SeSaveRecoverMultipleKeysTest"

Run it repeatedly (single run isn't enough — this is timing-dependent). Over 18 local runs (macOS, arm64, .NET 10.0.10, Release config) it failed 9 times (50%) — every single failure was the exact same test case, ("25k","18k"), and the exact same key, SeSaveRecoverTestKey0693. It's not a random/different key each time.

We also independently hit this same test (different parameterizations of the same signature — an empty-string assertion failure) twice in CI on unrelated PRs (#1945, #1946), and once saw a full 45-minute CI job timeout/hang after this test's ("16k","16k") case completed with no further log output at all (PR #1949's ubuntu-latest, net10.0, Debug job) — possibly a related but rarer failure mode in the same area, not independently reproduced locally yet.

Expected behavior

SeSaveRecoverTestKey0693 (and every other key) should recover with its original value ("SeSaveRecoverTestValue") after the server restart, matching the assertion already in the test.

What we found so far

The test writes 1000 keys, does a background SAVE, writes 1000 more keys, does COMMITAOF, disposes the server, then restarts it with tryRecover: true at a smaller memorySize/recoveryMemorySize than the original run — forcing recovery to happen under tighter memory pressure than the data was originally written under, which we believe is what makes this reproducible at all (the other, larger-memory test cases in the same parameterized test did not fail in our runs).

We traced (without being able to pin an exact line within the time we spent) that the likely interaction is between:

Our hypothesis (not confirmed at the exact line level): a record landing near a page/segment boundary during this eviction-while-recovering interleaving can end up with its index entry pointing below the newly-adjusted HeadAddress without the on-disk read fallback correctly kicking in for it, making it read back as absent/empty. #1932's own added comments (LogSizeTracker.cs, the resizerTask.IsCompleted "defense in depth... even if OnStopped() somehow did not run" check) suggest the original fix wasn't fully confident it eliminated every timing case in this area, which is consistent with what we're seeing.

We don't have deep expertise in Tsavorite's recovery state machine to pin this down further ourselves with confidence, but wanted to file this promptly given how reliably it reproduces (a maintainer familiar with this code, with the repro rate/key above, should be able to nail it quickly with targeted tracing/logging around key 0693's record address during that specific test case).

Happy to help test a fix, or dig further ourselves if useful — let us know.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions