Do not report a failed checkpoint as a successful save - #2145
Ted Hart (TedHartMS) wants to merge 9 commits into
Conversation
A checkpoint that failed was reported to clients as a successful save. DatabaseManagerBase.TakeCheckpointAsync returned long?, using null both for "this was an incremental checkpoint, so there is no tail address to record" and for "the checkpoint threw", and every caller then advanced LastSaveTime unconditionally. BGSAVE followed by the documented LASTSAVE-polling pattern therefore reported durability for data that was never written; the loss only surfaced as an empty store after the next restart, because recovery treats a missing hybrid log as a fresh start. Two further paths reported the same false success: - InitiateCheckpointAsync captured the result of StateMachineDriver.RunAsync and never read it. RunAsync returns false when another state machine (an index resize, for instance) is already running, meaning no checkpoint was taken, yet the code still truncated the AOF and registered a cluster checkpoint entry - discarding data the checkpoint did not cover. - The failure was logged through the optional logger parameter rather than the manager's own logger, so it frequently left no trace at all. Replace the overloaded null with a CheckpointResult carrying an explicit IsSuccessful alongside the nullable tail address, and record the outcome through a single UpdateLastSaveData that advances LastSaveTime only on success. Failures are returned rather than thrown so that a background checkpoint cannot tear down the server and one database's failure does not abort the bookkeeping of the databases checkpointed alongside it. IDatabaseManager.TakeCheckpointAsync now returns a CheckpointStatus, since its bool could not distinguish "another checkpoint is in progress" from "the checkpoint failed". A failed checkpoint also left the store unable to take any later one: the REST phase that releases _indexCheckpoint and _hybridLogCheckpoint is never entered when the state machine aborts, so the next checkpoint failed its Debug.Assert(_indexCheckpoint.IsDefault) in PREPARE. CompleteCheckpointAsync already performs exactly this cleanup on failure, but is not on the RunAsync path Garnet uses. Add an OnAbort hook to IStateMachineTask, called by the driver when it fast-forwards an aborted state machine to REST, and perform the same cleanup there. Client-visible changes: - SAVE now returns an error when the checkpoint did not complete, instead of replying OK. This matches Redis, which reports a failed SAVE as an error. - BGSAVE's reply is unchanged, as it is sent before the checkpoint runs. - LASTSAVE advances only for a checkpoint that completed, so it can be polled after BGSAVE to confirm persistence, as the documentation says. - INFO PERSISTENCE gains rdb_last_bgsave_status (ok/err), which is how a background save's failure is reported in Redis. The section was previously suppressed entirely unless the append-only file was enabled, even though its fields already fall back to "N/A" in that case; it is now always emitted so a checkpoint failure is visible without AOF. The regression tests inject the failure through a device factory that throws when creating store checkpoint devices, reproducing the original GetSnapshotObjectLogDevice failure without depending on path length, file permissions or platform, and they cover the multi-database manager as well.
There was a problem hiding this comment.
🟡 Changes recommended
Abort cleanup can leave RangeIndex barriers active, and multi-database SAVE can still report success after skipping a busy database.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Prevents failed checkpoints from being reported as successful and adds observable persistence status. Metadata matches the implementation, though no GitHub issue is linked.
Changes:
- Introduces explicit checkpoint result/status handling.
- Cleans up aborted Tsavorite checkpoints.
- Adds INFO metrics, documentation, and failure tests.
File summaries
| File | Description |
|---|---|
website/docs/commands/server.md |
Documents checkpoint status metrics. |
website/docs/commands/checkpoint.md |
Documents SAVE/BGSAVE failure behavior. |
test/standalone/Garnet.test/RespAdminCommandsTests.cs |
Updates status assertions. |
test/standalone/Garnet.test/FailingCheckpointDeviceFactoryCreator.cs |
Adds checkpoint failure injection. |
test/standalone/Garnet.test/CheckpointFailureTests.cs |
Tests client-visible failure handling. |
libs/storage/Tsavorite/cs/src/core/Index/Checkpointing/StateMachineDriver.cs |
Invokes abort cleanup. |
libs/storage/Tsavorite/cs/src/core/Index/Checkpointing/StateMachineBase.cs |
Delegates abort callbacks. |
libs/storage/Tsavorite/cs/src/core/Index/Checkpointing/IStateMachineTask.cs |
Adds the abort hook. |
libs/storage/Tsavorite/cs/src/core/Index/Checkpointing/IndexCheckpointSMTask.cs |
Resets aborted index checkpoints. |
libs/storage/Tsavorite/cs/src/core/Index/Checkpointing/HybridLogCheckpointSMTask.cs |
Disposes aborted log checkpoints. |
libs/server/StoreWrapper.cs |
Exposes detailed checkpoint status. |
libs/server/Resp/CmdStrings.cs |
Adds checkpoint failure error text. |
libs/server/Resp/AdminCommands.cs |
Returns correct SAVE/BGSAVE responses. |
libs/server/Metrics/Info/GarnetInfoMetrics.cs |
Exposes persistence status without AOF. |
libs/server/GarnetDatabase.cs |
Tracks the latest checkpoint outcome. |
libs/server/Databases/SingleDatabaseManager.cs |
Records successful and failed outcomes. |
libs/server/Databases/MultiDatabaseManager.cs |
Aggregates per-database outcomes. |
libs/server/Databases/IDatabaseManager.cs |
Updates the checkpoint contract. |
libs/server/Databases/DatabaseManagerBase.cs |
Centralizes outcome handling. |
libs/server/Databases/CheckpointStatus.cs |
Defines checkpoint result types. |
Review details
- Files reviewed: 20/20 changed files
- Comments generated: 4
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review feedback on the abort path introduced by the previous commit, from GitHub Copilot on the pull request and from the session that reported the original bug. Both independently identified that the abort hook released Tsavorite's own checkpoint state but left lifecycle side effects published by earlier phases in place. Fault the checkpoint task so waiters cannot hang. The REST phase completes store.checkpointTcs and publishes a fresh one; an aborted state machine never reaches it, so ClientSession.WaitForCommitAsync - which re-reads store.CheckpointTask in its loop - parked forever. OnAbort now publishes the replacement source before faulting the old one, so a continuation that immediately re-reads the field picks up the source for the next checkpoint rather than the one it just watched fail. This requires the aborting exception, so OnAbort takes it alongside the driver. Release the range-index checkpoint barrier. VersionShift sets a barrier that only FlushBegin clears, and range index operations spin-wait on it, so a checkpoint aborting between IN_PROGRESS and WAIT_FLUSH blocked them indefinitely. Add CheckpointTrigger.CheckpointFailed, raise it from the hybrid-log abort path, and clear the barrier in response. It deliberately does not run the vector manager's completion work: that reclaims deletions, which is only safe once a checkpoint has made them recoverable. The trigger can fire without VersionShift having been reached, so handling is idempotent. The tests had two blind spots, both now closed. The device injector threw for every checkpoint device, so a full checkpoint failed in the index checkpoint task before the hybrid-log checkpoint was initialized. The tests therefore only ever exercised the index cleanup and never reproduced the snapshot-device failure that motivated the work. The injector now takes a failure mode; SnapshotLogDevicesOnly lets the index device through and fails only the snapshot log devices, which are requested at WAIT_FLUSH with both checkpoints live. No device failure can abort at IN_PROGRESS, because no device is created there - so the range-index window above was unreachable from a device-based test. A phase-based callback that throws once on entry to a chosen phase covers it, and pins down the index cleanup in isolation. Tests now assert the absence of leaks directly - snapshot devices, object log flush buffers and the index hash table device are all null, and the index checkpoint is default - rather than only that a later checkpoint succeeds, which a leak would still allow while leaking a handle each time. Also covered: that a failed checkpoint faults the pending checkpoint task and publishes a new incomplete one, and that a checkpoint blocked by another state machine fails without advancing LASTSAVE or truncating the AOF. The latter exercises the RunAsync == false branch, which the device injector could not reach because it makes RunAsync throw instead.
The comment claimed the save path is the only place a checkpoint that was never written can be told apart from a fresh start. That holds when no checkpoint was ever written, because there are no tokens to find, but this catch also covers the case where tokens exist and none of them could be read, which is not a fresh start and is distinguishable here.
A general SAVE skips any database whose per-DB checkpoint lock is already held, but the aggregate outcome only considered the databases it actually pause-locked, so a skipped database was invisible and SAVE replied +OK. Track how many databases the request was asked to checkpoint and return AlreadyInProgress when fewer were attempted, prioritizing Failed so the more actionable outcome still wins. Background requests reply before the aggregate is computed, so the BGSAVE contract of skipping busy databases and reporting started is unchanged.
Blocker analysis: CI failure + open review verdictTwo things were blocking this PR. One is not mine; the other was, and I was wrong about it in my earlier reply. 1. The
|
| State raised during checkpoint | Raised at | Released on abort by | Origin |
|---|---|---|---|
RangeIndex checkpoint barrier (SetCheckpointBarrier) |
IN_PROGRESS, via CheckpointTrigger.VersionShift |
CheckpointTrigger.CheckpointFailed → ClearCheckpointBarrier() (GarnetRecordTriggers.cs:189-197) |
this PR |
store._indexCheckpoint |
PREPARE |
IndexCheckpointSMTask.OnAbort → Reset() |
this PR |
store._hybridLogCheckpoint (snapshot devices, flush buffers) |
PREPARE / WAIT_FLUSH |
HybridLogCheckpointSMTask.OnAbort → Dispose() |
this PR |
store.checkpointTcs |
continuous | OnAbort republishes a fresh TCS before faulting the old one, so no waiter observes a faulted-and-final TCS |
this PR |
lastVersion / lastVersionTransactionsDone |
IN_PROGRESS (TrackLastVersion) |
FastForwardStateMachineToRest() → ResetLastVersion() |
pre-existing |
waitForTransitionIn/Out semaphores, waitingList, waitForTransitionInException |
per transition | FastForwardStateMachineToRest() releases and clears |
pre-existing |
stateMachine registration slot, stateMachineCompleted |
Register / RunAsync |
RunStateMachine finally: Interlocked.Exchange(ref stateMachine, null), TCS faulted/cancelled |
pre-existing |
| Epoch protection | GlobalStateMachineStep |
try/finally epoch.Suspend() |
pre-existing |
Garnet per-DB CheckpointingLock |
TryPauseCheckpoints |
try/finally ResumeCheckpoints on every path, including RunPausedCheckpointsAndReleaseLocksAsync's finally |
mixed |
vectorManager deletion reclamation |
— | deliberately not run on failure — reclaiming is only safe once a checkpoint has made the deletions recoverable | this PR |
Two details worth stating explicitly:
- Ordering. In
RunStateMachine's catch,FastForwardStateMachineToRest()runs beforeReleaseAbortedStateMachineResources(e), so waiters are released before task-owned resources are disposed. The entire diff toStateMachineDriver.csis one added call plus that method, which wrapsstateMachine.OnAbortin try/catch so a cleanup failure cannot replace the actionable aborting exception. - Cluster mode. I chased
GarnetClusterCheckpointManager.CheckpointVersionShiftStart/Endto its delegate targets atReplicationManager.cs:311and:331. They acquire no lock and no barrier — they only enqueue an AOF marker (AofEntryType.CheckpointStartCommit/CheckpointEndCommit), and only on a primary. So there is nothing held between Start and End to leak. An abort in the fuzzy region does leave an unmatched start marker, butAofProcessor.cs:274-278already handles exactly that case by design (a subsequentCheckpointStartCommitclears the stale fuzzy-region buffer, and:295ignores an unmatched end marker). Pre-existing and unchanged by this PR.
No residual gap found.
Concern B — "multi-database SAVE can still report success after skipping a busy database": this was a real bug, and my earlier reply was wrong
I previously dismissed this on the grounds that making the change would break MultiDatabaseSaveInProgressTest. That was incorrect, and the mistake is worth recording because it is a specific conflation, not a judgment call. There are two different candidate changes and I evaluated the wrong one:
- Changing the pause phase — returning
AlreadyInProgresswhenpausedCount == 0. This happens beforeif (background) return Task.FromResult(CheckpointStatus.Success);, so it does affect BGSAVE and does break that test. This is what I tried, reverted, and then cited. - Changing the aggregation — which is consumed only on the foreground path (
return checkpointTask;when!background) and therefore cannot affect BGSAVE at all.
Copilot's comment was about (2). MultiDatabaseSaveInProgressTest issues only BGSAVE / BGSAVE 0 / BGSAVE 1 and never a foreground SAVE, so it never observes the aggregate.
The bug. MultiDatabaseManager.TakeCheckpointAsync silently skips a database it cannot pause-lock:
if (TryPauseCheckpoints(id))
pausedDbIds[pausedCount++] = id;and RunPausedCheckpointsAndReleaseLocksAsync then aggregated over pausedCount only, so a skipped database was invisible and a partial SAVE returned Success → +OK.
Fix (bc528d8b9): thread an explicit requestedCount (activeDbIdsMapSize on the all-DBs path, 1 on the single-DB path) into the aggregation, and resolve as: any attempted database failed → Failed; else pausedCount < requestedCount → AlreadyInProgress; else Success. Failed is prioritized so the more actionable outcome wins. The stale comment that asserted the opposite ("an empty set therefore leaves this a success") is replaced.
Regression coverage: MultiDatabaseForegroundSaveSkippingBusyDatabaseDoesNotReportSuccess. It holds DB 1's per-DB checkpoint lock directly via TryPauseCheckpoints(1) — the same state an in-flight per-DB BGSAVE leaves — which makes the skip deterministic instead of depending on a real checkpoint still being in flight. It asserts that foreground SAVE errors, that BGSAVE still replies Background saving started in the same state, and that SAVE succeeds again once no database is busy.
Verified against a control. With MultiDatabaseManager.cs reverted and only the test present (confirmed by git diff --stat HEAD showing the test file alone), the new test fails with Expected: <RedisServerException> But was: null — i.e. SAVE really did answer +OK after skipping a busy database. With the fix, it passes. The two pre-existing tests pass on both sides, confirming the BGSAVE contract is untouched.
Client-visible behavior change: a foreground
SAVEcovering all databases now returnsERR checkpoint already in progressinstead of+OKwhen it skipped a database whose checkpoint was already running. Per-databaseSAVE <dbid>is unchanged (that path already returned this error).BGSAVEin all forms is unchanged.Note the per-database durability signal was always truthful: a skipped database's own in-flight checkpoint owns its
LASTSAVEandrdb_last_bgsave_statusand records its outcome through the sameRecordCheckpointOutcomepath. What was untruthful is the aggregateSAVEreply — which is exactly the class of bug this PR exists to fix, so it belongs here.
Validation
All runs through the shared test lock; every log grepped for error CS first (a failed compile silently reruns a stale assembly).
| Suite | Result | error CS |
|---|---|---|
Garnet.test.scripting --filter MultiDatabase |
32 passed, 0 failed, 4 skipped | 0 |
Garnet.test --filter CheckpointFailureTests |
8 passed, 0 failed | 0 |
| New test against reverted libs (control) | fails as expected | 0 |
dotnet format Garnet.slnx --verify-no-changes |
exit 0 | — |
CI cannot go green until main's Tsavorite.test build is fixed; that failure is inherited and independent of everything above.
|
Correction to my remedy suggestion above, and a merge-overlap note. 1. One of the two fixes I suggested for the inherited
The second option is the validated one, and #2161 carries it with build results for all three affected projects. It is also semantically right rather than a compile workaround: 2. Merge overlap with #2153 is six files, not the one method I flagged earlier. Shared: |
…-failed-checkpoint # Conflicts: # libs/server/Databases/MultiDatabaseManager.cs # libs/server/Databases/SingleDatabaseManager.cs # libs/server/GarnetDatabase.cs
|
Merged current Six files were shared with #2153. Three auto-merged (
The second one is worth a sentence, because keeping both sides would have been actively wrong rather than merely redundant. This PR's comment asserted that a never-checkpointed directory is "indistinguishable from a fresh start". #2153's token counts are precisely what makes that distinguishable, so retaining the comment would have left a durability path documented as doing the opposite of what the code does. #2153's text was taken wholesale, and only the one clause of mine that is still true was preserved — the cross-reference explaining why a failed checkpoint must never be reported as a successful save — moved into the This PR's own aggregation change survived intact, including the deletion of the stale "an empty set therefore leaves this a success" comment. Validation after the merge, every log checked for
The CI result on the merge: 224 pass, 1 fail, and the one failure is not a test failure.
The job failed while uploading the results artifact, after the suite had already run green. It is the only failing step anywhere in the run (verified across all 225 checks), and the same suite passes on the other 15 matrix combinations of this exact commit, including No re-run is needed to interpret it, and it is not evidence of a regression here. |
Problem
A checkpoint that fails is reported to clients as a successful save.
DatabaseManagerBase.TakeCheckpointAsyncreturnslong?and usesnullfor two different outcomes — "this was an incremental checkpoint, so there is no tail address to record" and "the checkpoint threw". Every caller then advancesLastSaveTimeunconditionally.BGSAVEfollowed by the documentedLASTSAVE-polling pattern therefore reports durability for data that was never written. The loss only surfaces as an empty store after the next restart, because recovery treats a missing hybrid log as a fresh start.Two further paths reported the same false success:
InitiateCheckpointAsynccaptured the result ofStateMachineDriver.RunAsyncand never read it.RunAsyncreturnsfalsewhen another state machine is already running (reachable in practice viaIndexAutoGrowTask), meaning no checkpoint was taken — yet the code still truncated the AOF and registered a cluster checkpoint entry, discarding data the checkpoint did not cover.loggerparameter rather than the manager's ownLogger, so it frequently left no trace at all.Beyond
LASTSAVE, this also affectsTakeOnDemandCheckpointAsync, whichReplicaSyncSessionuses to decide that a primary's checkpoint is fresh enough to seed a replica, andTaskCheckpointBasedOnAofSizeLimitAsync, which truncates the AOF after "checkpointing".Fix
TakeCheckpointAsyncnow returns aCheckpointResultcarrying an explicitIsSuccessfulalongside the nullable tail address, and a singleRecordCheckpointOutcomeadvancesLastSaveTimeonly on success.Failures are returned rather than thrown, because
MultiDatabaseManagerfans out over databases withTask.WhenAll— letting exceptions escape would abort sibling databases' bookkeeping — and because a background checkpoint must not tear down the server.IDatabaseManager.TakeCheckpointAsyncnow returns aCheckpointStatus(Success/AlreadyInProgress/Failed), since itsboolcould not distinguish "another checkpoint is in progress" from "the checkpoint failed".An aborted checkpoint left the store permanently broken
The
Phase.RESThandling that releases checkpoint state is never entered when a state machine aborts, so the next checkpoint failed itsDebug.Assert(_indexCheckpoint.IsDefault)inPREPARE— one transient failure disabled checkpointing until restart.CompleteCheckpointAsyncalready performs this cleanup on failure, but it is not on theRunAsyncpath Garnet uses.Added an
OnAborthook toIStateMachineTask(a default no-op interface method, so no existing implementor changes), invoked by the driver when it fast-forwards an aborted state machine. It releases three things that REST would have:_indexCheckpoint.Reset()and_hybridLogCheckpoint.Dispose(), which also close any snapshot devices and flush buffers already created.store.checkpointTcsand publishes a fresh one; leaving it pending hangsClientSession.WaitForCommitAsync, which re-readsstore.CheckpointTaskin its loop. The replacement source is published before the old one is faulted, so a woken continuation picks up the next checkpoint's source rather than the one it just watched fail.OnAbortreceives the aborting exception for this.CheckpointTrigger.VersionShiftsets a barrier that onlyFlushBeginclears, and range index operations spin-wait on it, so a checkpoint aborting betweenIN_PROGRESSandWAIT_FLUSHblocked them indefinitely. A newCheckpointTrigger.CheckpointFailedis raised from the abort path and clears it. It deliberately does not run the vector manager's completion work, which reclaims deletions that are only safe to release once a checkpoint has made them recoverable. The trigger can fire withoutVersionShifthaving been reached, so handling is idempotent.Client-visible changes
SAVE+OKeven when the checkpoint threwSAVEas an error.SAVEacross all databases+OKeven when a database was skipped because its own checkpoint was already runningERR checkpoint already in progress. The request never attempted that database and cannot vouch for it. Per-databaseSAVE <dbid>is unchanged — that path already returned this error.BGSAVE+Background saving startedLASTSAVEBGSAVEto confirm persistence as the docs describeINFO PERSISTENCErdb_last_bgsave_status(ok/err), which is how a background save's failure is reported in RedisThe
PERSISTENCEsection was previously suppressed entirely unless the append-only file was enabled, even thoughGetDatabasePersistenceStatsalready falls back to"N/A"for every AOF field in that case. It is now always emitted, so a checkpoint failure is observable without AOF — which is the default configuration. Note this widens a public embedded API:MetricsApi.GetInfoMetricsfilters nulls, soPERSISTENCEnow appears in that enumeration on a non-AOF server where it previously did not.Not changed
RecoverCheckpointAsyncswallowsTsavoriteNoHybridLogExceptionand deliberately does not honorFailOnRecoveryError, so that a--recoverstart against a never-checkpointed directory comes up. That is intended and is left alone; a comment records the intent and the consequence — when no checkpoint was ever written there are no tokens to find, so this is indistinguishable from a fresh start, which is why the save path must never report a failed checkpoint as a successful save.That catch is also reached in a case this PR does not address: checkpoint tokens exist but none of them can be read, which is not a fresh start and is distinguishable at recovery time. #2150 reports that separately, with the fail-open startup it causes under
--fail-on-recovery-error trueand a cluster-mode purge that deletes the unreadable artifacts. This PR is the write-side fix — do not report a checkpoint as durable unless it completed — and deliberately leaves the read-side recovery policy to that issue.A general
BGSAVEthat skips a database whose own checkpoint is already in flight still replies+Background saving started. That is the documented BGSAVE contract, is asserted byMultiDatabaseSaveInProgressTest, and is left alone — the reply is sent before the aggregate outcome exists. The equivalent foregroundSAVEno longer reports success in that situation; see the client-visible table above. In both cases the skipped database's own in-flight checkpoint records its ownLASTSAVEandrdb_last_bgsave_status, so the per-database durability signal was always truthful; what was untruthful was only the aggregateSAVEreply.Tests
CheckpointFailureTestsinjects failures two ways, because neither alone is sufficient:SnapshotLogDevicesOnlylets the index device through and fails onlysnapshot.dat/snapshot.obj.dat, which are requested atWAIT_FLUSHwith both checkpoints live — the shape of the real failure that motivated this work, where the device layer rejected an over-long path. (An earlier revision failed every checkpoint device, which aborted in the index task before the hybrid-log checkpoint existed and so never exercised its cleanup.)IStateMachineCallbackthat throws once on entry to a chosen phase. This is device-agnostic and reaches abort points no device failure can — notablyIN_PROGRESS, which is the only window where the range-index barrier is set but not yet cleared.Neither depends on path length, file permissions or platform, and neither needs a product or
TestUtilschange —GetGarnetServerOptionsalready exposesDeviceFactoryCreator.Coverage:
BGSAVEdoes not advanceLASTSAVE, writes no checkpoint files, and reportsrdb_last_bgsave_status:errSAVEreturns an error and does not advanceLASTSAVELASTSAVEor truncating the AOF, covering theRunAsync == falsebranch that a device failure cannot reachSAVEsucceeds,LASTSAVEadvances, the status returns took, and a restart withtryRecoversees the dataMultiDatabaseManagerMultiDatabaseForegroundSaveSkippingBusyDatabaseDoesNotReportSuccess— a foregroundSAVEthat skips a busy database returns an error rather than+OK, whileBGSAVEin the same state still reports started, andSAVEsucceeds again once no database is busy. The skip is made deterministic by holding the per-database checkpoint lock directly, rather than depending on a real checkpoint still being in flight.Reverting only the
LastSaveTimeguard fails the save-reporting tests; restoring it passes them.Validation
main(cf1db86df)Garnet.testGarnet.test.clusterGarnet.test.scripting(MultiDatabase + Aof)Tsavorite.testTsavorite.test.recoveryGarnet.test.cluster.replicationEvery log was checked for
error CSbefore the result was trusted — 0 in all runs. A test project that fails to compile causesdotnet testto run a stale assembly and report a pass.dotnet format --verify-no-changesclean on bothGarnet.slnxandTsavorite.slnx, before and after the merge.Garnet.test.cluster.replicationwas added late, after a reviewer of a related change observedLASTSAVE did not advance within timeoutfailures elsewhere. That suite is the heaviest consumer of the exact pattern this PR changes:ClusterTestUtils.WaitCheckpointpollsLASTSAVEfor 30 s and fails if it does not advance, andClusterReplicationBaseTestscalls it on primaries and replicas in 13 places. Since this PR stopsLASTSAVEadvancing on a failed checkpoint, a genuinely failing checkpoint there would now turn a silent pass into a timeout.It does not. The run shows zero
LASTSAVE/WaitCheckpointfailures. The two failures are cluster-formation contention before any checkpoint is taken —ERR Slot 0 is already busyat 145 ms during setup, and a gossipWaitUntilNodeIsKnowntimeout inSimplePrimaryReplicaSetup— and both pass in isolation (8/8, 27 s).Merge with #2153 (resolved)
#2153 (refuse to serve when no HybridLog checkpoint is readable) has merged, and
mainis merged into this branch as ofcf1db86df. The two PRs shared six files; three auto-merged and three conflicted.libs/server/Databases/DatabaseManagerBase.cslibs/server/Databases/IDatabaseManager.csCheckpointStatuson the save APIlibs/server/StoreWrapper.cslibs/server/GarnetDatabase.cslibs/server/Databases/SingleDatabaseManager.cslibs/server/Databases/MultiDatabaseManager.csThe two comment hazards flagged before the merge were both real, and neither was resolved by keeping both sides:
CandidateTokenCount == 0branch, which is the case where it still holds.MultiDatabaseManager's checkpoint aggregation, the pre-existing comment asserting that a skipped database "leaves this a success" is deleted by this PR, because the code there now does the opposite: a skipped database yieldsAlreadyInProgress. That deletion survived the merge intact.GarnetDatabase.cswas a clean keep-both:LastSaveSucceededandCheckpointRecoveryare independent.CheckpointRecoveryis deliberately not copied bycopyLastSaveData, matching #2153's own handling — it describes what recovery found at startup, not what the last save wrote.Post-merge validation is the second column of the table above.