tidaldb/tidal-server/tests/cluster_lifecycle.rs

1055 lines
48 KiB
Rust

//! Tier-3 MULTI-PROCESS lifecycle chaos suite (m8p10 task 06): clock skew and
//! rolling upgrade.
//!
//! These two scenarios are kept in their OWN file (not `cluster_chaos.rs`, which is
//! already at a healthy size and owns the partition-injection suite) so each suite
//! stays independently runnable; both share the same `mod support;` tier-3 harness
//! and the same `cluster-e2e` feature gate.
//!
//! Run:
//! ```bash
//! cargo test -p tidal-server --features cluster-e2e --test cluster_lifecycle -- --nocapture
//! ```
//!
//! # What each test proves
//!
//! ## `mp_clock_skew_reconciliation_stays_causal`
//!
//! Three OS processes run with GENUINELY skewed HLCs (`TIDAL_HLC_SKEW_MS` = `+500`,
//! `0`, `-500`). The env var flows through `main.rs` → `TidalDbBuilder::
//! with_hlc_offset_ms` → `Hlc::for_shard_with_offset`, which the engine reads ONLY
//! when stamping hard-negative LWW registers in `take_crdt_snapshot` — signal-decay
//! timestamps use `Timestamp::now()` and are deliberately UNAFFECTED. The test pins
//! that scope and the convergence guarantee:
//!
//! * **Phase A — normal replication under skew.** Seed + converge; the leader's
//! `view` signals replicate over the WAL relay and every follower ranks an
//! IDENTICAL feed to 1e-6. Because decay reads the real wall clock, ±500ms of HLC
//! skew leaves decayed scores byte-for-byte equal across the three skewed
//! processes — proof the skew mechanism touches HLC (reconcile LWW) ONLY.
//! * **Phase B — divergence under a real partition.** We sever the skewed-BEHIND
//! follower (`-500ms`, ap-south) from every peer with the TCP proxy, then create
//! NODE-LOCAL divergence honestly (see "Divergence creation" below): the
//! skewed-AHEAD real leader (`+500ms`, us-east) hides one pair, and the severed
//! skewed-behind node — promoted to leader in its OWN partitioned view, a genuine
//! split-brain — hides a DIFFERENT pair. Each pair therefore has exactly ONE
//! writer.
//! * **Heal + reconcile BOTH directions.** After healing the link and re-promoting
//! the real leader (resolving the split brain), `/cluster/reconcile` is driven
//! from both sides. LWW is deterministic despite 1s of relative skew (HLC
//! `update()` advances past remote timestamps, `max(wall, last_seen+1)`), so both
//! nodes converge to the IDENTICAL hard-negative state: each hide is effective on
//! BOTH nodes (verified by the `/feed?user_id=` control-vs-filtered pattern). A
//! repeated reconcile leaves the converged STATE unchanged (no timestamp
//! oscillation; we assert on state, never on `ops_applied`, which is > 0 by
//! design). Reconcile merge+apply stays < 100ms both sides under skew.
//!
//! ### Divergence creation (honest choice)
//!
//! The task doc's divergence is "hide on the skewed-BEHIND node and a DIFFERENT pair
//! on the skewed-AHEAD leader during the window", which needs a NODE-LOCAL hide on
//! the partitioned follower. `/hardnegs` on a non-leader FORWARDS to the leader; while
//! severed that forward fails — which is the forwarding contract working, not a bug.
//! Rather than reach for the `x-tidal-internal` marker (a stand-in), we take the
//! CLEANER real path the runbook itself sanctions: promote the severed follower to
//! leader IN ITS OWN VIEW (`/cluster/promote` applies `promote_local` unconditionally;
//! the fan-out to severed peers fails, leaving a genuine SPLIT BRAIN — two
//! leader-views during the partition). A plain external `/hardnegs` to that
//! split-brain leader then applies LOCALLY because `state.is_leader()` is true on its
//! own view. This is exactly the production hazard reconciliation exists to repair:
//! two nodes each accepted a divergent write while partitioned. On heal we re-promote
//! the real leader to collapse the split brain, then reconcile — the durable
//! `Tag::HardNeg` rows survive the leadership change (they are store rows, not
//! leader state), so both hides converge by LWW.
//!
//! ## `mp_rolling_upgrade_no_loss_no_stall`
//!
//! Three processes on PERSISTENT data dirs (the harness always passes `--data-dir`
//! and reuses it on restart, so a restart recovers pre-restart state from the WAL).
//! A background writer thread continuously `POST /signals` round-robin across ALL
//! three live nodes (followers forward to the current leader) while we perform the
//! full rolling-upgrade choreography:
//!
//! * Boot "version N" (env `TIDAL_VERSION_TAG=N`).
//! * Upgrade followers one at a time UNDER LOAD: graceful SIGTERM (clean drain +
//! checkpoint + WAL fsync — NOT a crash), restart same data dir with
//! `TIDAL_VERSION_TAG=N+1`, `wait_healthy`, `heal_until_converged_busy` (the leader's
//! gRPC circuit breaker opened while the node was down — threshold 5, reset 30s — so
//! one heal can ship into an open breaker; we re-issue exactly like a runbook
//! operator, tolerating transient 408/429 from the pool-backed heal route under the
//! concurrent writer).
//! * Drain the writer (and read the zero-loss tally).
//! * Leader LAST: `/cluster/promote` an already-upgraded follower, `wait_leader_agreed`
//! on the live nodes, then graceful-restart the OLD leader the same way. Because the
//! old leader's topology file still names region 0 as leader, it boots with a STALE
//! leader view (a transient split brain) — so we re-promote the chosen leader after
//! it rejoins and `wait_leader_agreed` on ALL three, the real operator step.
//! * Heal each restarted node (the runbook recovery) and assert the WAL relay ALONE
//! reconverges it to the leader's ranking — NO `/cluster/reconcile` in the
//! convergence path. A final `/cluster/reconcile` round is then asserted to be a
//! true no-op on scores (the anti-entropy fixpoint, now reached immediately).
//!
//! Final asserts: the cluster converges to identical state via the WAL relay alone
//! (feed/decay parity to 1e-6 across all three — no loss, no duplication), the final
//! reconcile is an exact no-op (no creep), zero ACKNOWLEDGED logical writes lost
//! (the spec's hard guarantee, criterion 7: `lost == 0`), the leader is the promoted
//! node, and each restarted node served its pre-restart items after restart (WAL
//! recovery presence).
//!
//! ### Convergence is driven by the WAL RELAY (three reported bugs, now FIXED)
//!
//! The spec (docs/specs/14-scale-architecture.md §"consistency guarantees") makes signal
//! aggregates EVENTUAL (bounded-staleness), recovered by anti-entropy; the hard
//! guarantee is "no ACKNOWLEDGED signal events lost" (criterion 7). The m8p10 tier-3 UAT
//! surfaced three real product bugs that meant the WAL relay alone did NOT deliver the
//! eventual convergence the model promises after a restart. All three are now FIXED, so
//! this test asserts the REAL contract: heal + the WAL relay reconverge every restarted
//! node WITHOUT `/cluster/reconcile`, and the final reconcile is a true no-op:
//!
//! 1. **Lag gauge meaningless across a leadership change — FIXED.** The lag gauge now
//! tracks the leader high-water-mark PER SOURCE SHARD
//! (`ReplicationLagGauge::leader_seqno_for`), and `local_status` computes lag against
//! the CURRENT leader's shard. A fully-caught-up rejoined node reports lag 0, so
//! `converged()` (lag 0 + applied >= leader hwm) is an honest convergence signal.
//! 2. **`reconcile_with` NOT idempotent for signal state — FIXED.** `take_crdt_snapshot`
//! now attributes every node's signal contribution to ONE canonical replication shard
//! (`ShardId::SINGLE`), not the local shard, so a fully-replicated event is ONE LWW
//! register on every node — `merge` is idempotent. Reconcile of converged nodes is an
//! exact fixpoint immediately (no 0.5 → 0.375 → … creep). The final reconcile round
//! below asserts this directly (feeds byte-unchanged).
//! 3. **Graceful-restart item loss — FIXED twice over.** m8p10 patched it by making
//! `/cluster/heal` re-broadcast items over HTTP; m11p2 removed the root cause:
//! items and embeddings ride the one replicated WAL log (kind-1/2 records), so a
//! restarted node converges by pure log catch-up — live pushes plus its boot-time
//! `StreamSegments` pull — leaving it with EXACTLY the leader's data, proven by
//! the per-restart parity assertion (1e-6, no reconcile, no operator verb).
//!
//! ### Write-tally semantics (the zero-loss gate)
//!
//! A "logical write" is one intended signal POST. During a restart window a request
//! may transiently fail (connection refused while the process is down; 503 when a
//! follower forwards to a leader that is restarting or holds a stale view; 429 under
//! write-pool backpressure; 408 request timeout). The writer RETRIES the SAME logical
//! write (cycling target nodes) until it 204s, counting retries; `stop` is checked only
//! at the top of each logical write so a committed write is always driven to completion.
//! The gate is `lost == 0`: every logical write eventually returns 204 (the spec's
//! "no acknowledged events lost"). `total` is the number of logical writes, `retried`
//! the number that needed >= 1 retry, `lost` the number that never succeeded within
//! [`MAX_WRITE_ATTEMPTS`] (must be 0).
//!
//! ## `RollingUpgradeCoordinator` decision
//!
//! `tidaldb::replication::upgrade::RollingUpgradeCoordinator` is an ENGINE-INTERNAL
//! drain/rejoin gate over `ShardId`s on a single in-process `ControlPlane`: `drain`
//! marks a shard not-routable (refusing to leave zero serving shards) and `rejoin`
//! clears it. It is NOT surfaced on the multi-process server's HTTP API — there is no
//! `/cluster/drain` route, and each process owns exactly one region (its own
//! `ControlPlane`), so a coordinator in process A cannot drain process B. The
//! server-level rolling upgrade is instead choreographed by the OPERATOR surface that
//! DOES exist over the network — `/cluster/promote` (move leadership off the node
//! about to restart) + graceful SIGTERM (drain in-flight, checkpoint, WAL fsync) +
//! `/cluster/heal` (redeliver segments shipped while the node was down). Asserting
//! through the in-process coordinator here would test the wrong process's state, so
//! this suite exercises the real network choreography and documents the coordinator's
//! scope in this header rather than forcing it in. (Its own unit tests in
//! `upgrade.rs` cover the drain/rejoin invariant.)
//!
//! # Budget
//!
//! Tier-3 over real OS processes. Two tests: the skew test is a boot + converge +
//! one partition/heal/reconcile window; the upgrade test is a boot + four graceful
//! restarts, each followed by a breaker-bounded heal. Worst case the upgrade test's
//! heals each wait up to the 30s breaker reset, but in practice an eager-ship probe
//! (the continuous writer, while it runs) closes the breaker far sooner. The writer is
//! paced (~5 writes/sec) so it never starves the leader's small write pool and the
//! pool-backed heal can land. Every wait is poll-with-deadline; whole-suite wall budget
//! < 5 minutes on a developer laptop.
#![cfg(feature = "cluster-e2e")]
// Tier-3 harness allows, mirroring `cluster_chaos.rs` / `cluster_multiproc.rs`:
// `unwrap` on known-good fixtures is idiomatic test noise; the lossy numeric casts
// are the same pervasive-and-intentional scoring math the crate config documents.
#![allow(
clippy::unwrap_used,
clippy::missing_panics_doc,
clippy::too_many_lines,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::items_after_statements
)]
mod support;
/// This suite choreographs the LEGACY operator drills (manual promote,
/// deliberate split-brain via a severed self-promote, rolling restarts).
/// Auto-election is pinned OFF so the m11p4 failure detector cannot race the
/// drills — the automatic path has its own exit-gate suite
/// (`cluster_election.rs`).
const LEGACY_ELECTION_YAML: &str = "election:\n auto_election: false";
use std::{
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread,
time::{Duration, Instant},
};
use support::{
multiproc::{
BREAKER_RESET, ClusterOptions, MultiProcCluster, convergence_budget,
seed_items_and_embeddings, write_view,
},
partition::proxied_rewrite,
};
/// Region 0 = `us-east` = the initial leader in every topology.
const LEADER: usize = 0;
/// Region 1 = `eu-west` (a follower; promoted to leader in the upgrade test).
const EU_WEST: usize = 1;
/// Region 2 = `ap-south` (a follower; the skewed-BEHIND node in the skew test).
const AP_SOUTH: usize = 2;
// The leader-ships circuit breaker ([`BREAKER_RESET`], the `tidal-net` default)
// opens after 5 consecutive failed ships and stays open for 30s. After a node
// returns, the leader's transport cannot reach it until the breaker half-opens
// and one probe ship closes it — so a SINGLE `/cluster/heal` can ship into the
// open breaker and be a no-op. Tests bound post-return convergence at
// BREAKER_RESET + the convergence budget, re-issuing heal exactly like a
// runbook operator. (Same reasoning as `cluster_chaos.rs`.)
// ── Shared seeding / feed helpers (canonical bodies in support::multiproc) ──────
/// A node's local-region feed as a sorted `(entity_id, score)` vector.
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(),
it["score"].as_f64().unwrap(),
)
})
.collect();
pairs.sort_by_key(|(id, _)| *id);
pairs
}
/// A user-scoped feed's item-id set (sorted). Hard negatives for `user_id` are
/// filtered by the engine's user-context stage, so a hidden item is ABSENT here.
fn feed_item_ids_for_user(
cluster: &MultiProcCluster,
idx: usize,
user_id: u64,
profile: &str,
limit: u32,
) -> Vec<u64> {
let body = cluster.get_json(
idx,
&format!("/feed?profile={profile}&limit={limit}&user_id={user_id}"),
);
let mut ids: Vec<u64> = body["items"]
.as_array()
.unwrap_or(&Vec::new())
.iter()
.map(|it| it["entity_id"].as_u64().unwrap())
.collect();
ids.sort_unstable();
ids
}
/// Assert two feed views carry the SAME items with scores equal to 1e-6.
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}"
);
}
}
/// One node's `applied_events` from its OWN `/cluster/status/local` (direct addr).
fn applied(cluster: &MultiProcCluster, idx: usize) -> u64 {
cluster
.local_status(idx)
.and_then(|st| st["applied_events"].as_u64())
.unwrap_or(0)
}
/// True when every `followers` entry has applied up to (or past) the leader's
/// high-water-mark with zero lag. Non-panicking, so a retry loop can poll it.
fn converged(cluster: &MultiProcCluster, followers: &[usize]) -> bool {
let Some(target) = cluster.leader_last_seq() else {
return false;
};
followers.iter().all(|&idx| {
cluster.local_status(idx).is_some_and(|st| {
let applied = st["applied_events"].as_u64().unwrap_or(0);
let lag = st["lag_events"].as_u64().unwrap_or(u64::MAX);
lag == 0 && applied >= target
})
})
}
/// Drive convergence under CONCURRENT write load the way an operator does: re-issue
/// `POST /cluster/heal` for `region` until every `followers` entry converges, allowing
/// for the circuit-breaker reset window. The rolling-upgrade writer keeps hammering the
/// leader, so its write pool — and the axum request-timeout layer in front of the
/// pool-backed heal route — can make a `/cluster/heal` return 408 (request timeout) or
/// 429 (pool saturated) instead of 200. That is genuine production behavior, not a bug:
/// an operator re-issues the heal. So (unlike the chaos suite's strict heal, which runs
/// with no concurrent writer and asserts a 200) this TOLERATES a transient non-200 heal
/// and simply re-issues, bounded by the breaker reset + convergence budget; a heal that
/// DOES 200 is the one that redelivered. We always confirm convergence on STATE.
fn heal_until_converged_busy(cluster: &MultiProcCluster, region: &str, followers: &[usize]) {
let deadline = Instant::now() + BREAKER_RESET + convergence_budget();
loop {
// Re-issue heal; a 408/429/503 under write load is a no-op we retry, not a
// failure. Only a 4xx that is NOT timeout/backpressure (e.g. 400 unknown
// region) would be a real bug — but the region name is always valid here.
let status = cluster
.post(
LEADER,
"/cluster/heal",
&serde_json::json!({ "region": region }),
)
.status()
.as_u16();
assert!(
matches!(status, 200 | 408 | 429 | 503),
"/cluster/heal returned an unexpected status {status} (expected 200, or a \
transient 408/429/503 under write load)"
);
let check_deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() <= check_deadline {
if converged(cluster, followers) {
return;
}
thread::sleep(Duration::from_millis(100));
}
assert!(
Instant::now() <= deadline,
"region '{region}' did not converge under write load within budget; \
leader hwm={:?}, follower applied={:?}",
cluster.leader_last_seq(),
followers
.iter()
.map(|&i| applied(cluster, i))
.collect::<Vec<_>>()
);
}
}
/// Poll `pred()` until true or `budget` elapses, asserting with `msg` on timeout.
fn poll_until(budget: Duration, msg: &str, mut pred: impl FnMut() -> bool) {
let deadline = Instant::now() + budget;
while !pred() {
assert!(Instant::now() <= deadline, "timed out: {msg}");
thread::sleep(Duration::from_millis(50));
}
}
// ── Clock-skew reconciliation ────────────────────────────────────────────────
#[test]
fn mp_clock_skew_reconciliation_stays_causal() {
// Skewed-AHEAD leader, on-time follower, skewed-BEHIND follower. The env var
// genuinely offsets each process's HLC (parsed i64 in main.rs → engine), so
// this is real ±500ms skew, not a mock.
// All three regions proxied so ap-south can be isolated in BOTH
// directions (m11p4): an inbound-only sever leaves its outbound votes,
// ships and heartbeats flowing, which turns the intended split-brain
// into a genuine election win.
let (rewrite, proxies) = proxied_rewrite(&["us-east", "eu-west", "ap-south"]);
let cluster = MultiProcCluster::start_with(
ClusterOptions::new(3)
.with_topology_extra(LEGACY_ELECTION_YAML)
.with_env(LEADER, "TIDAL_HLC_SKEW_MS", "500")
.with_env(EU_WEST, "TIDAL_HLC_SKEW_MS", "0")
.with_env(AP_SOUTH, "TIDAL_HLC_SKEW_MS", "-500")
.with_rewrite(rewrite),
);
const ITEMS: u64 = 12;
/// The user whose hide the AHEAD leader records (on pair A).
const AHEAD_USER: u64 = 11;
/// The item the AHEAD leader (+500ms) hides.
const AHEAD_ITEM: u64 = 4;
/// The user whose hide the BEHIND split-brain node records (on pair B).
const BEHIND_USER: u64 = 22;
/// The item the BEHIND follower (-500ms) hides.
const BEHIND_ITEM: u64 = 9;
// ── PHASE A: normal replication under ±500ms skew ──────────────────────────
seed_items_and_embeddings(&cluster, LEADER, ITEMS);
for entity_id in 1..=ITEMS {
write_view(&cluster, LEADER, entity_id, entity_id as f64);
}
cluster.wait_converged_all(convergence_budget());
// Decay reads the real wall clock, so ±500ms of HLC skew leaves the ranked
// feed byte-identical across the three skewed processes. This pins the scope:
// the skew mechanism is HLC-only (reconcile LWW), never signal decay.
let leader_feed = feed_pairs(&cluster, LEADER, "trending", ITEMS as u32);
let eu_feed = feed_pairs(&cluster, EU_WEST, "trending", ITEMS as u32);
let ap_feed = feed_pairs(&cluster, AP_SOUTH, "trending", ITEMS as u32);
assert!(!leader_feed.is_empty(), "leader must rank the seeded items");
assert_feed_parity(
"leader(+500) vs eu-west(0) under skew",
&leader_feed,
&eu_feed,
);
assert_feed_parity(
"leader(+500) vs ap-south(-500) under skew",
&leader_feed,
&ap_feed,
);
println!(
"[skew] PHASE A: {} items rank identically (1e-6) across +500ms / 0 / -500ms processes \
(decay is wall-clock, unaffected by HLC skew)",
leader_feed.len()
);
// ── PHASE B: divergence under a real partition (split-brain hide) ──────────
// Sever the skewed-BEHIND follower (ap-south, -500ms) from every peer.
support::partition::isolate_region(&proxies, "ap-south", &["us-east", "eu-west"]);
println!("[skew] PHASE B: severed ap-south (-500ms) from all peers");
// AHEAD leader (+500ms, us-east) hides pair A. It is the real leader, so a plain
// external /hardnegs applies locally.
let resp = cluster.post(
LEADER,
"/hardnegs",
&serde_json::json!({ "user_id": AHEAD_USER, "item_id": AHEAD_ITEM }),
);
assert_eq!(resp.status().as_u16(), 204, "ahead-leader hardneg must 204");
// BEHIND follower (-500ms, ap-south): promote it to leader IN ITS OWN VIEW. The
// fan-out to the severed peers fails (it cannot reach them), leaving a genuine
// SPLIT BRAIN — exactly the production hazard reconciliation repairs. promote_local
// applies unconditionally, so ap-south now answers is_leader:true on its own view.
let ap_region = cluster.region_name(AP_SOUTH).to_string();
let resp = cluster.post(
AP_SOUTH,
"/cluster/promote",
&serde_json::json!({ "region": ap_region }),
);
assert_eq!(
resp.status().as_u16(),
200,
"split-brain self-promote on the severed follower must 200: {}",
resp.status()
);
poll_until(
Duration::from_secs(5),
"severed ap-south must adopt its own leader view (split brain)",
|| {
cluster
.local_status(AP_SOUTH)
.and_then(|st| st["is_leader"].as_bool())
== Some(true)
},
);
// A plain external /hardnegs on the split-brain leader (ap-south) applies LOCALLY
// (state.is_leader() is true on its own view) — a node-local divergent hide on a
// DIFFERENT pair (B). No internal marker, no forward: a real partitioned write.
let resp = cluster.post(
AP_SOUTH,
"/hardnegs",
&serde_json::json!({ "user_id": BEHIND_USER, "item_id": BEHIND_ITEM }),
);
assert_eq!(
resp.status().as_u16(),
204,
"split-brain follower hardneg must apply locally (204): {}",
resp.status()
);
println!(
"[skew] divergence created: +500 leader hid ({AHEAD_USER},{AHEAD_ITEM}); \
-500 split-brain ap-south hid ({BEHIND_USER},{BEHIND_ITEM})"
);
// Before reconcile, each hide exists on exactly ONE node.
assert!(
!feed_item_ids_for_user(&cluster, LEADER, AHEAD_USER, "trending", ITEMS as u32)
.contains(&AHEAD_ITEM),
"ahead hide must be effective on the leader pre-reconcile"
);
assert!(
feed_item_ids_for_user(&cluster, LEADER, BEHIND_USER, "trending", ITEMS as u32)
.contains(&BEHIND_ITEM),
"leader must NOT yet know the behind node's hide pre-reconcile"
);
// ── HEAL + collapse the split brain, then reconcile BOTH directions ────────
support::partition::rejoin_region(&proxies, "ap-south", &["us-east", "eu-west"]);
// Re-promote the real leader (us-east) to collapse the split brain — the runbook
// step after a partitioned node rejoins. The durable Tag::HardNeg rows survive the
// leadership change (store rows, not leader state), so the behind hide is intact.
let leader_region = cluster.region_name(LEADER).to_string();
let resp = cluster.post(
LEADER,
"/cluster/promote",
&serde_json::json!({ "region": leader_region }),
);
assert_eq!(
resp.status().as_u16(),
200,
"re-promote real leader must 200"
);
cluster.wait_leader_agreed(&leader_region, Duration::from_secs(15));
println!("[skew] healed + collapsed split brain (leader = {leader_region})");
// Reconcile leader -> ap-south (the call exchanges snapshots bidirectionally:
// ships ours into ap-south's merge AND applies ap-south's back). Assert < 100ms.
let (l_ms, r_ms) = reconcile(&cluster, LEADER, &ap_region);
assert!(
l_ms < 100 && r_ms < 100,
"reconcile under skew must be < 100ms both sides: leader-side local={l_ms}ms remote={r_ms}ms"
);
println!("[skew] reconcile leader->ap-south: local={l_ms}ms remote={r_ms}ms (both < 100ms)");
// Reconcile the OTHER direction (ap-south -> leader) to prove symmetry under skew.
let (l_ms2, r_ms2) = reconcile(&cluster, AP_SOUTH, &leader_region);
assert!(
l_ms2 < 100 && r_ms2 < 100,
"reverse reconcile under skew must be < 100ms both sides: local={l_ms2}ms remote={r_ms2}ms"
);
println!("[skew] reconcile ap-south->leader: local={l_ms2}ms remote={r_ms2}ms (both < 100ms)");
// ── CONVERGENCE: both hides effective on BOTH nodes, LWW deterministic ─────
assert_converged_hide(
&cluster,
LEADER,
AP_SOUTH,
AHEAD_USER,
AHEAD_ITEM,
"ahead hide",
);
assert_converged_hide(
&cluster,
LEADER,
AP_SOUTH,
BEHIND_USER,
BEHIND_ITEM,
"behind hide",
);
println!(
"[skew] converged: both hides effective on leader AND ap-south despite ±500ms skew \
(skewed-behind node's hide NOT lost; LWW deterministic)"
);
// ── STABILITY: a repeat reconcile leaves the STATE unchanged ───────────────
// (ops_applied may be > 0 by design — the LWW plan re-resolves the same hides —
// so we assert on STATE, never on ops_applied: no timestamp oscillation.)
let leader_before = (
feed_item_ids_for_user(&cluster, LEADER, AHEAD_USER, "trending", ITEMS as u32),
feed_item_ids_for_user(&cluster, LEADER, BEHIND_USER, "trending", ITEMS as u32),
);
let ap_before = (
feed_item_ids_for_user(&cluster, AP_SOUTH, AHEAD_USER, "trending", ITEMS as u32),
feed_item_ids_for_user(&cluster, AP_SOUTH, BEHIND_USER, "trending", ITEMS as u32),
);
let (l_ms3, r_ms3) = reconcile(&cluster, LEADER, &ap_region);
assert!(
l_ms3 < 100 && r_ms3 < 100,
"repeat reconcile must stay < 100ms both sides: local={l_ms3}ms remote={r_ms3}ms"
);
let leader_after = (
feed_item_ids_for_user(&cluster, LEADER, AHEAD_USER, "trending", ITEMS as u32),
feed_item_ids_for_user(&cluster, LEADER, BEHIND_USER, "trending", ITEMS as u32),
);
let ap_after = (
feed_item_ids_for_user(&cluster, AP_SOUTH, AHEAD_USER, "trending", ITEMS as u32),
feed_item_ids_for_user(&cluster, AP_SOUTH, BEHIND_USER, "trending", ITEMS as u32),
);
assert_eq!(
leader_before, leader_after,
"repeat reconcile changed leader hide state"
);
assert_eq!(
ap_before, ap_after,
"repeat reconcile changed ap-south hide state"
);
println!(
"[skew] STABLE: repeat reconcile left both nodes' hide state identical (no oscillation)"
);
}
/// Drive `POST /cluster/reconcile` from `from_idx` against `with_region`, asserting a
/// 200, and return `(local_elapsed_ms, remote_elapsed_ms)`.
fn reconcile(cluster: &MultiProcCluster, from_idx: usize, with_region: &str) -> (u64, u64) {
let resp = cluster.post(
from_idx,
"/cluster/reconcile",
&serde_json::json!({ "region": with_region }),
);
assert_eq!(
resp.status().as_u16(),
200,
"reconcile from node {from_idx} with '{with_region}' must 200: {}",
resp.status()
);
let body: serde_json::Value = resp.json().unwrap();
assert_eq!(
body["ok"].as_bool(),
Some(true),
"reconcile ok must be true: {body}"
);
(
body["local_elapsed_ms"].as_u64().unwrap(),
body["remote_elapsed_ms"].as_u64().unwrap(),
)
}
/// Assert the WAL relay ALONE (heal redelivery + item backfill — no
/// `/cluster/reconcile`) reconverges `follower` to the current leader's ranking to
/// 1e-6. The background writer is still running, so the leader's feed moves between
/// reads; we capture leader then follower back-to-back and retry until a snapshot
/// pair matches, bounded by the convergence budget. A persistent mismatch is a real
/// loss the WAL relay failed to close (bug 3), surfaced — not masked.
fn assert_wal_relay_parity_to_leader(
cluster: &MultiProcCluster,
follower: usize,
limit: u32,
label: &str,
) {
let leader_idx = current_leader_idx(cluster);
let deadline = Instant::now() + convergence_budget();
loop {
// Capture the follower first, then the leader: if they match, the follower is
// at least as current as a leader snapshot taken AFTER it, so the relay has
// delivered everything up to that point (a conservative, race-safe check).
let f = feed_pairs(cluster, follower, "trending", limit);
let l = feed_pairs(cluster, leader_idx, "trending", limit);
let matched = f.len() == l.len()
&& f.iter()
.zip(l.iter())
.all(|((fi, fs), (li, ls))| fi == li && (fs - ls).abs() <= 1e-6);
if matched {
println!(
"[upgrade] {label}: WAL relay alone reconverged to the leader's ranking \
({} items, 1e-6) — NO reconcile needed",
f.len()
);
return;
}
assert!(
Instant::now() <= deadline,
"{label}: WAL relay alone did NOT reconverge the restarted node to the leader \
within budget (bug 3 would manifest here as a permanent gap); \
follower={f:?} leader={l:?}"
);
thread::sleep(Duration::from_millis(100));
}
}
/// Index of the region every node currently agrees is the leader (from the leader's
/// own local status). Falls back to `LEADER` if unresolved.
fn current_leader_idx(cluster: &MultiProcCluster) -> usize {
for i in 0..cluster.len() {
if let Some(name) = cluster
.local_status(i)
.and_then(|st| st["leader"].as_str().map(str::to_string))
&& let Some(idx) = (0..cluster.len()).find(|&j| cluster.region_name(j) == name)
{
return idx;
}
}
LEADER
}
/// Whether all three nodes' `trending` feeds agree to 1e-6 (item set + scores).
fn three_way_parity(cluster: &MultiProcCluster, leader_idx: usize, limit: u32) -> bool {
let a = feed_pairs(cluster, leader_idx, "trending", limit);
let others: Vec<usize> = (0..cluster.len()).filter(|&i| i != leader_idx).collect();
others.iter().all(|&idx| {
let b = feed_pairs(cluster, idx, "trending", limit);
a.len() == b.len()
&& a.iter()
.zip(b.iter())
.all(|((ia, sa), (ib, sb))| ia == ib && (sa - sb).abs() <= 1e-6)
})
}
/// Assert a hide for `(user,item)` is effective on BOTH nodes: the control un-scoped
/// feed still ranks the item on each (proving it is a FILTER, not a disappearance),
/// while the `?user_id=` feed omits it on each.
fn assert_converged_hide(
cluster: &MultiProcCluster,
node_a: usize,
node_b: usize,
user: u64,
item: u64,
label: &str,
) {
const LIMIT: u32 = 12;
for &node in &[node_a, node_b] {
let control: Vec<u64> = feed_pairs(cluster, node, "trending", LIMIT)
.into_iter()
.map(|(id, _)| id)
.collect();
assert!(
control.contains(&item),
"{label}: control un-scoped feed on node {node} must still rank item {item}: {control:?}"
);
let scoped = feed_item_ids_for_user(cluster, node, user, "trending", LIMIT);
assert!(
!scoped.contains(&item),
"{label}: item {item} must be hidden from user {user} on node {node}: {scoped:?}"
);
}
}
// ── Rolling upgrade: no loss, no stall ───────────────────────────────────────
/// A background writer's tally. `total` logical writes, `retried` that needed >= 1
/// retry, `lost` that never landed (the zero-loss gate asserts `lost == 0`).
struct WriterTally {
total: AtomicU64,
retried: AtomicU64,
lost: AtomicU64,
}
impl WriterTally {
const fn new() -> Self {
Self {
total: AtomicU64::new(0),
retried: AtomicU64::new(0),
lost: AtomicU64::new(0),
}
}
fn snapshot(&self) -> (u64, u64, u64) {
(
self.total.load(Ordering::Relaxed),
self.retried.load(Ordering::Relaxed),
self.lost.load(Ordering::Relaxed),
)
}
}
#[test]
fn mp_rolling_upgrade_no_loss_no_stall() {
// Persistent data dirs are guaranteed by the harness (it always passes
// --data-dir and reuses it on restart). Boot "version N". The cluster handle
// stays owned on THIS thread (restarts need &mut self); the writer thread gets
// only the stable node base URLs + its own client, so the two never alias.
let mut cluster = MultiProcCluster::start_with(
ClusterOptions::new(3)
.with_topology_extra(LEGACY_ELECTION_YAML)
.with_env(LEADER, "TIDAL_VERSION_TAG", "N")
.with_env(EU_WEST, "TIDAL_VERSION_TAG", "N")
.with_env(AP_SOUTH, "TIDAL_VERSION_TAG", "N"),
);
const ITEMS: u64 = 16;
seed_items_and_embeddings(&cluster, LEADER, ITEMS);
// Steady-state warmup so every node shares a converged baseline before the
// continuous writer + restarts begin.
for entity_id in 1..=ITEMS {
write_view(&cluster, LEADER, entity_id, 1.0);
}
cluster.wait_converged_all(convergence_budget());
// Spot-check fixture: an item written on the leader BEFORE any restart, used to
// prove WAL recovery (each restarted node still serves it AFTER its restart).
let pre_restart_feed = feed_pairs(&cluster, LEADER, "trending", ITEMS as u32);
let pre_restart_ids: Vec<u64> = pre_restart_feed.iter().map(|(id, _)| *id).collect();
assert!(
!pre_restart_ids.is_empty(),
"leader must rank seeded items pre-restart"
);
// ── Background writer: continuous round-robin POST /signals across ALL nodes ─
// The writer holds only the node URLs (ports are stable across restarts) and its
// own blocking client — fully decoupled from the &mut harness on this thread.
let node_urls: Vec<String> = (0..cluster.len()).map(|i| cluster.node(i)).collect();
let tally = Arc::new(WriterTally::new());
let stop = Arc::new(AtomicBool::new(false));
let writer = {
let tally = Arc::clone(&tally);
let stop = Arc::clone(&stop);
thread::spawn(move || run_writer(&node_urls, &tally, &stop, ITEMS))
};
// Let the writer establish a cadence before the first restart.
thread::sleep(Duration::from_millis(300));
// ── Upgrade FOLLOWERS one at a time (graceful SIGTERM → restart N+1 → heal) ──
for &follower in &[EU_WEST, AP_SOUTH] {
let region = cluster.region_name(follower).to_string();
println!("[upgrade] gracefully stopping follower {region} for upgrade to N+1");
cluster.restart_graceful(follower, &[("TIDAL_VERSION_TAG", "N+1")]);
// The leader's breaker opened while this follower was down; drive heal through
// the reset exactly like an operator — tolerating transient 408/429 from the
// pool-backed heal route under the concurrent writer.
heal_until_converged_busy(&cluster, &region, &[follower]);
println!("[upgrade] follower {region} upgraded to N+1; WAL-relay caught up (lag 0)");
// WAL recovery: the just-restarted follower still SERVES the pre-restart items
// (presence).
let post_pairs = feed_pairs(&cluster, follower, "trending", ITEMS as u32);
let post_ids: Vec<u64> = post_pairs.iter().map(|(id, _)| *id).collect();
for id in &pre_restart_ids {
assert!(
post_ids.contains(id),
"{region}: pre-restart item {id} lost after restart (WAL recovery): {post_ids:?}"
);
}
// STRONGER CONTRACT (bugs 1+3 fixed): heal alone — `/cluster/heal` redelivers
// the missed signal segments AND backfills the missed item metadata, with an
// honest lag gauge — must reconverge the restarted follower to the LEADER's
// ranking WITHOUT any `/cluster/reconcile` in the path. We poll the follower's
// feed against the leader's to 1e-6 (the writer is still running, so the leader
// moves; poll until a snapshot pair matches). This is the eventual-consistency
// guarantee the WAL relay now delivers on its own.
assert_wal_relay_parity_to_leader(&cluster, follower, ITEMS as u32, &region);
}
// ── Drain the writer before the leadership handoff ──────────────────────────
// The two FOLLOWER upgrades above ran under continuous write load (the whole point:
// no lost LOGICAL writes during a restart — the lost==0 gate below). We drain before
// the leader handoff so the handoff happens over a quiescent cluster (faithful
// operator practice for a planned failover) and so the final anti-entropy reconcile
// snapshots a stable state.
stop.store(true, Ordering::Relaxed);
writer.join().expect("writer thread joined");
let (total, retried, lost) = tally.snapshot();
println!(
"[upgrade] writer drained before handoff; tally: total={total} retried={retried} lost={lost}"
);
// ── Leader LAST: promote an upgraded follower, then restart the old leader ───
let new_leader = cluster.region_name(EU_WEST).to_string();
println!("[upgrade] promoting upgraded follower {new_leader} before restarting the old leader");
let resp = cluster.post(
AP_SOUTH,
"/cluster/promote",
&serde_json::json!({ "region": new_leader }),
);
assert_eq!(
resp.status().as_u16(),
200,
"promote must 200: {}",
resp.status()
);
// The old leader is still up here, so it acks the fan-out and every LIVE node
// agrees eu-west leads.
cluster.wait_leader_agreed(&new_leader, Duration::from_secs(15));
let old_leader = cluster.region_name(LEADER).to_string();
println!("[upgrade] gracefully restarting the OLD leader {old_leader} to N+1");
cluster.restart_graceful(LEADER, &[("TIDAL_VERSION_TAG", "N+1")]);
// The restarted old leader read `leader: us-east` from its topology file, so it
// boots with a STALE leader view (transient split brain). Re-promote the chosen
// leader to collapse it — the real operator step after a node rejoins.
let resp = cluster.post(
LEADER,
"/cluster/promote",
&serde_json::json!({ "region": new_leader }),
);
assert_eq!(
resp.status().as_u16(),
200,
"re-promote after old-leader rejoin must 200: {}",
resp.status()
);
cluster.wait_leader_agreed(&new_leader, Duration::from_secs(15));
println!("[upgrade] old leader {old_leader} rejoined as a follower of {new_leader}");
// ── WAL RELAY ALONE reconverges the restarted old leader (bugs 1+3 fixed) ────
// The new leader heals the rejoined old leader: `/cluster/heal` redelivers the
// signal segments it missed while down AND backfills the item metadata, with an
// honest per-shard lag gauge. The writer is drained, so the cluster is quiescent
// (a planned-failover handoff over a quiescent cluster is faithful operator
// practice — we drain BEFORE the handoff for exactly this reason). After heal,
// ALL THREE feeds must agree to 1e-6 WITHOUT any `/cluster/reconcile` — the WAL
// relay is the convergence mechanism, and reaching three-way parity (no stall)
// with every item present (no loss) and scores equal (no duplication) is the
// exactly-once proof. Before the bug fixes this required a multi-round reconcile
// fixpoint loop; now the relay alone closes the gap.
heal_until_converged_busy(&cluster, &old_leader, &[LEADER]);
let new_leader_idx = EU_WEST;
poll_until(
convergence_budget(),
"WAL relay alone must reconverge all three nodes to 1e-6 after the rolling upgrade",
|| three_way_parity(&cluster, new_leader_idx, ITEMS as u32),
);
println!(
"[upgrade] WAL relay alone reconverged all three nodes (1e-6) — NO reconcile in the \
convergence path"
);
// ── Final reconcile is a TRUE no-op (bug 2 fixed: idempotent, fixpoint at once) ─
// Capture each node's feed, drive ONE pairwise reconcile round in BOTH directions,
// and assert every node's feed is BYTE-for-byte unchanged. Before bug 2 the signal
// merge summed already-replicated per-node contributions, so reconcile of converged
// nodes crept the scores (0.5 → 0.375 → …). Now `merge` is idempotent: reconcile of
// converged nodes is an exact fixpoint immediately, no creep.
let before_leader = feed_pairs(&cluster, EU_WEST, "trending", ITEMS as u32);
let before_us = feed_pairs(&cluster, LEADER, "trending", ITEMS as u32);
let before_ap = feed_pairs(&cluster, AP_SOUTH, "trending", ITEMS as u32);
let us_region = cluster.region_name(LEADER).to_string();
let ap_region = cluster.region_name(AP_SOUTH).to_string();
let _ = reconcile(&cluster, EU_WEST, &us_region);
let _ = reconcile(&cluster, LEADER, &new_leader);
let _ = reconcile(&cluster, EU_WEST, &ap_region);
let _ = reconcile(&cluster, AP_SOUTH, &new_leader);
assert_feed_parity(
"final reconcile no-op: eu-west unchanged",
&before_leader,
&feed_pairs(&cluster, EU_WEST, "trending", ITEMS as u32),
);
assert_feed_parity(
"final reconcile no-op: us-east unchanged",
&before_us,
&feed_pairs(&cluster, LEADER, "trending", ITEMS as u32),
);
assert_feed_parity(
"final reconcile no-op: ap-south unchanged",
&before_ap,
&feed_pairs(&cluster, AP_SOUTH, "trending", ITEMS as u32),
);
println!("[upgrade] final reconcile is an exact no-op on scores (bug 2 fixed: true fixpoint)");
// ── Final state: exactly-once (no stall, no loss), leader is the promoted node ─
let leader_feed = feed_pairs(&cluster, EU_WEST, "trending", ITEMS as u32); // current leader
let f1 = feed_pairs(&cluster, LEADER, "trending", ITEMS as u32);
let f2 = feed_pairs(&cluster, AP_SOUTH, "trending", ITEMS as u32);
assert_feed_parity("post-upgrade leader(eu-west) vs us-east", &leader_feed, &f1);
assert_feed_parity(
"post-upgrade leader(eu-west) vs ap-south",
&leader_feed,
&f2,
);
println!(
"[upgrade] exactly-once + no stall: {} items rank identically (1e-6) across all three upgraded nodes",
leader_feed.len()
);
// No acknowledged writes lost (the spec's hard guarantee, criterion 7): every
// logical write the writer issued returned 204 exactly once.
assert_eq!(
lost, 0,
"rolling upgrade LOST {lost} acknowledged logical writes (of {total})"
);
assert!(
total > 0,
"writer must have issued writes during the upgrade window"
);
assert!(
retried > 0,
"expected >= 1 retried write across the restart windows; got {retried} (window too quiet?)"
);
// WAL recovery proven for the restarted old leader: an item written BEFORE its
// restart is still served by it AFTER (and now ranks identically to the cluster).
let old_leader_ids: Vec<u64> = f1.iter().map(|(id, _)| *id).collect();
for id in &pre_restart_ids {
assert!(
old_leader_ids.contains(id),
"old leader {old_leader}: pre-restart item {id} lost after restart (WAL recovery): {old_leader_ids:?}"
);
}
// The new leader is the promoted node.
let leader_now = cluster
.local_status(EU_WEST)
.and_then(|st| st["leader"].as_str().map(str::to_string));
assert_eq!(
leader_now.as_deref(),
Some(new_leader.as_str()),
"final leader must be the promoted node {new_leader}"
);
}
/// Per logical write, the maximum number of (node-cycling) attempts before we give
/// up and count it LOST. Generous: four restart windows, each bounded by boot +
/// breaker reset, so a healthy cluster lands a write in a handful of attempts; this
/// cap only fires on a genuine, persistent failure (the exact bug the gate catches).
const MAX_WRITE_ATTEMPTS: u64 = 400;
/// Inter-write pacing for the background writer. A rolling upgrade rides STEADY
/// production traffic, not a saturation storm: the leader's cluster write pool has
/// only `MIN_WRITE_WORKERS = 2` slots, shared by signal writes AND the pool-backed
/// `/cluster/heal` redelivery. Pacing leaves headroom so a heal can land (otherwise
/// the writer starves both worker slots and the recovery path never runs — a
/// test-induced deadlock, not a product behavior). ~50 writes/sec is plenty of
/// continuous traffic to overlap every restart window with live writes.
const WRITE_PACING: Duration = Duration::from_millis(200);
/// The background writer: round-robins a `view` signal across the node base URLs,
/// retrying the SAME logical write (cycling targets) until it 204s. Tolerates the
/// failure modes a restart window produces — connection refused while a process is
/// down, 503 (`NotLeader` / leader-unreachable while forwarding), 429 (write-pool
/// backpressure), 408 (request timeout).
///
/// `stop` is checked only at the TOP of each logical write, so a write the writer has
/// COMMITTED to (incremented `total`) is always driven to completion or to `lost`,
/// never silently abandoned — the test settles a healthy steady-state window before
/// flipping `stop`, so the last committed write lands. A logical write that never
/// 204s within [`MAX_WRITE_ATTEMPTS`] is counted LOST (the zero-loss gate then fails,
/// as it should). Uses its OWN blocking client + the stable node URLs, so it never
/// aliases the &mut harness on the test thread.
fn run_writer(node_urls: &[String], tally: &WriterTally, stop: &AtomicBool, items: u64) {
let client = reqwest::blocking::Client::builder()
.build()
.expect("build writer client");
let mut node = 0usize;
let mut entity = 1u64;
while !stop.load(Ordering::Relaxed) {
tally.total.fetch_add(1, Ordering::Relaxed);
let mut attempts = 0u64;
let landed = loop {
let target = &node_urls[node % node_urls.len()];
let resp = client
.post(format!("{target}/signals"))
.json(&serde_json::json!({ "entity_id": entity, "signal": "view", "weight": 1.0 }))
.timeout(Duration::from_secs(3))
.send();
if let Ok(r) = resp
&& r.status().as_u16() == 204
{
break true;
}
attempts += 1;
if attempts >= MAX_WRITE_ATTEMPTS {
break false; // genuine persistent failure → LOST
}
// Cycle to another node (the target may be the one restarting; a live node
// forwards to the current leader) and retry the SAME logical write.
node += 1;
thread::sleep(Duration::from_millis(25));
};
if landed {
if attempts > 0 {
tally.retried.fetch_add(1, Ordering::Relaxed);
}
} else {
tally.lost.fetch_add(1, Ordering::Relaxed);
}
node += 1;
entity = (entity % items) + 1;
// Steady-traffic pacing: leave write-pool headroom for the pool-backed heal.
thread::sleep(WRITE_PACING);
}
}