Refuse to serve when no HybridLog checkpoint is readable - #2153
Merged
Ted Hart (TedHartMS) merged 5 commits intoSep 21, 2026
Merged
Conversation
With --recover --fail-on-recovery-error true, a server whose checkpoint tokens all failed to load would start anyway and serve only the retained AOF tail. Recovery.FindRecoveryInfo throws TsavoriteNoHybridLogException both when no checkpoint was ever written and when every token present was rejected as unreadable, and the database managers treated both as a fresh start without consulting FailOnRecoveryError. TsavoriteNoHybridLogException now reports how many checkpoint tokens the scan found and how many of those could not be read, so the two situations are distinguishable. The database managers record that outcome, and a new VerifyRecoveryIsComplete check runs after AOF recovery: it refuses to serve when tokens exist, none was readable, and the surviving AOF cannot cover the gap because it is disabled or begins past the first valid address. A non-origin AOF begin address is deliberately not a trigger on its own. It is the normal state of a diskless-sync replica, which initializes its AOF at its primary's begin address and writes no local checkpoint. A cluster replica reports the condition but still starts, because a full sync from its primary reconciles it. Also fix the cluster-mode amplification reported in the same issue. CheckpointStore.GetLatestCheckpointEntryFromDisk returns an entry with default tokens when nothing valid is found, which PurgeAllCheckpointsExceptEntry treated as the checkpoint to retain and so deleted every HybridLog and index token on disk, destroying the evidence before it could be diagnosed. The purge now declines to run without a valid token to keep, and a recovering node defers the orphan purge until after recovery has reported what it could read. Fixes #2150
The reject count is only meaningful as evidence about on-disk state while one metadata read is in flight at a time. Record that at the counting site and on the exception property so a future change that parallelizes the scan does not silently invalidate the premise.
Ted Hart (TedHartMS)
requested review from
Badrish Chandramouli (badrishc) and
Vasileios Zois (vazois)
and
a balanced review from Copilot
September 17, 2026 21:12
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The public interface change breaks external implementations, and several newly claimed recovery branches lack targeted coverage.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Hardens recovery so Garnet does not serve incomplete state when all checkpoint tokens are unreadable and the AOF cannot reconstruct the database.
Changes:
- Tracks unreadable checkpoint scan results and validates recovery completeness.
- Preserves cluster checkpoint artifacts when no valid checkpoint exists.
- Adds recovery tests and operational documentation.
File summaries
| File | Description |
|---|---|
website/docs/getting-started/configuration.md |
Documents startup recovery behavior. |
test/standalone/Garnet.test/RespRecoveryFailOpenTests.cs |
Tests recovery scenarios. |
libs/storage/Tsavorite/cs/test/test.recovery/NoHybridLogTests.cs |
Tests token counters. |
libs/storage/Tsavorite/cs/src/core/Utilities/TsavoriteException.cs |
Exposes scan statistics. |
libs/storage/Tsavorite/cs/src/core/Index/Recovery/Recovery.cs |
Counts rejected checkpoints. |
libs/server/StoreWrapper.cs |
Invokes completeness verification. |
libs/server/GarnetDatabase.cs |
Stores recovery outcome. |
libs/server/Databases/SingleDatabaseManager.cs |
Handles single-database recovery. |
libs/server/Databases/MultiDatabaseManager.cs |
Handles multi-database recovery. |
libs/server/Databases/IDatabaseManager.cs |
Adds verification API. |
libs/server/Databases/DatabaseManagerBase.cs |
Implements completeness checks. |
libs/server/Databases/CheckpointRecoveryOutcome.cs |
Defines recovery state. |
libs/cluster/Server/Replication/ReplicationManager.cs |
Applies cluster role policy. |
libs/cluster/Server/Replication/CheckpointStore.cs |
Prevents destructive purging. |
Review details
- Files reviewed: 14/14 changed files
- Comments generated: 5
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Give IDatabaseManager.VerifyRecoveryIsComplete a default implementation. The interface is public and StoreWrapper accepts one through its public constructor, so requiring a new member would break external implementations. The check reads recovery state that only a database manager recording its own databases can supply, so doing nothing is the honest default. Cover the two exemptions that were claimed but untested. FastAofTruncate and UseAofNullDevice both otherwise satisfy the refusal conditions, so without the exemption they would refuse to start; removing it fails both new cases. Cover the replica rethrow. The existing replica test injects a general exception before the token scan and so only exercises the unchanged general catch. Add an injection standing in for a transferred checkpoint whose metadata cannot be read, and a test asserting the sync aborts rather than advertising the primary's offset over an empty store. Removing the rethrow fails the new test while the existing one still passes. Scope the sequential-scan claims to startup. Sequential iteration only bounds this caller; another caller can use the same checkpoint manager concurrently and hit the race in #2149. What makes the counts trustworthy is that startup recovery runs before any listener or the metrics monitor starts, so callers such as GetLatestCheckpointTokens remain subject to that race.
Badrish Chandramouli (badrishc)
approved these changes
Sep 21, 2026
Ted Hart (TedHartMS)
deleted the
tedhar-fail-on-recovery-error-unreadable-checkp
branch
September 21, 2026 17:41
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.
Fixes #2150
Problem
With
--recover --fail-on-recovery-error true, a server whose HybridLog checkpoint tokens all failed to load would start anyway and serve only the retained AOF tail. In the reported reproduction that meant serving 5 keys instead of the 205 that were durably written, after printingReady to accept connections.Recovery.FindRecoveryInfothrowsTsavoriteNoHybridLogExceptionin two genuinely different situations: when no checkpoint was ever written, and when every token present on disk was rejected by thecatchinGetClosestHybridLogCheckpointInfo(the "Skipping unreadable HybridLog checkpoint" path).SingleDatabaseManagerandMultiDatabaseManagercaught that exception, logged it as an informational fresh start, and continued — never consultingFailOnRecoveryError, which only the following generalcatch (Exception)does.The issue also reports a second, destructive defect in cluster mode.
CheckpointStore.GetLatestCheckpointEntryFromDiskreturns a non-null entry withstoreVersion: -1and all-zero GUID tokens when nothing valid is found, so theif (entry == null) return;guard inPurgeAllCheckpointsExceptEntrywas unreachable. It then treated those default tokens as the checkpoint to retain and deleted every real HybridLog and index token on disk — destroying the evidence, and any chance of manual recovery, before the operator could diagnose anything.Approach
TsavoriteNoHybridLogExceptionnow reports what the scan saw:CandidateTokenCount(tokens enumerated) andUnreadableTokenCount(tokens whose metadata could not be read), populated where thatcatchalready logs. The database managers record that outcome on the database, and a newVerifyRecoveryIsCompleteruns after AOF recovery and before replay.It refuses to serve when all of the following hold:
BeginAddressis pastLogAddress.FirstValidAddress;FailOnRecoveryErroristrue.The refusal decision deliberately lives in one place, after the AOF state is known, rather than rethrowing at the catch site. Rethrowing there would refuse the case where an unreadable checkpoint sits beside a complete AOF from the first valid address — which full replay reconstructs, so refusing would be a false positive.
Why a non-origin AOF begin address is not a trigger on its own
The issue's expected-behavior section proposes gating on "tokens unreadable and AOF begins past 64". Anchoring instead on the unreadable-token evidence avoids two false positives that the broader rule would introduce:
--aofrecovers no checkpoint and has no AOF, so a rule phrased as "complete iff a checkpoint was recovered or the AOF begins at 64" would refuse a legitimate first start.ReplicaDisklessSync.TryReplicaDisklessRecoveryinitializes the replica's AOF at its primary'scurrentAofBeginAddress, normally far past 64, and deliberately writes no local checkpoint. "No tokens + AOF past 64" is therefore its normal, healthy steady state; gating on it would refuse a node that starts fine today.That state is also what #2150's cluster defect used to leave behind, so it is fixed at the source rather than papered over with a startup gate.
Scoping
FastAofTruncateand AOF null-device configurations are exempt, since they discard AOF history by design. Both would otherwise satisfy the refusal conditions, so the exemption is load-bearing rather than defensive and is pinned by a test. In cluster mode only aPRIMARYrefuses; aREPLICAreports the same error and continues, because a full sync from its primary reconciles it.Cluster purge
PurgeAllCheckpointsExceptTokensnow declines to purge when it has no valid token to retain — "keep nothing" must never mean "delete everything" — and a recovering node defers the orphan purge from theCheckpointStoreconstructor toInitialize(), so nothing is deleted before recovery has reported what it could read.Client-visible behavior changes
Exhaustive. The trigger requires checkpoint tokens present with none readable, so nothing without that evidence changes.
--fail-on-recovery-error true→ now fails to start. Previously started and served partial data. This is the reported bug.--fail-on-recovery-error true→ now fails to start. Previously started and served an empty database. This is beyond the issue's literal conjunction; it does not apply to a cluster replica.--fail-on-recovery-error false(the default) → no functional change. The same conditions are now logged at Error instead of Information, so operators grepping forNo Hybrid Log found for recoveryat Information level in the unreadable case will see an Error-level message instead.--fail-on-recovery-error→ checkpoint artifacts are no longer deleted when no valid checkpoint can be selected. Anyone who unknowingly relied on that cleanup will now see orphan token directories retained. This is the issue's explicit requirement.Explicitly unchanged, each pinned by a test: a fresh directory with or without
--aof; an AOF-only database whose complete log begins at address 64; a valid checkpoint plus AOF tail; an unreadable checkpoint beside a complete AOF from address 64; no checkpoint tokens with an AOF beginning past 64; and--fast-aof-truncate/ AOF-null-device configurations.There is also one behavior change independent of the option: a replica recovering from a primary-supplied token now fails the sync when every checkpoint token is unreadable, rather than continuing with an incomplete store while still advertising the replication offset the primary sent it.
Interaction with #2149
#2149 reports transient false
Invalid metadata length 0results from concurrent metadata reads. That race requires two concurrent operations on oneDeviceLogCommitCheckpointManagerinstance: the sharedSemaphoreSlimis a counter with balanced per-operation accounting, so a strictly sequential caller cannot consume a foreign release.During startup recovery there is no second reader.
GarnetServer.Start()fully awaitsProvider.RecoverAsync()before starting any listener or the metrics monitor, so neither a clientINFO ALLnor backgroundPopulateCheckpointInfosampling can overlap the scan;GetClosestHybridLogCheckpointInfoiterates tokens sequentially;MultiDatabaseManagerrecovers databases one at a time; and the AOF uses a separate checkpoint manager instance from the store's.A token rejected during recovery therefore reflects the on-disk state, and this change does not make a transient read failure fatal. Post-startup callers such as
INFO ALLremain subject to #2149 and are unaffected here. Sequential iteration alone would not be enough — another caller can use the same checkpoint manager concurrently — so what the comment at the counting site and theUnreadableTokenCountdocumentation record is the startup exclusivity specifically, noting that callers such asGetLatestCheckpointTokensremain subject to #2149.Tests
New
test/standalone/Garnet.test/RespRecoveryFailOpenTests.cscovers the issue's matrix. Fault injection is pure C# — it zeroesinfo.datin place, preserving length, matching the reproduction'sdd conv=notrunc— so it works on Windows and Linux without shelling out.Refusal / preservation:
BadCheckpointCutAofRefuses— the reported bug; also asserts the artifacts surviveBadCheckpointNoAofRefuses— unreadable checkpoint with AOF disabledClusterBadCkptRefuses— cluster primary refuses and both token directories surviveClusterBadCkptKeepsFiles— the purge declines to run with no valid selectionClusterReplicaUnreadableCheckpointAbortsSyncTest— a replica aborts the sync instead of advertising the primary's offset over an empty store. The pre-existing replica test injects a general exception before the token scan and so only covers the unchanged general catch; removing the rethrow fails this new test while that one still passesMust keep working:
FreshStartStarts(with and without--aof),AofOnlyReplaysFully,CheckpointAndTailRecover,BadCheckpointCutAofOptOut(theFailOnRecoveryError=falseopt-out),BadCheckpointFullAofStarts,NoCheckpointCutAofStarts,BadCheckpointTruncModesStart(both--fast-aof-truncateand AOF null-device)New
NoHybridLogTestsinTsavorite.test.recoveryasserts the counters directly:0/0for an empty checkpoint location,1/1for a single zeroed token.As a negative control, with the library changes reverted exactly the four refusal/preservation tests fail — one because no exception is thrown, one because the token directory was deleted — while the seven "must keep working" tests pass on both sides.
Validation
Garnet.test(full)Tsavorite.test.recovery(full)RespRecoveryFailOpenTestsNoHybridLogTestsGarnet.test.cluster(full)dotnet format(both solutions)Every log was grepped for
error CS(0 in all runs) to rule out a stale assembly being run after a failed test-project compile.The four cluster failures are three teardown-only
Timed out DeleteDirectory (primary failure — test itself passed)and oneClusterCheckpointUpgradeFrom. The latter is pre-existing flaky rather than a regression: sampled four times on this branch and four times with the library changes reverted, it failed in two of four runs on both sides, with the same ~65 s timeout signature, and the failure also hits theStandalonecase that never enters cluster mode. The teardown timeouts are the same path-length artifact described below.Garnet.test.cluster.replicationwas also run, on a quiet machine, and paired with a run of the same suite in the same worktree with the library changes reverted to277ea6c:Timed out DeleteDirectory(test body passed)LASTSAVE did not advance within timeouterror CSSame composition and magnitude on both sides, so none of it is attributable to this change. The teardown timeouts are an artifact of this worktree's path length rather than of load:
DeleteDirectory(wait: true)retries forever while swallowingIOException, andPathTooLongExceptionderives fromIOException, so a path overMAX_PATHspins until the 60 s cap and reports "test itself passed". Files under this worktree already reach 265 characters against the 260 limit, because the checkout directory name is 39 characters longer than in the worktree where the same suite reports only 2 failures. #2147 is addressing extended-length test paths separately.Merge note for #2145
Six files overlap with #2145 (
tedhar-bgsave-reports-failed-checkpoint). That PR has grown considerably — 23 files at headbc528d8b9, against 6 when this note was first written — so the overlap is no longer "one comment block".bc528d8b9GarnetDatabase.csLastSaveSucceededand related stateCheckpointRecoveryfieldIDatabaseManager.csTakeCheckpointAsyncreturns a checkpoint statusVerifyRecoveryIsCompletewith a default implementationStoreWrapper.csRecoverAsync()DatabaseManagerBase.csVerifyDatabaseRecoveryIsComplete,AofCanReconstructFromOrigin, and thedb.CheckpointRecoveryassignmentSingleDatabaseManager.cscatch (TsavoriteNoHybridLogException)blockMultiDatabaseManager.csThe two manager files need a per-hunk resolution, not a per-file one. Most of #2145's hunks there are substantive checkpoint-status plumbing that must be kept. Only the comment-only hunk on the
catch (TsavoriteNoHybridLogException)block conflicts with this PR.On that one block, do not keep both sides. #2145's comment explains that the catch is deliberately not gated on
FailOnRecoveryErrorbecause a never-checkpointed directory has no tokens to find and is therefore indistinguishable from a fresh start. After this PR that rationale holds only for theCandidateTokenCount == 0branch — distinguishing those two situations is the entire point of #2150. Keeping both sides is the natural instinct when one side is "just a comment", but it would leave a comment asserting the opposite of the code directly beneath it, on a data-durability path. This PR's replacement comment states the post-change rationale, so take it and drop #2145's.Note also that #2145's own rework of
MultiDatabaseManager.RunPausedCheckpointsAndReleaseLocksAsyncinverted the meaning of a pre-existing comment in that method; that is internal to #2145 and does not interact with this PR, but it is the same union-the-comments hazard in a second place.Whoever lands second should also expect to need a fresh review approval: this repository sets
require_last_push_approval, so a conflict-resolution commit drops an existing approval, while GitHub's native "Update branch" base-merge does not.