Skip to content

feat(consensus): persist metadata VSR state in a durable superblock - #3767

Open
krishvishal wants to merge 3 commits into
masterfrom
vsrconsensus-persist
Open

feat(consensus): persist metadata VSR state in a durable superblock#3767
krishvishal wants to merge 3 commits into
masterfrom
vsrconsensus-persist

Conversation

@krishvishal

Copy link
Copy Markdown
Contributor

A metadata replica kept its consensus numbers only in RAM. After a crash it
rebuilt at view = 0 and inferred a view from the last WAL prepare, so it could
re-enter a superseded view and re-vote, splitting the log. Sessions and the
at-most-once dedup cache were volatile too, so a returning client hit
NoSession and a retried request re-executed. And once a checkpoint drained the
WAL prefix, nothing durably identified the snapshot it folded into.

What

journal::superblock (new). SuperblockStore trait plus PingPongSuperblock:
two files written alternately, each replaced temp, fsync, rename, dir-fsync,
highest valid sequence wins. Rename is atomic, so an update can never destroy
the last good record. read_latest returns three outcomes, kept distinct because
collapsing them into an Option is exactly what lets a lost superblock
masquerade as a fresh deployment:

Outcome Meaning Response
Empty never written fresh boot
Present(payload) newest checksum-clean record recover from it
Unreadable { version } written, now torn / foreign / unknown version refuse boot

consensus::VsrState (new). The 58-byte little-endian payload: cluster,
replica id and count, view, log_view, commit_max, and the paired
checkpoint_op / checkpoint_checksum. Offsets pinned by test.

Durable-before-send gate. VsrConsensus tracks view_durable /
log_view_durable, exposing needs_superblock_persist() and
mark_superblock_durable(view, log_view), which takes the values written rather
than re-reading self.view (the in-memory view can advance across the write's
await while a checkpoint holds the lock). Every metadata dispatch site (SVC /
DVC / StartView / RequestStartView, the tick, the stale-heartbeat reply,
send_prepare_ok) persists first and withholds on failure. A debug_assertions
tripwire in dispatch_vsr_actions asserts callers did so. RequestStartView is
exempt: it is a probe asking to learn the view.

Three-phase checkpoint. Persist snapshot, record the pairing, then drain the
WAL. A crash before the pairing write recovers the prior checkpoint with the WAL
intact; after it, the new one. One LocalGate serializes checkpoints against
view-change persists, since both drive the single superblock, whose write picks
its slot before it awaits, and in-process submits each run on their own task.

Client-table durability. ClientTableSnapshot folds into MetadataSnapshot
under #[serde(default)], preserving slot positions so eviction order survives.
Boot restores it from the snapshot and replays the committed suffix through the
same apply_committed_prepare the live commit walk uses.

Verify-then-decode recovery. Recovery cross-checks the superblock against the
on-disk snapshot before trusting its contents. New typed refusals: superblock
ahead of snapshot, checksum mismatch at the same op, undecodable payload,
unreadable on every slot. A superblock lagging a newer atomically-complete
snapshot is accepted (commit_max is only a recovery lower bound). An absent
superblock stays a fresh boot; refusing there would brick healthy nodes.

WAL body integrity. The primary seals checksum_body once and replicates it
verbatim. The scan truncates a genuinely torn tail but refuses boot on interior
bit-rot rather than discarding the committed entries behind it.

Incarnation nonce. RequestStartView carries a per-boot u128; the answering
StartView echoes it. While Recovering, a replica only adopts a StartView
provably post-restart.

Wire compatibility

StartViewHeader and RequestStartViewHeader gain incarnation: u128, carved
from the tail of their existing reserved regions. Placed last and
16-aligned, so op / commit / namespace keep their offsets and the structs
stay padding-free (static asserts enforce both). A peer that predates the field
sends zeros, decoding as incarnation == 0, inert per the adoption guard. Header
size unchanged, so a mixed-version rolling upgrade is safe.

Determinism invariant

Recovery trusts a checkpoint by recomputing hash(encode(decode(disk))) against
the persist-time hash(encode(state)). That holds only while every collection in
the serialized form keeps a deterministic order, so an unordered map anywhere in
MetadataSnapshot would make a healthy node refuse boot after a restart.
Documented on the three snapshot types and guarded by two byte-stability tests.

Not in scope

  • Partition-plane consensus is not superblock-backed and is exempt from the gate.
  • No metadata state transfer, so a corrupt checkpoint refuses boot rather than
    repairing from a peer. Marked TODO(state-transfer) where the fetch belongs.
  • Header checksum and its parent chain stay unsealed: activating them needs
    the retransmit path to re-seal a re-stamped header.
  • checksum_body has no format-version gate, so a WAL written before this change
    refuses boot. Fail-safe, but a hard upgrade break; TODO(wal-integrity).

@numinnex numinnex left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blockers, tagged inline (reviewed in the context of the upcoming metadata state transfer work, which builds on this).

Comment thread core/journal/src/prepare_journal.rs Outdated
body_buf = vec![0u8; body_len];
}
body_buf = storage.read_at(pos + HEADER_SIZE as u64, body_buf).await?;
if u128::from(XxHash3_64::oneshot(&body_buf)) != header.checksum_body {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: no format gate on checksum_body, so any pre-existing WAL refuses boot.

Every entry written before this change carries checksum_body == 0, so this check fails all of them and the interior-corruption branch below refuses boot. It also breaks mixed-version clusters: an old-version primary sends zero-checksum prepares, a new-version backup journals them verbatim, and that backup's next restart refuses boot, so a rolling upgrade is unsafe in that direction.

The TODO(wal-integrity) above already names this gap, but I think it needs to be part of this PR rather than a follow-up: treat checksum_body == 0 as unsealed and skip verification for that entry, or gate on a WAL entry format version.

Comment thread core/journal/src/superblock.rs Outdated
payload: payload_b,
},
) => SuperblockContents::Present(if seq_a >= seq_b { payload_a } else { payload_b }),
(SlotClass::Valid { payload, .. }, _) | (_, SlotClass::Valid { payload, .. }) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: (Valid, Corrupt) silently regresses durable state on newest-slot bit-rot.

A torn write dies in the .tmp (rename is atomic), so a corrupt non-temp slot can only be bit-rot. When that hits the newest slot, this arm falls back to the older generation: older view/log_view, and the replica can re-vote in a view it already acted in, which is exactly the split-brain this PR exists to prevent.

Since the corrupt slot's sequence is unreadable, there is no way to tell whether it was the newer one, so I believe the sound response for Valid + Corrupt is refusing boot (Unreadable), the same as both-corrupt.

Comment thread core/journal/src/superblock.rs Outdated
}
}

async fn read_sequence(path: &Path) -> io::Result<Option<u64>> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compounding the combine_slots fallback above: read_sequence maps both Absent and Corrupt to None, so open aims the next write at the corrupt (possibly newest) slot and overwrites the evidence with sequence = valid + 1, retroactively legitimizing the regression. Distinguishing Absent from Corrupt here would fall out of the same fix.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.72727% with 252 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.62%. Comparing base (5ca0bbb) to head (e2baf44).

Files with missing lines Patch % Lines
core/server-ng/src/bootstrap.rs 0.00% 60 Missing ⚠️
core/metadata/src/impls/metadata.rs 78.86% 32 Missing and 9 partials ⚠️
core/metadata/src/impls/recovery.rs 79.59% 30 Missing and 10 partials ⚠️
core/journal/src/superblock.rs 86.68% 14 Missing and 25 partials ⚠️
core/consensus/src/client_table.rs 84.72% 16 Missing and 6 partials ⚠️
core/simulator/src/deps.rs 75.00% 9 Missing and 5 partials ⚠️
core/simulator/src/replica.rs 80.85% 4 Missing and 5 partials ⚠️
core/shard/src/lib.rs 74.19% 8 Missing ⚠️
core/simulator/src/lib.rs 97.41% 4 Missing and 3 partials ⚠️
core/consensus/src/vsr_state.rs 91.93% 5 Missing ⚠️
... and 2 more
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3767      +/-   ##
============================================
+ Coverage     75.53%   75.62%   +0.08%     
  Complexity      969      969              
============================================
  Files          1317     1319       +2     
  Lines        154895   156131    +1236     
  Branches     128519   129833    +1314     
============================================
+ Hits         117004   118070    +1066     
- Misses        34344    34380      +36     
- Partials       3547     3681     +134     
Components Coverage Δ
Rust Core 75.65% <84.72%> (+0.14%) ⬆️
Java SDK 62.71% <ø> (ø)
C# SDK 71.13% <ø> (-1.14%) ⬇️
Python SDK 92.27% <ø> (ø)
PHP SDK 84.52% <ø> (ø)
Node SDK 95.30% <ø> (+0.17%) ⬆️
Go SDK 43.08% <ø> (ø)
Files with missing lines Coverage Δ
core/binary_protocol/src/consensus/header.rs 81.23% <ø> (+0.87%) ⬆️
core/consensus/src/lib.rs 0.00% <ø> (ø)
core/journal/src/lib.rs 25.00% <100.00%> (+25.00%) ⬆️
core/metadata/src/stm/snapshot.rs 86.63% <100.00%> (+0.10%) ⬆️
core/metadata/src/stm/stream.rs 65.59% <100.00%> (+3.60%) ⬆️
core/metadata/src/stm/user.rs 87.95% <ø> (ø)
core/server-ng/src/dispatch.rs 39.95% <100.00%> (+0.02%) ⬆️
core/server-ng/src/partition_reconciler.rs 87.33% <100.00%> (ø)
core/consensus/src/impls.rs 82.31% <97.34%> (+6.67%) ⬆️
core/journal/src/prepare_journal.rs 89.84% <96.74%> (+1.17%) ⬆️
... and 10 more

... and 60 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@krishvishal
krishvishal force-pushed the vsrconsensus-persist branch from c1699cf to 5fa9cf9 Compare July 30, 2026 12:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-waiting-on-review PR is waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants