tidaldb/tidal/tests/m12_reseed_term_marker.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

208 lines
8.6 KiB
Rust

//! m12 reseed-loop-fix — the durable term marker the reseed seed synthesizes.
//!
//! The boot-time snapshot install restores a checkpoint-based artifact whose
//! engine WAL is EMPTY, so `wal_term_mark()` would report `tail_term = 0`. The
//! post-open seed (`ShardReplica::new`) then durably synthesizes the artifact's
//! term marker via [`TidalDb::append_term_marker`] so the WAL-tail term is
//! truthful — on this boot AND every reboot — which is what stops `decide_join`
//! from looping a checkpoint-restored node through `ReseedRequired` forever.
//!
//! This test proves the DURABILITY MECHANISM that fix relies on at the engine
//! layer (the `decide_join` classification itself is unit-tested in
//! `cluster::election_driver`):
//!
//! - `append_term_marker` folds the WAL-tail term cell immediately (this boot);
//! - the marker survives a replication checkpoint whose WAL marker advances
//! PAST the marker's seqno (the exact reseed-seed ordering: marker, then
//! `persist_replication_checkpoint`) AND a hard crash-reopen — because blob
//! records are deliberately NOT checkpoint-filtered on recovery
//! (`wal::reader`), so recovery re-derives the truthful term on every restart;
//! - the append path refuses term 0 (the topology era) and non-cluster mode.
#![allow(clippy::unwrap_used)]
use tidaldb::{
TempTidalHome, TidalDb,
db::config::{NodeConfig, NodeRole},
replication::shard::ShardId,
schema::{DecaySpec, EntityKind, SchemaBuilder, Window},
testing::{CrashInjector, CrashPoint, crash_injector::run_with_crash},
wal::format::{MemberEntry, MemberRole, MembershipRecord},
};
use std::time::Duration;
fn make_schema() -> tidaldb::schema::Schema {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
builder.build().unwrap()
}
/// Open a PERSISTENT cluster node (non-empty `peer_shards` → `replicate_blobs`,
/// the WAL becomes the one replicated log so kind-3 markers can ride it).
fn open_cluster_node(home: &TempTidalHome, schema: tidaldb::schema::Schema) -> TidalDb {
TidalDb::builder()
.with_data_dir(home.path())
.with_schema(schema)
.with_cluster(NodeConfig {
role: NodeRole::Single,
shard_id: ShardId(0),
peer_shards: vec![ShardId(1)],
..NodeConfig::default()
})
.open()
.expect("persistent cluster node opens")
}
/// A kind-4 record used only to push the WAL frontier (and thus the checkpoint
/// seq) PAST the term marker, so the durability assertion is "the marker
/// survives a checkpoint advanced beyond it", not merely "at it".
fn roster() -> MembershipRecord {
MembershipRecord {
version: 1,
term: 7,
members: vec![
MemberEntry {
id: 0,
name: "us-east".to_string(),
grpc_addr: "tidaldb-0:9500".to_string(),
http_addr: "http://tidaldb-0:9501".to_string(),
role: MemberRole::Voter,
},
MemberEntry {
id: 1,
name: "eu-west".to_string(),
grpc_addr: "tidaldb-1:9500".to_string(),
http_addr: "http://tidaldb-1:9501".to_string(),
role: MemberRole::Voter,
},
],
}
}
/// The reseed seed's `append_term_marker` folds the WAL-tail term cell
/// immediately: `wal_term_mark()` reports the artifact's (term, region) the
/// moment the seed runs, on the very boot that installed the snapshot.
#[test]
fn append_term_marker_folds_the_cell_this_boot() {
let home = TempTidalHome::new().unwrap();
let db = open_cluster_node(&home, make_schema());
// A checkpoint-restored artifact's WAL is empty → topology era, term 0.
assert_eq!(db.wal_term_mark(), (0, 0, 0));
// The seed synthesizes the discovered leader's (term 7, region 1).
let seq = db.append_term_marker(7, 1).unwrap();
assert!(seq >= 1, "the marker consumes a real seqno, got {seq}");
let (term, marker_seq, region) = db.wal_term_mark();
assert_eq!(term, 7, "the WAL-tail term is the artifact's term, not 0");
assert_eq!(marker_seq, seq);
assert_eq!(region, 1, "the marker names the reseed-leader stream");
}
/// THE durability property the reseed-loop-fix hinges on: a synthesized term
/// marker survives a replication checkpoint whose WAL marker lands PAST the
/// marker's seqno AND a hard crash-reopen. If it did not, the next boot would
/// re-read `tail_term = 0` and re-latch `ReseedRequired` — the loop.
///
/// A crash (not a clean `close()`) is the faithful test: it exercises the
/// re-derive-from-the-surviving-log path and sidesteps the shutdown-time WAL
/// compaction that could reclaim a control-only segment.
#[test]
fn term_marker_survives_checkpoint_past_it_and_crash_reopen() {
let home = TempTidalHome::new().unwrap();
let schema = make_schema();
// The first CheckpointPreFlush passes (our explicit force_replication_checkpoint
// lands the WAL marker durably PAST the term marker's seq); the second (the
// shutdown ledger checkpoint) fires the crash, so no compaction runs.
let injector = CrashInjector::new(CrashPoint::CheckpointPreFlush, 1);
let outcome = run_with_crash(&injector, || {
let db = open_cluster_node(&home, schema.clone());
// The reseed seed: synthesize the artifact's term marker (lands at seq 1).
let marker_seq = db.append_term_marker(7, 1).unwrap();
// Push the frontier PAST the marker so the checkpoint seq exceeds it —
// this is what proves the marker survives a checkpoint advanced BEYOND
// it (blobs are not checkpoint-filtered), not merely a checkpoint at it.
let later_seq = db.append_membership_record(roster()).unwrap();
assert!(
later_seq > marker_seq,
"the membership record must sit above the term marker (got {later_seq} > {marker_seq})"
);
// The live cell already reports the truthful term before the crash.
assert_eq!(db.wal_term_mark().0, 7);
// Land a crash-consistent checkpoint: the WAL marker advances to
// `later_seq` — strictly past the term marker at `marker_seq`. This is
// the exact reseed-seed ordering (append marker, then persist checkpoint),
// via the SAME public API the seed calls.
db.persist_replication_checkpoint().unwrap();
// CRASH during the shutdown ledger checkpoint — nothing past here, and
// no compaction, reaches disk.
let _ = db.close();
});
assert!(
matches!(outcome, Err(CrashPoint::CheckpointPreFlush)),
"the crash injector must have fired during shutdown, got {outcome:?}"
);
// Reopen the crashed node: recovery scans the surviving log. The term marker
// sits BELOW the checkpoint boundary, but blob records are not checkpoint-
// filtered, so recovery re-folds it → the WAL-tail term is truthful again.
let db = open_cluster_node(&home, schema);
let (term, _seq, region) = db.wal_term_mark();
assert_eq!(
term, 7,
"recovery re-derives the artifact's term across the checkpoint boundary — \
not 0, so decide_join will NOT re-latch ReseedRequired"
);
assert_eq!(
region, 1,
"the recovered marker still names the reseed-leader stream"
);
}
/// The append path refuses term 0 (the topology era never journals a marker, so
/// the seed correctly SKIPS the synthesis for a term-0 artifact).
#[test]
fn append_term_marker_refuses_term_zero() {
let home = TempTidalHome::new().unwrap();
let db = open_cluster_node(&home, make_schema());
let err = db.append_term_marker(0, 1).unwrap_err();
assert!(
err.to_string().contains("elected terms"),
"term 0 is refused, got: {err}"
);
assert_eq!(db.wal_term_mark(), (0, 0, 0), "no marker entered the log");
}
/// A non-cluster (standalone) node refuses the append: the WAL is not a
/// replicated log there, so the reseed seed (which only runs in cluster mode)
/// could never reach this.
#[test]
fn standalone_node_refuses_term_marker_append() {
let home = TempTidalHome::new().unwrap();
let db = TidalDb::builder()
.with_data_dir(home.path())
.with_schema(make_schema())
.open()
.expect("standalone node opens");
let err = db.append_term_marker(7, 1).unwrap_err();
assert!(err.to_string().contains("cluster mode"), "got: {err}");
}