Three real defects, plus a retracted fourth that was a probe artifact. P3 (fixed) - query/stored normalization asymmetry. The write path L2-normalized every stored vector; the read path passed the caller's raw query straight to the index, so the two sides lived in different spaces. With unit v, d = |q|^2 - 2q.v + 1, so a non-unit query shifted and scaled every distance by |q|^2. Measured live: 591-1174 against a documented [0,4], and an exact match scoring |q|^2 - 1 instead of ~0. vector_search_items now normalizes with the canonical l2_normalize; a zero-norm query (no direction, so nearest-by-cosine is undefined) is rejected with 400. Ranking is unchanged - |q|^2 and 1 are constant across candidates - which is why it went unnoticed; what broke was every absolute use of the number. WIRE-VISIBLE, recorded in CHANGELOG. P2 (fixed) - the blob path had zero instrumentation. Added per-kind tidaldb_cluster_blobs_originated/applied/apply_failed totals. Label cardinality is fixed at 4 by construction via a new BlobKind enum, and BlobRecord::blob_kind is now the ONE exhaustive match over the variants (kind() derives from it), so a new variant is a compile error in one place instead of a silent zero in three. Only the live apply path is counted - boot replay would inflate applied past originated on every restart. Coverage gap (fixed) - tidaldb_usearch_vector_count rendered only the metrics owner's shard group, so on a 3-group node two thirds of the corpus had no vector-count series at all. Co-located groups now render shard="N"; the owner stays unlabeled for wire compatibility, so an alert grouped by (shard) buckets each replica set separately without double-counting. P1 (RETRACTED) - the "replica-divergent vector index" does not exist. Every probe wrote through the /sharded/ surface, which hash-partitions and applies to the owning region's local store with no WAL append, and therefore does not replicate BY DESIGN (cluster/node.rs:8828-8829). A controlled A/B settled it: on /items plus /embeddings all 6 entities reach all 3 replicas; on the sharded surface four of six reach exactly one node. Both are now pinned by tests. See tmp/vector-search-correctness/diagnosis.md and the k3s-fleet cluster-state.yaml entry RETRACTED_blob_replication_rf1_2026_08_30. Pre-work: usearch_index.rs 872 to 503 lines by extracting its tests to a sibling (the project's existing path-attribute convention), and the three hand-rolled l2_normalize copies collapsed to one. The two entity copies used a zero threshold about 2900x looser than the canonical one; normalize_centroid now names the centroid zero-tolerance policy once, and a test pins the tightened behavior. Tests: 2107 lib (+5), 8 vector_search e2e (+4, three of which fail without the P3 fix), 4 cluster_sharding e2e (+2). The heavy multiproc tests in cluster_sharding are now serialized - four concurrent 3-node clusters made the pre-existing failover test miss its 10s budget.
722 lines
31 KiB
Rust
722 lines
31 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}: /sharded/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}: /sharded/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 — pin it.
|
||
///
|
||
/// `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.
|
||
#[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.
|
||
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(
|
||
via,
|
||
"/sharded/items",
|
||
&serde_json::json!({ "entity_id": entity, "metadata": {} })
|
||
)
|
||
.status()
|
||
.as_u16(),
|
||
201
|
||
);
|
||
assert_eq!(
|
||
cluster
|
||
.post(
|
||
via,
|
||
"/sharded/embeddings",
|
||
&serde_json::json!({ "entity_id": entity, "values": embedding_for(*entity) })
|
||
)
|
||
.status()
|
||
.as_u16(),
|
||
204
|
||
);
|
||
}
|
||
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:?}"
|
||
);
|
||
}
|