fix(restore): force joiners of a restored cluster through checkpoint sync#1596
Conversation
…sync A restored node's FSM genesis is the whole backup, but its raft log was brand new and claimed completeness from index 1: bootstrap planted the WAL snapshot at the marker's lastAppliedIndex, which the restore prep had reset to 0. A learner joining before the first post-restore raft snapshot was then "caught up" by plain MsgApp replay from index 1 onto an empty store — it silently missed every restored row, kept only what the replayed entries' cache preloads re-materialized, and desynced from the rest of the cluster. The Antithesis model test caught it as an aggregated-volume-imbalance sentinel panic on the freshly joined follower right after a restore cycle (only `world` and post-restore accounts existed in its volume table); whether a joiner raced the leader's first raft snapshot decided corruption vs. correctness. PrepareForBackup now pins the restored store's lastAppliedIndex to 1, so the RESTORED bootstrap plants its WAL snapshot at 1 (the CreateSnapshot restore branch built for exactly this) and the new log starts at 2. Raft must then route any fresh peer through MsgSnap → InstallSnapshot → checkpoint sync, which transfers the full store. The e2e repro joins a fresh learner to a freshly restored single-node cluster and asserts it serves a pre-backup balance that no post-restore entry can re-materialize; before the fix the learner's store lacked even the ledger row.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🛑 Changes requested — automated reviewThe core restore-index fix (pinning applied index to the checkpoint boundary) and the cache generation realignment are sound and well-tested. The main outstanding structural concern is the commit ordering in the finalize path: if the process crashes or encounters an error after Findings outside the diff🟠 [major] Checkpoint left in place when marker write fails, making restore non-retryable — If the process is killed or encounters an error after Suggestion: On failure from |
gfyrag
left a comment
There was a problem hiding this comment.
Thanks, the production direction looks right: pinning the restored genesis at Raft index 1 makes the empty-WAL restore snapshot push the first real entry to 2, and the local WAL/state behavior lines up with that.
I do not think this should merge yet because the regression coverage can currently pass without proving that the learner received the restored store.
Blocking items:
- The new learner test uses default linearizable
GetAccountcalls against the joiner.RoutedController.readCtrlfalls back to the leader while the local node is syncing, so thejoin-fence,alice, andtreasuryreads can be served by node 1. Force local reads with outgoing metadatax-consistency: stale, or explicitly wait for the joiner to leave sync before checking restored rows. - The new test runs at the end of the ordered restore suite.
DefaultTestInstrumentsenables background maintenance every 5s, and the restored node has already executed several post-restore writes before the learner joins, so a maintenance WAL snapshot/checkpoint may already exist. That undercuts the advertisedbefore any raft snapshotregression. Move the learner join right after restored bootstrap, or override maintenance so no background snapshot can happen first. - Keep the docs aligned with the new invariant:
docs/ops/cli.mdanddocs/technical/architecture/subsystems/fsm/deterministic-fsm.mdstill say prepare resets the applied index to0; theRESTOREDmarker JSON example indocs/ops/backup-restore.mdstill shows12345; the test comment ininternal/infra/attributes/prepare_test.gostill says0. gofmt -l tests/e2e/cluster/restore_test.gostill reports the e2e file.- Since restored bootstrap now relies on
RESTORED.lastAppliedIndex >= 1, please fail loudly if the marker says0beforewal.CreateSnapshot. Otherwise a stale/manual marker silently re-enters the old unsafe path: snapshot at 0, log starts at 1, and a fresh learner can replay onto an empty store.
Codecov Report❌ Patch coverage is ❌ Your patch check has failed because the patch coverage (46.15%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## release/v3.0 #1596 +/- ##
=============================================
Coverage 74.72% 74.72%
=============================================
Files 430 430
Lines 45722 45782 +60
=============================================
+ Hits 34164 34209 +45
- Misses 8510 8518 +8
- Partials 3048 3055 +7
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Review feedback on #1596 (gfyrag), all five items: - The joiner's reads now carry x-consistency: stale — linearizable reads on a syncing node transparently fall back to the leader, which could answer for the learner and green the test without proving anything about the learner's store. Stale reads pin every assertion to the learner's own state (the re-run red-proof fails with the learner serving "ledger not found" locally, as on Antithesis). - The restored node runs with maintenance effectively off and a huge compaction margin, so no background raft snapshot can exist before the join — the spec deterministically exercises the log-as-only- catch-up-source window it advertises. - Restored bootstrap now fails loudly when the marker carries lastAppliedIndex 0 instead of silently re-entering the unsafe snapshot-at-0 path (stale or hand-written marker). - Remaining applied-index-reset mentions aligned: cli.md, deterministic-fsm.md, the RESTORED marker JSON example, and the prepare_test comment. - gofmt on the e2e file.
Sweep for leftovers of the reset-to-0 wording: the FinalizeRestore step list omitted the (now load-bearing) prepare step, the bootstrap section did not mention that a marker carrying 0 is refused, the RestoredMarker field lacked its >= 1 contract, and the prepare error string and the finalize comment still said "reset".
|
@gfyrag all five items addressed in 3456662 + f61e165:
Suite state with the hardened test: 37/37 green with the fix, red without it. |
gfyrag
left a comment
There was a problem hiding this comment.
Follow-up after the latest commits: the five blocking points from my previous review look addressed now. The learner regression now uses local stale reads, prevents background maintenance/compaction from creating a later snapshot first, the docs are aligned with the current model, formatting is clean, and RESTORED.lastAppliedIndex == 0 now fails loudly before wal.CreateSnapshot.
On the applied-index model itself, I rechecked the alternative of preserving the checkpoint's existing lastAppliedIndex instead of pinning it to 1. I think that approach is valid and arguably cleaner than a magic 1, but only with a precise contract:
- Treat the preserved value as the restored cluster's genesis snapshot boundary, not as the final source-cluster applied index.
ApplyExports/RebuildDeltado not currently advance or export a final Raft applied index for incremental backups, so after full + incremental restore this value is the full checkpoint boundary, while the restored state may include later exported audit/log data. - Keep the same
Nin both places: PebbleSubGlobLastAppliedIndexand theRESTOREDmarker used bywal.CreateSnapshot(N). With snapshotN, the new log starts atN + 1; the FSM gap detector also expects the next entry after Pebble'slastAppliedIndex, so those two values must stay identical. - Require
N > 0before boot. Snapshot0is the unsafe empty-snapshot shape this PR is fixing, so preserving0would re-open the learner replay bug. If we want to support a full checkpoint taken at index0plus later incrementals, we would need either a fallback genesis index (1) or an exported final restore boundary. - Guard pathological overflow (
N == math.MaxUint64) because multiple Raft paths doN + 1. - Add coverage with
N > 1: prepare should preserve the seeded index, finalize/bootstrap should write the marker with that same index, restored boot should create the WAL snapshot atN, the first post-restore proposal should apply atN + 1, and the current learner e2e should keep the stale-read/no-maintenance protections so it proves checkpoint sync rather than leader fallback or a later maintenance snapshot.
The rest of the cleanup still needs to remain exactly as this PR has it: persisted config, peers, cluster-transient state, persisted bloom blocks, and cache rows are source/checkpoint-local and should not survive restore preparation.
So I would be fine with switching from lastAppliedIndex = 1 to preserving the restored checkpoint boundary N, provided the implementation documents that boundary semantics and covers the non-1 case explicitly.
…boundary Design change proposed in review (gfyrag): instead of pinning the restored store's applied index to a synthetic 1, restore preparation preserves the checkpoint's own applied index as the boundary the restored genesis occupies in the new raft log. The join-safety mechanism is unchanged — bootstrap plants the WAL snapshot at the boundary, the log starts just above it, and any fresh peer is forced through the snapshot → checkpoint-sync path. The contract the review spelled out: - the boundary labels the new log's start, NOT the state's provenance (incremental exports are sequence-keyed and never advance it, so the restored state is newer than the boundary after full + incrementals); - Pebble and the RESTORED marker carry the same value (the marker is read back from the prepared store, as before); - a genesis checkpoint (index 0) gets the fallback boundary 1, and bootstrap still refuses a marker carrying 0; - MaxUint64 is refused (raft paths compute boundary+1); - non-fallback coverage: prepare preserves a seeded index (unit), the marker equals the manifest checkpoint's index with N > 1 (e2e), the restored boot + first apply at N+1 are exercised by the whole Phase 3 suite, and the learner join now runs at N > 1.
|
@gfyrag switched to the preserved-boundary design in c3e4766, following your contract point by point:
Suite state: 38/38 restore specs green; red-proof with the old reset-to-0 restored shows the new marker spec failing and the 0-guard refusing every restore boot. |
NumaryBot
left a comment
There was a problem hiding this comment.
NumaryBot posted 1 new inline finding.
Summary: #1596 (comment)
gfyrag
left a comment
There was a problem hiding this comment.
I re-reviewed c3e4766 in detail. The preserved-boundary model itself looks sound: using the checkpoint applied index as the restored genesis snapshot boundary keeps Pebble lastAppliedIndex and the initial WAL snapshot aligned, and the normal Raft/WAL path starts the new log above that boundary.
I also checked the cache-generation finding from NumaryBot. I do not think that is the main issue as stated: after restored boot, admission waits on WaitLeaderReady; the leadership no-op at N+1 goes through PrepareDecodedEntries, and CheckRotationNeeded(N+1) realigns currentGeneration / BaseIndex before writes are admitted. So a high preserved N should not by itself leave admission permanently two generations ahead.
That said, I still do not think this should merge yet:
-
The restore/bootstrap freshness guard only rejects existing
checkpoints/, not an existinglive/database.RestoreModuleandledgerctl store bootstrapboth callScanLatestCheckpointID, but normal startup prefers an existinglive/overcheckpoints/0. If someone runs restore against a data dir containinglive/but no checkpoints, finalize/bootstrap can write a restored checkpoint plusRESTORED, then the next boot opens the stalelive/store and creates the restore WAL snapshot from the marker against the wrong Pebble state. The preserved-boundary design makes this marker/store alignment load-bearing, so the guard needs to reject existinglive/as well (and probably any pre-existingRESTORED/WAL state). -
The gRPC
FinalizeRestorepath writesRESTOREDbefore hard-linking staging intocheckpoints/0. IfHardLinkfails, or the process dies after the marker write but before checkpoint placement, the next normal boot can observe a valid nonzero marker without the restored checkpoint in place. That can makeNewNodecreate a WAL snapshot at the marker index over an empty or stalelive/store. Please write the marker only after checkpoint placement succeeds, preferably via temp-file + rename, and clean up/refuse stale markers on failure. -
PrepareForBackupreads the boundary withdal.ReadUint64, which returns the default for both missing and too-short values. A corrupt shortSubGlobLastAppliedIndexis therefore silently treated as0and rewritten as fallback boundary1. Since this path already treatsMaxUint64as corrupt, it should also fail closed on malformed length while still allowing the genuinely-missing genesis case if that case is intentional. -
The consumer-side marker guard rejects
0but notmath.MaxUint64. A manual/corruptRESTOREDmarker withlastAppliedIndex = MaxUint64can still reachwal.CreateSnapshot;WAL.FirstIndex()computessnapshot.Index + 1, which wraps. The marker consumer should duplicate the overflow guard. -
The
Dirtycheck is currently failing. The CI log showsgolangci-lint --fixrewrites two constantfmt.Errorf(...)calls toerrors.New(...)ininternal/infra/attributes/prepare.goandinternal/infra/node/node.go; those generated fixes need to be committed. -
Some API comments are still stale.
misc/proto/restore.protostill saysFinalizeRestore compacts attributes, andPreviewRestoreResponse.last_applied_indexstill saysLast Raft index applied. With this PR, the finalized value is a restore genesis boundary, and preview can still show0beforePrepareForBackupconverts a genesis checkpoint to fallback1. Please update the proto comments and regenerate the restore protobufs.
Focused checks I ran locally:
GOROOT= go test ./internal/infra/attributes ./internal/infra/cache ./internal/infra/plan ./internal/storage/wal ./internal/infra/node -run 'TestPrepareForBackup|TestAttributeCache_CheckCache|TestBuildPreloads_RejectsCacheHorizonExceeded|TestCreateSnapshot_RestoreOnEmptyStorage|TestRaftApplied_ControlsCommittedEntriesDelivery' -count=1
gofmt -l ... and git diff --check origin/release/v3.0...HEAD were clean locally.
The Dirty CI gate's golangci --fix rewrites verb-less fmt.Errorf.
…ut meta Review feedback on #1596 (NumaryBot): with the restore now preserving the applied index as the genesis boundary, "cache meta absent" no longer implies generation 0 — a restored store carries a boundary far past it (PrepareForBackup wipes the cache zone), and a pre-meta store after an upgrade has the same shape. RestoreFromStore left the in-memory generation at 0, so until the first applied entry (the election no-op) jump-rotated it forward, admission's CheckCache saw Gen(boundary+1) as 2+ generations ahead and transiently rejected proposals as CacheUnreachable. The bot's stronger claim — a restored cluster permanently unable to accept writes — does not hold: CheckRotationNeeded rotates straight to Gen(entryIndex) on the first apply, which the leader's election no-op guarantees (verified by running the restore e2e at rotation threshold 3 without this change). This closes the transient window and makes the boot state consistent by construction instead of one-entry-later. RestoreFromStore realigns via the new forward-only Cache.RealignGeneration — the same math ResetWithThreshold already uses for the threshold-change flavour of this problem — on all three hydration paths (boot, restart, follower sync). The restore e2e now runs the restored node at rotation threshold 3, keeping the whole suite on a high-generation boundary.
NumaryBot
left a comment
There was a problem hiding this comment.
NumaryBot review complete: no remaining inline findings.
Resolved 1 stale NumaryBot review thread (1 fixed, 0 outdated).
Summary: #1596 (comment)
…lies on Review feedback on #1596 (gfyrag), items 1-4 + 6; the Dirty fix (item 5) landed in 763f000. The preserved-boundary model makes the RESTORED marker and the store it describes load-bearing as a pair, so every path that could tear them apart now refuses to: - Restore mode and `store bootstrap` require a genuinely fresh data directory: dal.ValidateFreshRestoreTarget rejects existing live/, live.staging/, live.discard/ and checkpoints, and both entry points reject a pre-existing RESTORED marker. Normal startup prefers live/ over the restored checkpoint, so restoring next to one would boot the stale store under the marker's boundary. - FinalizeRestore places the checkpoint BEFORE writing the marker, and the marker write is atomic (temp + rename, shared node.WriteRestoredMarker used by both flows): the marker is the restore's commit point, so a crash mid-finalize can no longer leave a marker describing a checkpoint that never landed. - PrepareForBackup fails closed on a present-but-malformed applied index instead of silently treating it as a genesis checkpoint; only genuine absence takes the fallback boundary. - The marker consumer duplicates the MaxUint64 overflow guard (raft paths compute boundary+1, which would wrap). - restore.proto comments updated for the boundary semantics (finalize does not compact attributes; preview shows the pre-preparation staged value) and the restore protobufs regenerated.
|
@gfyrag all six items addressed (688189a; the Dirty fix was already in 763f000, and c108eae hardened the NumaryBot cache-generation window you also analyzed — RestoreFromStore now realigns the generation when meta is absent, closing the transient between boot and the election no-op):
Restore e2e 38/38 green, full pre-commit clean. |
NumaryBot
left a comment
There was a problem hiding this comment.
NumaryBot posted 1 new inline finding.
Summary: #1596 (comment)
Review feedback on #1596 (NumaryBot): with the checkpoint placed before the marker, a marker-write failure stranded a checkpoint without its commit point — the staging handle is already closed so an in-process re-finalize reports "no backup downloaded", and a restore-mode restart is refused by the freshness guard on the orphaned checkpoint. Both finalize flows now remove the checkpoint on marker-write failure, so the restore returns to a cleanly retryable state.
NumaryBot
left a comment
There was a problem hiding this comment.
🛑 Changes requested — automated review
The core restore-index fix (pinning applied index to the checkpoint boundary) and the cache generation realignment are sound and well-tested. The main outstanding structural concern is the commit ordering in the finalize path: if the process crashes or encounters an error after HardLink creates checkpoints/0 but before WriteRestoredMarker completes, the staging store is already invalidated. A subsequent retry then fails the fresh-target guard because the checkpoint exists without a RESTORED marker, making the restore non-retryable without manual cleanup. This applies to both the gRPC server restore path and the offline bootstrap path. This issue was raised in prior discussion (NumaryBot thread, thread 2) and remains unresolved per the available evidence — the prior reply from Azorlogh describes switching to a temp+rename approach for the marker write itself but does not address the case where the checkpoint (checkpoints/0) has already been placed and the marker rename fails. The fix should either clean up the checkpoint on marker-write failure, or detect and tolerate the partial state on retry.
Findings outside the diff
🟠 [major] Checkpoint left in place when marker write fails, making restore non-retryable — internal/adapter/grpc/server_restore.go:368 (codex, NumaryBot)
If the process is killed or encounters an error after HardLink creates checkpoints/0 but before WriteRestoredMarker completes (e.g. ENOSPC, permissions, cross-filesystem issue, or power loss), the error-path rollback never removes the checkpoint. A subsequent retry then fails the fresh-target guard because checkpoints/0 already exists without a RESTORED marker, leaving the data directory in a permanently broken half-restored state that requires manual cleanup. The same commit-ordering window exists in the offline ledgerctl store bootstrap path.
Suggestion: On failure from WriteRestoredMarker, explicitly remove the newly created checkpoint directory before returning the error. Alternatively, detect the partial-restore state on retry (checkpoint present, no marker) and re-attempt only the marker creation step rather than rejecting as non-fresh.
Summary: #1596 (comment)
…e retries Review feedback on #1596 (NumaryBot): the error-path rollback (7363c16) does not run when the process dies between checkpoint placement and the marker write — the orphaned checkpoints/0 then trips the freshness guard on every retry, stranding the restore behind manual cleanup. checkpoints/0 with no live database and no RESTORED marker is a state only that crash window produces, so the freshness guard now reclaims it (removes the orphan and proceeds) instead of refusing; any other checkpoint layout is still rejected. Both entry points check the marker before the guard, since the reclaim is only safe with the marker known absent.
gfyrag
left a comment
There was a problem hiding this comment.
Approved current head 8311f1ff. I rechecked the restore finalize path and the previous checkpoint/RESTORED marker concern is addressed: both finalize flows roll back the placed checkpoint if marker creation fails, and the fresh-target guard can reclaim the lone half-finalized checkpoints/0 state after a crash. The cache-generation realignment path also looks correct for restored stores with no persisted cache meta.
Caveats: codecov/patch is still failing and the NumaryBot status is still in an error state, but I did not find a remaining code blocker in this pass.
Bug
A restored node's FSM genesis is the entire backup, but its raft log is brand new and claims completeness from index 1: bootstrap plants the WAL snapshot at the RESTORED marker's
lastAppliedIndex, which restore preparation reset to 0.Raft only sends
MsgSnapwhen a follower needs entries that have been compacted away. With a snapshot at 0 and an intact log from index 1, a learner joining before the leader's first post-restore raft snapshot is "caught up" by plainMsgAppreplay from index 1 — applied onto an empty store. It silently misses every restored row and keeps only what the replayed entries' cache preloads re-materialize, permanently desynced from the rest of the cluster. Whether a given joiner races the leader's first raft snapshot decides corruption vs. correctness.Found by the model test on Antithesis (#1593 runs
87dbab…-57-8/5e600bbb…-57-8): right after each completed restore cycle, the freshly joined follower's post-commit sentinel firedaggregated volume imbalanceat raft index 8–32 and the node crash-looped (WAL replay reproduces it deterministically). Its volume table held onlyworld+ post-restore accounts; pod-0, applying the same entries on the restored store, never fired. A control run whose restore cycles never completed shows zero hits.Fix
PrepareForBackupnow pins the restored store'slastAppliedIndexto 1 — the index the restored FSM genesis occupies in the new log. The RESTORED bootstrap plants its WAL snapshot there (CreateSnapshot's restore branch — empty storage, index > 0, term 1 — existed for exactly this), so the log starts at 2 and raft must route every fresh peer throughMsgSnap→InstallSnapshot→ checkpoint sync, which transfers the full store. The join-safety becomes structural instead of racing the first raft snapshot.Both restore flows (gRPC
restore finalizeand offlineledgerctl store bootstrap) share this preparation step.Repro / regression test
New spec at the end of the restore e2e: joins a fresh learner to the freshly restored node, proves log catch-up via a fence transaction, then asserts a pre-backup balance that no post-restore entry (and no cache preload) can re-materialize — the learner serves it iff the restored store itself was transferred. Before the fix, the learner's store lacked even the ledger row (
ledger restore-ledger not found), matching the Antithesis signature.-tags "e2e,s3" -ginkgo.focus Restore: 37/37 specs green with the fix (36 passed + this one failed without it).