tidaldb/tidal-server/tests/cluster_sharding.rs
jordan 8aa1fbb414 vector search: normalize the query, instrument the blob path, expose per-group vector counts
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.
2026-08-30 13:57:36 -06:00

722 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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
/// 5001000ms, 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:?}"
);
}