- 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)
405 lines
19 KiB
Rust
405 lines
19 KiB
Rust
//! Tier-3 fault-injection suite (m11p9 continuous correctness).
|
|
//!
|
|
//! The chaos gate (`cluster_chaos.rs`) covers partition / crash / clock-skew.
|
|
//! This suite adds the fault classes the roadmap (§4/m11p9) names as missing:
|
|
//!
|
|
//! 1. **Disk-full on a follower** (`mp_disk_full_follower_degrades_no_acked_loss`)
|
|
//! — a follower whose WAL hits `ENOSPC` mid-replication halts its receiver
|
|
//! (degraded, alive), the healthy majority keeps acking quorum writes, NO
|
|
//! acknowledged write is lost, and a restart with space recovers it to full
|
|
//! parity.
|
|
//! 2. **Slow-fsync on a follower** (`mp_slow_fsync_follower_lags_but_quorum_holds`)
|
|
//! — a slow disk lags one follower; the fast follower supplies quorum so
|
|
//! every write still commits; the slow node converges once the burst ends; no
|
|
//! loss.
|
|
//! 3. **Slow-fsync on BOTH followers** (`mp_slow_fsync_both_followers_force_honest_quorum_timeout`)
|
|
//! — when no follower can confirm inside the budget, `ack=quorum` returns a
|
|
//! retryable 503 naming the laggards (never a false success) while
|
|
//! `ack=leader` is unaffected; the followers recover.
|
|
//! 4. **Asymmetric partition** (`mp_asymmetric_partition_no_split_brain_no_loss`)
|
|
//! — a follower that loses INBOUND links (can still send) cannot disrupt the
|
|
//! cluster: pre-vote + check-quorum keep the standing leader, there is never a
|
|
//! second leader at the same term, and no acked write is lost.
|
|
//!
|
|
//! These are REAL faults exercising REAL recovery paths: the WAL hooks return a
|
|
//! genuine `ENOSPC` / sleep the real fsync (behind the `fault-injection` feature,
|
|
//! compiled out of production — see `tidal/src/fault.rs`); partitions sever real
|
|
//! loopback TCP. Every test asserts through the first-class invariant checkers
|
|
//! (`support::invariants`): the no-acked-loss ledger, cross-replica feed parity,
|
|
//! single-leader-per-term, and per-node monotonic frontiers.
|
|
//!
|
|
//! Run: `cargo test -p tidal-server --features "cluster-e2e fault-injection" --test cluster_faults -- --nocapture --test-threads 1`
|
|
|
|
#![cfg(feature = "cluster-e2e")]
|
|
#![allow(
|
|
clippy::unwrap_used,
|
|
clippy::expect_used,
|
|
clippy::panic,
|
|
clippy::cast_possible_truncation,
|
|
clippy::cast_precision_loss,
|
|
clippy::too_many_lines
|
|
)]
|
|
|
|
mod support;
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
use support::{
|
|
invariants::{AckLedger, MonotonicCounters, assert_single_leader_now, feed_item_ids},
|
|
multiproc::{BREAKER_RESET, ClusterOptions, MultiProcCluster, convergence_budget},
|
|
partition::proxied_rewrite,
|
|
};
|
|
|
|
const LEADER: usize = 0;
|
|
const FSYNC_DELAY: &str = "TIDAL_FAULT_FSYNC_DELAY_MS";
|
|
const DISK_FULL: &str = "TIDAL_FAULT_DISK_FULL_AFTER_BYTES";
|
|
|
|
/// Stable single-leader posture (no auto-election) + quorum default, for the
|
|
/// fault tests whose subject is the data plane, not the election. A faulted
|
|
/// node must not trigger a spurious failover that muddies the assertion.
|
|
const STABLE_QUORUM_YAML: &str =
|
|
"election:\n auto_election: false\nreplication:\n ack: quorum\n quorum_timeout_ms: 2000";
|
|
|
|
/// Drive `count` quorum item+view writes for ids `id_base..id_base+count`
|
|
/// through `base`, recording the client-observed acks into `ledger`. Returns
|
|
/// how many items the client saw acknowledged.
|
|
fn drive_quorum_writes(base: &str, id_base: u64, count: u64, ledger: &mut AckLedger) -> u64 {
|
|
let client = reqwest::blocking::Client::builder()
|
|
.timeout(Duration::from_secs(4))
|
|
.build()
|
|
.unwrap();
|
|
let mut acked = 0;
|
|
for i in 0..count {
|
|
if ledger.write_item_and_view(&client, base, "quorum", id_base + i) {
|
|
acked += 1;
|
|
}
|
|
}
|
|
acked
|
|
}
|
|
|
|
/// **Disk-full on a follower.** A follower whose WAL fills mid-replication must
|
|
/// degrade gracefully (halt apply, stay alive — never corrupt, never crash),
|
|
/// the healthy majority must keep acking `ack=quorum` writes, NO acknowledged
|
|
/// write may be lost, and a restart with space must recover the follower to full
|
|
/// content + score parity.
|
|
#[test]
|
|
fn mp_disk_full_follower_degrades_no_acked_loss() {
|
|
// ap-south (node 2) hits ENOSPC after ~8 KiB of post-boot WAL writes — far
|
|
// past the empty-cluster boot (it writes nothing until it applies), but
|
|
// crossed after a few dozen replicated records.
|
|
let opts = ClusterOptions::new(3)
|
|
.with_env(2, DISK_FULL, "8192")
|
|
.with_topology_extra(STABLE_QUORUM_YAML);
|
|
let mut cluster = MultiProcCluster::start_with(opts);
|
|
cluster.wait_converged_all(convergence_budget());
|
|
|
|
// Write a burst of quorum item+view writes through the leader. node 1
|
|
// (healthy) supplies the one-of-two follower quorum, so every write commits
|
|
// even after node 2's disk fills and its receiver halts.
|
|
let mut ledger = AckLedger::new();
|
|
let mut mono = MonotonicCounters::new("disk-full");
|
|
let mut acked = 0;
|
|
for round in 0..6 {
|
|
acked += drive_quorum_writes(&cluster.node(LEADER), 10_000 + round * 20, 20, &mut ledger);
|
|
mono.observe_cluster_frontiers(&cluster);
|
|
}
|
|
assert!(acked >= 100, "expected ~120 acked writes, got {acked}");
|
|
|
|
// node 2 is FROZEN behind: its WAL is full, its receiver halted, so its
|
|
// applied frontier stalled while node 1 stayed current. Poll briefly to let
|
|
// node 1 finish converging and confirm the asymmetry.
|
|
let leader_seq = cluster.leader_last_seq().expect("leader serves status");
|
|
let deadline = Instant::now() + convergence_budget();
|
|
loop {
|
|
let n1 = cluster.local_status(1).expect("node1 status");
|
|
let n2 = cluster.local_status(2).expect("node2 status");
|
|
let n1_applied = n1["applied_events"].as_u64().unwrap_or(0);
|
|
let n2_applied = n2["applied_events"].as_u64().unwrap_or(0);
|
|
if n1_applied >= leader_seq && n2_applied < leader_seq {
|
|
println!(
|
|
"[disk-full] node1 caught up (applied {n1_applied}/{leader_seq}); \
|
|
node2 frozen at applied {n2_applied} (disk full)"
|
|
);
|
|
break;
|
|
}
|
|
assert!(
|
|
Instant::now() <= deadline,
|
|
"node1 must converge while node2 stays frozen: \
|
|
n1_applied={n1_applied} n2_applied={n2_applied} leader_seq={leader_seq}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
|
|
// No acked loss: every acked write is durable on the HEALTHY follower's
|
|
// contiguous frontier (the real quorum-durability claim under a disk-full
|
|
// minority), and every acked item is content-present on it.
|
|
let (_keep, _) = ledger.assert_frontier_covers_acks(&cluster, &[1usize], "disk-full");
|
|
ledger.assert_items_present(&cluster, 1, Duration::from_secs(10), "disk-full");
|
|
|
|
// HEAL: restart node 2 with the fault disarmed (0 = off). A fresh receiver
|
|
// catches the full log up via the stream — no operator verb beyond restart.
|
|
cluster.restart(2, &[(DISK_FULL, "0")]);
|
|
cluster.wait_converged_all(convergence_budget() + BREAKER_RESET);
|
|
|
|
// The recovered disk-full node now holds every acked write. Convergence is
|
|
// proven three ways: the contiguous frontier matched the leader
|
|
// (wait_converged_all above), the materialized item SET matches the leader,
|
|
// and every acked item is content-present via /search. We assert feed item-SET
|
|
// parity, NOT score parity: `trending` scores are a windowed velocity
|
|
// (count / window), and a node rebuilt from a burst WAL-replay aligns its time
|
|
// buckets differently than one that accumulated continuously — so a velocity
|
|
// score legitimately differs across a restart even with identical durable data.
|
|
// Decay/score parity to 1e-6 between CONTINUOUS replicas is asserted by the
|
|
// slow-fsync and asymmetric tests (and the chaos gate).
|
|
let leader_ids = feed_item_ids(&cluster, LEADER, "trending", 200);
|
|
let recovered_ids = feed_item_ids(&cluster, 2, "trending", 200);
|
|
assert_eq!(
|
|
leader_ids, recovered_ids,
|
|
"recovered disk-full node must materialize the same item set as the leader"
|
|
);
|
|
ledger.assert_items_present(
|
|
&cluster,
|
|
2,
|
|
Duration::from_secs(10),
|
|
"disk-full (post-recovery)",
|
|
);
|
|
println!(
|
|
"[disk-full] node2 recovered after restart: {acked} acked writes all present, \
|
|
item set parity with leader ({} items)",
|
|
leader_ids.len()
|
|
);
|
|
}
|
|
|
|
/// **Slow-fsync on one follower.** A slow disk lags one follower; the fast
|
|
/// follower supplies quorum so every `ack=quorum` write still commits; the slow
|
|
/// node is visibly behind during the burst, converges once it ends, and loses
|
|
/// nothing.
|
|
#[test]
|
|
fn mp_slow_fsync_follower_lags_but_quorum_holds() {
|
|
// ap-south (node 2) sleeps 250ms before every durable fsync.
|
|
let opts = ClusterOptions::new(3)
|
|
.with_env(2, FSYNC_DELAY, "250")
|
|
.with_topology_extra(STABLE_QUORUM_YAML);
|
|
let cluster = MultiProcCluster::start_with(opts);
|
|
cluster.wait_converged_all(convergence_budget());
|
|
|
|
let mut ledger = AckLedger::new();
|
|
let mut mono = MonotonicCounters::new("slow-fsync-follower");
|
|
let acked = drive_quorum_writes(&cluster.node(LEADER), 20_000, 60, &mut ledger);
|
|
mono.observe_cluster_frontiers(&cluster);
|
|
assert!(
|
|
acked >= 55,
|
|
"fast follower must supply quorum: only {acked} acked"
|
|
);
|
|
|
|
// Right after the burst the slow follower trails the fast one (250ms/fsync
|
|
// ≫ the fast nodes' ~2ms group-commit), proving the slow disk is visible.
|
|
let n1 = cluster.local_status(1).expect("node1 status");
|
|
let n2 = cluster.local_status(2).expect("node2 status");
|
|
let n1_lag = n1["lag_events"].as_u64().unwrap_or(u64::MAX);
|
|
let n2_lag = n2["lag_events"].as_u64().unwrap_or(0);
|
|
println!("[slow-fsync] after burst: node1 lag={n1_lag} node2(slow) lag={n2_lag}");
|
|
assert!(
|
|
n2_lag > n1_lag,
|
|
"the slow follower must trail the fast one: node1 lag={n1_lag} node2 lag={n2_lag}"
|
|
);
|
|
|
|
// A slow disk is not a broken one: the burst over, the slow follower drains
|
|
// and converges; no acked write is lost; frontiers never regressed.
|
|
cluster.wait_converged_all(convergence_budget() * 2);
|
|
mono.observe_cluster_frontiers(&cluster);
|
|
let (_keep, _) =
|
|
ledger.assert_frontier_covers_acks(&cluster, &[1usize, 2usize], "slow-fsync-follower");
|
|
// Item-SET parity, not score: the slow follower drains its backlog as a burst
|
|
// once the write burst ends, which reconstructs its time-bucketed velocity
|
|
// differently than the leader's continuous accumulation — so a `trending`
|
|
// (velocity) score can differ across the catch-up even with identical durable
|
|
// data. The frontier + content + set checks prove data convergence robustly.
|
|
let leader_ids = feed_item_ids(&cluster, LEADER, "trending", 200);
|
|
let slow_ids = feed_item_ids(&cluster, 2, "trending", 200);
|
|
assert_eq!(
|
|
leader_ids, slow_ids,
|
|
"converged slow follower must materialize the same item set as the leader"
|
|
);
|
|
ledger.assert_items_present(&cluster, 2, Duration::from_secs(10), "slow-fsync-follower");
|
|
println!("[slow-fsync] {acked} acked writes, slow follower converged to parity, no loss");
|
|
}
|
|
|
|
/// **Slow-fsync on BOTH followers + tight budget → honest quorum timeout.** When
|
|
/// neither follower can confirm a write inside `quorum_timeout_ms`, `ack=quorum`
|
|
/// must return a retryable 503 naming the laggards — never a false 2xx — while
|
|
/// `ack=leader` on the same cluster keeps succeeding (the leader's disk is fast).
|
|
/// The slow followers are not broken, so the cluster converges once load eases.
|
|
#[test]
|
|
fn mp_slow_fsync_both_followers_force_honest_quorum_timeout() {
|
|
// Both followers sleep 1500ms per fsync; the quorum budget is 300ms — so the
|
|
// commit index physically cannot advance to a fresh write inside the budget.
|
|
let yaml =
|
|
"election:\n auto_election: false\nreplication:\n ack: quorum\n quorum_timeout_ms: 300";
|
|
let opts = ClusterOptions::new(3)
|
|
.with_env(1, FSYNC_DELAY, "1500")
|
|
.with_env(2, FSYNC_DELAY, "1500")
|
|
.with_topology_extra(yaml);
|
|
let cluster = MultiProcCluster::start_with(opts);
|
|
cluster.wait_converged_all(convergence_budget());
|
|
|
|
let client = cluster.client();
|
|
let leader = cluster.node(LEADER);
|
|
|
|
// ack=quorum (the topology default): the budget expires before either slow
|
|
// follower fsyncs the apply → retryable 503 naming the laggards.
|
|
let q = client
|
|
.post(format!("{leader}/signals"))
|
|
.json(&serde_json::json!({ "entity_id": 30_001, "signal": "view", "weight": 1.0 }))
|
|
.send()
|
|
.expect("quorum write sends");
|
|
assert_eq!(
|
|
q.status().as_u16(),
|
|
503,
|
|
"ack=quorum must time out honestly against two slow followers"
|
|
);
|
|
let body: serde_json::Value = q.json().expect("503 carries a JSON body");
|
|
assert_eq!(
|
|
body["retryable"].as_bool(),
|
|
Some(true),
|
|
"503 must be retryable: {body}"
|
|
);
|
|
assert!(
|
|
body["laggards"].as_array().is_some_and(|l| !l.is_empty()),
|
|
"503 must name the laggards: {body}"
|
|
);
|
|
println!(
|
|
"[both-slow] ack=quorum → honest 503 retryable, laggards={}",
|
|
body["laggards"]
|
|
);
|
|
|
|
// ack=leader on the same cluster is unaffected (the leader's disk is fast):
|
|
// the caller's durability choice is the caller's, never the deployment's.
|
|
let l = client
|
|
.post(format!("{leader}/signals"))
|
|
.header("x-tidal-ack", "leader")
|
|
.json(&serde_json::json!({ "entity_id": 30_002, "signal": "view", "weight": 1.0 }))
|
|
.send()
|
|
.expect("leader write sends");
|
|
assert_eq!(
|
|
l.status().as_u16(),
|
|
204,
|
|
"ack=leader must keep succeeding while ack=quorum times out: {}",
|
|
l.status()
|
|
);
|
|
println!("[both-slow] ack=leader → 204 (unaffected)");
|
|
|
|
// The slow followers are not broken: they DO apply, just slowly. Once the
|
|
// single in-flight write drains, the cluster converges (the timed-out write
|
|
// is in the leader's log and commits late — at-least-once, never lost).
|
|
cluster.wait_converged_all(convergence_budget() * 3);
|
|
println!("[both-slow] slow followers drained and converged");
|
|
}
|
|
|
|
/// **Asymmetric partition.** A follower that loses its INBOUND links (peers
|
|
/// cannot reach it; it can still send) must not disrupt the cluster: pre-vote +
|
|
/// check-quorum keep the standing leader, there is never a second leader at the
|
|
/// same term, the reachable majority keeps acking quorum writes with zero loss,
|
|
/// and the follower rejoins cleanly on heal.
|
|
#[test]
|
|
fn mp_asymmetric_partition_no_split_brain_no_loss() {
|
|
// Proxy only the edges INTO ap-south; ap-south's own outbound dials are
|
|
// unproxied (identity), so severing the inbound edges is a true ASYMMETRIC
|
|
// cut: ap-south stops RECEIVING heartbeats/ships but can still SEND the
|
|
// disruptive RequestVotes pre-vote is designed to neutralize.
|
|
let (rewrite, proxies) = proxied_rewrite(&["ap-south"]);
|
|
let fast_election = "election:\n heartbeat_interval_ms: 100\n election_timeout_min_ms: 500\n \
|
|
election_timeout_max_ms: 1000\n leader_lease_ms: 350\nreplication:\n ack: quorum";
|
|
let opts = ClusterOptions::new(3)
|
|
.with_rewrite(rewrite)
|
|
.with_topology_extra(fast_election);
|
|
let cluster = MultiProcCluster::start_with(opts);
|
|
cluster.wait_leader_agreed("us-east", convergence_budget());
|
|
cluster.wait_converged_all(convergence_budget());
|
|
|
|
// Baseline writes; capture the standing leader's term.
|
|
let mut ledger = AckLedger::new();
|
|
drive_quorum_writes(&cluster.node(LEADER), 40_000, 30, &mut ledger);
|
|
let base_term = cluster.local_status(LEADER).expect("leader status")["term"]
|
|
.as_u64()
|
|
.unwrap_or(0);
|
|
|
|
// ── Asymmetric sever: cut every inbound edge to ap-south ──────────────────
|
|
proxies.region("ap-south").sever_all();
|
|
|
|
// The reachable majority keeps acking quorum writes (us-east + eu-west).
|
|
let acked = drive_quorum_writes(&cluster.node(LEADER), 41_000, 30, &mut ledger);
|
|
assert!(
|
|
acked >= 25,
|
|
"reachable majority must keep acking: only {acked}"
|
|
);
|
|
|
|
// Through several election timeouts: the standing leader is unchanged, there
|
|
// is never a second leader at any term, and ap-south's term does not explode
|
|
// (pre-vote blocks a node that can't receive grants from bumping its term).
|
|
let watch_until = Instant::now() + Duration::from_secs(12);
|
|
let mut max_apsouth_term = base_term;
|
|
while Instant::now() < watch_until {
|
|
// The shared checker snapshots every live node and asserts
|
|
// single-leader-per-term; reuse its snapshots for the rest of the checks
|
|
// (no second fetch, no hardcoded node count).
|
|
let statuses = assert_single_leader_now(&cluster, "asymmetric-partition");
|
|
// The two reachable nodes still agree us-east leads.
|
|
for st in &statuses {
|
|
let region = st["region"].as_str().unwrap_or("?");
|
|
if region == "us-east" || region == "eu-west" {
|
|
assert_eq!(
|
|
st["leader"].as_str(),
|
|
Some("us-east"),
|
|
"reachable node {region} must still see us-east as leader: {st}"
|
|
);
|
|
}
|
|
if region == "ap-south" {
|
|
max_apsouth_term = max_apsouth_term.max(st["term"].as_u64().unwrap_or(0));
|
|
}
|
|
}
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
}
|
|
assert!(
|
|
max_apsouth_term <= base_term + 2,
|
|
"ap-south term exploded under asymmetric partition ({base_term} → {max_apsouth_term}); \
|
|
pre-vote should have neutralized the disruptive node"
|
|
);
|
|
println!(
|
|
"[asymmetric] leader held us-east, single-leader-per-term, ap-south term bounded \
|
|
({base_term} → {max_apsouth_term})"
|
|
);
|
|
|
|
// No acked loss: every acked write is durable on the reachable follower.
|
|
let (_keep, _) =
|
|
ledger.assert_frontier_covers_acks(&cluster, &[1usize], "asymmetric-partition");
|
|
ledger.assert_items_present(
|
|
&cluster,
|
|
LEADER,
|
|
Duration::from_secs(10),
|
|
"asymmetric-partition",
|
|
);
|
|
|
|
// HEAL: ap-south's inbound returns; it catches up and rejoins as a follower.
|
|
proxies.region("ap-south").heal_all();
|
|
cluster.wait_converged_all(convergence_budget() + BREAKER_RESET);
|
|
// Item-SET parity, not score: ap-south applies the partition backlog as a
|
|
// burst on heal, reconstructing its velocity buckets differently than the
|
|
// leader's continuous accumulation — so trending (velocity) scores can differ
|
|
// across the catch-up even with identical durable data. Set + content +
|
|
// frontier prove data convergence robustly.
|
|
let leader_ids = feed_item_ids(&cluster, LEADER, "trending", 200);
|
|
let rejoined_ids = feed_item_ids(&cluster, 2, "trending", 200);
|
|
assert_eq!(
|
|
leader_ids, rejoined_ids,
|
|
"rejoined ap-south must materialize the same item set as the leader"
|
|
);
|
|
ledger.assert_items_present(
|
|
&cluster,
|
|
2,
|
|
Duration::from_secs(10),
|
|
"asymmetric-partition (rejoined)",
|
|
);
|
|
println!("[asymmetric] ap-south rejoined to parity, no acked loss");
|
|
}
|