tidaldb/tidal-server/src/cluster/reseed.rs
jx12n c8ea05b032 fix(m12): break the post-reseed false-ReseedRequired loop (durable term marker + readiness gating + restart coordinator)
Root cause: after a checkpoint-based snapshot install the engine WAL is empty,
so wal_term_mark() reports tail_term=0. decide_join compares (tail_term, frontier)
lexicographically — tail_term FIRST — so 0 < leader_term classifies the reseeded
shard ReseedRequired on EVERY boot regardless of the correctly-seeded frontier,
re-latching the marker and self-restarting forever. Observed live on tidaldb-2:
30 CrashLoopBackOff restarts, leader tidaldb-1 term 5, baseline=536647, the
frontier seeded correctly (from_seqno=536647) yet the loop persists because the
(tail_term, frontier) compare never reaches the frontier.

Fix 1 (already in tree): seed the post-open frontier from sentinel.snapshot_seq,
  not last_wal_seq() (which a checkpoint restore leaves at 0).
Fix 2 (loop-breaker): durably synthesize the artifact's kind-3 TERM_MARKER WAL
  record in the post-open reseed seed, at the artifact's captured term + the
  reseed-leader region (threaded through an extended 18-byte install sentinel,
  back-compat with 10/8-byte). Makes wal_term_mark() truthful on this boot AND
  every reboot (blob records are NOT checkpoint-filtered on recovery), so
  decide_join returns Clean. Truthful, not a bypass: the artifact IS the leader's
  authoritative state at (term, seq); a genuinely-divergent node (no install
  sentinel) still surfaces tail_term > term -> Quarantine. Crash-idempotent via a
  monotonic-by-term guard.
Fix 3: node-level reseed-restart coordinator — the single process-wide exit fires
  once, only after every hosted shard requests a restart or a bounded grace
  elapses, so one shard's self-restart never aborts a co-hosted sibling's
  in-flight install (S>1). No-op on the S=1 production topology.
Fix 4: is_ready() returns 503 while any reseed marker (SnapshotRequired or
  Quarantine) is latched, closing the plain-restart serve-while-behind gap;
  readiness is bounded staleness, not "ready the instant the process is up".

Tests: decide_join loop/fix/bounded-reseed unit; install-sentinel 18-byte
  round-trip + back-compat; engine durability (term marker survives a checkpoint
  advanced past it + crash-reopen); reseed-restart gate (5 cases); reseed_install
  carries the term. Verified: cluster_reseed 4/4 (zero-loss rolling restart x2,
  quarantine reseed, failover oracle), reseed_install 3/3, m12_reseed_term_marker
  4/4, cluster_membership mp_idle/mp_dns/mp_remove x2, tidal-server lib 154/154.
  mp_scale_3_5_3 and mp_seed_join_snapshot_catchup OOM on this host (22GB colima
  VM); their /health/startup failure is process-down, not the is_ready path Fix 4
  touches.
2026-06-18 21:06:18 -06:00

1517 lines
63 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Boot-time snapshot install + swap-recovery (m11p5 §2.2§2.7).
//!
//! When a node's data dir carries a durable `reseed_required` marker, it does
//! NOT open the existing engine and serve degraded forever — instead, BEFORE
//! constructing [`ShardReplica`], it:
//!
//! 1. **Recovers any interrupted prior swap** (§2.3) — this runs FIRST, before
//! `ElectionStore` classification, because classifying first re-opens restart
//! amnesia (a mid-rename crash would otherwise classify `Fresh` at term 0 and
//! re-vote in an already-voted term).
//! 2. **Discovers the current leader** (§2.7) by polling
//! `GET /cluster/status/local` across the topology's HTTP addresses (plus the
//! durable membership cache when present), with bounded backoff.
//! 3. **Adopts the discovered term** durably (persist-before-act) when it is
//! above the local durable term.
//! 4. **Fetches a snapshot** from the leader's advertised gRPC address into a
//! sibling staging dir, verifies every file's BLAKE3 + manifest completeness,
//! copies the node's own identity files INTO staging, writes a `COMPLETE`
//! sentinel + a `reseed-install-pending` install sentinel, fsyncs, then
//! performs the §2.3 swap (rename old aside → rename staging in → fsync parent
//! → delete discard).
//!
//! A marker boot that cannot complete the handshake within a bounded window
//! **falls back to opening the existing data dir** — degraded, marker latched,
//! voting enabled (§2.2; the reseed needs a leader and the leader may need this
//! node's vote, so blocking boot forever would deadlock the cluster). The
//! post-open seed (§2.6) is driven by the install sentinel inside
//! [`ShardReplica::new`], not here.
//!
//! # The swap protocol (§2.3, review-corrected ordering)
//!
//! ```text
//! staging = <parent>/<name>.reseed-staging (same filesystem as data_dir)
//! discard = <parent>/<name>.discard
//!
//! fetch → staging
//! verify BLAKE3 + completeness
//! copy own election_state + membership cache INTO staging (§2.3 step 2)
//! write reseed-install-pending sentinel INTO staging (crash-idempotent §2.6 seed)
//! write COMPLETE sentinel INTO staging; fsync (§2.3 step 3)
//! rename(data_dir → discard) (§2.3 step 4)
//! rename(staging → data_dir)
//! fsync(parent)
//! delete discard
//! ```
//!
//! The reseed marker lives in the OLD dir, which becomes `.discard` and is
//! deleted; the new dir (from staging, which the leader's manifest never
//! includes a marker in) simply has none. Nothing recreates it on the success
//! path.
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use tidaldb::replication::{
ElectionStore, HardState, ReseedMarker, ReseedMarkerStore, ReseedReason, ShardId,
};
use super::topology::{GrpcTlsSpec, TopologySpec};
use crate::error::{Result, ServerError};
/// The `COMPLETE` sentinel inside staging (§2.3 step 3): its presence means the
/// staged dir is fully fetched + verified + identity-copied and the swap may be
/// (re)done. Without it, a staging dir is an interrupted fetch and is deleted.
const COMPLETE_SENTINEL: &str = "COMPLETE";
/// The install sentinel copied INTO staging BEFORE the swap (§2.6).
///
/// Its presence in the canonical data dir AFTER open tells
/// `ShardReplica::new` to run the post-open seed exactly once
/// (crash-idempotent: a crash before the seed's sentinel-delete re-runs the same
/// seed against the unchanged WAL).
pub const INSTALL_PENDING_SENTINEL: &str = "reseed-install-pending";
/// Staging dir suffix (a sibling of `data_dir` in its parent, §2.3).
const STAGING_SUFFIX: &str = "reseed-staging";
/// Discard dir suffix (the old `data_dir`, renamed aside during the swap).
const DISCARD_SUFFIX: &str = "discard";
/// Default handshake window before a marker boot falls back to opening the
/// existing data dir (§2.2). Env-overridable for tests via
/// `TIDAL_RESEED_HANDSHAKE_MS`.
const DEFAULT_HANDSHAKE_MS: u64 = 60_000;
/// Per-status-poll HTTP timeout (short — the discovery loop iterates).
const STATUS_POLL_TIMEOUT: Duration = Duration::from_millis(500);
/// Backoff bounds for the discovery loop.
const BACKOFF_MIN: Duration = Duration::from_millis(250);
const BACKOFF_MAX: Duration = Duration::from_secs(5);
/// The set of swap-related paths derived from a data dir.
///
/// All three (staging, discard, and `data_dir` itself) live in `data_dir`'s
/// PARENT on the SAME filesystem, so the swap renames are atomic (`EXDEV` /
/// mount-root failures surface loudly at reseed time, never silently — §2.3).
#[derive(Debug, Clone)]
pub struct SwapDirs {
/// The canonical data dir (`--data-dir`).
pub data_dir: PathBuf,
/// `<parent>/<name>.reseed-staging`.
pub staging: PathBuf,
/// `<parent>/<name>.discard`.
pub discard: PathBuf,
}
impl SwapDirs {
/// Derive the swap paths for `data_dir`.
///
/// # Errors
///
/// [`ServerError::Cluster`] when `data_dir` has no parent (it is a
/// filesystem/volume mount root) or no final component — the §2.3 constraint
/// that the data dir must be a subdirectory of a mount (e.g. `/data/db`, the
/// k8s manifests' layout), so staging/discard can be same-filesystem
/// siblings.
pub fn derive(data_dir: &Path) -> Result<Self> {
let parent = data_dir
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or_else(|| {
ServerError::Cluster(format!(
"reseed swap requires --data-dir ({}) to be a SUBDIRECTORY of a mount, not a \
filesystem/volume root: the swap renames the data dir itself, so staging and \
.discard must be same-filesystem siblings in its parent (§2.3). The k8s \
manifests mount the PVC at /data and set --data-dir /data/db.",
data_dir.display()
))
})?;
let name = data_dir.file_name().ok_or_else(|| {
ServerError::Cluster(format!(
"reseed swap: --data-dir ({}) has no final path component",
data_dir.display()
))
})?;
let mut staging = name.to_os_string();
staging.push(format!(".{STAGING_SUFFIX}"));
let mut discard = name.to_os_string();
discard.push(format!(".{DISCARD_SUFFIX}"));
Ok(Self {
data_dir: data_dir.to_path_buf(),
staging: parent.join(staging),
discard: parent.join(discard),
})
}
}
/// Recover from any swap interrupted by a crash (§2.3 boot recovery).
///
/// Runs BEFORE `ElectionStore` classification (the review found that classifying
/// first re-opens restart amnesia: a mid-rename crash leaves `data_dir` absent,
/// which a naive classifier would read as a fresh node at term 0 and re-vote).
///
/// The crash windows and their idempotent redo:
/// - staging WITHOUT `COMPLETE` → an interrupted fetch: delete staging.
/// - staging WITH `COMPLETE` + `data_dir` present → the crash landed between
/// writing `COMPLETE` and the first rename: redo the swap from step 4.
/// - staging WITH `COMPLETE` + `data_dir` ABSENT → the mid-rename crash (old dir
/// already renamed aside, staging not yet renamed in): complete the rename.
/// - stray `.discard` (any case) → a swap that crashed after `staging → data_dir`
/// but before the discard delete: delete it.
///
/// Every window is an idempotent redo; the identity files travel inside staging
/// so there is never an instant at which the canonical path lacks them post-swap.
///
/// # Errors
///
/// [`ServerError::Cluster`] on a rename/delete failure (loud, naming the §2.3
/// constraint when it is an `EXDEV`/mount-root cross-device error).
pub fn recover_swap(dirs: &SwapDirs) -> Result<()> {
let staging_exists = dirs.staging.exists();
let staging_complete = dirs.staging.join(COMPLETE_SENTINEL).exists();
let data_exists = dirs.data_dir.exists();
if staging_exists {
if staging_complete {
if data_exists {
// Crash between COMPLETE and the first rename: the old dir is
// still canonical. Redo the swap from step 4 (rename old aside,
// rename staging in, fsync parent, delete discard).
tracing::warn!(
staging = %dirs.staging.display(),
data_dir = %dirs.data_dir.display(),
"reseed swap recovery: a COMPLETE staging dir and a present data dir — \
redoing the swap from step 4 (idempotent)"
);
complete_swap(dirs)?;
} else {
// The mid-rename crash: old dir already renamed aside (to
// discard or gone), staging not yet renamed in. Complete the
// rename of staging → data_dir, fsync parent, delete any discard.
tracing::warn!(
staging = %dirs.staging.display(),
"reseed swap recovery: COMPLETE staging with an ABSENT data dir (mid-rename \
crash) — completing the rename so a naive classifier never reads Fresh"
);
rename_same_fs(&dirs.staging, &dirs.data_dir)?;
fsync_parent(&dirs.data_dir)?;
delete_discard(dirs)?;
}
} else {
// Interrupted fetch (no COMPLETE): the staged bytes are partial and
// unverified — delete the whole staging dir and let the boot loop
// retry or fall back.
tracing::warn!(
staging = %dirs.staging.display(),
"reseed swap recovery: staging dir WITHOUT a COMPLETE sentinel (interrupted \
fetch) — deleting it"
);
remove_dir_all_loud(&dirs.staging, "interrupted staging")?;
}
}
// A stray discard from a swap that crashed after the second rename but before
// the discard delete. Safe to remove unconditionally (it is the OLD data dir,
// already superseded by the new canonical one).
if dirs.discard.exists() {
tracing::warn!(
discard = %dirs.discard.display(),
"reseed swap recovery: deleting a stray .discard (post-swap cleanup)"
);
delete_discard(dirs)?;
}
Ok(())
}
/// The §2.3 step-4 swap, factored so both the live install and the recovery
/// redo run the identical sequence: rename `data_dir` aside → rename staging in
/// → fsync parent → delete discard.
fn complete_swap(dirs: &SwapDirs) -> Result<()> {
// A leftover discard from a prior partial swap would make the first rename
// fail (target exists); clear it first.
if dirs.discard.exists() {
delete_discard(dirs)?;
}
rename_same_fs(&dirs.data_dir, &dirs.discard)?;
rename_same_fs(&dirs.staging, &dirs.data_dir)?;
fsync_parent(&dirs.data_dir)?;
delete_discard(dirs)?;
Ok(())
}
fn delete_discard(dirs: &SwapDirs) -> Result<()> {
remove_dir_all_loud(&dirs.discard, "discard")
}
fn remove_dir_all_loud(path: &Path, what: &str) -> Result<()> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(ServerError::Cluster(format!(
"reseed swap: failed to delete {what} dir {}: {e}",
path.display()
))),
}
}
/// Rename `from` → `to` on the SAME filesystem, surfacing `EXDEV` loudly with
/// the §2.3 constraint named.
fn rename_same_fs(from: &Path, to: &Path) -> Result<()> {
std::fs::rename(from, to).map_err(|e| {
// `EXDEV` (raw OS error 18 on Linux/macOS) means the rename crossed a
// filesystem boundary — exactly the mount-root mistake §2.3 forbids.
let is_exdev = e.raw_os_error() == Some(18);
if is_exdev {
ServerError::Cluster(format!(
"reseed swap: rename {}{} crossed a filesystem boundary (EXDEV). The §2.3 \
constraint REQUIRES --data-dir to be a subdirectory of a mount so staging and \
.discard are same-filesystem siblings (k8s: mount the PVC at /data, \
--data-dir /data/db). Refusing to copy across devices — fix the mount layout.",
from.display(),
to.display()
))
} else {
ServerError::Cluster(format!(
"reseed swap: rename {}{} failed: {e}",
from.display(),
to.display()
))
}
})
}
/// Durably fsync a directory's entry (the parent of `child`), so a swap's
/// renames survive a crash.
fn fsync_parent(child: &Path) -> Result<()> {
let Some(parent) = child.parent() else {
return Ok(());
};
let dir = std::fs::File::open(parent).map_err(|e| {
ServerError::Cluster(format!(
"reseed swap: open parent {} for fsync failed: {e}",
parent.display()
))
})?;
dir.sync_all().map_err(|e| {
ServerError::Cluster(format!(
"reseed swap: fsync parent {} failed: {e}",
parent.display()
))
})?;
Ok(())
}
/// Majority of `total_voters` voters (true `floor(n/2)+1`), matching the m11p5
/// §3.0 `majority()` fix — even sizes are mandatory transit states, so this must
/// be correct for every n.
#[must_use]
pub const fn majority(total_voters: usize) -> usize {
total_voters / 2 + 1
}
/// Whether a self-restart for reseed is SAFE given the node's local view of how
/// many OTHER voters are alive (§2.4 quorum refusal).
///
/// A reseed self-restart removes THIS node from the live set while it is down.
/// It is only safe if the remaining voters can still form a quorum without it:
/// `alive_voters_excluding_self >= majority(total_voters)`. Otherwise exiting
/// during (e.g.) a 2-voter window is total write unavailability, so the
/// self-restart is REFUSED.
///
/// Note `total_voters` is the FULL voter count INCLUDING this node; the
/// remaining voters must form a majority of the whole set (the set size does
/// not shrink just because one node restarts — it is expected back).
#[must_use]
pub const fn self_restart_quorum_safe(
alive_voters_excluding_self: usize,
total_voters: usize,
) -> bool {
alive_voters_excluding_self >= majority(total_voters)
}
/// The handshake window (env-overridable for tests).
fn handshake_window() -> Duration {
let ms = std::env::var("TIDAL_RESEED_HANDSHAKE_MS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(DEFAULT_HANDSHAKE_MS);
Duration::from_millis(ms)
}
/// What the discovery loop learned about the current leader (§2.7).
#[derive(Debug, Clone)]
struct LeaderInfo {
/// The leader's advertised gRPC `host:port` (the snapshot fetch target).
grpc_addr: String,
/// The leadership term the snapshot fetch + subsequent stream pulls stamp.
term: u64,
/// The discovered leader's `RegionId` (its 0-based topology index). Persisted
/// in the install sentinel so the §2.6 post-open seed AND the post-install
/// catch-up pull target the DISCOVERED leader's shard — not the boot
/// topology leader, which for a reseeded ex-leader is the node ITSELF
/// (m11p5 §2, the install-boot catch-up correction).
region: u16,
}
/// One candidate the discovery loop polls: its HTTP status address plus the gRPC
/// address it advertises (so a `is_leader` answer immediately yields the fetch
/// target without a second lookup), and its `RegionId` (its 0-based topology
/// index) so a discovered leader's shard is known without a second lookup.
#[derive(Debug, Clone)]
struct Candidate {
region: u16,
http_addr: String,
grpc_addr: String,
}
/// The outcome of a boot-time install attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallOutcome {
/// A snapshot was installed (the swap completed). `ShardReplica::new`
/// will find the install sentinel and run the §2.6 post-open seed.
Installed,
/// The leader reported `needed=false` (the live WAL still serves our
/// frontier): the marker was cleared; open the existing dir and let the
/// runtime path re-latch if catch-up still fails (self-correcting, §2.2d).
NotNeeded,
/// The handshake could not complete within the bounded window: fall back to
/// opening the existing data dir, degraded, marker still latched, voting
/// enabled (§2.2 review-blocker fix — never block boot forever).
FellBack,
}
/// Run the boot-time snapshot install for a marker boot (§2.2§2.7).
///
/// Returns the outcome; `ShardReplica::new` is constructed afterward in
/// every case (a fresh dir after `Installed`, the existing dir otherwise).
///
/// # Errors
///
/// [`ServerError::Cluster`] only for UNRECOVERABLE faults (a corrupt reseed
/// marker, a swap that crossed a filesystem boundary, a failed durable persist).
/// A reachability failure is NOT an error — it returns [`InstallOutcome::FellBack`]
/// so boot proceeds (the cluster may need this node's vote, §2.2).
pub fn run_boot_install(
topology: &TopologySpec,
region_name: &str,
data_dir: &Path,
shard: ShardId,
) -> Result<InstallOutcome> {
run_boot_install_with(topology, region_name, data_dir, shard, handshake_window())
}
/// Run the boot-time reseed install for EVERY shard group this region hosts,
/// each against that group's OWN data subdir (the m11p6 layout) and with
/// SHARD-TARGETED leader discovery.
///
/// Fixes the multi-shard reseed loop: the per-group reseed marker + the divergent
/// WAL live in `<data_dir>/shard-{:05}` (`ShardReplica`'s own dir), but the legacy
/// single-call `run_boot_install(.., data_dir)` loaded the marker from the PARENT
/// dir, found none, returned `NotNeeded`, and never healed — so a divergent shard
/// re-quarantined and self-restarted forever. This visits each hosted group's
/// subdir (S==1 resolves to `data_dir` verbatim, preserving the byte-for-byte
/// legacy path) and discovers/dials THAT shard's leader (not the default group's).
///
/// # Errors
///
/// Propagates an unrecoverable install fault (see [`run_boot_install`]) or a
/// topology resolution error.
pub fn run_boot_install_for_region(
topology: &TopologySpec,
region_name: &str,
data_dir: &Path,
) -> Result<Vec<(ShardId, InstallOutcome)>> {
let groups = topology.resolve_shard_groups()?;
let single = groups.len() == 1;
let mut outcomes = Vec::new();
for group in &groups {
// Only groups THIS region replicates have local data to heal.
if !group.replicas.iter().any(|r| r.name == region_name) {
continue;
}
let group_dir = if single {
data_dir.to_path_buf()
} else {
data_dir.join(super::node::shard_subdir(group.shard))
};
// A group subdir may not exist yet on a never-run node; the install's
// recover_swap + marker load both tolerate an absent/empty dir (=>
// NotNeeded), so this is safe to call unconditionally per hosted group.
let outcome = run_boot_install(topology, region_name, &group_dir, group.shard)?;
outcomes.push((group.shard, outcome));
}
Ok(outcomes)
}
/// [`run_boot_install`] with an explicit handshake window.
///
/// The window is the bounded time before the degraded fallback (§2.2). The
/// public entry point derives it from `TIDAL_RESEED_HANDSHAKE_MS` (or the 60 s
/// default); this variant lets a test bound it without mutating process env (the
/// crate forbids `unsafe`, so `set_var` is not an option).
///
/// # Errors
///
/// Same as [`run_boot_install`].
pub fn run_boot_install_with(
topology: &TopologySpec,
region_name: &str,
data_dir: &Path,
shard: ShardId,
handshake_window: Duration,
) -> Result<InstallOutcome> {
let dirs = SwapDirs::derive(data_dir)?;
// (a) Swap-recovery FIRST — before any ElectionStore classification (§2.3).
recover_swap(&dirs)?;
// The marker may have been consumed by a recovered swap (its OLD dir, with
// the marker, became .discard and was deleted). Re-read after recovery.
let marker_store = ReseedMarkerStore::new(data_dir);
let Some(marker) = marker_store
.load()
.map_err(|e| ServerError::Cluster(format!("refusing to boot: {e}")))?
else {
// No marker (or a recovered swap consumed it): nothing to install — the
// caller opens the existing/fresh dir normally (NotNeeded = no install).
return Ok(InstallOutcome::NotNeeded);
};
tracing::info!(
region = region_name,
reason = marker.reason.as_str(),
from_seqno = marker.from_seqno,
"boot-time reseed marker present: discovering a leader to snapshot-install from (§2.7)"
);
let candidates = candidates_for(topology, region_name, shard)?;
if candidates.is_empty() {
tracing::error!(
"reseed boot: no peer candidates in the topology to discover a leader from; \
falling back to opening the existing data dir (degraded, marker latched)"
);
return Ok(InstallOutcome::FellBack);
}
let tls = own_grpc_tls(topology, region_name);
// m11p7: a TLS cluster's reseed discovery dials `https://` and trusts the
// cluster CA, matching the rest of the inter-node HTTP plane.
super::forward::set_inter_node_https(tls.is_some());
let api_key = std::env::var("TIDAL_API_KEY").ok();
let deadline = Instant::now() + handshake_window;
let mut backoff = BACKOFF_MIN;
// The blocking reqwest client for status polls (this runs on a dedicated
// boot thread, not a reactor). A fresh runtime per fetch is acceptable — the
// reseed is a rare boot operation, not a hot path.
let mut status_builder = reqwest::blocking::Client::builder().timeout(STATUS_POLL_TIMEOUT);
if let Some(t) = &tls {
let Some(ca) = std::fs::read(&t.ca_cert)
.ok()
.and_then(|pem| reqwest::Certificate::from_pem(&pem).ok())
else {
tracing::error!("reseed boot: reading the inter-node CA cert failed; falling back");
return Ok(InstallOutcome::FellBack);
};
status_builder = status_builder.add_root_certificate(ca);
}
let status_client = match status_builder.build() {
Ok(c) => c,
Err(e) => {
tracing::error!(error = %e, "reseed boot: status client build failed; falling back");
return Ok(InstallOutcome::FellBack);
}
};
while Instant::now() < deadline {
let Some(leader) = discover_leader(&status_client, &candidates, api_key.as_deref(), shard)
else {
std::thread::sleep(backoff);
backoff = (backoff * 2).min(BACKOFF_MAX);
continue;
};
// (c) Adopt the discovered term durably BEFORE pulling (§2.7
// persist-before-act) when it exceeds our durable term. A failed durable
// persist is unrecoverable (we cannot prove the term) — surface loudly.
adopt_term_if_higher(data_dir, leader.term)?;
// (d) Fetch the snapshot into staging and swap it in.
match attempt_install(&dirs, &leader, marker, tls.as_ref()) {
Ok(AttemptResult::Installed) => return Ok(InstallOutcome::Installed),
Ok(AttemptResult::NotNeeded) => {
// needed=false: the stream refused us at from_seqno previously,
// but the leader now says the live WAL covers it — clear the
// marker and retry the normal boot (self-correcting §2.2d). The
// runtime path re-latches if catch-up still breaks.
marker_store.clear().map_err(|e| {
ServerError::Cluster(format!(
"reseed boot: clear marker after needed=false: {e}"
))
})?;
tracing::info!(
"reseed boot: leader reports the live WAL still covers our frontier \
(needed=false); cleared the marker and opening the existing data dir \
(self-correcting — the runtime re-latches if catch-up still fails)"
);
return Ok(InstallOutcome::NotNeeded);
}
Ok(AttemptResult::Retry) => {
// A term-fence abort (an election landed mid-fetch) or a
// transient transport failure: re-run discovery, re-stamp,
// retry — bounded by the handshake deadline.
std::thread::sleep(backoff);
backoff = (backoff * 2).min(BACKOFF_MAX);
}
Err(e) => return Err(e),
}
}
tracing::error!(
window_ms = handshake_window.as_millis(),
"reseed boot: could not complete the snapshot handshake within the bounded window; \
falling back to opening the existing data dir (degraded, marker latched, VOTING \
ENABLED — the cluster may need this node's vote to elect the leader the reseed needs, \
§2.2). The reseed retries on the next boot."
);
Ok(InstallOutcome::FellBack)
}
/// Install a snapshot from an ALREADY-DISCOVERED leader (m11p5 §2.7 / §3.4).
///
/// The seed-join path. Unlike [`run_boot_install`], the caller has learned the
/// leader from a `JoinCluster` response (or the membership cache), so there is no
/// discovery loop and no reseed marker — just the §2.3 staging/verify/swap.
///
/// Returns:
/// - `Ok(Some(snapshot_seq))` — a snapshot was streamed, verified, and swapped
/// in; the new data dir carries the install sentinel (recording
/// `leader_region`) so `ShardReplica::new` runs the §2.6 post-open seed.
/// - `Ok(None)` — the leader reported `needed=false` (the live WAL still serves
/// `from_seqno`): nothing was installed, proceed empty (the small-cluster case
/// where the stream serves from 1).
///
/// `recover_swap` runs FIRST (a crash during a prior seed-join install's swap is
/// recovered idempotently, exactly as a marker boot's).
///
/// # Errors
///
/// [`ServerError::Cluster`] for an unrecoverable fault (a swap that crossed a
/// filesystem boundary, a failed durable persist, a fatal local filesystem
/// state). A transport/verification failure is RETRYABLE — surfaced as
/// `Ok(Err(reason))` is NOT used here; the caller's seed loop re-discovers and
/// re-invokes on a retryable failure, which this maps into a typed error the
/// caller distinguishes via [`SeedInstallError`].
pub fn install_snapshot_from_leader(
data_dir: &Path,
leader_grpc_addr: &str,
leader_region: u16,
term: u64,
from_seqno: u64,
tls: Option<&tidal_net::config::TlsConfig>,
) -> std::result::Result<Option<u64>, SeedInstallError> {
let dirs = SwapDirs::derive(data_dir).map_err(SeedInstallError::Fatal)?;
// Recover any interrupted prior swap before staging a fresh one (idempotent).
recover_swap(&dirs).map_err(SeedInstallError::Fatal)?;
let leader = LeaderInfo {
grpc_addr: leader_grpc_addr.to_string(),
term,
region: leader_region,
};
// A synthetic marker carrying the resume seqno (the seed-join install has no
// durable marker; this only conveys `from_seqno` into the shared
// `attempt_install` path, which reads `marker.from_seqno`).
let marker = ReseedMarker {
reason: ReseedReason::Operator,
from_seqno,
};
match attempt_install(&dirs, &leader, marker, tls) {
Ok(AttemptResult::Installed) => {
// Recover the installed artifact's recorded seq from the sentinel the
// swap wrote into the (now canonical) data dir.
let seq = read_install_sentinel(data_dir)
.map_err(SeedInstallError::Fatal)?
.map_or(0, |s| s.snapshot_seq);
Ok(Some(seq))
}
Ok(AttemptResult::NotNeeded) => Ok(None),
Ok(AttemptResult::Retry) => Err(SeedInstallError::Retry),
Err(e) => Err(SeedInstallError::Fatal(e)),
}
}
/// The outcome class of [`install_snapshot_from_leader`].
#[derive(Debug)]
pub enum SeedInstallError {
/// Re-discover the leader and retry within the join window (term fence,
/// transport error, verification miss).
Retry,
/// Unrecoverable (a swap that crossed a filesystem boundary, a failed
/// durable persist): boot cannot proceed.
Fatal(ServerError),
}
impl std::fmt::Display for SeedInstallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Retry => write!(f, "snapshot install retryable (re-discover the leader)"),
Self::Fatal(e) => write!(f, "snapshot install fatal: {e}"),
}
}
}
/// The candidate poll set (§2.7) for the reseed of shard group `shard`: the
/// OTHER replicas of THAT group, each carrying the group's PER-SHARD gRPC address
/// (the resolved replica's `grpc_addr` already includes the `+shard_id` port
/// offset) plus the replica's node-level HTTP address (the `:9500` plane is
/// per-node, not per-shard). Shard-targeting is load-bearing: a parent-default
/// poll would discover the WRONG shard's leader and install foreign data/term
/// into shard `shard`'s subdir.
///
/// # Errors
///
/// Propagates a topology resolution error.
fn candidates_for(
topology: &TopologySpec,
region_name: &str,
shard: ShardId,
) -> Result<Vec<Candidate>> {
let groups = topology.resolve_shard_groups()?;
let Some(group) = groups.iter().find(|g| g.shard == shard) else {
return Ok(Vec::new());
};
// The node-level HTTP address for a region (the `:9500` status/forward plane
// is shared by every shard the node hosts; only the gRPC port is per-shard).
let http_for = |name: &str| {
topology
.regions
.iter()
.find(|r| r.name == name)
.and_then(|r| r.http_addr.clone())
};
Ok(group
.replicas
.iter()
.filter(|r| r.name != region_name)
.filter_map(|r| {
http_for(&r.name).map(|http| Candidate {
region: r.region.0,
http_addr: http,
grpc_addr: r.grpc_addr.clone(),
})
})
.collect())
}
/// This region's own gRPC TLS material (the snapshot fetch dials the leader with
/// the same TLS posture this node advertises — the topology's `grpc_tls` block).
fn own_grpc_tls(
topology: &TopologySpec,
region_name: &str,
) -> Option<tidal_net::config::TlsConfig> {
topology
.regions
.iter()
.find(|r| r.name == region_name)
.and_then(|r| r.grpc_tls.as_ref())
.map(GrpcTlsSpec::to_tls_config)
}
/// Poll every candidate's `/cluster/status/local`; return the first that reports
/// itself the leader, with its advertised gRPC address + term.
fn discover_leader(
client: &reqwest::blocking::Client,
candidates: &[Candidate],
api_key: Option<&str>,
shard: ShardId,
) -> Option<LeaderInfo> {
// The `?shard=N` selector makes `/cluster/status/local` report SHARD N's
// leadership (status_local resolves `replica_for(sel.shard_id())`); without it
// a multi-leader sharded node answers for its default (lowest-id) group, so we
// would discover the wrong shard's leader/term.
let path = format!("/cluster/status/local?shard={}", shard.0);
for cand in candidates {
let url = super::forward::peer_url(&cand.http_addr, &path);
let mut req = client.get(&url);
if let Some(key) = api_key {
req = req.bearer_auth(key);
}
let Ok(resp) = req.send() else { continue };
if !resp.status().is_success() {
continue;
}
let Ok(json) = resp.json::<serde_json::Value>() else {
continue;
};
let is_leader = json
.get("is_leader")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if !is_leader {
continue;
}
let term = json
.get("term")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
return Some(LeaderInfo {
grpc_addr: cand.grpc_addr.clone(),
term,
region: cand.region,
});
}
None
}
/// Adopt `term` durably (persist `{term, voted_for: None}`) when it is above the
/// node's durable term (§2.7 persist-before-act). A corrupt `election_state`
/// refuses boot (a reseed does NOT restore term knowledge; the m11p4 rule
/// stands, §2.5).
fn adopt_term_if_higher(data_dir: &Path, term: u64) -> Result<()> {
let store = ElectionStore::new(data_dir);
let wal_exists = data_dir
.join("wal")
.read_dir()
.map(|mut it| it.next().is_some())
.unwrap_or(false);
let durable_term = match store.load(wal_exists) {
Ok(tidaldb::replication::BootState::Rejoin(h)) => h.current_term,
Ok(_) => 0, // Fresh / StateFileLost: term 0.
Err(e) => {
return Err(ServerError::Cluster(format!(
"reseed boot: refusing to adopt a discovered term — {e} (a reseed does not \
restore term knowledge; the m11p4 corrupt-state rule stands, §2.5)"
)));
}
};
if term > durable_term {
store
.persist(HardState {
current_term: term,
voted_for: None,
})
.map_err(|e| {
ServerError::Cluster(format!(
"reseed boot: persist discovered term {term} (persist-before-act) failed: {e}"
))
})?;
tracing::info!(
durable_term,
adopted = term,
"reseed boot: adopted the discovered leadership term durably before pulling (§2.7)"
);
}
Ok(())
}
/// One install attempt's result.
enum AttemptResult {
/// The snapshot streamed, verified, and swapped in.
Installed,
/// The leader reported `needed=false`.
NotNeeded,
/// A retryable failure (term fence, transport error, verification miss):
/// re-discover and retry within the handshake window.
Retry,
}
/// Fetch a snapshot from `leader` into staging, verify, copy identity files,
/// write the sentinels, and swap (§2.3/§2.6/§2.7).
fn attempt_install(
dirs: &SwapDirs,
leader: &LeaderInfo,
marker: ReseedMarker,
tls: Option<&tidal_net::config::TlsConfig>,
) -> Result<AttemptResult> {
// A stale staging dir from a prior aborted attempt: clear it.
remove_dir_all_loud(&dirs.staging, "stale staging")?;
std::fs::create_dir_all(&dirs.staging).map_err(|e| {
ServerError::Cluster(format!(
"reseed boot: create staging {} failed: {e}",
dirs.staging.display()
))
})?;
// The leader's shard is its own shard; for the single-shard-per-region
// mapping the snapshot's source shard is the leader's. We dial the leader's
// advertised gRPC addr; the server-side handler resolves its own shard.
// The puller stamps the discovered term and requests from the marker's
// recorded from_seqno (§2.7 / §2.2d honesty: the stream already refused us
// there).
let fetch = fetch_into_staging(
&leader.grpc_addr,
tls,
marker.from_seqno,
leader.term,
&dirs.staging,
);
match fetch {
Ok(FetchResult::Needed { snapshot_seq }) => {
// Copy the node's own identity files INTO staging (§2.3 step 2),
// then the install sentinel (carrying the DISCOVERED leader's region
// AND term so the post-open seed + catch-up pull target its shard —
// not the boot topology leader's — and durably synthesize the term
// marker), then COMPLETE, fsync, swap.
copy_identity_into_staging(&dirs.data_dir, &dirs.staging)?;
write_install_sentinel(&dirs.staging, snapshot_seq, leader.region, leader.term)?;
write_complete_sentinel(&dirs.staging)?;
complete_swap(dirs)?;
tracing::info!(
snapshot_seq,
term = leader.term,
"reseed boot: snapshot installed and swapped in; the post-open seed runs once \
ShardReplica opens the new data dir (§2.6)"
);
Ok(AttemptResult::Installed)
}
Ok(FetchResult::NotNeeded) => {
remove_dir_all_loud(&dirs.staging, "needed=false staging")?;
Ok(AttemptResult::NotNeeded)
}
Err(FetchError::Retryable(why)) => {
tracing::warn!(reason = %why, "reseed boot: snapshot fetch retryable; re-discovering");
remove_dir_all_loud(&dirs.staging, "retryable staging")?;
Ok(AttemptResult::Retry)
}
Err(FetchError::Fatal(why)) => Err(ServerError::Cluster(format!(
"reseed boot: snapshot install failed unrecoverably: {why}"
))),
}
}
/// The result of streaming a snapshot into staging.
enum FetchResult {
/// A snapshot was streamed + verified into staging.
Needed { snapshot_seq: u64 },
/// The leader reported `needed=false`.
NotNeeded,
}
/// A snapshot fetch error.
enum FetchError {
/// Re-discover the leader and retry (term fence, transport, verification).
Retryable(String),
/// Unrecoverable (an impossible local filesystem state).
Fatal(String),
}
/// Stream the snapshot from `grpc_addr` into `staging`, verifying each file's
/// BLAKE3 against the manifest and the manifest's completeness.
///
/// Runs a dedicated current-thread tokio runtime (this is a rare boot operation
/// off the main reactor — `fetch_snapshot_standalone` is async).
// The streaming decode + per-file verification is one linear flow; splitting it
// would scatter the integrity-contract ordering (decode → write → hash → verify
// completeness → fsync).
#[allow(clippy::too_many_lines)]
fn fetch_into_staging(
grpc_addr: &str,
tls: Option<&tidal_net::config::TlsConfig>,
from_seqno: u64,
term: u64,
staging: &Path,
) -> std::result::Result<FetchResult, FetchError> {
use std::collections::HashMap;
use std::io::Write;
use tidal_net::client::fetch_snapshot_standalone;
use tidal_net::proto::snapshot_chunk::Chunk;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| FetchError::Retryable(format!("build fetch runtime: {e}")))?;
runtime.block_on(async move {
// The `FetchSnapshot` handler stages from the leader's OWN wired
// `SnapshotSource` (its own shard) regardless of the requested shard id —
// unlike `StreamSegments`, it does not route by shard. So the request's
// `shard_id` is informational here; `from_seqno` is what drives the
// needed/not-needed decision. We send shard 0 as the canonical value.
let shard = ShardId(0);
let mut stream = fetch_snapshot_standalone(grpc_addr, tls, shard, from_seqno, term)
.await
.map_err(|e| FetchError::Retryable(format!("open snapshot stream: {e}")))?;
// Decode the header chunk first; then file chunks in contiguous order.
let mut header: Option<tidal_net::proto::SnapshotHeader> = None;
let mut open_files: HashMap<String, std::fs::File> = HashMap::new();
let mut hashers: HashMap<String, blake3::Hasher> = HashMap::new();
let mut sizes: HashMap<String, u64> = HashMap::new();
loop {
let msg = match stream.message().await {
Ok(Some(m)) => m,
Ok(None) => break,
Err(status) => {
return Err(FetchError::Retryable(format!(
"snapshot stream error ({}): {}",
status.code(),
status.message()
)));
}
};
let Some(chunk) = msg.chunk else {
return Err(FetchError::Retryable("snapshot chunk had no variant".into()));
};
match chunk {
Chunk::Header(h) => {
if !h.needed {
return Ok(FetchResult::NotNeeded);
}
header = Some(h);
}
Chunk::File(f) => {
let entry = open_files.entry(f.path.clone());
let file = match entry {
std::collections::hash_map::Entry::Occupied(o) => o.into_mut(),
std::collections::hash_map::Entry::Vacant(v) => {
let abs = staging.join(&f.path);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
FetchError::Fatal(format!(
"create staging subdir {}: {e}",
parent.display()
))
})?;
}
let file = std::fs::File::create(&abs).map_err(|e| {
FetchError::Fatal(format!("create staged file {}: {e}", abs.display()))
})?;
hashers.insert(f.path.clone(), blake3::Hasher::new());
sizes.insert(f.path.clone(), 0);
v.insert(file)
}
};
// Contiguous-offset enforcement: a gap means a corrupt/torn
// stream — refuse (retryable; re-fetch from scratch).
let written = sizes.get(&f.path).copied().unwrap_or(0);
if f.offset != written {
return Err(FetchError::Retryable(format!(
"snapshot file '{}' arrived out of order (offset {} != written {})",
f.path, f.offset, written
)));
}
file.write_all(&f.data).map_err(|e| {
FetchError::Fatal(format!("write staged file '{}': {e}", f.path))
})?;
if let Some(h) = hashers.get_mut(&f.path) {
h.update(&f.data);
}
*sizes.entry(f.path.clone()).or_insert(0) += f.data.len() as u64;
}
}
}
let Some(header) = header else {
return Err(FetchError::Retryable(
"snapshot stream ended with no header (needed=true expected)".into(),
));
};
// Completeness: every manifest entry must have streamed, with its exact
// size and BLAKE3 (the end-to-end integrity contract — the bytes a
// joiner installs MUST be bit-exact or the open refuses).
for entry in &header.files {
let got_size = sizes.get(&entry.path).copied().ok_or_else(|| {
FetchError::Retryable(format!("manifest file '{}' never streamed", entry.path))
})?;
if got_size != entry.size {
return Err(FetchError::Retryable(format!(
"manifest file '{}' size mismatch: got {got_size}, expected {}",
entry.path, entry.size
)));
}
let got_hash = hashers
.get(&entry.path)
.map(blake3::Hasher::finalize)
.ok_or_else(|| {
FetchError::Retryable(format!("manifest file '{}' had no hasher", entry.path))
})?;
if got_hash.as_bytes().as_slice() != entry.blake3.as_slice() {
return Err(FetchError::Retryable(format!(
"manifest file '{}' BLAKE3 mismatch — refusing the install (identify-or-refuse)",
entry.path
)));
}
}
// Reject any streamed file NOT in the manifest (a hostile/torn extra).
for streamed in sizes.keys() {
if !header.files.iter().any(|e| &e.path == streamed) {
return Err(FetchError::Retryable(format!(
"snapshot streamed an unmanifested file '{streamed}'"
)));
}
}
// fsync every staged file so the verified bytes are durable before the
// sentinels / swap.
for file in open_files.values() {
file.sync_all()
.map_err(|e| FetchError::Fatal(format!("fsync staged file: {e}")))?;
}
Ok(FetchResult::Needed {
snapshot_seq: header.snapshot_seq,
})
})
}
/// Copy the node's own identity files INTO staging (§2.3 step 2): `election_state`
/// (vote safety, §2.5) and the `membership` cache. These travel inside staging so
/// the canonical path is never without them post-swap.
fn copy_identity_into_staging(data_dir: &Path, staging: &Path) -> Result<()> {
for name in ["election_state", "membership"] {
let src = data_dir.join(name);
if src.exists() {
let dst = staging.join(name);
std::fs::copy(&src, &dst).map_err(|e| {
ServerError::Cluster(format!(
"reseed boot: copy identity file {name} into staging failed: {e}"
))
})?;
if let Ok(f) = std::fs::File::open(&dst) {
let _ = f.sync_all();
}
}
}
Ok(())
}
/// The install sentinel's body (m11p5 §2.6, extended m12 reseed-loop-fix).
///
/// It carries the artifact's recovered seq, the DISCOVERED leader's region, and
/// the artifact's captured TERM. All three drive the post-open path:
/// `ShardReplica::new` seeds the frontier, durably synthesizes the term marker
/// the artifact represents (so `wal_term_mark()` reports the artifact's term —
/// not 0 — on this boot and every reboot), and issues the post-install catch-up
/// pull against the discovered leader's shard (NOT the boot topology leader,
/// which for a reseeded ex-leader is the node itself).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InstallSentinel {
/// The installed artifact's recovered WAL tail seq.
pub snapshot_seq: u64,
/// The discovered leader's `RegionId` (its 0-based topology index). `None`
/// when the sentinel predates this field (an interrupted older install): the
/// caller then falls back to the boot topology leader's shard.
pub leader_region: Option<u16>,
/// The artifact's captured leadership TERM (the discovered leader's term at
/// fetch time). `None` when the sentinel predates this field (a legacy 8- or
/// 10-byte install): the post-open seed then SKIPS the durable term-marker
/// synthesis (it cannot fabricate a term it does not know) and falls back to
/// the historical behavior. A truthful term ≥ 1 lets the seed make
/// `wal_term_mark()` report it, so `decide_join` classifies the reseeded
/// shard on a real `(term, frontier)` instead of the false `(0, …)` that
/// loops `ReseedRequired` after a checkpoint-restore empties the WAL.
pub artifact_term: Option<u64>,
}
/// The install sentinel's on-disk size, 18 bytes: 8 bytes seq (LE), then 2
/// bytes region (LE), then 8 bytes term (LE). A 10-byte sentinel (seq + region,
/// no term) and an 8-byte one (seq only) are still read for back-compat with an
/// install that was staged by an older binary and interrupted across the upgrade.
const INSTALL_SENTINEL_SIZE: usize = 8 + 2 + 8;
/// Legacy size: 8 bytes seq + 2 bytes region, no term.
const INSTALL_SENTINEL_SIZE_NO_TERM: usize = 8 + 2;
/// Write the `reseed-install-pending` install sentinel INTO staging BEFORE the
/// swap (§2.6): it carries the artifact's recovered seq, the discovered
/// leader's region, AND the artifact's captured term, so the post-open seed
/// advances the right shard's frontier, durably synthesizes the term marker,
/// and the catch-up pull dials the discovered leader. Crash-idempotent: a crash
/// before the seed's sentinel-delete re-runs the seed against the unchanged WAL.
fn write_install_sentinel(
staging: &Path,
snapshot_seq: u64,
leader_region: u16,
artifact_term: u64,
) -> Result<()> {
let path = staging.join(INSTALL_PENDING_SENTINEL);
let mut bytes = [0u8; INSTALL_SENTINEL_SIZE];
bytes[0..8].copy_from_slice(&snapshot_seq.to_le_bytes());
bytes[8..10].copy_from_slice(&leader_region.to_le_bytes());
bytes[10..18].copy_from_slice(&artifact_term.to_le_bytes());
std::fs::write(&path, bytes).map_err(|e| {
ServerError::Cluster(format!(
"reseed boot: write install sentinel {} failed: {e}",
path.display()
))
})?;
if let Ok(f) = std::fs::File::open(&path) {
let _ = f.sync_all();
}
Ok(())
}
/// Write the `COMPLETE` sentinel INTO staging and fsync it (§2.3 step 3): its
/// presence makes the staged dir swap-ready (the recovery path keys off it).
fn write_complete_sentinel(staging: &Path) -> Result<()> {
let path = staging.join(COMPLETE_SENTINEL);
let f = std::fs::File::create(&path).map_err(|e| {
ServerError::Cluster(format!(
"reseed boot: create COMPLETE sentinel {} failed: {e}",
path.display()
))
})?;
f.sync_all().map_err(|e| {
ServerError::Cluster(format!("reseed boot: fsync COMPLETE sentinel failed: {e}"))
})?;
// fsync the staging dir entry so the sentinel is durable before the rename.
fsync_parent(&path)?;
Ok(())
}
/// Read the install sentinel's recorded body from `data_dir`, if present.
///
/// Called by `ShardReplica::new` after `open_region_db` to drive the §2.6
/// post-open seed AND the post-install catch-up pull. `None` ⇒ this was not an
/// install boot. An 18-byte sentinel carries the discovered leader's region AND
/// the artifact's captured term; a 10-byte one carries the region but no term
/// (`artifact_term: None`); an 8-byte one (an interrupted older install) carries
/// neither (`leader_region: None`, `artifact_term: None`) so the caller falls
/// back to the boot topology leader's shard and skips the term-marker synthesis.
///
/// # Errors
///
/// [`ServerError::Cluster`] when the sentinel exists but is unreadable or
/// malformed (a present-but-corrupt sentinel must NOT be silently ignored — it
/// would skip the seed and leave the frontier wrong).
pub fn read_install_sentinel(data_dir: &Path) -> Result<Option<InstallSentinel>> {
let path = data_dir.join(INSTALL_PENDING_SENTINEL);
match std::fs::read(&path) {
Ok(bytes) => {
// All accepted lengths begin with the 8-byte seq; the 10-byte form
// adds the 2-byte region; the 18-byte form adds the 8-byte term. No
// slice `expect` — every index here is bounds-checked by the length
// match (panic-free).
let len = bytes.len();
if len != INSTALL_SENTINEL_SIZE && len != INSTALL_SENTINEL_SIZE_NO_TERM && len != 8 {
return Err(ServerError::Cluster(format!(
"reseed install sentinel {} is malformed ({len} bytes, expected \
{INSTALL_SENTINEL_SIZE}, {INSTALL_SENTINEL_SIZE_NO_TERM}, or 8); the \
post-open seed cannot proceed",
path.display()
)));
}
let mut seq_bytes = [0u8; 8];
seq_bytes.copy_from_slice(&bytes[0..8]);
let snapshot_seq = u64::from_le_bytes(seq_bytes);
let leader_region = if len >= INSTALL_SENTINEL_SIZE_NO_TERM {
let mut region_bytes = [0u8; 2];
region_bytes.copy_from_slice(&bytes[8..10]);
Some(u16::from_le_bytes(region_bytes))
} else {
None
};
let artifact_term = if len == INSTALL_SENTINEL_SIZE {
let mut term_bytes = [0u8; 8];
term_bytes.copy_from_slice(&bytes[10..18]);
Some(u64::from_le_bytes(term_bytes))
} else {
None
};
Ok(Some(InstallSentinel {
snapshot_seq,
leader_region,
artifact_term,
}))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(ServerError::Cluster(format!(
"reseed install sentinel {} unreadable: {e}",
path.display()
))),
}
}
/// Delete the install sentinel after the §2.6 post-open seed is durably
/// persisted (and dir-fsync the data dir so the delete survives a crash).
///
/// # Errors
///
/// [`ServerError::Cluster`] on a delete/fsync failure.
pub fn clear_install_sentinel(data_dir: &Path) -> Result<()> {
let path = data_dir.join(INSTALL_PENDING_SENTINEL);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => {
return Err(ServerError::Cluster(format!(
"reseed boot: clear install sentinel {} failed: {e}",
path.display()
)));
}
}
fsync_parent(&path)?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
/// Build a [`SwapDirs`] over a tempdir-resident `data_dir` subdirectory.
fn dirs_in(tmp: &Path) -> SwapDirs {
let data = tmp.join("db");
std::fs::create_dir_all(&data).unwrap();
SwapDirs::derive(&data).unwrap()
}
#[test]
fn derive_refuses_a_mount_root() {
// A path with no usable parent (the empty parent of a bare relative
// name) must be refused naming the §2.3 constraint. `/` has parent `/`,
// which is non-empty, so to exercise the refusal we use a bare name.
let err = SwapDirs::derive(Path::new("db")).err();
// "db" has parent "" (empty) → refused.
assert!(err.is_some(), "a parentless data dir must be refused");
assert!(
err.unwrap().to_string().contains("§2.3"),
"the refusal names the §2.3 constraint"
);
}
#[test]
fn derive_paths_are_same_fs_siblings() {
let tmp = tempfile::tempdir().unwrap();
let dirs = dirs_in(tmp.path());
assert_eq!(dirs.staging.parent(), dirs.data_dir.parent());
assert_eq!(dirs.discard.parent(), dirs.data_dir.parent());
assert!(dirs.staging.to_string_lossy().ends_with(".reseed-staging"));
assert!(dirs.discard.to_string_lossy().ends_with(".discard"));
}
/// §2.3 window: staging WITHOUT COMPLETE = interrupted fetch → deleted.
#[test]
fn recover_deletes_incomplete_staging() {
let tmp = tempfile::tempdir().unwrap();
let dirs = dirs_in(tmp.path());
std::fs::create_dir_all(&dirs.staging).unwrap();
std::fs::write(dirs.staging.join("partial"), b"x").unwrap();
recover_swap(&dirs).unwrap();
assert!(!dirs.staging.exists(), "incomplete staging is deleted");
assert!(
dirs.data_dir.exists(),
"the canonical data dir is untouched"
);
}
/// §2.3 window: staging WITH COMPLETE + `data_dir` present → redo the swap
/// from step 4 (the new staging becomes canonical, old goes to
/// discard+deleted).
#[test]
fn recover_redoes_swap_when_complete_and_data_present() {
let tmp = tempfile::tempdir().unwrap();
let dirs = dirs_in(tmp.path());
// Mark the OLD (current) data dir so we can prove it was superseded.
std::fs::write(dirs.data_dir.join("old-marker"), b"old").unwrap();
// A COMPLETE staging dir with a distinct file.
std::fs::create_dir_all(&dirs.staging).unwrap();
std::fs::write(dirs.staging.join("new-marker"), b"new").unwrap();
std::fs::write(dirs.staging.join(COMPLETE_SENTINEL), b"").unwrap();
recover_swap(&dirs).unwrap();
assert!(dirs.data_dir.exists(), "data dir present after redo");
assert!(
dirs.data_dir.join("new-marker").exists(),
"the staged content became canonical"
);
assert!(
!dirs.data_dir.join("old-marker").exists(),
"the old data dir was discarded"
);
assert!(!dirs.staging.exists(), "staging consumed");
assert!(!dirs.discard.exists(), "discard cleaned up");
}
/// §2.3 window: the mid-rename crash — COMPLETE staging + ABSENT `data_dir`
/// → complete the rename so a naive classifier never reads Fresh.
#[test]
fn recover_completes_mid_rename_when_data_absent() {
let tmp = tempfile::tempdir().unwrap();
let dirs = dirs_in(tmp.path());
// Simulate the crash: old data dir already renamed aside (remove it),
// staging present + COMPLETE.
std::fs::remove_dir_all(&dirs.data_dir).unwrap();
std::fs::create_dir_all(&dirs.staging).unwrap();
std::fs::write(dirs.staging.join("new-marker"), b"new").unwrap();
std::fs::write(dirs.staging.join(COMPLETE_SENTINEL), b"").unwrap();
recover_swap(&dirs).unwrap();
assert!(dirs.data_dir.exists(), "the rename was completed");
assert!(
dirs.data_dir.join("new-marker").exists(),
"the staged content is now canonical"
);
assert!(!dirs.staging.exists(), "staging consumed");
}
/// §2.3 window: a stray .discard from a swap that crashed after the second
/// rename but before the discard delete → deleted, data dir untouched.
#[test]
fn recover_deletes_stray_discard() {
let tmp = tempfile::tempdir().unwrap();
let dirs = dirs_in(tmp.path());
std::fs::write(dirs.data_dir.join("live"), b"live").unwrap();
std::fs::create_dir_all(&dirs.discard).unwrap();
std::fs::write(dirs.discard.join("old"), b"old").unwrap();
recover_swap(&dirs).unwrap();
assert!(!dirs.discard.exists(), "stray discard deleted");
assert!(
dirs.data_dir.join("live").exists(),
"the live data dir is untouched"
);
}
/// `recover_swap` is idempotent: a clean tree (no staging, no discard) is a
/// no-op, and re-running after a recovery changes nothing.
#[test]
fn recover_is_idempotent_on_a_clean_tree() {
let tmp = tempfile::tempdir().unwrap();
let dirs = dirs_in(tmp.path());
std::fs::write(dirs.data_dir.join("live"), b"live").unwrap();
recover_swap(&dirs).unwrap();
recover_swap(&dirs).unwrap();
assert!(dirs.data_dir.join("live").exists());
}
/// §2.4 quorum-refusal arithmetic: a self-restart is safe iff the remaining
/// voters can form a majority of the FULL voter set without this node.
#[test]
fn quorum_refusal_arithmetic() {
// n=3: majority 2. With this node down, 2 others alive → safe; 1 → refuse.
assert_eq!(majority(3), 2);
assert!(
self_restart_quorum_safe(2, 3),
"3 voters, 2 others alive: safe"
);
assert!(
!self_restart_quorum_safe(1, 3),
"3 voters, 1 other alive: refuse"
);
// n=2: majority 2. Restarting either leaves 1 other, < 2 → ALWAYS refuse
// (a 2-voter window cannot lose a voter without losing write quorum).
assert_eq!(majority(2), 2);
assert!(
!self_restart_quorum_safe(1, 2),
"2 voters: refuse (total outage)"
);
// n=5: majority 3. 3+ others alive → safe; 2 → refuse.
assert_eq!(majority(5), 3);
assert!(self_restart_quorum_safe(3, 5));
assert!(self_restart_quorum_safe(4, 5));
assert!(!self_restart_quorum_safe(2, 5));
// n=4 (an even transit state): majority 3. 3 others alive → safe; 2 →
// refuse (proves the even-n correctness, not the buggy n/2+1=3 vs the
// disjoint-quorum hazard).
assert_eq!(majority(4), 3);
assert!(self_restart_quorum_safe(3, 4));
assert!(!self_restart_quorum_safe(2, 4));
// n=1 (single node): majority 1; with this node down 0 others → refuse.
assert_eq!(majority(1), 1);
assert!(!self_restart_quorum_safe(0, 1));
}
/// The install + clear sentinel round-trips (carrying the discovered leader
/// region AND the artifact term), reads a legacy 10-byte sentinel as
/// `artifact_term: None`, an 8-byte one as `leader_region: None` too, and
/// refuses a malformed sentinel.
#[test]
fn install_sentinel_roundtrip_and_malformed() {
let tmp = tempfile::tempdir().unwrap();
let data = tmp.path();
assert_eq!(read_install_sentinel(data).unwrap(), None);
// The 18-byte sentinel carries the discovered leader's region AND term.
write_install_sentinel(data, 4096, 2, 5).unwrap();
assert_eq!(
read_install_sentinel(data).unwrap(),
Some(InstallSentinel {
snapshot_seq: 4096,
leader_region: Some(2),
artifact_term: Some(5),
})
);
clear_install_sentinel(data).unwrap();
assert_eq!(read_install_sentinel(data).unwrap(), None);
// clear is idempotent.
clear_install_sentinel(data).unwrap();
// A legacy 10-byte sentinel (region but no term — staged by an older
// binary and interrupted across the upgrade) reads with artifact_term
// None, so the seed skips the term-marker synthesis.
let mut ten = [0u8; INSTALL_SENTINEL_SIZE_NO_TERM];
ten[0..8].copy_from_slice(&9u64.to_le_bytes());
ten[8..10].copy_from_slice(&1u16.to_le_bytes());
std::fs::write(data.join(INSTALL_PENDING_SENTINEL), ten).unwrap();
assert_eq!(
read_install_sentinel(data).unwrap(),
Some(InstallSentinel {
snapshot_seq: 9,
leader_region: Some(1),
artifact_term: None,
})
);
clear_install_sentinel(data).unwrap();
// A legacy 8-byte sentinel (an interrupted older install) reads with
// leader_region None — the caller falls back to the boot topology leader.
std::fs::write(data.join(INSTALL_PENDING_SENTINEL), 7u64.to_le_bytes()).unwrap();
assert_eq!(
read_install_sentinel(data).unwrap(),
Some(InstallSentinel {
snapshot_seq: 7,
leader_region: None,
artifact_term: None,
})
);
clear_install_sentinel(data).unwrap();
// A malformed sentinel (wrong length) is refused, not silently ignored.
std::fs::write(data.join(INSTALL_PENDING_SENTINEL), b"short").unwrap();
assert!(read_install_sentinel(data).is_err());
}
/// `adopt_term_if_higher` persists a higher term and is a no-op for a lower
/// one.
#[test]
fn adopt_term_persists_only_when_higher() {
let tmp = tempfile::tempdir().unwrap();
let data = tmp.path();
// Fresh dir, no election_state, no wal → durable term 0.
adopt_term_if_higher(data, 5).unwrap();
let store = ElectionStore::new(data);
let h = match store.load(false).unwrap() {
tidaldb::replication::BootState::Rejoin(h) => h,
other => panic!("expected Rejoin after adopt, got {other:?}"),
};
assert_eq!(h.current_term, 5);
assert_eq!(h.voted_for, None);
// A lower discovered term is a no-op (never regresses).
adopt_term_if_higher(data, 3).unwrap();
let h2 = match store.load(false).unwrap() {
tidaldb::replication::BootState::Rejoin(h) => h,
other => panic!("{other:?}"),
};
assert_eq!(
h2.current_term, 5,
"a lower term never regresses durable state"
);
}
}