Implements tmp/tidaldb-fleet-hardening (20 planned tasks + 2 found by measurement). Ring 0 — restore verification. .woodpecker.yaml step pods ran at the namespace default of 1500m/2Gi, which OOMKilled a prior pipeline and starved the release gate past its budget. Both push-path steps now declare backend_options.kubernetes.resources as two YAML anchors declared once on their first consuming step. The values are CALIBRATED against measured free node capacity, not against the LimitRange max: `requests: cpu 2` (this roadmap's original figure) fits on NO node and would sit Pending forever, because `ci-build-bounds` grants permission and the nodes supply capacity, and those are not the same thing. The `nightly` cron described in this file for 216 days was never created, so tier-3 chaos, the fault classes, mTLS and the PITR test produced exactly zero signal while reading like standing coverage. nightly-chaos and nightly-security-ops now alias the anchors and have budgets matching the gate (their 120/90 were TIGHTER on the same runner, so they would have failed nightly for a budget reason, not a correctness one). nightly-soak is REMOVED, not scheduled: it drives 1000 rps for 600s gating on p99 <= 250ms, and the best node has 1700m free CPU, so it would fail on starvation rather than regression — manufacturing a nightly false alarm. Its commands move verbatim to docs/runbooks/nightly-soak.md. Ring 1 — four fabrications removed from the wire. - scatter_merge sorted and truncated without re-stamping rank, so /feed and /search returned 1,1,2 under full placement. Reuses merge_cross_shard's existing stamp; asserted on BOTH the multi-group merge path and the single-group [only] fast path that bypasses it. - aggregate_region_row's None arm invented `applied_events: 0` plus a deficit derived from it. applied_events/lag_events are now Option<u64>, null on the wire. leader_last_seq was also unwrap_or(0), so a node that could not reach the LEADER computed 0 - applied = 0 for every region and reported a converged cluster it had never measured — a fabrication pointing the dangerous way. - tidalctl inferred NO REPORT from `applied == 0 && lag > 0`. That heuristic was actively hiding the PVC-wipe shape: a measured zero with a real deficit rendered as "no report" instead of BEHIND. Now read off the wire; converged exits 0, partitioned still exits nonzero. - /sharded/* answered 201/204 for single-copy writes with nothing anywhere saying so. Now requires `x-tidal-ack: local`, rejecting with 400 via the existing invalid_input path. Six call sites migrated, not the two this roadmap predicted — including docs/runbooks/cluster.md §16.3, which told operators to run a quorum-write probe via POST /sharded/items. That probe cannot verify quorum: the surface applies locally with no WAL append. It was used as the safety check between every step of a staged deploy earlier today. Ring 2 — observability. JSON_LOGS was already implemented and the deployment simply never asked for it; the StatefulSet now sets it, plus TIDAL_SERVICE_NAME=tidaldb because enabling it silently renames the VictoriaLogs `service` stream field and would have blinded every query keyed on it. Adds tidaldb_usearch_replicated_vectors_total, incremented on BOTH the origin (wal_blob_first -> Ok(Some)) and the follower apply path — counting only the origin would mean each vector lands on exactly one node, replicas never agree, and the alert built on it pages forever. Found by measurement, not planned: the 401 path discarded every fact about every rejection. Traefik has served 101,858 rejected requests to the public ingress — 87.6% of all its traffic — with no record of who or why anywhere. unauthorized_response now emits reason (missing_token vs invalid_token, the distinction that separates a scanner from a rotation that missed a consumer) and the forwarded client. The token is never logged. Also: scripts/restore-fleet.sh --cluster started the soak monitor while deliberately leaving its gate suspended, orphaning a watcher that has reported "0/30 green nights" for 13 days. The pair now moves together. Doc-guard's three-warning backlog is cleared with real backfill for M4/M6/M12. Verified: fmt clean; clippy 5 crates 0 new warnings (74 vs 74 baseline, counted in a detached worktree at HEAD); lib 2110 passed; cluster_sharding 5; cluster_runbook 10; tidalctl 38; doc-guard 0 warnings. Playwright 32/34 with the two remaining failures asserting the rank fix against the not-yet-rolled image — they are the post-deploy proof.
882 lines
37 KiB
Rust
882 lines
37 KiB
Rust
//! Tier-3 sharding × replication suite (m11p6 L4, REAL multi-process cluster).
|
||
//!
|
||
//! The m11p6 exit gate, run over real OS processes — 3 shard groups × RF=3, every
|
||
//! node a replica of every group, each group its own elected leader (balanced:
|
||
//! group `s` led by node `s`). Two pillars:
|
||
//!
|
||
//! 1. **Kill-node failover localizes to the dead node's groups** — SIGKILL a node
|
||
//! under concurrent `ack=quorum` load and prove: (a) ONLY the groups that node
|
||
//! led re-elect a new leader (the groups led by survivors keep theirs); (b) the
|
||
//! re-election completes inside the failover budget (<10s); (c) READS NEVER STOP
|
||
//! (a concurrent `/feed` poller on a survivor sees zero failures across the
|
||
//! window — reads are served from local replicas, no leader needed); and
|
||
//! (d) ZERO acknowledged-write loss per group — every item the client saw a
|
||
//! 2xx + `x-tidal-seq` for is present afterwards on its shard's NEW leader (the
|
||
//! m11p4 vote restriction guarantees the elected leader holds every committed
|
||
//! write).
|
||
//!
|
||
//! 2. **The L3 rebalance verbs move exactly one group** — `POST
|
||
//! /cluster/shards/{id}/transfer` and `/cluster/promote?shard=` move one
|
||
//! group's leadership and leave the others untouched, and `POST
|
||
//! /cluster/shards/{id}/replicas` (remove) runs a per-group fenced conf-change.
|
||
//!
|
||
//! Kill-point count: `TIDAL_SHARDING_KILLPOINTS` (default 3 for CI; the recorded
|
||
//! exit-gate run sweeps more — see docs/planning/milestone-11/phase-6.md).
|
||
//!
|
||
//! Run: `cargo test -p tidal-server --features cluster-e2e --test cluster_sharding -- --nocapture`
|
||
|
||
#![cfg(feature = "cluster-e2e")]
|
||
#![allow(
|
||
clippy::unwrap_used,
|
||
clippy::expect_used,
|
||
clippy::panic,
|
||
clippy::cast_possible_truncation,
|
||
clippy::cast_precision_loss,
|
||
clippy::too_many_lines
|
||
)]
|
||
|
||
mod support;
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::sync::{
|
||
Arc,
|
||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||
};
|
||
use std::time::{Duration, Instant};
|
||
|
||
use support::{
|
||
invariants::{AckLedger, item_token, post_acked},
|
||
multiproc::{MultiProcCluster, convergence_budget},
|
||
};
|
||
use tidaldb::{replication::shard::ShardRouter, schema::EntityId};
|
||
|
||
/// Fast election (mirrors `cluster_election.rs`): heartbeat 100ms, timeout
|
||
/// 500–1000ms, lease 350ms — so a SIGKILL failover completes well inside the
|
||
/// budget. Applied to every group (each `ShardReplica` reads this block).
|
||
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";
|
||
|
||
/// "Failover < 10s p99" — the roadmap's m11p6 bound (also p4's).
|
||
const FAILOVER_BUDGET: Duration = Duration::from_secs(10);
|
||
|
||
const NODES: usize = 3;
|
||
const SHARDS: usize = 3;
|
||
|
||
/// CI-default kill points; the exit-gate run sets `TIDAL_SHARDING_KILLPOINTS` higher.
|
||
fn killpoints() -> usize {
|
||
std::env::var("TIDAL_SHARDING_KILLPOINTS")
|
||
.ok()
|
||
.and_then(|v| v.parse().ok())
|
||
.filter(|&n| n > 0)
|
||
.unwrap_or(3)
|
||
}
|
||
|
||
/// Serializes the heavy multi-process tests in THIS target.
|
||
///
|
||
/// Each test here spawns 3 OS processes hosting 3 shard groups each. The harness's
|
||
/// `spawn_lock` only serializes the spawn itself and is released as soon as
|
||
/// `start_sharded` returns, so without this every test in the file can have a live
|
||
/// cluster simultaneously — 4 clusters, 12 processes, all electing and shipping at
|
||
/// once. That contention makes `mp_sharded_kill_node_moves_only_its_leaderships_zero_loss`
|
||
/// miss its 10s failover budget: it passes alone and fails in the full target.
|
||
///
|
||
/// Held for the whole test body, so exactly one cluster is alive at a time. Poison is
|
||
/// recovered rather than propagated: one failing test must not cascade into "the rest
|
||
/// panicked on a poisoned lock", which hides the original failure.
|
||
fn heavy_test_guard() -> std::sync::MutexGuard<'static, ()> {
|
||
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||
LOCK.get_or_init(|| std::sync::Mutex::new(()))
|
||
.lock()
|
||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||
}
|
||
|
||
/// Find the node index whose region name matches `name`.
|
||
fn idx_of(cluster: &MultiProcCluster, name: &str) -> usize {
|
||
(0..cluster.len())
|
||
.find(|&i| cluster.region_name(i) == name)
|
||
.unwrap_or_else(|| panic!("no node named {name}"))
|
||
}
|
||
|
||
/// The m11p6 headline exit gate over real OS processes: kill a node under
|
||
/// `ack=quorum` load and prove only ITS shard-leaderships move (<10s), reads
|
||
/// never stop, and zero acknowledged writes are lost — across several kill points.
|
||
#[test]
|
||
fn mp_sharded_kill_node_moves_only_its_leaderships_zero_loss() {
|
||
let _heavy = heavy_test_guard();
|
||
let mut cluster = MultiProcCluster::start_sharded(NODES, SHARDS, Some(FAST_ELECTION_YAML));
|
||
// The gateway's entity→shard hash (the same FNV-1a router every node routes by).
|
||
let router = ShardRouter::hash(SHARDS as u16).expect("build shard router");
|
||
|
||
// Balanced placement: each group `s` is led (term 0) by node `s`.
|
||
let initial = cluster.wait_shard_leaders_agreed(convergence_budget());
|
||
for s in 0..SHARDS as u16 {
|
||
assert_eq!(
|
||
initial[&s],
|
||
cluster.region_name(usize::from(s)),
|
||
"group {s} should boot led by node {s} (balanced placement)"
|
||
);
|
||
}
|
||
|
||
let mut entity: u64 = 1;
|
||
for round in 0..killpoints() {
|
||
// Wait for a fully-converged steady state (all groups agree their leader).
|
||
let before = cluster.wait_shard_leaders_agreed(convergence_budget());
|
||
// Target the leader of group `round % SHARDS` — "kill any node".
|
||
let target_shard = (round % SHARDS) as u16;
|
||
let target_name = before[&target_shard].clone();
|
||
let target_idx = idx_of(&cluster, &target_name);
|
||
// The groups this node currently leads (its leaderships must move) and the
|
||
// rest (must stay put — the localization property).
|
||
let led: Vec<u16> = (0..SHARDS as u16)
|
||
.filter(|s| before[s] == target_name)
|
||
.collect();
|
||
let others: Vec<u16> = (0..SHARDS as u16)
|
||
.filter(|s| before[s] != target_name)
|
||
.collect();
|
||
// Write + read through a SURVIVOR gateway so the client's entry node never
|
||
// dies mid-request (it forwards group-leader writes; reads serve locally).
|
||
let survivor = (0..NODES)
|
||
.find(|&i| i != target_idx)
|
||
.expect("a live survivor gateway distinct from the kill target");
|
||
let survivor_base = cluster.node(survivor);
|
||
|
||
// ── Concurrent reads that must never stop ─────────────────────────────
|
||
let stop = Arc::new(AtomicBool::new(false));
|
||
let read_failures = Arc::new(AtomicU64::new(0));
|
||
let read_ok = Arc::new(AtomicU64::new(0));
|
||
let reader = {
|
||
let stop = Arc::clone(&stop);
|
||
let fails = Arc::clone(&read_failures);
|
||
let oks = Arc::clone(&read_ok);
|
||
let base = survivor_base.clone();
|
||
std::thread::spawn(move || {
|
||
let client = reqwest::blocking::Client::builder()
|
||
.timeout(Duration::from_secs(3))
|
||
.build()
|
||
.unwrap();
|
||
while !stop.load(Ordering::Acquire) {
|
||
match client
|
||
.get(format!("{base}/feed?profile=for_you&limit=24"))
|
||
.send()
|
||
{
|
||
Ok(r) if r.status().is_success() => {
|
||
oks.fetch_add(1, Ordering::Relaxed);
|
||
}
|
||
_ => {
|
||
fails.fetch_add(1, Ordering::Relaxed);
|
||
}
|
||
}
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
}
|
||
})
|
||
};
|
||
|
||
// ── Two writer threads: ack=quorum items+views, entities spread across
|
||
// all groups by the gateway hash. Record only what the client saw acked.
|
||
let mut writers = Vec::new();
|
||
for w in 0..2u64 {
|
||
let stop = Arc::clone(&stop);
|
||
let base = survivor_base.clone();
|
||
let first = entity + w * 100_000;
|
||
writers.push(std::thread::spawn(move || {
|
||
let client = reqwest::blocking::Client::builder()
|
||
.timeout(Duration::from_secs(3))
|
||
.build()
|
||
.unwrap();
|
||
let mut acked: Vec<(u64, u64, Option<u64>)> = Vec::new();
|
||
let mut e = first;
|
||
while !stop.load(Ordering::Acquire) {
|
||
let item_seq = post_acked(
|
||
&client,
|
||
&base,
|
||
"/items",
|
||
"quorum",
|
||
&serde_json::json!({
|
||
"entity_id": e, "metadata": { "title": item_token(e) }
|
||
}),
|
||
);
|
||
let view_seq = post_acked(
|
||
&client,
|
||
&base,
|
||
"/signals",
|
||
"quorum",
|
||
&serde_json::json!({ "entity_id": e, "signal": "view", "weight": 1.0 }),
|
||
);
|
||
if let Some(s) = item_seq {
|
||
acked.push((e, s, view_seq));
|
||
}
|
||
e += 1;
|
||
}
|
||
acked
|
||
}));
|
||
}
|
||
|
||
// Pseudo-random kill point per round (reproducible — no Math.random here).
|
||
std::thread::sleep(Duration::from_millis(200 + (round as u64 * 131) % 500));
|
||
cluster.kill_hard(target_idx);
|
||
stop.store(true, Ordering::Release);
|
||
|
||
// ── Collect the acked ledger and the per-shard coverage. ──────────────
|
||
let mut ledger = AckLedger::new();
|
||
let mut per_shard: BTreeMap<u16, usize> = BTreeMap::new();
|
||
for wj in writers {
|
||
for (e, item_seq, view_seq) in wj.join().expect("writer thread") {
|
||
let s = router.route(EntityId::new(e));
|
||
*per_shard.entry(s.0).or_default() += 1;
|
||
ledger.record(e, item_seq, view_seq);
|
||
}
|
||
}
|
||
reader.join().expect("reader thread");
|
||
|
||
// ── (a)+(b) Failover localizes to the killed node's groups, <budget. ──
|
||
let deadline = Instant::now() + FAILOVER_BUDGET;
|
||
let after = loop {
|
||
if let Some(now) = cluster.agreed_shard_leaders() {
|
||
let led_moved = led
|
||
.iter()
|
||
.all(|s| now.get(s).is_some_and(|l| l != &target_name));
|
||
let others_stable = others.iter().all(|s| now.get(s) == before.get(s));
|
||
if led_moved && others_stable {
|
||
break now;
|
||
}
|
||
}
|
||
assert!(
|
||
Instant::now() <= deadline,
|
||
"round {round}: killing {target_name} (led {led:?}) did not localize within \
|
||
{FAILOVER_BUDGET:?}: before={before:?}, now={:?}",
|
||
cluster.agreed_shard_leaders()
|
||
);
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
};
|
||
for s in &others {
|
||
assert_eq!(
|
||
after.get(s),
|
||
before.get(s),
|
||
"round {round}: group {s} (led by a survivor) must NOT change leader"
|
||
);
|
||
}
|
||
|
||
// ── (c) Reads never stopped. ──────────────────────────────────────────
|
||
assert_eq!(
|
||
read_failures.load(Ordering::Relaxed),
|
||
0,
|
||
"round {round}: {} feed reads on survivor {survivor} FAILED during failover \
|
||
(reads must never stop); {} succeeded",
|
||
read_failures.load(Ordering::Relaxed),
|
||
read_ok.load(Ordering::Relaxed)
|
||
);
|
||
assert!(
|
||
read_ok.load(Ordering::Relaxed) > 0,
|
||
"round {round}: the read poller never got a single 2xx — it was not exercising reads"
|
||
);
|
||
|
||
// A round with ZERO acked writes proves nothing — the failover would have
|
||
// had no acknowledged state to lose. The writers run hundreds of quorum
|
||
// writes in the pre-kill window, so an empty ledger means a setup fault
|
||
// (writers never got a 2xx), not a passing round. The ledger spans every
|
||
// group the gateway hash routed to (logged per-shard); the loss proof
|
||
// below probes each acked write on ITS shard's new leader, so coverage of
|
||
// a killed node's groups is whatever genuinely routed there this round.
|
||
assert!(
|
||
!ledger.is_empty(),
|
||
"round {round}: no acked writes recorded — the kill tested nothing \
|
||
(writers saw 0 quorum acks before the kill at {target_name})"
|
||
);
|
||
println!(
|
||
"round {round}: killed {target_name} (led {led:?}); failover -> {after:?}; \
|
||
{} acked writes, per-shard {per_shard:?}; reads {} ok / 0 failed",
|
||
ledger.len(),
|
||
read_ok.load(Ordering::Relaxed)
|
||
);
|
||
|
||
// ── (d) ZERO acknowledged-write loss: every acked item is present on ITS
|
||
// shard's NEW leader. A quorum ack means the write committed (a majority
|
||
// held it durably), and the m11p4 vote restriction guarantees the
|
||
// elected leader holds every committed write — so the post-failover
|
||
// shard leader is the authoritative place to prove presence (a still
|
||
// catching-up follower is not). Poll past the text index's 2s
|
||
// auto-commit (the leader scatter-reads all its hosted groups).
|
||
for w in ledger.writes() {
|
||
let shard = router.route(EntityId::new(w.entity_id)).0;
|
||
let leader_name = after.get(&shard).expect("post-failover leader for shard");
|
||
let leader_idx = idx_of(&cluster, leader_name);
|
||
assert!(
|
||
item_present(&cluster, leader_idx, w.entity_id, Duration::from_secs(15)),
|
||
"round {round} (killed {target_name}): ACKNOWLEDGED LOSS — item {} \
|
||
(seq {}, shard {shard}, acked) is missing on shard {shard}'s new leader \
|
||
{leader_name} (node {leader_idx})",
|
||
w.entity_id,
|
||
w.item_seq
|
||
);
|
||
}
|
||
|
||
// Restart the killed node so the cluster is whole for the next kill point
|
||
// (one node down at a time keeps every group's quorum intact). The doubled
|
||
// budget covers BOTH the rejoiner's boot+catch-up AND any group whose
|
||
// leadership is still settling — stacked, not just convergence.
|
||
entity += 10_000;
|
||
cluster.restart(target_idx, &[]);
|
||
let _ = cluster.wait_shard_leaders_agreed(convergence_budget() + convergence_budget());
|
||
}
|
||
}
|
||
|
||
/// The L3 rebalance verbs over real processes: `POST /cluster/shards/{id}/transfer`
|
||
/// and `/cluster/promote?shard=` move EXACTLY one group's leadership, and the
|
||
/// per-group remove verb runs a fenced conf-change — proving `?shard=` selection
|
||
/// and the per-group reuse of the m11p4/m11p5 machinery end to end.
|
||
#[test]
|
||
fn mp_sharded_rebalance_verbs_move_one_group() {
|
||
let _heavy = heavy_test_guard();
|
||
let cluster = MultiProcCluster::start_sharded(NODES, SHARDS, Some(FAST_ELECTION_YAML));
|
||
let before = cluster.wait_shard_leaders_agreed(convergence_budget());
|
||
for s in 0..SHARDS as u16 {
|
||
assert_eq!(before[&s], cluster.region_name(usize::from(s)));
|
||
}
|
||
let node0 = cluster.region_name(0).to_string();
|
||
let node1 = cluster.region_name(1).to_string();
|
||
|
||
// ── Transfer group 0's leadership node0 -> node1 (RESTful rebalance verb). ──
|
||
let resp = cluster.post(
|
||
0,
|
||
"/cluster/shards/0/transfer",
|
||
&serde_json::json!({ "region": node1 }),
|
||
);
|
||
assert!(
|
||
resp.status().is_success(),
|
||
"shards/0/transfer must succeed, got {}",
|
||
resp.status()
|
||
);
|
||
wait_until(FAILOVER_BUDGET, || {
|
||
cluster
|
||
.agreed_shard_leaders()
|
||
.is_some_and(|m| m[&0] == node1 && m[&1] == before[&1] && m[&2] == before[&2])
|
||
});
|
||
let mid = cluster
|
||
.agreed_shard_leaders()
|
||
.expect("agreed after transfer");
|
||
assert_eq!(
|
||
mid[&0], node1,
|
||
"group 0 leadership must have moved to node1"
|
||
);
|
||
assert_eq!(mid[&1], before[&1], "group 1 leadership must be untouched");
|
||
assert_eq!(mid[&2], before[&2], "group 2 leadership must be untouched");
|
||
|
||
// ── Move it back with the shard-scoped promote (`?shard=` selector). ──────
|
||
let resp = cluster.post(
|
||
0,
|
||
"/cluster/promote?shard=0",
|
||
&serde_json::json!({ "region": node0 }),
|
||
);
|
||
assert!(
|
||
resp.status().is_success(),
|
||
"promote?shard=0 must succeed, got {}",
|
||
resp.status()
|
||
);
|
||
wait_until(FAILOVER_BUDGET, || {
|
||
cluster
|
||
.agreed_shard_leaders()
|
||
.is_some_and(|m| m[&0] == node0)
|
||
});
|
||
|
||
// ── The per-group replica verb is wired + `?shard=`-scoped: assert its input
|
||
// validation and that `/cluster/members?shard=` selects the right group's
|
||
// roster. (The live add/remove conf-change reuses the m11p5 machinery
|
||
// per-group — proven by the membership suite — but a node's readiness is
|
||
// still node-global across its co-hosted groups, so removing a node from
|
||
// ONE group of a multi-group node is a tracked S>1 follow-up, NOT asserted
|
||
// here. See docs/planning/milestone-11/phase-6.md.)
|
||
let bad_action = cluster.post(
|
||
0,
|
||
"/cluster/shards/0/replicas",
|
||
&serde_json::json!({ "action": "frobnicate", "name": node1 }),
|
||
);
|
||
assert_eq!(
|
||
bad_action.status().as_u16(),
|
||
400,
|
||
"an unknown shard-replica action must be a 400"
|
||
);
|
||
let add_missing_addrs = cluster.post(
|
||
0,
|
||
"/cluster/shards/0/replicas",
|
||
&serde_json::json!({ "action": "add", "name": "region-9" }),
|
||
);
|
||
assert_eq!(
|
||
add_missing_addrs.status().as_u16(),
|
||
400,
|
||
"add without grpc_addr/http_addr must be a 400"
|
||
);
|
||
// The `?shard=` selector resolves a hosted group's roster (full placement ⇒
|
||
// every group lists all three nodes); an unhosted shard id is a 400.
|
||
let roster = cluster.get_json(0, "/cluster/members?shard=2");
|
||
assert_eq!(
|
||
roster["members"].as_array().map(Vec::len),
|
||
Some(NODES),
|
||
"group 2's roster must list every node under full placement"
|
||
);
|
||
let unhosted = cluster.post(
|
||
0,
|
||
"/cluster/shards/9/transfer",
|
||
&serde_json::json!({ "region": node0 }),
|
||
);
|
||
assert_eq!(
|
||
unhosted.status().as_u16(),
|
||
400,
|
||
"targeting a shard this node does not host must be a 400"
|
||
);
|
||
}
|
||
|
||
/// Whether item `entity` is searchable on node `idx` within `budget` — the
|
||
/// content presence probe (`/search?query=<token>`), polling past the text
|
||
/// index's ~2s auto-commit. A clean 2xx-with-no-hit through the whole budget is
|
||
/// genuine absence (returns false); transport/non-2xx is retried until the
|
||
/// budget, so a just-promoted leader still warming up is not charged as absence
|
||
/// prematurely.
|
||
fn item_present(cluster: &MultiProcCluster, idx: usize, entity: u64, budget: Duration) -> bool {
|
||
let token = item_token(entity);
|
||
let base = cluster.node(idx);
|
||
let client = reqwest::blocking::Client::builder()
|
||
.timeout(Duration::from_secs(4))
|
||
.build()
|
||
.unwrap();
|
||
let deadline = Instant::now() + budget;
|
||
loop {
|
||
if let Ok(resp) = client
|
||
.get(format!("{base}/search?query={token}&limit=5"))
|
||
.send()
|
||
&& resp.status().is_success()
|
||
{
|
||
let body: serde_json::Value = resp.json().unwrap_or(serde_json::Value::Null);
|
||
let hit = body["items"].as_array().is_some_and(|items| {
|
||
items
|
||
.iter()
|
||
.any(|it| it["entity_id"].as_u64() == Some(entity))
|
||
});
|
||
if hit {
|
||
return true;
|
||
}
|
||
}
|
||
if Instant::now() > deadline {
|
||
return false;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(200));
|
||
}
|
||
}
|
||
|
||
/// Poll `cond` every 100ms until it returns true or `budget` elapses; returns the
|
||
/// final value of `cond` (so a caller can assert it true with context).
|
||
fn wait_until(budget: Duration, mut cond: impl FnMut() -> bool) -> bool {
|
||
let deadline = Instant::now() + budget;
|
||
loop {
|
||
if cond() {
|
||
return true;
|
||
}
|
||
if Instant::now() > deadline {
|
||
return false;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
}
|
||
}
|
||
|
||
/// An embedding written through ANY node must be searchable on EVERY replica of
|
||
/// its shard group, WITHOUT a restart.
|
||
///
|
||
/// Guards the replication contract of the NON-sharded write surface (`/items`,
|
||
/// `/embeddings`): those ride the leader WAL relay, so every replica of the entity's
|
||
/// group must end up able to answer for it.
|
||
///
|
||
/// Uses `/items` + `/embeddings` DELIBERATELY, not `/sharded/*`. The `/sharded/*`
|
||
/// surface hash-partitions and applies to the owner's LOCAL store without a WAL
|
||
/// append (`node.rs:8828-8829`, `ShardReplica::apply_embedding_local`), so it is
|
||
/// single-copy BY DESIGN and a parity assertion against it fails correctly — see
|
||
/// `mp_sharded_surface_writes_are_local_to_the_owner` below, which pins that
|
||
/// intended behavior. Writing a parity test against the wrong surface is exactly
|
||
/// the mistake that made a 2026-08-30 live probe look like a durability incident.
|
||
///
|
||
/// **The assertion is deliberately PRE-restart.** `rebuild_from_store` at open
|
||
/// re-derives the whole index from the durable store, so a test that restarts before
|
||
/// asserting converges the replicas regardless of whether the live path works — it
|
||
/// would have PASSED against the bug it exists to catch. That is exactly how the
|
||
/// live divergence stayed hidden across a rolling deploy.
|
||
///
|
||
/// Full placement (3 groups on every node) means `/vector_search` scatters over all
|
||
/// locally hosted groups and is corpus-complete per node, so "absent here" is a real
|
||
/// absence and not a fan-out artifact.
|
||
#[test]
|
||
fn mp_embedding_is_searchable_on_every_replica_without_restart() {
|
||
let _heavy = heavy_test_guard();
|
||
let cluster = MultiProcCluster::start_sharded(NODES, SHARDS, Some(FAST_ELECTION_YAML));
|
||
let leaders = cluster.wait_shard_leaders_agreed(convergence_budget());
|
||
println!("[parity] group leaders: {leaders:?}");
|
||
|
||
// Entity ids are hash-routed across groups, so a spread covers all three and
|
||
// the test does not depend on which group any single id lands in.
|
||
const ENTITIES: [u64; 6] = [911_001, 911_002, 911_003, 911_004, 911_005, 911_006];
|
||
|
||
// Write each entity through a DIFFERENT node, round-robin. The live failure was
|
||
// sensitive to which node accepted the write (it never landed on the acceptor),
|
||
// so exercising every entry point is the point.
|
||
for (n, entity) in ENTITIES.iter().enumerate() {
|
||
let via = n % NODES;
|
||
let resp = cluster.post(
|
||
via,
|
||
"/items",
|
||
&serde_json::json!({ "entity_id": entity, "metadata": { "title": "parity" } }),
|
||
);
|
||
assert_eq!(
|
||
resp.status().as_u16(),
|
||
201,
|
||
"entity {entity} via node {via}: /items must 201"
|
||
);
|
||
let resp = cluster.post(
|
||
via,
|
||
"/embeddings",
|
||
&serde_json::json!({ "entity_id": entity, "values": embedding_for(*entity) }),
|
||
);
|
||
assert_eq!(
|
||
resp.status().as_u16(),
|
||
204,
|
||
"entity {entity} via node {via}: /embeddings must 204"
|
||
);
|
||
}
|
||
|
||
cluster.wait_converged_all(convergence_budget());
|
||
|
||
// Poll every node for every entity, bounded by the convergence budget. Replication
|
||
// is asynchronous, so a brief absence is legitimate; a PERSISTENT one is the bug.
|
||
let deadline = Instant::now() + convergence_budget();
|
||
let mut missing: Vec<(usize, u64)> = Vec::new();
|
||
loop {
|
||
missing.clear();
|
||
for entity in ENTITIES {
|
||
for node in 0..NODES {
|
||
if !vector_search_finds(&cluster, node, entity) {
|
||
missing.push((node, entity));
|
||
}
|
||
}
|
||
}
|
||
if missing.is_empty() || Instant::now() >= deadline {
|
||
break;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(250));
|
||
}
|
||
|
||
if !missing.is_empty() {
|
||
// Dump the blob ledger before failing: `originated` on the writer vs
|
||
// `applied`/`apply_failed` per peer localises the gap to enqueue, ship, or
|
||
// apply. Diagnosing from the failure output beats re-running by hand.
|
||
for node in 0..NODES {
|
||
println!(
|
||
"[parity] node {node} ({}) blob ledger:\n{}",
|
||
cluster.region_name(node),
|
||
blob_ledger(&cluster, node)
|
||
);
|
||
}
|
||
}
|
||
assert!(
|
||
missing.is_empty(),
|
||
"an embedding must be searchable on EVERY replica without a restart, but \
|
||
(node, entity) pairs are still missing after the convergence budget: {missing:?}. \
|
||
The non-sharded surface rides the leader WAL relay, so this means a blob left \
|
||
the leader and never landed - check the blob ledger dumped above."
|
||
);
|
||
}
|
||
|
||
/// This node's `tidaldb_cluster_blobs_*` and per-group vector-count lines.
|
||
fn blob_ledger(cluster: &MultiProcCluster, node: usize) -> String {
|
||
let resp = cluster.get(node, "/metrics");
|
||
if resp.status().as_u16() != 200 {
|
||
return format!(" <metrics unavailable: HTTP {}>", resp.status());
|
||
}
|
||
resp.text().map_or_else(
|
||
|e| format!(" <metrics body unreadable: {e}>"),
|
||
|body| {
|
||
body.lines()
|
||
.filter(|l| {
|
||
(l.starts_with("tidaldb_cluster_blobs_")
|
||
|| l.starts_with("tidaldb_usearch_vector_count"))
|
||
&& !l.ends_with(" 0")
|
||
})
|
||
.map(|l| format!(" {l}"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
},
|
||
)
|
||
}
|
||
|
||
/// A deterministic, entity-varying 4-dim embedding.
|
||
///
|
||
/// NOT a constant vector: every constant vector normalizes to the same unit vector,
|
||
/// so all of them are equidistant and a presence test over them proves nothing.
|
||
fn embedding_for(entity: u64) -> Vec<f32> {
|
||
#[allow(clippy::cast_precision_loss)]
|
||
let v = (entity % 997) as f32;
|
||
vec![v, v + 1.0, v + 2.0, v + 3.0]
|
||
}
|
||
|
||
/// Does `node`'s own `/vector_search` return `entity` for its exact stored vector?
|
||
///
|
||
/// Queries the node's OWN address, never a shared/load-balanced endpoint — routing a
|
||
/// probe through a gateway hides precisely this class of bug (it answers from an
|
||
/// arbitrary replica).
|
||
fn vector_search_finds(cluster: &MultiProcCluster, node: usize, entity: u64) -> bool {
|
||
let resp = cluster.post(
|
||
node,
|
||
"/vector_search",
|
||
&serde_json::json!({ "vector": embedding_for(entity), "k": 5 }),
|
||
);
|
||
if resp.status().as_u16() != 200 {
|
||
return false;
|
||
}
|
||
let body: serde_json::Value = match resp.json() {
|
||
Ok(b) => b,
|
||
Err(_) => return false,
|
||
};
|
||
body["items"].as_array().is_some_and(|items| {
|
||
items
|
||
.iter()
|
||
.any(|it| it["entity_id"].as_u64() == Some(entity))
|
||
})
|
||
}
|
||
|
||
/// The `/sharded/*` write surface is single-copy BY DESIGN, and now says so — pin
|
||
/// both halves.
|
||
///
|
||
/// `node.rs:8828-8829` states it: the `/sharded/*` surface hash-partitions and applies
|
||
/// to the owning region's LOCAL store, and does NOT ride the leader WAL relay (that is
|
||
/// the non-sharded surface). `sharded_write_embedding` therefore calls
|
||
/// `ShardReplica::apply_embedding_local`, which performs no WAL append and so ships
|
||
/// nothing to peers.
|
||
///
|
||
/// This test exists because that property is easy to mistake for a replication defect:
|
||
/// a 2026-08-30 live probe wrote embeddings through `/sharded/embeddings`, found each
|
||
/// one on exactly one of three nodes, and was initially recorded as a durability
|
||
/// incident. It is not one — but a caller who assumes `/sharded/*` writes are
|
||
/// replicated is building on sand, so the semantics deserve an executable statement
|
||
/// rather than a comment.
|
||
///
|
||
/// If a future change makes `/sharded/*` replicate, this test SHOULD fail: that is a
|
||
/// deliberate contract change, and the failure is the prompt to update the docs, the
|
||
/// runbook, and any durability claim that depends on it.
|
||
///
|
||
/// Since the opt-in landed the surface REQUIRES `x-tidal-ack: local`, so this test
|
||
/// pins two things: the single-copy semantics WHEN opted in, and the 400 (naming
|
||
/// the header and the replicating alternative) when not. Making the tradeoff
|
||
/// explicit is what stops a caller taking it by accident; it does not change what
|
||
/// the tradeoff is.
|
||
#[test]
|
||
fn mp_sharded_surface_writes_are_local_to_the_owner() {
|
||
let _heavy = heavy_test_guard();
|
||
let cluster = MultiProcCluster::start_sharded(NODES, SHARDS, Some(FAST_ELECTION_YAML));
|
||
let _leaders = cluster.wait_shard_leaders_agreed(convergence_budget());
|
||
|
||
// Six ids spread across the hash space, each written through a different node.
|
||
//
|
||
// The writes carry `x-tidal-ack: local` (via `post_sharded`) because the
|
||
// surface now REQUIRES that opt-in. The point of this test is unchanged: the
|
||
// surface is still single-copy WHEN opted in. The opt-in makes the tradeoff a
|
||
// decision; it does not soften it.
|
||
const ENTITIES: [u64; 6] = [922_001, 922_002, 922_003, 922_004, 922_005, 922_006];
|
||
for (n, entity) in ENTITIES.iter().enumerate() {
|
||
let via = n % NODES;
|
||
assert_eq!(
|
||
cluster
|
||
.post_sharded(
|
||
via,
|
||
"/sharded/items",
|
||
&serde_json::json!({ "entity_id": entity, "metadata": {} })
|
||
)
|
||
.status()
|
||
.as_u16(),
|
||
201
|
||
);
|
||
assert_eq!(
|
||
cluster
|
||
.post_sharded(
|
||
via,
|
||
"/sharded/embeddings",
|
||
&serde_json::json!({ "entity_id": entity, "values": embedding_for(*entity) })
|
||
)
|
||
.status()
|
||
.as_u16(),
|
||
204
|
||
);
|
||
}
|
||
|
||
// And WITHOUT the opt-in the same write is refused, with a body that names the
|
||
// header and the replicating alternative. This is the half that makes the
|
||
// single-copy tradeoff impossible to take by accident.
|
||
let refused = cluster.post(
|
||
0,
|
||
"/sharded/items",
|
||
&serde_json::json!({ "entity_id": 922_099_u64, "metadata": {} }),
|
||
);
|
||
assert_eq!(
|
||
refused.status().as_u16(),
|
||
400,
|
||
"a /sharded/* write with no x-tidal-ack: local opt-in must be refused"
|
||
);
|
||
let body = refused.text().unwrap_or_default();
|
||
for expected in ["x-tidal-ack", "local", "SINGLE-COPY", "/items"] {
|
||
assert!(
|
||
body.contains(expected),
|
||
"the rejection must name {expected:?}: {body}"
|
||
);
|
||
}
|
||
cluster.wait_converged_all(convergence_budget());
|
||
// Generous settle: the claim is "never replicates", so give replication every
|
||
// chance to happen before asserting that it did not.
|
||
std::thread::sleep(Duration::from_secs(5));
|
||
|
||
// At least one entity must be visible on strictly fewer than all replicas.
|
||
// Asserting "exactly 1 node" for EVERY entity would be over-fitting: reads scatter
|
||
// over all locally hosted groups, so an owner that co-locates the querying group
|
||
// can legitimately answer for more than one id.
|
||
let spread: Vec<(u64, usize)> = ENTITIES
|
||
.iter()
|
||
.map(|&e| {
|
||
(
|
||
e,
|
||
(0..NODES)
|
||
.filter(|&n| vector_search_finds(&cluster, n, e))
|
||
.count(),
|
||
)
|
||
})
|
||
.collect();
|
||
println!("[sharded-semantics] visible-replica counts: {spread:?}");
|
||
assert!(
|
||
spread.iter().any(|&(_, n)| n < NODES),
|
||
"the /sharded/* surface must NOT replicate (node.rs:8828-8829); every entity \
|
||
reached all {NODES} replicas, so the contract changed: {spread:?}"
|
||
);
|
||
assert!(
|
||
spread.iter().all(|&(_, n)| n >= 1),
|
||
"every /sharded/* write must still be durable on its owner: {spread:?}"
|
||
);
|
||
}
|
||
|
||
/// The shared title token every seeded item carries, so ONE `/search?query=` hit
|
||
/// returns the whole seeded page (`item_token` is per-entity unique and would
|
||
/// return exactly one row, which cannot show a rank sequence).
|
||
const RANK_TOKEN: &str = "rnkdense";
|
||
|
||
/// Entity ids spread across the hash space so all three groups contribute rows
|
||
/// on the multi-group shape. 12 ⇒ several rows per group, so a per-group counter
|
||
/// that was never re-stamped shows up as duplicates rather than by luck.
|
||
const RANK_ENTITIES: [u64; 12] = [
|
||
933_001, 933_002, 933_003, 933_004, 933_005, 933_006, 933_007, 933_008, 933_009, 933_010,
|
||
933_011, 933_012,
|
||
];
|
||
|
||
/// One ranked page from `node`, as `(rank, score)` in wire order.
|
||
fn ranked_page(cluster: &MultiProcCluster, node: usize, path: &str) -> Vec<(u64, f64)> {
|
||
let body = cluster.get_json(node, path);
|
||
body["items"]
|
||
.as_array()
|
||
.unwrap_or(&Vec::new())
|
||
.iter()
|
||
.map(|it| {
|
||
(
|
||
it["rank"].as_u64().unwrap_or_default(),
|
||
it["score"].as_f64().unwrap_or_default(),
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Seed `RANK_ENTITIES` on a fresh `shards`-group cluster and assert every node's
|
||
/// `/feed` and `/search` page carries a DENSE ascending `rank` with descending
|
||
/// scores.
|
||
fn assert_dense_ranks(shards: usize) {
|
||
let cluster = MultiProcCluster::start_sharded(NODES, shards, Some(FAST_ELECTION_YAML));
|
||
let _leaders = cluster.wait_shard_leaders_agreed(convergence_budget());
|
||
|
||
for (n, entity) in RANK_ENTITIES.iter().enumerate() {
|
||
let via = n % NODES;
|
||
let resp = cluster.post(
|
||
via,
|
||
"/items",
|
||
&serde_json::json!({
|
||
"entity_id": entity,
|
||
"metadata": { "title": format!("{RANK_TOKEN} {}", item_token(*entity)) }
|
||
}),
|
||
);
|
||
assert_eq!(
|
||
resp.status().as_u16(),
|
||
201,
|
||
"entity {entity} via node {via}: /items must 201"
|
||
);
|
||
}
|
||
cluster.wait_converged_all(convergence_budget());
|
||
|
||
let limit = RANK_ENTITIES.len();
|
||
let feed_path = format!("/feed?profile=for_you&limit={limit}");
|
||
let search_path = format!("/search?query={RANK_TOKEN}&limit={limit}");
|
||
|
||
// The text index auto-commits on a ~2s cadence, so poll until the whole
|
||
// seeded page is visible on every node. A SHORT page would make the density
|
||
// assertion pass vacuously (a 1-row page is trivially dense).
|
||
for path in [&feed_path, &search_path] {
|
||
let full = wait_until(convergence_budget(), || {
|
||
(0..NODES).all(|n| ranked_page(&cluster, n, path).len() == limit)
|
||
});
|
||
assert!(
|
||
full,
|
||
"shards={shards}: every node must serve all {limit} seeded rows on {path} before \
|
||
rank density can be judged; got {:?}",
|
||
(0..NODES)
|
||
.map(|n| ranked_page(&cluster, n, path).len())
|
||
.collect::<Vec<_>>()
|
||
);
|
||
}
|
||
|
||
for node in 0..NODES {
|
||
for path in [&feed_path, &search_path] {
|
||
let page = ranked_page(&cluster, node, path);
|
||
let ranks: Vec<u64> = page.iter().map(|&(r, _)| r).collect();
|
||
let expected: Vec<u64> = (1..=page.len() as u64).collect();
|
||
assert_eq!(
|
||
ranks, expected,
|
||
"shards={shards} node {node} {path}: rank must be a dense ascending 1..n \
|
||
sequence. Duplicates are the signature of per-group slices merged without a \
|
||
re-stamp (scatter_merge); zeros mean a remote slice was never stamped."
|
||
);
|
||
for w in page.windows(2) {
|
||
assert!(
|
||
w[1].1 <= w[0].1 + 1e-9,
|
||
"shards={shards} node {node} {path}: scores must stay descending — the \
|
||
rank stamp is not a re-sort. Got {:?} then {:?}",
|
||
w[0],
|
||
w[1]
|
||
);
|
||
}
|
||
}
|
||
}
|
||
println!("[rank] shards={shards}: dense 1..{limit} on /feed and /search, all {NODES} nodes");
|
||
}
|
||
|
||
/// `rank` on a cluster's ranked reads must be DENSE and ascending — on BOTH of
|
||
/// the two structurally different paths the same handler reaches the wire by.
|
||
///
|
||
/// Under full placement (production: every group on every node) `missing_groups()`
|
||
/// is empty and `/feed` / `/search` return straight out of `scatter_merge`
|
||
/// (`node.rs`), skipping `merge_cross_shard` — the sibling that DOES re-stamp.
|
||
/// Live `/search?query=verification` returned ranks `1,1,2`: each hosted group
|
||
/// ranks its own slice `1..k` locally, the slices get concatenated and
|
||
/// score-sorted, and nothing renumbered them. `scatter_merge` now takes the same
|
||
/// `set_rank` closure and stamps after `truncate`.
|
||
///
|
||
/// Two shapes, because there are two paths:
|
||
/// - **multi-group** (`shards = SHARDS`): the fan-out + merge path, where the
|
||
/// stamp lives. This is the production shape and the one that was broken.
|
||
/// - **single-group** (`shards = 1`): the `[only]` fast path returns the engine's
|
||
/// own result and deliberately BYPASSES the stamp. That the engine already
|
||
/// ranks densely is an assumption until asserted, so assert it.
|
||
///
|
||
/// Scores are checked to stay descending on both: the fix is a renumbering, never
|
||
/// a re-sort, so an ordering change here would be a different and worse defect.
|
||
#[test]
|
||
fn mp_ranked_reads_stamp_dense_rank_on_both_merge_shapes() {
|
||
let _heavy = heavy_test_guard();
|
||
assert_dense_ranks(SHARDS);
|
||
assert_dense_ranks(1);
|
||
}
|