tidaldb/tidal-server/tests/support/invariants.rs
jx12n 1265140e28 feat(m11): continuous correctness (m11p9) — fault classes, invariant checkers, soak gates, nightly pipeline
- fault-injection cargo feature (compiled OUT of prod): slow-fsync + disk-full
  WAL hooks in tidal/src/fault.rs, inert until armed, tier-3 builds with feature
- first-class invariant checkers (tests/support/invariants.rs): AckLedger
  no-acked-loss (now consumed by m11p3 gate), feed parity, single-leader-per-term,
  monotonic frontiers
- cluster_faults.rs tier-3 suite 4/4: disk-full degrade+recover, slow-fsync
  lag+converge, both-slow quorum 503, asymmetric partition no-split-brain
- tidal-stress soak gates: --json-summary + --max-p99-ms/--max-error-pct/
  --fail-on-knee → non-zero exit on regression
- Woodpecker cron nightly flow (chaos + gated soak), event-routed, not GH Actions
- guarantee-traceability.md: roadmap §2 guarantees → named tests (closes G-C
  apparatus; 30-day-green is a calendar criterion)
2026-06-13 15:23:59 -06:00

589 lines
24 KiB
Rust

//! First-class correctness invariant checkers for the tier-3 chaos suites (m11p9).
//!
//! Before m11p9 each suite carried its own inline copy of these checks — the
//! zero-acked-loss ledger lived in `cluster_quorum.rs`, feed-score parity in
//! `cluster_chaos.rs`, the single-leader-per-term assertion in
//! `cluster_election.rs`. m11p9 makes this module the **canonical, audited home**
//! so a new fault class proves the SAME invariants the durability gate does, and
//! a fix to a checker fixes it for every CONSUMER at once.
//!
//! Consumers today: `cluster_quorum.rs` (the m11p3 ledger gate, migrated onto
//! `AckLedger`) and the new `cluster_faults.rs`. The legacy suites
//! (`cluster_chaos`, `cluster_election`, `cluster_membership`, `cluster_reseed`,
//! `cluster_multiproc`, `cluster_lifecycle`) still carry their own pre-m11p9
//! local copies of `item_token`/`post_acked`/`feed_pairs`/`assert_feed_parity`
//! and their inline single-leader assertions — migrating them onto this module is
//! a tracked follow-up (note: some are deliberate variants, e.g. `cluster_chaos`'s
//! `feed_pairs` panics via `.unwrap()` where the shared one defaults via
//! `.unwrap_or`, so the migration is a behavior review, not a blind swap).
//!
//! The four checkers map to the roadmap's named invariants:
//!
//! 1. [`AckLedger`] — **no acknowledged-write loss**. Records every write the
//! client saw a 2xx + `x-tidal-seq` for, then proves after a leader kill that
//! (A, frontier) no acked seqno exceeds the max-applied survivor's contiguous
//! durable frontier, and (B, content) every acked item is actually present on
//! the promoted leader. This is the m11p3 exit-gate proof, extracted verbatim.
//! 2. [`assert_feed_parity`] — **cross-replica decay parity to 1e-6**. Two
//! replicas that have applied the same log must rank identical items with
//! identical decayed scores (a doubly-applied or dropped segment inflates or
//! deflates a score past the tolerance).
//! 3. [`assert_single_leader_per_term`] — **membership safety**: at most one
//! leader per term per shard, across a set of node status snapshots. A second
//! leader at the same term is a split brain.
//! 4. [`MonotonicCounters`] — **per-entity monotonic counters**: a named u64
//! quantity (a node's applied frontier, a leader's commit index, an entity's
//! durable visibility) must never move backward across observations. A
//! regression means durably-acknowledged state was forgotten — the failure
//! mode disk-full / slow-fsync chaos is most likely to expose.
#![allow(dead_code)]
use std::{
collections::HashMap,
time::{Duration, Instant},
};
use super::multiproc::MultiProcCluster;
// ── No acknowledged-write loss (G-D durability) ──────────────────────────────
/// A unique all-alpha search token for an entity id (digits 0-9 → letters a-j),
/// so `/search?query=<token>` is an exact item-presence probe under the default
/// tokenizer. Stable across suites so a recorded item is probeable everywhere.
#[must_use]
pub fn item_token(entity_id: u64) -> String {
let mut token = String::from("kpq");
for d in entity_id.to_string().bytes() {
token.push(char::from(b'a' + (d - b'0')));
}
token
}
/// POST with the `x-tidal-ack` header through a caller-supplied client. Returns
/// `Some(seq)` only for a 2xx carrying `x-tidal-seq` — the ledger's definition
/// of "acknowledged". A non-2xx, a missing header, or any transport error is
/// `None`: by the at-least-once contract an un-acked write is owed nothing.
#[must_use]
pub fn post_acked(
client: &reqwest::blocking::Client,
base: &str,
path: &str,
ack: &str,
body: &serde_json::Value,
) -> Option<u64> {
let resp = client
.post(format!("{base}{path}"))
.header("x-tidal-ack", ack)
.json(body)
.send()
.ok()?;
if !resp.status().is_success() {
return None;
}
resp.headers()
.get("x-tidal-seq")?
.to_str()
.ok()?
.parse()
.ok()
}
/// One acknowledged write the ledger tracks: the entity, the item record's
/// seqno, and the view signal's seqno if it too was acked.
#[derive(Clone, Copy, Debug)]
pub struct AckedWrite {
pub entity_id: u64,
pub item_seq: u64,
pub view_seq: Option<u64>,
}
/// The client-observed ledger of acknowledged writes — the ground truth a
/// no-acked-loss proof replays against post-recovery state.
#[derive(Default)]
pub struct AckLedger {
acked: Vec<AckedWrite>,
}
impl AckLedger {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Write one item (+ its `view` signal) at `ack` durability through `base`
/// and record whatever the client saw acknowledged. The item is recorded
/// only if its own write was acked (an un-acked item owes nothing); the view
/// seqno is recorded as `Some` only if the view was acked too. Returns
/// whether the item was acked (so a writer loop can pace itself).
pub fn write_item_and_view(
&mut self,
client: &reqwest::blocking::Client,
base: &str,
ack: &str,
entity_id: u64,
) -> bool {
let Some(item_seq) = post_acked(
client,
base,
"/items",
ack,
&serde_json::json!({
"entity_id": entity_id,
"metadata": { "title": item_token(entity_id) }
}),
) else {
return false;
};
let view_seq = post_acked(
client,
base,
"/signals",
ack,
&serde_json::json!({ "entity_id": entity_id, "signal": "view", "weight": 1.0 }),
);
self.acked.push(AckedWrite {
entity_id,
item_seq,
view_seq,
});
true
}
/// Fold another writer thread's ledger into this one.
pub fn extend(&mut self, other: impl IntoIterator<Item = AckedWrite>) {
self.acked.extend(other);
}
/// Record a raw acked write (for callers that post through their own path).
pub fn record(&mut self, entity_id: u64, item_seq: u64, view_seq: Option<u64>) {
self.acked.push(AckedWrite {
entity_id,
item_seq,
view_seq,
});
}
#[must_use]
pub const fn len(&self) -> usize {
self.acked.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.acked.is_empty()
}
#[must_use]
pub fn writes(&self) -> &[AckedWrite] {
&self.acked
}
/// The highest seqno the client ever saw acknowledged (item or view).
#[must_use]
pub fn max_acked_seq(&self) -> u64 {
self.acked
.iter()
.map(|w| w.view_seq.unwrap_or(w.item_seq).max(w.item_seq))
.max()
.unwrap_or(0)
}
/// INVARIANT A (frontier): no acked seqno exceeds the max-applied survivor's
/// contiguous durable frontier. A quorum ack for seqno S means some
/// follower's applied frontier reached S durably; the operator rule "promote
/// the max-applied survivor" therefore guarantees the promoted node holds
/// every acked seqno. Returns the chosen survivor's `(index, applied)` so the
/// caller can promote it.
///
/// SCOPE: reads the FLAT `applied_events` field, which under m11p6 sharding
/// mirrors only the node's DEFAULT (lowest-id) hosted group — exact for S=1
/// (the shipped topology). A multi-shard ledger must record each acked write's
/// shard and compare against that shard's `shards[]` frontier (the follow-up,
/// alongside the shard-aware path in [`assert_single_leader_per_term`]).
///
/// # Panics
///
/// Panics (ACKNOWLEDGED LOSS) if `max_acked_seq > max survivor applied`, or
/// if no survivor serves status.
#[must_use]
pub fn assert_frontier_covers_acks(
&self,
cluster: &MultiProcCluster,
survivors: &[usize],
ctx: &str,
) -> (usize, u64) {
let applied: Vec<(usize, u64)> = survivors
.iter()
.map(|&idx| {
let status = cluster
.local_status(idx)
.unwrap_or_else(|| panic!("{ctx}: survivor {idx} must serve status"));
(idx, status["applied_events"].as_u64().unwrap_or(0))
})
.collect();
let (chosen, chosen_applied) = applied
.iter()
.copied()
.max_by_key(|&(_, a)| a)
.unwrap_or_else(|| panic!("{ctx}: no survivors supplied"));
let max_acked = self.max_acked_seq();
assert!(
max_acked <= chosen_applied,
"{ctx}: ACKNOWLEDGED LOSS — max acked seq {max_acked} exceeds the max-applied \
survivor's frontier {chosen_applied} (applied: {applied:?}, {} acked writes)",
self.acked.len()
);
(chosen, chosen_applied)
}
/// INVARIANT B (content): every acked item is present on `leader_idx` via
/// `/search`, polling up to `budget` (the text index auto-commits every 2s,
/// so a fresh item can race the first probe — presence is asserted, not
/// commit timing). The frontier can't lie about data it lacks, but this
/// catches a frontier that lies about data it claims.
///
/// # Panics
///
/// Panics (ACKNOWLEDGED LOSS) if any acked item is absent within `budget`.
pub fn assert_items_present(
&self,
cluster: &MultiProcCluster,
leader_idx: usize,
budget: Duration,
ctx: &str,
) {
let base = cluster.node(leader_idx);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(4))
.build()
.expect("build probe client");
let deadline = Instant::now() + budget;
for w in &self.acked {
let token = item_token(w.entity_id);
// The text index auto-commits every 2s, so a fresh item can race the
// first probe — poll up to `budget`, asserting presence not timing.
// Track whether we ever got a CLEAN 2xx-with-no-hit: a poll that only
// ever saw transport errors / non-2xx is a REACHABILITY failure (a
// just-promoted leader still warming up), NOT durability loss — it
// must not be charged as ACKNOWLEDGED LOSS.
let mut saw_clean_miss = false;
let mut last_note = String::from("no response");
let present = loop {
match client
.get(format!("{base}/search?query={token}&limit=5"))
.send()
{
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value =
resp.json().unwrap_or(serde_json::Value::Null);
let hit = body["items"]
.as_array()
.unwrap_or(&Vec::new())
.iter()
.any(|it| it["entity_id"].as_u64() == Some(w.entity_id));
if hit {
break true;
}
saw_clean_miss = true;
last_note = format!("2xx, item absent: {body}");
}
Ok(resp) => last_note = format!("HTTP {}", resp.status()),
Err(e) => last_note = format!("transport error: {e}"),
}
if Instant::now() > deadline {
break false;
}
std::thread::sleep(Duration::from_millis(200));
};
// A clean 2xx-with-no-hit through the whole budget is true loss; a
// budget spent only on transport/non-2xx failures is a reachability
// bug in the probe path, surfaced distinctly so it is never
// misattributed to data loss. The two guards must match their
// messages: PROBE UNREACHABLE fires only when we NEVER saw a clean
// miss (transport/non-2xx the whole time); ACKNOWLEDGED LOSS fires
// only when we DID see a clean 2xx-with-no-hit (the item is genuinely
// absent on a reachable leader).
assert!(
present || saw_clean_miss,
"{ctx}: PROBE UNREACHABLE — item {} (seq {}, acked) could not be probed on \
leader node {leader_idx} within the budget (last: {last_note}); this is a \
reachability failure, not necessarily data loss",
w.entity_id,
w.item_seq
);
assert!(
present || !saw_clean_miss,
"{ctx}: ACKNOWLEDGED LOSS — item {} (seq {}, acked) is missing on leader \
node {leader_idx} (last: {last_note})",
w.entity_id,
w.item_seq
);
}
}
}
// ── Cross-replica decay parity (G-D / G-C) ───────────────────────────────────
/// A node's local-region feed as a sorted `(entity_id, score)` vector.
///
/// # Panics
///
/// Panics if the feed body is not the expected shape.
#[must_use]
pub fn feed_pairs(
cluster: &MultiProcCluster,
idx: usize,
profile: &str,
limit: u32,
) -> Vec<(u64, f64)> {
let body = cluster.get_json(idx, &format!("/feed?profile={profile}&limit={limit}"));
let mut pairs: Vec<(u64, f64)> = body["items"]
.as_array()
.unwrap_or(&Vec::new())
.iter()
.map(|it| {
(
it["entity_id"].as_u64().unwrap_or(0),
it["score"].as_f64().unwrap_or(f64::NAN),
)
})
.collect();
pairs.sort_by_key(|(id, _)| *id);
pairs
}
/// A node's local-region feed as a sorted entity-id vector — the data-convergence
/// view (which items are materialized), independent of score. Robust across a
/// node RESTART, where a velocity/rate score (`trending`/`hot`) legitimately
/// differs because its time-bucketed windowed counts are rebuilt from a burst
/// WAL-replay rather than continuous accumulation, even though the durable data
/// is identical. Use this for cross-replica DATA parity after a restart; use
/// [`assert_feed_parity`] for decay/score parity between continuously-running
/// replicas.
///
/// # Panics
///
/// Panics if the feed body is not the expected shape.
#[must_use]
pub fn feed_item_ids(
cluster: &MultiProcCluster,
idx: usize,
profile: &str,
limit: u32,
) -> Vec<u64> {
feed_pairs(cluster, idx, profile, limit)
.into_iter()
.map(|(id, _)| id)
.collect()
}
/// Assert two replicas' feed views carry the SAME items with scores equal to
/// `1e-6`. A doubly-applied segment inflates decayed scores past the tolerance;
/// a dropped one deflates them — either trips this.
///
/// # Panics
///
/// Panics if the item sets differ or any score differs by more than `1e-6`.
pub fn assert_feed_parity(label: &str, a: &[(u64, f64)], b: &[(u64, f64)]) {
assert_eq!(
a.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
b.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
"{label}: feed item sets differ"
);
for ((id_a, score_a), (_, score_b)) in a.iter().zip(b.iter()) {
assert!(
(score_a - score_b).abs() <= 1e-6,
"{label}: score for item {id_a} differs: {score_a} vs {score_b}"
);
}
}
// ── Membership safety: single leader per term per shard (G-A availability) ────
/// Assert that across `statuses` (each a `/cluster/status/local` body) no two
/// nodes claim to be leader at the same term. Reads the per-shard `shards[]`
/// rows when present (m11p6 multi-shard) and falls back to the flat
/// `role`/`term` fields (S=1), so it is correct on both surfaces. A second
/// leader at the same `(shard, term)` is a split brain.
///
/// # Panics
///
/// Panics naming both offending nodes if a `(shard, term)` has two leaders.
pub fn assert_single_leader_per_term(statuses: &[serde_json::Value], ctx: &str) {
// (shard, term) -> the region already seen leading it.
let mut leader_of: HashMap<(u64, u64), String> = HashMap::new();
for st in statuses {
let region = st["region"].as_str().unwrap_or("<unknown>").to_string();
let rows = st["shards"].as_array();
let claims: Vec<(u64, u64, bool)> = match rows {
Some(rows) if !rows.is_empty() => rows
.iter()
.map(|r| {
(
r["shard"].as_u64().unwrap_or(0),
r["term"].as_u64().unwrap_or(0),
r["role"].as_str() == Some("leader"),
)
})
.collect(),
_ => vec![(
0,
st["term"].as_u64().unwrap_or(0),
st["role"].as_str() == Some("leader"),
)],
};
for (shard, term, is_leader) in claims {
if !is_leader {
continue;
}
// A genuine split brain is TWO DISTINCT regions leading the same
// (shard, term). The same region's status observed twice in one
// snapshot (a duplicate sample, a retry-appended list) is NOT a split
// brain — gate on `prev != region` so it is idempotently ignored.
if let Some(prev) = leader_of.insert((shard, term), region.clone())
&& prev != region
{
panic!(
"{ctx}: SPLIT BRAIN — {prev} and {region} both lead shard {shard} term {term}"
);
}
}
}
}
/// Convenience: snapshot every live node's `/cluster/status/local` and assert
/// single-leader-per-term. Returns the snapshots for further assertions.
#[must_use]
pub fn assert_single_leader_now(cluster: &MultiProcCluster, ctx: &str) -> Vec<serde_json::Value> {
let statuses: Vec<serde_json::Value> = (0..cluster.len())
.filter_map(|i| cluster.local_status(i))
.collect();
assert_single_leader_per_term(&statuses, ctx);
statuses
}
// ── Per-entity monotonic counters (G-C continuous correctness) ────────────────
/// Asserts a set of named `u64` counters never moves backward across
/// observations. Each [`observe`](Self::observe) checks the new value against
/// the last seen for that key; a decrease panics. Used for per-node applied
/// frontiers, a leader's commit index, and per-entity durable visibility
/// (0→1, never 1→0): quantities that are monotonic by construction, so a
/// regression is durably-acknowledged state being forgotten.
pub struct MonotonicCounters {
label: String,
last: HashMap<String, u64>,
/// The leader region observed on the previous frontier sweep, so a leader
/// change (a new epoch, where `commit_index` legitimately resets to the
/// promote baseline) clears the commit-index tracking instead of false-firing.
last_leader: Option<String>,
/// Per-region role (`true` = leader) observed on the previous sweep. A
/// region's `applied_events` is role-RELATIVE — a leader reports its own
/// flushed frontier, a follower reports how far it applied the CURRENT
/// leader's stream — so a leader↔follower flip re-baselines the value and is
/// NOT a regression. We re-baseline `applied:<region>` on any role flip.
last_role: HashMap<String, bool>,
}
impl MonotonicCounters {
#[must_use]
pub fn new(label: &str) -> Self {
Self {
label: label.to_string(),
last: HashMap::new(),
last_leader: None,
last_role: HashMap::new(),
}
}
/// Observe `value` for `key`; panic if it is below the last value seen.
///
/// # Panics
///
/// Panics if `value` regressed below a prior observation of `key`.
pub fn observe(&mut self, key: &str, value: u64) {
if let Some(&prev) = self.last.get(key) {
assert!(
value >= prev,
"{}: NON-MONOTONIC — {key} regressed {prev} → {value} (acknowledged state lost)",
self.label
);
}
self.last.insert(key.to_string(), value);
}
/// Forget any tracking for `key` (e.g. when a node legitimately resets its
/// frontier by reseeding, so the next observation starts a fresh baseline).
pub fn reset(&mut self, key: &str) {
self.last.remove(key);
}
/// Observe every live node's `applied_events` frontier (keyed by region) and
/// the current leader's `commit_index`. Call repeatedly through a fault
/// window: within a stable leadership epoch no node's durable frontier and
/// no leader's commit index may ever regress.
///
/// Legitimate epoch resets are handled, not flagged. `applied_events` is
/// role-RELATIVE (a leader reports its own flushed frontier; a follower
/// reports how far it applied the current leader's stream), so it is
/// re-baselined — not asserted — whenever a region (a) reports
/// `reseeding`/`quarantined` (mid snapshot-reinstall) OR (b) flips role
/// leader↔follower (a clean failover, where the value changes WHAT it
/// measures). `commit_index` is leadership-scoped and re-baselined on a
/// leader-region change. What remains asserted is the real invariant: within
/// a stable role/epoch a node never forgets durably-applied data, and a leader
/// never un-commits a quorum-durable write within its term.
/// SCOPE: this reads the FLAT top-level status fields (`is_leader`,
/// `applied_events`, `commit_index`), which under m11p6 sharding mirror only
/// the node's DEFAULT (lowest-id) hosted group — so it is exact for S=1 (the
/// shipped topology) and tracks only the default group's frontier under S>1.
/// The per-group `shards[]` rows (which [`assert_single_leader_per_term`]
/// already consults) are the follow-up for multi-group frontier tracking.
pub fn observe_cluster_frontiers(&mut self, cluster: &MultiProcCluster) {
let mut leader_now: Option<String> = None;
// Capture the leader's commit_index in the SAME sweep (no second N-request
// scan): the value is in `st` while we hold it.
let mut leader_commit: u64 = 0;
for idx in 0..cluster.len() {
let Some(st) = cluster.local_status(idx) else {
continue;
};
let region = st["region"].as_str().unwrap_or("?").to_string();
let is_leader = st["is_leader"].as_bool() == Some(true);
let reseeding = st["reseeding"].as_bool() == Some(true)
|| st["quarantined"].as_bool() == Some(true);
// A role flip (leader↔follower) re-baselines the role-relative
// applied frontier; the first observation of a region is not a flip.
let role_flipped = self
.last_role
.insert(region.clone(), is_leader)
.is_some_and(|was| was != is_leader);
let applied_key = format!("applied:{region}");
if reseeding || role_flipped {
self.reset(&applied_key);
// Re-seed the baseline at the current value so the NEXT sweep
// asserts from here, not from the stale pre-flip frontier.
self.last
.insert(applied_key, st["applied_events"].as_u64().unwrap_or(0));
} else {
// A leader reports its flushed frontier as applied (m11p8); a
// follower reports its applied frontier. Both are durable.
self.observe(&applied_key, st["applied_events"].as_u64().unwrap_or(0));
}
if is_leader {
leader_now = Some(region);
leader_commit = st["commit_index"].as_u64().unwrap_or(0);
}
}
// commit_index is leadership-scoped: clear it on a leader change.
if leader_now != self.last_leader {
self.reset("commit_index");
self.last_leader.clone_from(&leader_now);
}
if leader_now.is_some() {
self.observe("commit_index", leader_commit);
}
}
}