1402 lines
59 KiB
Rust
1402 lines
59 KiB
Rust
//! m11p5 §3.4–§3.6 — seed-join elasticity over real OS processes (tier 3, C3).
|
||
//!
|
||
//! Exit-gate 1's full proof plus the clean-decommission drill:
|
||
//!
|
||
//! 1. `mp_seed_join_snapshot_catchup` (EXIT GATE 1) — a 3-node elected cluster
|
||
//! seeds content, the leader's WAL is COMPACTED past seq 1 (graceful leader
|
||
//! restart + re-election), then a FOURTH node `add_node`s via `--seed`. It:
|
||
//! - joins as a **Learner** (the `/cluster/members` roster shows it);
|
||
//! - installs a **snapshot** (its own status converges to full content — the
|
||
//! snapshot carried the compacted history, the stream carried the suffix);
|
||
//! - **auto-promotes** to Voter within budget (zero operator verbs);
|
||
//! - serves **quorum** (an `ack=quorum` write with the joiner as one of the
|
||
//! now-4 voters' quorum advances the commit index);
|
||
//! - has **full CONTENT parity** (every seeded item searchable on it).
|
||
//!
|
||
//! 2. `mp_remove_node_clean_decommission` — from a grown cluster, remove a voter
|
||
//! via the verb → the roster shrinks EVERYWHERE (the `/cluster/members`
|
||
//! endpoint on every live node), the removed node reports removed/not-ready,
|
||
//! quorum still works with the smaller set, and the removed node has NO reseed
|
||
//! marker.
|
||
//!
|
||
//! # Budget
|
||
//!
|
||
//! Tier-3 over real OS processes. Two tests, each boots one fresh cluster (the
|
||
//! binary build amortizes to a `cargo build` no-op) and runs a bounded number of
|
||
//! join/promote/remove + convergence waits. Every wait is poll-with-deadline (no
|
||
//! bare sleeps as correctness gates). Whole-suite wall budget < 4 minutes on a
|
||
//! developer laptop.
|
||
//!
|
||
//! ```bash
|
||
//! cargo test -p tidal-server --features cluster-e2e --test cluster_membership -- --nocapture
|
||
//! ```
|
||
#![cfg(feature = "cluster-e2e")]
|
||
#![allow(
|
||
clippy::unwrap_used,
|
||
clippy::missing_panics_doc,
|
||
clippy::too_many_lines,
|
||
clippy::cast_precision_loss,
|
||
clippy::cast_possible_truncation,
|
||
// The scale test's `a`/`b` join-phase suffixes (`joiner_a`/`joiner_b`,
|
||
// `join_a_start`/`join_b_start`) are intentional and readable parallel names.
|
||
clippy::similar_names
|
||
)]
|
||
|
||
mod support;
|
||
|
||
use std::{
|
||
sync::{
|
||
Arc, Mutex,
|
||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||
},
|
||
thread,
|
||
time::{Duration, Instant},
|
||
};
|
||
|
||
use support::multiproc::{
|
||
BREAKER_RESET, ClusterOptions, MultiProcCluster, convergence_budget, hostname_rewrite,
|
||
};
|
||
|
||
/// Region 0 is the initial topology leader.
|
||
const LEADER: usize = 0;
|
||
|
||
/// The fast election block (the elected era is the real world — exit gate 1
|
||
/// runs against an elected cluster, not the legacy term-0 promote path). The C2
|
||
/// lease bound enforced at topology load: `lease (350) + heartbeat (100) <
|
||
/// timeout_min (500)`.
|
||
const FAST_ELECTION_YAML: &str = "election:\n heartbeat_interval_ms: 100\n \
|
||
election_timeout_min_ms: 500\n election_timeout_max_ms: 1000\n leader_lease_ms: 350";
|
||
|
||
/// A small `learner_promote_lag` so auto-promotion fires quickly once the joiner
|
||
/// catches up, plus a fast `catchup_retry_ms` so an idle cluster's last-event
|
||
/// gap closes quickly (the 30s default would race the 30s convergence budget
|
||
/// after a leader rejoin).
|
||
const PROMOTE_LAG_YAML: &str = "replication:\n learner_promote_lag: 8\n catchup_retry_ms: 2000";
|
||
|
||
/// The DEFAULT items the leader writes before the join (enough to MATTER and —
|
||
/// with the per-item blob — to push the WAL past one 16 MiB segment so the
|
||
/// graceful-shutdown compaction deletes the segments below seq 1, forcing the
|
||
/// joiner onto the snapshot path rather than a full-log replay).
|
||
const DEFAULT_SEEDED_ITEMS: u64 = 320;
|
||
|
||
/// Items the leader writes before the join. Default [`DEFAULT_SEEDED_ITEMS`];
|
||
/// `TIDAL_MEMBERSHIP_SEED_ITEMS` env-scales it for the exit-gate-2 "≤5 min @
|
||
/// 100k items" evidence run (recorded in the phase doc, NOT a CI default). At
|
||
/// any scale the snapshot path stays exercised because the heavy blob batch
|
||
/// always pushes the WAL past one 16 MiB segment.
|
||
fn seeded_items() -> u64 {
|
||
std::env::var("TIDAL_MEMBERSHIP_SEED_ITEMS")
|
||
.ok()
|
||
.and_then(|v| v.trim().parse::<u64>().ok())
|
||
.filter(|&n| n >= 4)
|
||
.unwrap_or(DEFAULT_SEEDED_ITEMS)
|
||
}
|
||
|
||
/// Per-item non-indexed blob keys/bytes (mirrors `cluster_reseed.rs`): 8 keys ×
|
||
/// ~7 KiB ≈ 56 KiB/item × 320 ≈ 18 MiB > the 16 MiB segment ⇒ compaction.
|
||
const BLOB_KEYS: usize = 8;
|
||
const BLOB_VALUE_BYTES: usize = 7 * 1024;
|
||
|
||
fn blob_value() -> String {
|
||
"z".repeat(BLOB_VALUE_BYTES)
|
||
}
|
||
|
||
/// The handshake window for the (rare) install path, low enough to stay in test
|
||
/// budget.
|
||
const RESEED_HANDSHAKE_MS: &str = "30000";
|
||
|
||
/// A unique all-alpha search token for an entity id (digits → letters), so
|
||
/// `/search?query=<token>` is an exact item-presence probe.
|
||
fn item_token(entity_id: u64) -> String {
|
||
let mut token = String::from("mem");
|
||
for d in entity_id.to_string().bytes() {
|
||
token.push(char::from(b'a' + (d - b'0')));
|
||
}
|
||
token
|
||
}
|
||
|
||
/// Write one item (small searchable `title` token + large non-indexed `blobN`
|
||
/// values that inflate the WAL within the engine's metadata caps) plus its 4-dim
|
||
/// embedding and a `view` signal to the node at `idx`. `heavy` toggles the
|
||
/// WAL-inflating blobs.
|
||
fn write_heavy_item(
|
||
cluster: &MultiProcCluster,
|
||
idx: usize,
|
||
entity_id: u64,
|
||
blob: &str,
|
||
heavy: bool,
|
||
) {
|
||
let mut metadata = serde_json::Map::new();
|
||
metadata.insert("title".into(), item_token(entity_id).into());
|
||
if heavy {
|
||
for k in 0..BLOB_KEYS {
|
||
metadata.insert(format!("blob{k}"), blob.into());
|
||
}
|
||
}
|
||
let resp = cluster.post(
|
||
idx,
|
||
"/items",
|
||
&serde_json::json!({ "entity_id": entity_id, "metadata": metadata }),
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 201, "leader /items must 201");
|
||
|
||
let v = entity_id as f32;
|
||
let resp = cluster.post(
|
||
idx,
|
||
"/embeddings",
|
||
&serde_json::json!({ "entity_id": entity_id, "values": [v, v + 1.0, v + 2.0, v + 3.0] }),
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 204, "leader /embeddings must 204");
|
||
|
||
let resp = cluster.post(
|
||
idx,
|
||
"/signals",
|
||
&serde_json::json!({ "entity_id": entity_id, "signal": "view", "weight": 1.0 }),
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 204, "leader /signals must 204");
|
||
}
|
||
|
||
/// Whether `/search?query=<token>` on node `idx` returns `entity_id`.
|
||
fn item_searchable(cluster: &MultiProcCluster, idx: usize, entity_id: u64) -> bool {
|
||
let token = item_token(entity_id);
|
||
let found = cluster.get_json(idx, &format!("/search?query={token}&limit=5"));
|
||
found["items"]
|
||
.as_array()
|
||
.is_some_and(|r| r.iter().any(|x| x["entity_id"].as_u64() == Some(entity_id)))
|
||
}
|
||
|
||
/// POST with the `x-tidal-ack` header. `Some(seq)` only for a 2xx carrying
|
||
/// `x-tidal-seq` (the ledger's "acknowledged").
|
||
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()
|
||
}
|
||
|
||
/// The `membership_role` field of node `idx`'s `/cluster/status/local`
|
||
/// (`voter` / `learner` / `removed` / absent).
|
||
fn membership_role(cluster: &MultiProcCluster, idx: usize) -> Option<String> {
|
||
cluster
|
||
.local_status(idx)
|
||
.and_then(|s| s["membership_role"].as_str().map(str::to_string))
|
||
}
|
||
|
||
/// The roster a node reports at `/cluster/members`: `(name → role)`.
|
||
fn roster_roles(
|
||
cluster: &MultiProcCluster,
|
||
idx: usize,
|
||
) -> std::collections::HashMap<String, String> {
|
||
let body = cluster.get_json(idx, "/cluster/members");
|
||
let mut out = std::collections::HashMap::new();
|
||
if let Some(members) = body["members"].as_array() {
|
||
for m in members {
|
||
if let (Some(name), Some(role)) = (m["name"].as_str(), m["role"].as_str()) {
|
||
out.insert(name.to_string(), role.to_string());
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Poll until node `idx`'s `membership_role` equals `expected`, or panic.
|
||
fn await_membership_role(
|
||
cluster: &MultiProcCluster,
|
||
idx: usize,
|
||
expected: &str,
|
||
budget: Duration,
|
||
what: &str,
|
||
) {
|
||
let deadline = Instant::now() + budget;
|
||
loop {
|
||
if membership_role(cluster, idx).as_deref() == Some(expected) {
|
||
return;
|
||
}
|
||
assert!(
|
||
Instant::now() < deadline,
|
||
"{what}: node {idx} did not reach membership_role={expected} within {budget:?}; \
|
||
last status: {:?}",
|
||
cluster.local_status(idx)
|
||
);
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
}
|
||
}
|
||
|
||
/// Poll until node `idx`'s OWN status reports zero lag and an applied frontier at
|
||
/// or above the live leader's `last_seq`.
|
||
fn await_self_converged(cluster: &MultiProcCluster, idx: usize, budget: Duration, what: &str) {
|
||
let deadline = Instant::now() + budget;
|
||
loop {
|
||
let leader_seq = cluster.leader_last_seq();
|
||
if let (Some(target), Some(st)) = (leader_seq, cluster.local_status(idx)) {
|
||
let applied = st["applied_events"].as_u64().unwrap_or(0);
|
||
let lag = st["lag_events"].as_u64().unwrap_or(u64::MAX);
|
||
if lag == 0 && applied >= target {
|
||
return;
|
||
}
|
||
}
|
||
assert!(
|
||
Instant::now() < deadline,
|
||
"{what}: node {idx} did not converge within {budget:?}; status={:?}",
|
||
cluster.local_status(idx)
|
||
);
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
}
|
||
}
|
||
|
||
/// Poll the live candidates for an elected leader above `after_term`. Returns
|
||
/// `(idx, term)`.
|
||
fn await_elected_leader(
|
||
cluster: &MultiProcCluster,
|
||
candidates: &[usize],
|
||
after_term: u64,
|
||
budget: Duration,
|
||
) -> (usize, u64) {
|
||
let deadline = Instant::now() + budget;
|
||
loop {
|
||
for &idx in candidates {
|
||
if let Some(status) = cluster.local_status(idx) {
|
||
let term = status["term"].as_u64().unwrap_or(0);
|
||
if status["role"].as_str() == Some("leader") && term > after_term {
|
||
return (idx, term);
|
||
}
|
||
}
|
||
}
|
||
assert!(
|
||
Instant::now() < deadline,
|
||
"no leader elected among {candidates:?} above term {after_term} within {budget:?}"
|
||
);
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
/// The index of the current live leader (by `is_leader`).
|
||
fn current_leader_idx(cluster: &MultiProcCluster) -> Option<usize> {
|
||
(0..cluster.len()).find(|&i| {
|
||
cluster
|
||
.local_status(i)
|
||
.is_some_and(|s| s["is_leader"].as_bool() == Some(true))
|
||
})
|
||
}
|
||
|
||
/// EXIT GATE 1: a fresh node seed-joins a cluster whose leader's WAL has rotated
|
||
/// AND compacted past seq 1; it converges via snapshot + stream, auto-promotes to
|
||
/// Voter, serves quorum, and has full content parity — ZERO operator verbs
|
||
/// beyond `add_node`.
|
||
#[test]
|
||
fn mp_seed_join_snapshot_catchup() {
|
||
// An elected (FAST_ELECTION) cluster with a tight learner_promote_lag so the
|
||
// auto-promotion fires quickly once the joiner catches up.
|
||
let extra = format!("{FAST_ELECTION_YAML}\n{PROMOTE_LAG_YAML}");
|
||
let opts = ClusterOptions::new(3).with_topology_extra(&extra);
|
||
let mut cluster = MultiProcCluster::start_with(opts);
|
||
|
||
// ── Seed content. Every item carries an `item_token`-derived `title` so the
|
||
// content-parity probes (`/search?query=<token>`) match — the baseline items
|
||
// 1..=3 are written WITH the same token scheme (a light write), then the
|
||
// heavy WAL-inflating batch 4..=seeded pushes the WAL past one segment.
|
||
let seeded = seeded_items();
|
||
let blob = blob_value();
|
||
let seed_start = Instant::now();
|
||
for entity in 1..=3u64 {
|
||
write_heavy_item(&cluster, LEADER, entity, "", false);
|
||
}
|
||
for entity in 4..=seeded {
|
||
write_heavy_item(&cluster, LEADER, entity, &blob, true);
|
||
}
|
||
// At a large env-scaled item count the seed write + replication can exceed the
|
||
// default 30s convergence budget; scale the seed-converge wait with the count
|
||
// (the gate's catch-up measurement starts AFTER this and is reported on its own).
|
||
let seed_converge_budget = convergence_budget() + Duration::from_secs(seeded / 200);
|
||
cluster.wait_converged_all(seed_converge_budget);
|
||
println!(
|
||
"[seed-join] seeded {seeded} items in {:?}; the 3-node cluster converged",
|
||
seed_start.elapsed()
|
||
);
|
||
|
||
// ── Force WAL COMPACTION past seq 1 on the leader. A GRACEFUL RESTART writes
|
||
// the WAL checkpoint marker and compacts the segments below the leader's
|
||
// frontier. The restarted leader boots a FOLLOWER (durable §1.4-1 rule); the
|
||
// survivors re-elect (auto-election). ALL THREE original nodes stay UP — the
|
||
// join's §3.1 capability gate requires every CURRENT voter to have reported
|
||
// kind-4 capability, and a DOWN voter never reports (a new leader cannot
|
||
// collect a stopped peer's capability), which would refuse the join.
|
||
cluster.restart_graceful(LEADER, &[]);
|
||
// The cluster re-elects after the old leader's restart (it booted a follower).
|
||
let leader_idx = current_leader_idx(&cluster).unwrap_or_else(|| {
|
||
await_elected_leader(&cluster, &[0, 1, 2], 0, Duration::from_secs(15)).0
|
||
});
|
||
println!(
|
||
"[seed-join] leader gracefully restarted (WAL compacted); current leader is node {leader_idx}"
|
||
);
|
||
// All three converge (the fast catchup_retry closes the idle last-event gap).
|
||
cluster.wait_converged_all(convergence_budget());
|
||
let leader_idx = current_leader_idx(&cluster).expect("a leader after re-election");
|
||
|
||
// ── ADD a fresh node via --seed (the only operator verb). It seed-joins as a
|
||
// Learner against the current leader, installs a snapshot when the leader's
|
||
// WAL no longer covers seq 1, and converges via the stream. `add_node` waits
|
||
// for /health/startup, which only answers once the node finished joining +
|
||
// installing + constructing AND first-converged (§4 sticky readiness) — so the
|
||
// wall time spans the WHOLE join→snapshot→catch-up→converged path. This is the
|
||
// exit-gate-2 "≤5 min @ 100k items" measurement (localhost loopback, env-scaled
|
||
// via TIDAL_MEMBERSHIP_SEED_ITEMS + TIDAL_TEST_*_BUDGET_SECS — recorded, not
|
||
// hard-asserted, to keep CI non-flaky).
|
||
let join_start = Instant::now();
|
||
let joiner = cluster.add_node(leader_idx);
|
||
let join_converged = join_start.elapsed();
|
||
println!(
|
||
"[seed-join] EVIDENCE: items={seeded} join->converged(first-ready)={join_converged:?} \
|
||
joiner_data_dir={} bytes",
|
||
cluster.data_dir_bytes(joiner)
|
||
);
|
||
println!("[seed-join] seed-join node {joiner} is healthy (first-converged)");
|
||
|
||
// ── Joined as a LEARNER (the members endpoint, before auto-promotion). The
|
||
// join → learner → (auto)voter sequence means we may already see voter if the
|
||
// promote raced; assert it was a learner OR is now a voter (never absent).
|
||
let role_now = membership_role(&cluster, joiner);
|
||
assert!(
|
||
matches!(role_now.as_deref(), Some("learner" | "voter")),
|
||
"the joiner must be a learner or an auto-promoted voter, got {role_now:?}"
|
||
);
|
||
// The roster on the LEADER shows the joiner present (learner or voter).
|
||
let roster = roster_roles(&cluster, leader_idx);
|
||
let joiner_name = cluster.region_name(joiner).to_string();
|
||
assert!(
|
||
roster.contains_key(&joiner_name),
|
||
"the leader roster must include the seed-joined node: {roster:?}"
|
||
);
|
||
println!("[seed-join] roster includes the joiner: {roster:?}");
|
||
|
||
// ── AUTO-PROMOTION: the joiner becomes a Voter within budget, zero verbs.
|
||
await_membership_role(
|
||
&cluster,
|
||
joiner,
|
||
"voter",
|
||
convergence_budget() + Duration::from_secs(20),
|
||
"the joiner auto-promotes to Voter",
|
||
);
|
||
println!("[seed-join] joiner auto-promoted to Voter");
|
||
// The whole cluster (now 4 nodes) converges, and the roster on EVERY live
|
||
// node lists the joiner as a voter.
|
||
cluster.wait_converged_all(convergence_budget());
|
||
for i in 0..cluster.len() {
|
||
if let Some(rr) = cluster.local_status(i).map(|_| roster_roles(&cluster, i)) {
|
||
assert_eq!(
|
||
rr.get(&joiner_name).map(String::as_str),
|
||
Some("voter"),
|
||
"node {i}'s roster must show the joiner as a voter: {rr:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── SERVES QUORUM: with the joiner now a voter, the voter set is 4. An
|
||
// ack=quorum write must still commit (3-of-4), and the joiner's reports
|
||
// advance the commit index. We prove the commit ADVANCES by reading the
|
||
// leader's commit_index before/after a quorum write.
|
||
let client = reqwest::blocking::Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
let leader_idx = current_leader_idx(&cluster).expect("a leader for the quorum write");
|
||
let commit_before = cluster
|
||
.local_status(leader_idx)
|
||
.and_then(|s| s["commit_index"].as_u64())
|
||
.unwrap_or(0);
|
||
let seq = post_acked(
|
||
&client,
|
||
&cluster.node(leader_idx),
|
||
"/items",
|
||
"quorum",
|
||
&serde_json::json!({ "entity_id": 900_001, "metadata": { "title": item_token(900_001) } }),
|
||
)
|
||
.expect("a quorum write must commit with the joiner as one of the 4 voters");
|
||
println!("[seed-join] ack=quorum write committed at seq {seq} (4-voter quorum)");
|
||
// The commit index advanced past the prior value (the quorum write committed).
|
||
let commit_deadline = Instant::now() + Duration::from_secs(10);
|
||
loop {
|
||
let commit_now = cluster
|
||
.local_status(leader_idx)
|
||
.and_then(|s| s["commit_index"].as_u64())
|
||
.unwrap_or(0);
|
||
if commit_now > commit_before {
|
||
println!("[seed-join] commit index advanced {commit_before} -> {commit_now}");
|
||
break;
|
||
}
|
||
assert!(
|
||
Instant::now() < commit_deadline,
|
||
"the commit index did not advance after the quorum write (commit_before={commit_before})"
|
||
);
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
}
|
||
|
||
// ── FULL CONTENT PARITY: every seeded item is searchable on the joiner (the
|
||
// snapshot carried the compacted history; the stream carried the suffix).
|
||
// Poll past the ~2s text-index auto-commit.
|
||
await_self_converged(
|
||
&cluster,
|
||
joiner,
|
||
convergence_budget() + Duration::from_secs(seeded / 200),
|
||
"the joiner converges to the full content",
|
||
);
|
||
// A small set of probes that stays in-bounds at any env-scaled item count
|
||
// (the heads, the tails, and a few interior samples). Each is <= `seeded`.
|
||
let probes: Vec<u64> = {
|
||
let mut p = vec![1u64, 4, seeded / 4, seeded / 2, (seeded * 3) / 4, seeded];
|
||
p.retain(|&e| (1..=seeded).contains(&e));
|
||
p.sort_unstable();
|
||
p.dedup();
|
||
p
|
||
};
|
||
let probe_deadline = Instant::now() + Duration::from_secs(20);
|
||
for entity in probes {
|
||
loop {
|
||
if item_searchable(&cluster, joiner, entity) {
|
||
break;
|
||
}
|
||
assert!(
|
||
Instant::now() < probe_deadline,
|
||
"the seed-joined node is missing item {entity} (snapshot+stream must deliver \
|
||
every seeded item)"
|
||
);
|
||
std::thread::sleep(Duration::from_millis(200));
|
||
}
|
||
}
|
||
println!("[seed-join] every probed seeded item is searchable on the joiner — exit gate 1 met");
|
||
}
|
||
|
||
/// A voter removed via the verb decommissions cleanly: the roster shrinks
|
||
/// everywhere, the removed node reports removed/not-ready, quorum follows the
|
||
/// smaller set, and the removed node has NO reseed marker.
|
||
#[test]
|
||
fn mp_remove_node_clean_decommission() {
|
||
let opts = ClusterOptions::new(3)
|
||
.with_topology_extra(FAST_ELECTION_YAML)
|
||
.with_env(0, "TIDAL_RESEED_HANDSHAKE_MS", RESEED_HANDSHAKE_MS);
|
||
let cluster = MultiProcCluster::start_with(opts);
|
||
|
||
// A committed prefix every node shares (quorum-acked while links are intact).
|
||
let client = reqwest::blocking::Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
for entity in 1..=5u64 {
|
||
post_acked(
|
||
&client,
|
||
&cluster.node(LEADER),
|
||
"/items",
|
||
"quorum",
|
||
&serde_json::json!({ "entity_id": entity, "metadata": { "title": item_token(entity) } }),
|
||
)
|
||
.expect("committed-prefix quorum write");
|
||
}
|
||
cluster.wait_converged_all(convergence_budget());
|
||
|
||
// Begin the membership era so the conf-change has a kind-4 record to extend.
|
||
// The remove verb itself begins the era (era 0 → first conf-change), but it
|
||
// requires every voter to report kind-4 capability (every node here is p5).
|
||
let leader_idx = current_leader_idx(&cluster).expect("a leader");
|
||
// Pick a FOLLOWER voter to remove (never the leader — removing the leader is
|
||
// a different drill; the clean-decommission proof targets a non-leader voter).
|
||
let victim_idx = (0..cluster.len())
|
||
.find(|&i| i != leader_idx && cluster.is_alive(i))
|
||
.expect("a follower voter to remove");
|
||
let victim = cluster.region_name(victim_idx).to_string();
|
||
println!(
|
||
"[remove] leader is node {leader_idx}; removing follower voter '{victim}' (node {victim_idx})"
|
||
);
|
||
|
||
// ── REMOVE via the verb (forwards to the leader). 200 = the Removed record
|
||
// committed.
|
||
let status = cluster.remove_node(&victim);
|
||
assert_eq!(
|
||
status, 200,
|
||
"the remove verb must 200 (Removed record committed)"
|
||
);
|
||
println!("[remove] remove verb accepted; Removed record committed");
|
||
|
||
// ── The roster SHRINKS EVERYWHERE: the removed name is a `removed` tombstone
|
||
// (still in the roster, role removed) on every LIVE node. The LIVE voters are
|
||
// the survivors. Poll until every live node's roster shows the victim removed.
|
||
let deadline = Instant::now() + convergence_budget();
|
||
loop {
|
||
let mut pending: Vec<String> = Vec::new();
|
||
for i in 0..cluster.len() {
|
||
if !cluster.is_alive(i) {
|
||
continue;
|
||
}
|
||
// The removed node may have flipped to 503 on /health but still serves
|
||
// /cluster/members; tolerate it being unreachable mid-transition.
|
||
if let Some(role) = roster_roles(&cluster, i).get(&victim) {
|
||
if role != "removed" {
|
||
pending.push(format!("node {i}: victim role={role}"));
|
||
}
|
||
} else {
|
||
pending.push(format!("node {i}: victim absent from roster"));
|
||
}
|
||
}
|
||
if pending.is_empty() {
|
||
break;
|
||
}
|
||
assert!(
|
||
Instant::now() < deadline,
|
||
"the Removed record did not reach every live node within budget; pending: {pending:?}"
|
||
);
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
}
|
||
println!("[remove] every live node's roster shows '{victim}' as a removed tombstone");
|
||
|
||
// ── The REMOVED node reports removed/not-ready: its membership_role is
|
||
// `removed` and /health/startup is 503.
|
||
await_membership_role(
|
||
&cluster,
|
||
victim_idx,
|
||
"removed",
|
||
convergence_budget(),
|
||
"the removed node learns it is removed (via the stream)",
|
||
);
|
||
// `/health` is the §4 READINESS predicate (the k8s readiness probe);
|
||
// `/health/startup` is an unconditional "process up" 200, so the
|
||
// decommission 503 lives on `/health`.
|
||
let health = cluster.get(victim_idx, "/health");
|
||
assert_eq!(
|
||
health.status().as_u16(),
|
||
503,
|
||
"the removed node must report /health 503 (decommissioned, not ready): {:?}",
|
||
cluster.local_status(victim_idx)
|
||
);
|
||
// It has NO reseed marker — a remove is not a reseed (the typed `removed`
|
||
// signal is exempt from the reseed marker, §3.3).
|
||
assert_eq!(
|
||
cluster
|
||
.local_status(victim_idx)
|
||
.and_then(|s| s["reseed_required"].as_bool()),
|
||
Some(false),
|
||
"a removed node must NOT latch a reseed marker (remove is exempt, §3.3)"
|
||
);
|
||
println!("[remove] the removed node reports removed + 503 + no reseed marker");
|
||
|
||
// ── QUORUM FOLLOWS THE SMALLER SET: with the victim removed, the voter set is
|
||
// 2 (the leader + one survivor). An ack=quorum write must still commit
|
||
// (2-of-2 = majority of 2). Issue it on the current leader.
|
||
let leader_idx = current_leader_idx(&cluster).expect("a leader after the remove");
|
||
let seq = post_acked(
|
||
&client,
|
||
&cluster.node(leader_idx),
|
||
"/items",
|
||
"quorum",
|
||
&serde_json::json!({ "entity_id": 800_001, "metadata": { "title": item_token(800_001) } }),
|
||
)
|
||
.expect("ack=quorum must still commit with the smaller (2-voter) set");
|
||
println!("[remove] ack=quorum still commits with the 2-voter set (seq {seq})");
|
||
|
||
// The surviving FOLLOWER voter converges the new write (the leader writes
|
||
// directly — `await_self_converged` is a follower check; a leader's
|
||
// applied-against-itself is 0 by design, so skip it).
|
||
let leader_after = current_leader_idx(&cluster).expect("a leader after the remove");
|
||
for i in 0..cluster.len() {
|
||
if i == victim_idx || i == leader_after || !cluster.is_alive(i) {
|
||
continue;
|
||
}
|
||
await_self_converged(
|
||
&cluster,
|
||
i,
|
||
convergence_budget(),
|
||
"a surviving follower voter converges the post-remove write",
|
||
);
|
||
}
|
||
println!(
|
||
"[remove] surviving voters converged the post-remove quorum write — clean decommission"
|
||
);
|
||
}
|
||
|
||
/// The MISSED-RECORD decommission path (m11p5 §3.3): a voter that is DOWN during
|
||
/// its own removal never folds the `Removed` record (its cell still shows it as a
|
||
/// member). On restart it learns of its removal out-of-band — a voter's
|
||
/// heartbeat/vote refusal carries the typed `removed` signal — and flips to
|
||
/// readiness 503 WITHOUT latching a reseed marker (a remove is not a reseed). The
|
||
/// leader's removal-delivery grace expires (the peer was unreachable) and retires
|
||
/// the ship cell on the bounded give-up, never racing a delivery that cannot land.
|
||
#[test]
|
||
fn mp_remove_missed_record_learns_via_signal() {
|
||
let opts = ClusterOptions::new(3)
|
||
.with_topology_extra(FAST_ELECTION_YAML)
|
||
// A SHORT removal-delivery grace: the victim is down during the remove,
|
||
// so the leader's grace cannot be satisfied by delivery — it must give up
|
||
// and retire the ship cell on the deadline. Keep it well under budget.
|
||
.with_env(0, "TIDAL_REMOVE_DELIVERY_GRACE_MS", "3000")
|
||
.with_env(1, "TIDAL_REMOVE_DELIVERY_GRACE_MS", "3000")
|
||
.with_env(2, "TIDAL_REMOVE_DELIVERY_GRACE_MS", "3000");
|
||
let mut cluster = MultiProcCluster::start_with(opts);
|
||
|
||
let client = reqwest::blocking::Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
// A committed prefix every node shares (begins the membership era via the
|
||
// first capability-clean conf-change later; every node is p5-capable).
|
||
for entity in 1..=5u64 {
|
||
post_acked(
|
||
&client,
|
||
&cluster.node(LEADER),
|
||
"/items",
|
||
"quorum",
|
||
&serde_json::json!({ "entity_id": entity, "metadata": { "title": item_token(entity) } }),
|
||
)
|
||
.expect("committed-prefix quorum write");
|
||
}
|
||
cluster.wait_converged_all(convergence_budget());
|
||
|
||
let leader_idx = current_leader_idx(&cluster).expect("a leader");
|
||
// A FOLLOWER voter (never the leader): this is the node we take down THEN
|
||
// remove, so it misses the Removed record entirely.
|
||
let victim_idx = (0..cluster.len())
|
||
.find(|&i| i != leader_idx && cluster.is_alive(i))
|
||
.expect("a follower voter");
|
||
let victim = cluster.region_name(victim_idx).to_string();
|
||
println!(
|
||
"[missed] leader is node {leader_idx}; victim '{victim}' (node {victim_idx}) goes DOWN \
|
||
BEFORE the remove so it misses the Removed record"
|
||
);
|
||
|
||
// ── Take the victim DOWN, then remove it. With 1 of 3 voters down, the
|
||
// remaining 2 are still a majority, so the Removed record commits. The victim
|
||
// never folds it (it is down) — the MISSED-RECORD case.
|
||
cluster.stop_graceful(victim_idx);
|
||
assert!(!cluster.is_alive(victim_idx), "victim is down");
|
||
|
||
let status = cluster.remove_node(&victim);
|
||
assert_eq!(
|
||
status, 200,
|
||
"the remove must 200 even with the victim down (2-of-3 voters still a majority)"
|
||
);
|
||
println!("[missed] Removed record committed while the victim was down");
|
||
|
||
// The LIVE nodes' rosters show the victim as a removed tombstone (delivered
|
||
// through the log to the survivors).
|
||
let deadline = Instant::now() + convergence_budget();
|
||
loop {
|
||
let all_see = (0..cluster.len())
|
||
.filter(|&i| i != victim_idx && cluster.is_alive(i))
|
||
.all(|i| roster_roles(&cluster, i).get(&victim).map(String::as_str) == Some("removed"));
|
||
if all_see {
|
||
break;
|
||
}
|
||
assert!(
|
||
Instant::now() < deadline,
|
||
"the Removed record did not reach the live survivors within budget"
|
||
);
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
}
|
||
println!("[missed] live survivors' rosters show '{victim}' removed");
|
||
|
||
// ── RESTART the victim on its SAME data dir. Its WAL has NO Removed record,
|
||
// so its local cell still shows it as a member (membership_role != removed).
|
||
// It MUST learn of its removal via the typed signal: it campaigns on its
|
||
// election timeout, a surviving voter's vote refusal carries removed=true,
|
||
// and the victim flips readiness 503 — WITHOUT a reseed marker.
|
||
cluster.restart(victim_idx, &[]);
|
||
println!("[missed] victim restarted on its same data dir (no Removed record in its WAL)");
|
||
|
||
// /health/startup is 200 (process up); /health is the readiness predicate.
|
||
// Poll /health until the typed removed signal flips it to 503.
|
||
let deadline = Instant::now() + convergence_budget();
|
||
loop {
|
||
let health = cluster.get(victim_idx, "/health");
|
||
if health.status().as_u16() == 503 {
|
||
break;
|
||
}
|
||
assert!(
|
||
Instant::now() < deadline,
|
||
"the restarted victim did not learn it is removed (typed signal → 503) within budget; \
|
||
status={:?}",
|
||
cluster.local_status(victim_idx)
|
||
);
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
}
|
||
println!(
|
||
"[missed] the restarted victim learned it is removed via the typed signal → /health 503"
|
||
);
|
||
|
||
// It has NO reseed marker (a remove is not a reseed — the typed signal is
|
||
// exempt, §3.3).
|
||
assert_eq!(
|
||
cluster
|
||
.local_status(victim_idx)
|
||
.and_then(|s| s["reseed_required"].as_bool()),
|
||
Some(false),
|
||
"a signal-decommissioned node must NOT latch a reseed marker (§3.3)"
|
||
);
|
||
println!("[missed] the signal-decommissioned victim has NO reseed marker — exact close-out");
|
||
|
||
// ── The surviving 2-voter cluster still commits quorum writes (the victim's
|
||
// ship cell was retired on the leader's grace give-up, so it is not pinning
|
||
// the quorum).
|
||
let leader_idx = current_leader_idx(&cluster).expect("a leader after the remove");
|
||
let seq = post_acked(
|
||
&client,
|
||
&cluster.node(leader_idx),
|
||
"/items",
|
||
"quorum",
|
||
&serde_json::json!({ "entity_id": 900_001, "metadata": { "title": item_token(900_001) } }),
|
||
)
|
||
.expect("ack=quorum commits with the surviving 2-voter set");
|
||
println!("[missed] ack=quorum still commits with the surviving 2-voter set (seq {seq})");
|
||
}
|
||
|
||
// ── EXIT GATE 2: scale 3→5→3 online under load, zero acked loss ───────────────
|
||
|
||
/// The server's own write-pool retry-after (`WRITE_BACKPRESSURE_RETRY_AFTER_MS`).
|
||
/// A 429 carries no `Retry-After` header (the body names the backpressure), so a
|
||
/// gate writer HONORS it by sleeping this long before retrying the SAME logical
|
||
/// write — neither lost nor an SLO violation (§2 quiesce honesty: a retried-then-
|
||
/// acked write under a `create_backup` 429 window is a clean ack).
|
||
const BACKPRESSURE_BACKOFF: Duration = Duration::from_millis(50);
|
||
|
||
/// Per logical write, the maximum (gateway-cycling) attempts before we declare it
|
||
/// LOST. Generous: a join/promote/remove transition is bounded by a few election
|
||
/// and ship rounds, so a healthy cluster lands a quorum write in a handful of
|
||
/// tries; this cap fires only on a genuine, persistent failure — the exact bug
|
||
/// the gate catches.
|
||
const MAX_QUORUM_ATTEMPTS: u64 = 600;
|
||
|
||
/// Inter-write pacing for the background quorum writer. Leaves the leader's
|
||
/// 2-slot cluster write pool headroom for replication ships and the per-join
|
||
/// snapshot/stream traffic (a saturating writer starves the pool and induces a
|
||
/// test-only deadlock — the `cluster_lifecycle` lesson). ~25 writes/sec across
|
||
/// two gateways is ample continuous load to overlap every scale transition.
|
||
const QUORUM_PACING: Duration = Duration::from_millis(40);
|
||
|
||
/// The background `ack=quorum` writer's shared tally + ledger state. `lost == 0`
|
||
/// is the gate. `max_acked_seq` feeds the frontier invariant; `acked_entities`
|
||
/// feeds the content invariant; `samples` feed the before/during/after p99.
|
||
struct QuorumWriter {
|
||
total: AtomicU64,
|
||
retried: AtomicU64,
|
||
lost: AtomicU64,
|
||
/// The highest `x-tidal-seq` any acked write observed (INVARIANT A input).
|
||
max_acked_seq: AtomicU64,
|
||
/// Every acked entity id (INVARIANT B input) — sampled by the content probes.
|
||
acked_entities: Mutex<Vec<u64>>,
|
||
/// `(observed_at, latency)` per acked write, for the windowed p99 evidence.
|
||
samples: Mutex<Vec<(Instant, Duration)>>,
|
||
}
|
||
|
||
impl QuorumWriter {
|
||
const fn new() -> Self {
|
||
Self {
|
||
total: AtomicU64::new(0),
|
||
retried: AtomicU64::new(0),
|
||
lost: AtomicU64::new(0),
|
||
max_acked_seq: AtomicU64::new(0),
|
||
acked_entities: Mutex::new(Vec::new()),
|
||
samples: Mutex::new(Vec::new()),
|
||
}
|
||
}
|
||
|
||
fn note_seq(&self, seq: u64) {
|
||
// A monotone max via a CAS loop: the writer threads race, and we want the
|
||
// true maximum acked seq across both gateways (INVARIANT A's left side).
|
||
let mut cur = self.max_acked_seq.load(Ordering::Relaxed);
|
||
while seq > cur {
|
||
match self.max_acked_seq.compare_exchange_weak(
|
||
cur,
|
||
seq,
|
||
Ordering::Relaxed,
|
||
Ordering::Relaxed,
|
||
) {
|
||
Ok(_) => break,
|
||
Err(observed) => cur = observed,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// p99 of a duration sample set (rounded-up index), or zero if empty. Integer
|
||
/// ceil of `n * 99 / 100` avoids a float cast (and its sign-loss lint).
|
||
fn p99_of(samples: &mut [Duration]) -> Duration {
|
||
if samples.is_empty() {
|
||
return Duration::ZERO;
|
||
}
|
||
samples.sort_unstable();
|
||
let n = samples.len();
|
||
// ceil(n * 99 / 100), then to a 0-based index in [0, n).
|
||
let rank = (n * 99).div_ceil(100);
|
||
samples[rank.saturating_sub(1).min(n - 1)]
|
||
}
|
||
|
||
/// p99 of the latency samples observed within `[start, end)`.
|
||
fn windowed_p99(writer: &QuorumWriter, start: Instant, end: Instant) -> (Duration, usize) {
|
||
let mut window: Vec<Duration> = writer
|
||
.samples
|
||
.lock()
|
||
.unwrap()
|
||
.iter()
|
||
.filter(|(at, _)| *at >= start && *at < end)
|
||
.map(|(_, d)| *d)
|
||
.collect();
|
||
let n = window.len();
|
||
(p99_of(&mut window), n)
|
||
}
|
||
|
||
/// The background quorum writer: round-robins `ack=quorum` `/items` writes across
|
||
/// the two fixed gateway base URLs, retrying the SAME logical write (cycling
|
||
/// gateways, honoring 429 backoff) until it acks (2xx + `x-tidal-seq`). Each item
|
||
/// carries a unique `item_token` title so the content invariant can search for it.
|
||
/// `stop` is checked only at the TOP of each logical write, so a committed write
|
||
/// is always driven to ack or to `lost` — never silently abandoned.
|
||
fn run_quorum_writer(
|
||
gateways: &[String],
|
||
writer: &QuorumWriter,
|
||
stop: &AtomicBool,
|
||
entity_base: u64,
|
||
) {
|
||
let client = reqwest::blocking::Client::builder()
|
||
.timeout(Duration::from_secs(3))
|
||
.build()
|
||
.expect("build quorum-writer client");
|
||
let mut g = 0usize;
|
||
let mut entity = entity_base;
|
||
while !stop.load(Ordering::Relaxed) {
|
||
writer.total.fetch_add(1, Ordering::Relaxed);
|
||
let started = Instant::now();
|
||
let mut attempts = 0u64;
|
||
let acked_seq = loop {
|
||
let target = &gateways[g % gateways.len()];
|
||
let body = serde_json::json!({
|
||
"entity_id": entity,
|
||
"metadata": { "title": item_token(entity) }
|
||
});
|
||
let resp = client
|
||
.post(format!("{target}/items"))
|
||
.header("x-tidal-ack", "quorum")
|
||
.json(&body)
|
||
.send();
|
||
match resp {
|
||
Ok(r) if r.status().is_success() => {
|
||
if let Some(seq) = r
|
||
.headers()
|
||
.get("x-tidal-seq")
|
||
.and_then(|v| v.to_str().ok())
|
||
.and_then(|s| s.parse::<u64>().ok())
|
||
{
|
||
break Some(seq);
|
||
}
|
||
// 2xx without a seq header is NOT an ack (the ledger contract);
|
||
// retry the same logical write rather than count a phantom ack.
|
||
g += 1;
|
||
}
|
||
Ok(r) if r.status().as_u16() == 429 => {
|
||
// Honor the backpressure window (a `create_backup`/pool 429),
|
||
// then retry the same write on the same gateway.
|
||
thread::sleep(BACKPRESSURE_BACKOFF);
|
||
}
|
||
_ => {
|
||
// 503 (NotLeader/forwarding mid-transition), 408, or a
|
||
// connection error while a process restarts → cycle gateways.
|
||
g += 1;
|
||
}
|
||
}
|
||
attempts += 1;
|
||
if attempts >= MAX_QUORUM_ATTEMPTS {
|
||
break None;
|
||
}
|
||
thread::sleep(Duration::from_millis(15));
|
||
};
|
||
match acked_seq {
|
||
Some(seq) => {
|
||
if attempts > 0 {
|
||
writer.retried.fetch_add(1, Ordering::Relaxed);
|
||
}
|
||
writer.note_seq(seq);
|
||
writer
|
||
.samples
|
||
.lock()
|
||
.unwrap()
|
||
.push((Instant::now(), started.elapsed()));
|
||
writer.acked_entities.lock().unwrap().push(entity);
|
||
}
|
||
None => {
|
||
writer.lost.fetch_add(1, Ordering::Relaxed);
|
||
}
|
||
}
|
||
g += 1;
|
||
entity += 1;
|
||
thread::sleep(QUORUM_PACING);
|
||
}
|
||
}
|
||
|
||
/// INVARIANT A (frontier): the highest acked `x-tidal-seq` must be `<=` the
|
||
/// current leader's durable applied frontier (`applied_events`). Poll briefly —
|
||
/// the leader's own status row lags its acks by a tick. m11p3 ledger checker,
|
||
/// reused verbatim against whichever node leads at the transition.
|
||
fn assert_frontier_invariant(cluster: &MultiProcCluster, max_acked_seq: u64, what: &str) {
|
||
if max_acked_seq == 0 {
|
||
return; // no acks yet at this transition (the writer just started)
|
||
}
|
||
let deadline = Instant::now() + Duration::from_secs(15);
|
||
loop {
|
||
if let Some(leader_idx) = current_leader_idx(cluster)
|
||
&& let Some(st) = cluster.local_status(leader_idx)
|
||
{
|
||
let frontier = st["applied_events"].as_u64().unwrap_or(0);
|
||
let last_seq = st["last_seq"].as_u64().unwrap_or(0);
|
||
// The leader writes directly, so its own frontier is the max of its
|
||
// applied count and its relay high-water-mark — either bounds the ack.
|
||
if frontier.max(last_seq) >= max_acked_seq {
|
||
return;
|
||
}
|
||
}
|
||
assert!(
|
||
Instant::now() < deadline,
|
||
"{what}: INVARIANT A violated — max acked seq {max_acked_seq} exceeds the leader \
|
||
frontier; status={:?}",
|
||
current_leader_idx(cluster).and_then(|i| cluster.local_status(i))
|
||
);
|
||
thread::sleep(Duration::from_millis(100));
|
||
}
|
||
}
|
||
|
||
/// INVARIANT B (content): every SAMPLED acked item is searchable on the cluster
|
||
/// after the scale event. The probe re-resolves the leader on each attempt
|
||
/// (leadership can move during a transition) and accepts a hit on ANY live node —
|
||
/// the m11p3 contract is "every acked item is findable", and a replicated item is
|
||
/// queryable on whichever node has folded + indexed it; a leader that JUST took
|
||
/// over may still be draining its text-index commit backlog while a settled
|
||
/// follower already serves it. The budget generously exceeds the ~2s text-index
|
||
/// auto-commit plus any apply backlog drain. Sampling (not exhaustive) keeps the
|
||
/// search load bounded while still proving acked writes are durable + queryable.
|
||
fn assert_content_invariant(cluster: &MultiProcCluster, sample: &[u64], what: &str) {
|
||
let deadline = Instant::now() + Duration::from_secs(30);
|
||
for &entity in sample {
|
||
loop {
|
||
// Hit on the current leader OR any live node (replicated + indexed).
|
||
let found = (0..cluster.len())
|
||
.filter(|&i| cluster.is_alive(i))
|
||
.any(|i| item_searchable(cluster, i, entity));
|
||
if found {
|
||
break;
|
||
}
|
||
assert!(
|
||
Instant::now() < deadline,
|
||
"{what}: INVARIANT B violated — acked item {entity} not searchable on any live node"
|
||
);
|
||
thread::sleep(Duration::from_millis(250));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Count the live VOTERS in the roster as reported by the current leader.
|
||
fn voter_count(cluster: &MultiProcCluster) -> usize {
|
||
let Some(leader_idx) = current_leader_idx(cluster) else {
|
||
return 0;
|
||
};
|
||
roster_roles(cluster, leader_idx)
|
||
.values()
|
||
.filter(|r| *r == "voter")
|
||
.count()
|
||
}
|
||
|
||
/// Poll until the leader's roster shows exactly `expected` voters, or panic.
|
||
fn await_voter_count(cluster: &MultiProcCluster, expected: usize, budget: Duration, what: &str) {
|
||
let deadline = Instant::now() + budget;
|
||
loop {
|
||
let n = voter_count(cluster);
|
||
if n == expected {
|
||
return;
|
||
}
|
||
assert!(
|
||
Instant::now() < deadline,
|
||
"{what}: expected {expected} voters, saw {n} within {budget:?}; leader roster={:?}",
|
||
current_leader_idx(cluster).map(|i| roster_roles(cluster, i))
|
||
);
|
||
thread::sleep(Duration::from_millis(100));
|
||
}
|
||
}
|
||
|
||
/// A few SETTLED acked entities to probe for content. We skip the freshest
|
||
/// `IN_FLIGHT_TAIL` acks (whose text-index commit / apply-fold may still be in
|
||
/// flight on a just-changed leader — a timing artifact, not a loss) and sample
|
||
/// the `n` acks just below that tail: writes old enough to have committed but
|
||
/// recent enough to exercise the post-transition state. Falls back to whatever
|
||
/// exists when fewer than the tail have been acked.
|
||
fn sample_acked(writer: &QuorumWriter, n: usize) -> Vec<u64> {
|
||
const IN_FLIGHT_TAIL: usize = 30;
|
||
let acked = writer.acked_entities.lock().unwrap();
|
||
let len = acked.len();
|
||
let end = len.saturating_sub(IN_FLIGHT_TAIL).max(n.min(len));
|
||
acked[end.saturating_sub(n)..end].to_vec()
|
||
}
|
||
|
||
/// EXIT GATE 2 (local scale): a 3-process cluster under continuous background
|
||
/// `ack=quorum` load through TWO gateways grows 3→4→5 (two seed-joins, each
|
||
/// auto-promoted to Voter), commits quorum writes with the grown set, then shrinks
|
||
/// to 4 (remove one added voter + stop its process). The m11p3 ledger invariants
|
||
/// (frontier + content) hold at EVERY transition, `lost == 0` across the whole
|
||
/// run, and the before/during/after write p99s are recorded for the evidence
|
||
/// table (the Ref-A `<2x for <60s` figure is reported from these numbers, NOT
|
||
/// hard-asserted, to keep the suite non-flaky).
|
||
#[test]
|
||
fn mp_scale_3_5_3_under_load_zero_loss() {
|
||
let extra = format!("{FAST_ELECTION_YAML}\n{PROMOTE_LAG_YAML}");
|
||
let opts = ClusterOptions::new(3).with_topology_extra(&extra);
|
||
let mut cluster = MultiProcCluster::start_with(opts);
|
||
|
||
// A committed prefix so every node shares a converged baseline before load.
|
||
let client = reqwest::blocking::Client::builder()
|
||
.timeout(Duration::from_secs(5))
|
||
.build()
|
||
.unwrap();
|
||
for entity in 1..=5u64 {
|
||
post_acked(
|
||
&client,
|
||
&cluster.node(LEADER),
|
||
"/items",
|
||
"quorum",
|
||
&serde_json::json!({ "entity_id": entity, "metadata": { "title": item_token(entity) } }),
|
||
)
|
||
.expect("committed-prefix quorum write");
|
||
}
|
||
cluster.wait_converged_all(convergence_budget());
|
||
|
||
// ── Background ack=quorum writers through TWO gateways. Each gateway is a
|
||
// STABLE node base URL (ports survive restarts), so the writer threads never
|
||
// alias the &mut harness on this thread. Entity id spaces are disjoint per
|
||
// writer (each owns a 1M-wide band) so two writers never collide on an id.
|
||
let gw_a = vec![cluster.node(0), cluster.node(1)];
|
||
let gw_b = vec![cluster.node(2), cluster.node(0)];
|
||
let writer = Arc::new(QuorumWriter::new());
|
||
let stop = Arc::new(AtomicBool::new(false));
|
||
let handles: Vec<_> = [(gw_a, 1_000_000u64), (gw_b, 2_000_000u64)]
|
||
.into_iter()
|
||
.map(|(gateways, base)| {
|
||
let writer = Arc::clone(&writer);
|
||
let stop = Arc::clone(&stop);
|
||
thread::spawn(move || run_quorum_writer(&gateways, &writer, &stop, base))
|
||
})
|
||
.collect();
|
||
|
||
// Let the writers establish a cadence; capture the steady-state p99 window.
|
||
let phase_before = Instant::now();
|
||
thread::sleep(Duration::from_secs(5));
|
||
let before_end = Instant::now();
|
||
let (p99_before, n_before) = windowed_p99(&writer, phase_before, before_end);
|
||
println!("[scale] steady-state p99 (n={n_before}) = {p99_before:?}");
|
||
|
||
// ── GROW 3 → 4: seed-join one node, wait Learner → Voter, ledger invariants.
|
||
let leader_idx = current_leader_idx(&cluster).expect("a leader");
|
||
let join_a_start = Instant::now();
|
||
let joiner_a = cluster.add_node(leader_idx);
|
||
let joiner_a_name = cluster.region_name(joiner_a).to_string();
|
||
println!("[scale] seed-joined node {joiner_a} ('{joiner_a_name}') — healthy (first-converged)");
|
||
await_membership_role(
|
||
&cluster,
|
||
joiner_a,
|
||
"voter",
|
||
convergence_budget() + Duration::from_secs(25),
|
||
"first joiner auto-promotes to Voter",
|
||
);
|
||
await_voter_count(&cluster, 4, convergence_budget(), "grow to 4 voters");
|
||
let p99_join_a = windowed_p99(&writer, join_a_start, Instant::now());
|
||
println!(
|
||
"[scale] grew to 4 voters in {:?}; p99 during join-A (n={}) = {:?}",
|
||
join_a_start.elapsed(),
|
||
p99_join_a.1,
|
||
p99_join_a.0
|
||
);
|
||
assert_frontier_invariant(
|
||
&cluster,
|
||
writer.max_acked_seq.load(Ordering::Relaxed),
|
||
"after grow to 4",
|
||
);
|
||
assert_content_invariant(&cluster, &sample_acked(&writer, 4), "after grow to 4");
|
||
|
||
// ── GROW 4 → 5: seed-join a second node, wait Voter, assert 5 voters.
|
||
let leader_idx = current_leader_idx(&cluster).expect("a leader");
|
||
let join_b_start = Instant::now();
|
||
let joiner_b = cluster.add_node(leader_idx);
|
||
let joiner_b_name = cluster.region_name(joiner_b).to_string();
|
||
println!("[scale] seed-joined node {joiner_b} ('{joiner_b_name}') — healthy (first-converged)");
|
||
await_membership_role(
|
||
&cluster,
|
||
joiner_b,
|
||
"voter",
|
||
convergence_budget() + Duration::from_secs(25),
|
||
"second joiner auto-promotes to Voter",
|
||
);
|
||
await_voter_count(&cluster, 5, convergence_budget(), "grow to 5 voters");
|
||
let p99_join_b = windowed_p99(&writer, join_b_start, Instant::now());
|
||
println!(
|
||
"[scale] grew to 5 voters in {:?}; p99 during join-B (n={}) = {:?}",
|
||
join_b_start.elapsed(),
|
||
p99_join_b.1,
|
||
p99_join_b.0
|
||
);
|
||
assert_frontier_invariant(
|
||
&cluster,
|
||
writer.max_acked_seq.load(Ordering::Relaxed),
|
||
"after grow to 5",
|
||
);
|
||
assert_content_invariant(&cluster, &sample_acked(&writer, 4), "after grow to 5");
|
||
|
||
// ── QUORUM WITH THE GROWN SET: a direct ack=quorum write must commit with 5
|
||
// voters (3-of-5), and the commit index must advance (the joiners' reports
|
||
// feed it). Read commit_index before/after on the leader.
|
||
let leader_idx = current_leader_idx(&cluster).expect("a leader for the 5-voter write");
|
||
let commit_before = cluster
|
||
.local_status(leader_idx)
|
||
.and_then(|s| s["commit_index"].as_u64())
|
||
.unwrap_or(0);
|
||
let seq = post_acked(
|
||
&client,
|
||
&cluster.node(leader_idx),
|
||
"/items",
|
||
"quorum",
|
||
&serde_json::json!({ "entity_id": 700_001, "metadata": { "title": item_token(700_001) } }),
|
||
)
|
||
.expect("a quorum write must commit with the 5-voter set");
|
||
println!("[scale] ack=quorum write committed at seq {seq} (5-voter quorum)");
|
||
let commit_deadline = Instant::now() + Duration::from_secs(10);
|
||
loop {
|
||
let commit_now = cluster
|
||
.local_status(leader_idx)
|
||
.and_then(|s| s["commit_index"].as_u64())
|
||
.unwrap_or(0);
|
||
if commit_now > commit_before {
|
||
println!("[scale] commit index advanced {commit_before} -> {commit_now} (5 voters)");
|
||
break;
|
||
}
|
||
assert!(
|
||
Instant::now() < commit_deadline,
|
||
"commit index did not advance after the 5-voter quorum write"
|
||
);
|
||
thread::sleep(Duration::from_millis(100));
|
||
}
|
||
|
||
// ── SHRINK 5 → 4: remove ONE added voter via the verb, then STOP its process.
|
||
// The remaining 4 voters keep quorum (3-of-4). Remove the SECOND joiner (never
|
||
// the leader). The remove forwards to the leader; 200 = Removed record
|
||
// committed. We then stop the removed node's process (a decommission).
|
||
let leader_idx = current_leader_idx(&cluster).expect("a leader for the remove");
|
||
// Pick an added joiner that is NOT the current leader to remove (consume the
|
||
// names — neither is used after this point).
|
||
let (victim_idx, victim_name) = if joiner_b == leader_idx {
|
||
(joiner_a, joiner_a_name)
|
||
} else {
|
||
(joiner_b, joiner_b_name)
|
||
};
|
||
println!(
|
||
"[scale] removing added voter '{victim_name}' (node {victim_idx}); leader is {leader_idx}"
|
||
);
|
||
let status = cluster.remove_node(&victim_name);
|
||
assert_eq!(
|
||
status, 200,
|
||
"remove verb must 200 (Removed record committed)"
|
||
);
|
||
// The removed node becomes a tombstone on every live node's roster.
|
||
let deadline = Instant::now() + convergence_budget();
|
||
loop {
|
||
let all_see = (0..cluster.len())
|
||
.filter(|&i| i != victim_idx && cluster.is_alive(i))
|
||
.all(|i| {
|
||
roster_roles(&cluster, i)
|
||
.get(&victim_name)
|
||
.map(String::as_str)
|
||
== Some("removed")
|
||
});
|
||
if all_see {
|
||
break;
|
||
}
|
||
assert!(
|
||
Instant::now() < deadline,
|
||
"the Removed record did not reach every live survivor within budget"
|
||
);
|
||
thread::sleep(Duration::from_millis(100));
|
||
}
|
||
// Stop the removed node's process (decommission). Use the harness graceful stop.
|
||
cluster.stop_graceful(victim_idx);
|
||
println!("[scale] removed voter '{victim_name}' decommissioned (process stopped)");
|
||
await_voter_count(&cluster, 4, convergence_budget(), "shrink to 4 voters");
|
||
|
||
// ── 4-VOTER QUORUM CONTINUES: a direct ack=quorum write commits with the
|
||
// smaller (4-voter) set, and the surviving live followers converge it.
|
||
let leader_idx = current_leader_idx(&cluster).expect("a leader after the remove");
|
||
let seq = post_acked(
|
||
&client,
|
||
&cluster.node(leader_idx),
|
||
"/items",
|
||
"quorum",
|
||
&serde_json::json!({ "entity_id": 600_001, "metadata": { "title": item_token(600_001) } }),
|
||
)
|
||
.expect("ack=quorum must still commit with the 4-voter set");
|
||
println!("[scale] ack=quorum still commits with the 4-voter set (seq {seq})");
|
||
let p99_after = {
|
||
let after_start = Instant::now();
|
||
thread::sleep(Duration::from_secs(4));
|
||
windowed_p99(&writer, after_start, Instant::now())
|
||
};
|
||
println!(
|
||
"[scale] post-shrink p99 (n={}) = {:?}",
|
||
p99_after.1, p99_after.0
|
||
);
|
||
assert_frontier_invariant(
|
||
&cluster,
|
||
writer.max_acked_seq.load(Ordering::Relaxed),
|
||
"after shrink to 4",
|
||
);
|
||
assert_content_invariant(&cluster, &sample_acked(&writer, 4), "after shrink to 4");
|
||
|
||
// ── DRAIN the writers and assert the gate: ZERO acked loss. The latency p99s
|
||
// are recorded (NOT hard-asserted): in-flight writes never error out beyond
|
||
// retries (that IS the lost==0 assertion), and the windowed p99s above are the
|
||
// evidence-table numbers for the <2x-for-<60s Ref-A figure.
|
||
stop.store(true, Ordering::Relaxed);
|
||
for h in handles {
|
||
h.join().expect("quorum writer joined");
|
||
}
|
||
let total = writer.total.load(Ordering::Relaxed);
|
||
let retried = writer.retried.load(Ordering::Relaxed);
|
||
let lost = writer.lost.load(Ordering::Relaxed);
|
||
let max_seq = writer.max_acked_seq.load(Ordering::Relaxed);
|
||
println!(
|
||
"[scale] EVIDENCE: total={total} retried={retried} lost={lost} max_acked_seq={max_seq} | \
|
||
p99 before={:?} (n={n_before}) join-A={:?} (n={}) join-B={:?} (n={}) after={:?} (n={})",
|
||
p99_before,
|
||
p99_join_a.0,
|
||
p99_join_a.1,
|
||
p99_join_b.0,
|
||
p99_join_b.1,
|
||
p99_after.0,
|
||
p99_after.1
|
||
);
|
||
assert_eq!(
|
||
lost, 0,
|
||
"SCALE LOST {lost} acknowledged quorum writes (of {total})"
|
||
);
|
||
assert!(
|
||
total > 0,
|
||
"the writers must have issued quorum writes during the scale window"
|
||
);
|
||
// NOTE: `retried` is evidence, NOT a gate. Unlike a rolling upgrade (which
|
||
// restarts nodes under load and therefore forces retries), a 3→5→3 scale never
|
||
// takes a serving voter down — joins are additive and the removed node's writes
|
||
// had already settled — so a smooth run can legitimately need ZERO retries. The
|
||
// gate is `lost == 0` plus the per-transition frontier + content invariants;
|
||
// asserting `retried > 0` would falsely fail the cleanest possible scale.
|
||
let _ = retried;
|
||
|
||
// Final ledger close-out against the post-shrink leader.
|
||
assert_frontier_invariant(&cluster, max_seq, "final close-out");
|
||
assert_content_invariant(&cluster, &sample_acked(&writer, 6), "final close-out");
|
||
println!("[scale] exit gate 2 met: 3→5→3 online under load, zero acked loss, invariants held");
|
||
}
|
||
|
||
// ── EXIT GATE 4: DNS-hostname topology replicates ────────────────────────────
|
||
|
||
/// EXIT GATE 4 (DNS): a 3-node cluster whose PEER `grpc_addr` entries are
|
||
/// HOSTNAMES (`localhost:<port>`, via [`hostname_rewrite`]) boots, replicates a
|
||
/// leader write to every follower, and survives a kill + restart drill — proving
|
||
/// the full dial stack routes through the DNS resolver (`Channel::from_shared`
|
||
/// re-resolution), not the pre-m11p5 literal `SocketAddr::parse` that would have
|
||
/// REFUSED `localhost:<port>` at boot. The own-region entry stays a real bind
|
||
/// addr (the bind/advertise split, §1), so only the advertised peer name is a
|
||
/// hostname.
|
||
#[test]
|
||
fn mp_dns_hostname_topology_replicates() {
|
||
let opts = ClusterOptions::new(3)
|
||
.with_topology_extra(FAST_ELECTION_YAML)
|
||
.with_rewrite(hostname_rewrite());
|
||
let mut cluster = MultiProcCluster::start_with(opts);
|
||
// Booting at all is the first half of the gate: a pre-m11p5 binary rejects a
|
||
// hostname grpc_addr at boot, so `start_with`'s health wait would have failed.
|
||
println!("[dns] 3-node cluster booted with hostname (localhost:<port>) peer grpc_addrs");
|
||
|
||
// ── REPLICATE: a leader write reaches every follower (the dial goes through
|
||
// the resolver). Seed a few searchable items + signals and converge.
|
||
for entity in 1..=6u64 {
|
||
write_heavy_item(&cluster, LEADER, entity, "", false);
|
||
}
|
||
cluster.wait_converged_all(convergence_budget());
|
||
for i in 0..cluster.len() {
|
||
if i == LEADER {
|
||
continue;
|
||
}
|
||
await_self_converged(
|
||
&cluster,
|
||
i,
|
||
convergence_budget(),
|
||
"a follower converges over the hostname-dialed gRPC stream",
|
||
);
|
||
}
|
||
// Content reached a follower (proves the bytes crossed the resolver-dialed link).
|
||
let follower = (0..cluster.len()).find(|&i| i != LEADER).unwrap();
|
||
let probe_deadline = Instant::now() + Duration::from_secs(20);
|
||
for entity in [1u64, 3, 6] {
|
||
loop {
|
||
if item_searchable(&cluster, follower, entity) {
|
||
break;
|
||
}
|
||
assert!(
|
||
Instant::now() < probe_deadline,
|
||
"[dns] follower {follower} missing item {entity} over the hostname-dialed stream"
|
||
);
|
||
thread::sleep(Duration::from_millis(200));
|
||
}
|
||
}
|
||
println!("[dns] leader writes replicated to a follower over hostname-dialed gRPC");
|
||
|
||
// ── KILL + RESTART DRILL: SIGKILL a follower, then restart it on the SAME
|
||
// ports + data dir. Its boot-time catch-up pull re-dials the leader THROUGH
|
||
// the resolver (re-resolution on reconnect is the whole point of the String
|
||
// peer retype). It must reconverge and serve the pre-kill content.
|
||
cluster.kill_hard(follower);
|
||
println!(
|
||
"[dns] SIGKILLed follower {follower}; restarting it (resolver must re-dial on reconnect)"
|
||
);
|
||
cluster.restart(follower, &[]);
|
||
// Write one more item AFTER the restart so the follower must pull fresh data
|
||
// over the re-dialed link (not just recover its own WAL).
|
||
write_heavy_item(&cluster, LEADER, 7, "", false);
|
||
cluster.wait_converged_all(convergence_budget() + BREAKER_RESET);
|
||
await_self_converged(
|
||
&cluster,
|
||
follower,
|
||
convergence_budget() + BREAKER_RESET,
|
||
"the restarted follower reconverges over the re-dialed hostname link",
|
||
);
|
||
let probe_deadline = Instant::now() + Duration::from_secs(20);
|
||
for entity in [1u64, 6, 7] {
|
||
loop {
|
||
if item_searchable(&cluster, follower, entity) {
|
||
break;
|
||
}
|
||
assert!(
|
||
Instant::now() < probe_deadline,
|
||
"[dns] restarted follower {follower} missing item {entity} after re-dial"
|
||
);
|
||
thread::sleep(Duration::from_millis(200));
|
||
}
|
||
}
|
||
println!(
|
||
"[dns] exit gate 4 met: hostname topology boots, replicates, and survives kill+restart"
|
||
);
|
||
}
|