tidaldb/tidal-server/tests/support/multiproc.rs
jx12n a0399550d6 feat(m12p6): persist HNSW graph + bounded SIGTERM drain — boot loads, no rebuild
Boot now LOADS the per-slot HNSW graph instead of rebuilding it. Clean
shutdown writes {data_dir}/vector/<kind>__<slot>.usearch; the next open loads
it when it matches the durable corpus (seconds), falling back to a full rebuild
only when the graph is missing/stale/corrupt. Eliminates the multi-minute boot
rebuild (~50-70 min at 1M/1536-D) that let the WAL compact past a restarting
node and triggered the reseed cascade.

Graceful SIGTERM now actually runs the close: bounded_drain caps the post-signal
HTTP drain (TIDAL_SHUTDOWN_DRAIN_MS, default 15s) then runs the deterministic
close regardless — sibling keep-alive connections no longer block the drain past
the k8s 60s grace into a SIGKILL (which cannot run Drop). ClusterNode and
ShardReplica::shutdown are now &self (db handle is an ArcSwapOption) so the close
fires even when a stuck connection task holds an Arc.

Fix USearch insert to be a true upsert (remove+add): it was unconditional add,
which a multi:false index rejects on a reseeding follower's post-snapshot WAL
replay -> applied_events stalls -> catch-up deadlock -> unrecoverable cluster.

Also: circuit-breaker peer last-contact tracking; real k3s 1536-dim deploy +
recall findings (recall@10 0.9869, read p99 8.71ms @ 200rps @ 100k) in
docs/profiling/m12-cluster-deploy-findings.md; new tidal-stress k8s jobs and
m12p6 graph-persistence + SIGTERM tier-3 regression tests.
2026-06-15 13:09:20 -06:00

1680 lines
68 KiB
Rust

//! `MultiProcCluster`: the true one-process-per-region tier-3 harness (m8p10).
//!
//! # What this harness fixes
//!
//! `cluster_e2e.rs` spawned `n` `tidal-server cluster` processes, but EVERY
//! process held ALL regions (a single-process `ClusterState` per process), so a
//! write to `nodes[0]` replicated inside node 0's own fabric and never crossed
//! into `nodes[1]`'s process. That harness's own module docs flag this as the
//! gap. `MultiProcCluster` closes it: each process runs `tidal-server cluster
//! --region <name>` and owns exactly ONE region (a `ShardReplica`), peering
//! with siblings over real gRPC and forwarding over real HTTP. Convergence is
//! therefore verified against EVERY follower process's own `/cluster/status/local`,
//! not a single node's internal view.
//!
//! # Topology model
//!
//! Every region declares both a `grpc_addr` and an `http_addr` (multi-process
//! mode requires both). The harness allocates a free loopback port for each, then
//! writes a per-process topology file:
//!
//! * a process's OWN region entry always lists its REAL bind addresses (so the
//! process binds the port the harness opened);
//! * every PEER region entry lists the PUBLISHED address from the
//! [`AddrRewrite`] hook (identity by default; task 05 interposes TCP proxies by
//! returning a proxy address for a peer's real address).
//!
//! With the identity hook every process's topology file is byte-identical and
//! uses the real ports — a plain shared topology. The per-process files are how
//! the rewrite hook stays surgical: a proxy only sits on the path peers use to
//! reach a region, never on the region's own self-bind.
//!
//! # Budgets
//!
//! Tier-3 over real OS processes: 60s boot (each process opens a `TidalDb`, builds
//! a gRPC transport on its own runtime, and the suite may spawn several at once),
//! 30s convergence (WAL relay over loopback gRPC). Every wait polls with a
//! deadline; there are no bare sleeps used as a correctness gate (a small bounded
//! settle sleep after spawn is the only sleep, and it never gates correctness).
use std::{
collections::HashMap,
fmt::Write as _,
io::Write as _,
net::{SocketAddr, TcpListener},
path::{Path, PathBuf},
process::{Child, Command},
sync::{Mutex, OnceLock},
time::{Duration, Instant},
};
/// Tier-3 boot budget: every process opens a `TidalDb` and builds a gRPC
/// transport on its own runtime, and the suite may spawn several concurrently.
/// Default 60s; override with `TIDAL_TEST_BOOT_BUDGET_SECS` on a slow runner.
pub fn boot_budget() -> Duration {
env_budget("TIDAL_TEST_BOOT_BUDGET_SECS", 60)
}
/// Tier-3 convergence budget for the WAL relay over loopback gRPC.
/// Default 30s; override with `TIDAL_TEST_CONVERGENCE_BUDGET_SECS` on a slow
/// runner.
pub fn convergence_budget() -> Duration {
env_budget("TIDAL_TEST_CONVERGENCE_BUDGET_SECS", 30)
}
/// Resolve a budget override from the environment (whole seconds, > 0), or the
/// compiled-in default.
fn env_budget(var: &str, default_secs: u64) -> Duration {
let secs = std::env::var(var)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&s| s > 0)
.unwrap_or(default_secs);
Duration::from_secs(secs)
}
/// How long a tripped gRPC circuit breaker stays open before allowing a probe —
/// mirrors `GrpcTransportConfig::default().circuit_breaker_reset` (30s), which
/// is what the spawned `tidal-server` processes run with. Suites that wait for
/// breaker-mediated recovery budget this much on top of [`convergence_budget`].
pub const BREAKER_RESET: Duration = Duration::from_secs(30);
/// Poll cadence for every deadline-bounded wait loop in the harness.
const POLL_INTERVAL: Duration = Duration::from_millis(100);
/// Bounded settle pause after spawning all processes, before the first health
/// poll. Never a correctness gate (health/convergence are polled with deadlines);
/// it just avoids hammering a socket that has not started listening yet.
const SETTLE: Duration = Duration::from_millis(150);
/// Which address a topology entry carries, for the [`AddrRewrite`] hook.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AddrKind {
/// The gRPC replication address (`grpc_addr`).
Grpc,
/// The public HTTP address (`http_addr`).
Http,
}
/// Maps a peer region's REAL bind address to the address the OBSERVER process
/// should publish for it.
///
/// `(observer_region, peer_region, kind, real_addr) -> published_addr`. The default
/// is identity (`real_addr.to_string()`). Task 05 supplies a rewrite that returns a
/// TCP-proxy address for a peer's real address so severing the proxy is a real
/// network partition while the process keeps binding its real port.
///
/// The `observer_region` is the region whose topology file is being written (the
/// process that will DIAL the published address). Carrying it lets a rewrite
/// interpose a proxy on a SPECIFIC directed edge (`observer → peer`) — the
/// granularity the follower↔follower partition test needs (cut eu-west↔ap-south
/// while leaving leader↔ap-south intact). A rewrite that only cares about the
/// target ignores `observer_region`.
pub type AddrRewrite = Box<dyn Fn(&str, &str, AddrKind, SocketAddr) -> String + Send + Sync>;
/// Identity rewrite: peers publish the real address verbatim.
#[must_use]
pub fn identity_rewrite() -> AddrRewrite {
Box::new(|_observer, _peer, _kind, addr| addr.to_string())
}
/// DNS-hostname rewrite (m11p5 §1, exit gate 4): every PEER `grpc_addr` is
/// published as `localhost:<port>` — a HOSTNAME, not a literal `SocketAddr` — so
/// the full dial stack must route through the DNS resolver (`Channel::from_shared`
/// re-resolution, the entire point of the `String` peer retype). The loopback
/// loses no reachability (`localhost` resolves to `127.0.0.1`), but a peer entry
/// that the pre-m11p5 `SocketAddr::parse` would have REJECTED at boot now must
/// boot, replicate, and survive restarts — proving the resolver is in the path.
///
/// HTTP peer entries keep the real IP: the test client dials each node's HTTP
/// addr DIRECTLY (no rewrite on the operator console), and forwarding between
/// nodes is exercised separately; the gate is the gRPC *replication* dial. A
/// process's OWN region entry is never rewritten ([`write_topology_for`]), so the
/// bind side stays a real `0.0.0.0`/loopback `SocketAddr` and only the advertised
/// peer name is a hostname — the exact bind/advertise split §1 specifies.
#[must_use]
pub fn hostname_rewrite() -> AddrRewrite {
Box::new(|_observer, _peer, kind, addr| match kind {
AddrKind::Grpc => format!("localhost:{}", addr.port()),
AddrKind::Http => addr.to_string(),
})
}
/// Options for [`MultiProcCluster::start_with`].
pub struct ClusterOptions {
/// Number of regions (one OS process each). Must be >= 2.
pub regions: usize,
/// Per-node extra environment, keyed by region INDEX (0-based, topology
/// order). Merged onto the base env at spawn (e.g. `TIDAL_HLC_SKEW_MS`,
/// a version tag) — used by the clock-skew / rolling-upgrade scenarios.
pub extra_env: HashMap<usize, Vec<(String, String)>>,
/// Peer-address rewrite hook (default identity). See [`AddrRewrite`].
pub rewrite: AddrRewrite,
/// Per-node `TIDAL_SERVER_LOG` value (default `"warn"`).
pub log: String,
/// Extra topology YAML appended verbatim to every per-process topology
/// file (default none). Used to tune the m11p4 `election:` block — e.g.
/// faster timeouts for the failover gates, or `auto_election: false` for
/// suites that exercise the legacy manual-promote protocol.
pub topology_extra: Option<String>,
}
impl ClusterOptions {
/// Default options for `n` regions: no extra env, identity rewrite, `warn` logs.
#[must_use]
pub fn new(regions: usize) -> Self {
Self {
regions,
extra_env: HashMap::new(),
rewrite: identity_rewrite(),
log: "warn".into(),
topology_extra: None,
}
}
/// Append extra YAML (e.g. an `election:` block) to every topology file.
/// Builder-style; chainable.
#[must_use]
pub fn with_topology_extra(mut self, yaml: &str) -> Self {
self.topology_extra = Some(yaml.to_string());
self
}
/// Add extra env for one region (by index). Builder-style; chainable.
#[must_use]
pub fn with_env(mut self, region_idx: usize, key: &str, value: &str) -> Self {
self.extra_env
.entry(region_idx)
.or_default()
.push((key.to_string(), value.to_string()));
self
}
/// Replace the peer-address rewrite hook. Builder-style; chainable.
#[must_use]
pub fn with_rewrite(mut self, rewrite: AddrRewrite) -> Self {
self.rewrite = rewrite;
self
}
}
/// One region's static identity within the cluster: its name, real bind ports,
/// and the data dir its `TidalDb` persists to (so a restart reuses the same one).
#[derive(Clone)]
struct RegionPlan {
name: String,
grpc: SocketAddr,
http: SocketAddr,
data_dir: PathBuf,
/// Per-shard gRPC bind ports for the m11p6 sharded harness: index `s` is the
/// real loopback port this node's replica of shard group `s` binds. Empty for
/// the single-group (`shards:` absent) harness — that path uses [`grpc`]
/// verbatim and stays byte-for-byte the pre-m11p6 topology.
shard_grpc: Vec<SocketAddr>,
}
/// A running single-region process.
struct NodeHandle {
name: String,
http: SocketAddr,
process: Option<Child>,
}
impl NodeHandle {
/// Send `kill -9` (SIGKILL) via the `kill` binary — a real crash, no graceful
/// drain. Stays `unsafe_code = forbid`-clean (no `libc::kill` FFI), matching
/// the SIGTERM path in `cluster_e2e.rs`. Reaps the process so it is not a
/// zombie. Idempotent: a node already taken/killed is a no-op.
fn sigkill(&mut self) {
if let Some(mut child) = self.process.take() {
#[cfg(unix)]
let _ = Command::new("kill")
.args(["-9", &child.id().to_string()])
.status();
#[cfg(not(unix))]
let _ = child.kill();
let _ = child.wait();
}
}
/// Graceful SIGTERM-then-SIGKILL shutdown for Drop (same contract as
/// `cluster_e2e.rs`): give the process 2s to checkpoint + WAL fsync + join,
/// then hard-kill if it has not exited.
fn graceful_stop(&mut self) {
let Some(mut child) = self.process.take() else {
return;
};
#[cfg(unix)]
{
let _ = Command::new("kill")
.args(["-TERM", &child.id().to_string()])
.status();
let deadline = Instant::now() + Duration::from_secs(2);
loop {
match child.try_wait() {
Ok(Some(_)) => break,
_ if Instant::now() > deadline => {
let _ = child.kill();
break;
}
_ => std::thread::sleep(POLL_INTERVAL),
}
}
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
let _ = child.wait();
}
}
impl Drop for NodeHandle {
fn drop(&mut self) {
self.graceful_stop();
}
}
/// The multi-process cluster harness: one OS process per region.
pub struct MultiProcCluster {
plans: Vec<RegionPlan>,
nodes: Vec<NodeHandle>,
schema_path: PathBuf,
/// Per-process topology files (index = region index). With the identity
/// rewrite these are byte-identical; with a rewrite each lists real addrs for
/// self and published addrs for peers.
topology_paths: Vec<PathBuf>,
rewrite: AddrRewrite,
log: String,
extra_env: HashMap<usize, Vec<(String, String)>>,
client: reqwest::blocking::Client,
/// Shard-group count (m11p6). `1` for the single-group harness (`shards:`
/// absent); `S` for a [`start_sharded`](MultiProcCluster::start_sharded)
/// cluster. Used by the per-shard status helpers.
shards: usize,
/// m12p4 partial placement: `placement[g]` = node indices hosting group `g`.
/// `None` for full placement (every node hosts every group). Lets the
/// per-shard leader wait know which node OWNS which group.
partial_placement: Option<Vec<Vec<usize>>>,
/// m12p4: how many shard groups each node hosts (index = node). Empty for the
/// full-placement harnesses (every node hosts `shards`). The partial-aware
/// leader wait uses this to size each node's expected `shards[]` row count.
host_group_counts: Vec<usize>,
/// Held so the tempdir (configs + per-node data dirs) outlives every process.
_tmp: tempfile::TempDir,
}
impl MultiProcCluster {
/// Spawn an `n`-region cluster with default options and wait for every
/// process to become healthy. Region 0 is the leader.
///
/// # Panics
///
/// Panics if `n < 2`, if config/topology files cannot be written, if the
/// binary cannot be built/spawned, or if any process does not become healthy
/// within [`boot_budget`].
#[must_use]
pub fn start(n: usize) -> Self {
Self::start_with(ClusterOptions::new(n))
}
/// Spawn a cluster with explicit options (extra env, rewrite hook, log level).
///
/// # Panics
///
/// See [`MultiProcCluster::start`].
#[must_use]
pub fn start_with(opts: ClusterOptions) -> Self {
assert!(opts.regions >= 2, "need at least 2 regions for a cluster");
// Serialize the spawn phase (build + port alloc + process start) across
// test threads. Cargo runs the integration tests in this file in
// parallel; several harnesses racing to `cargo build` the same binary and
// claim ports at once is the only observed flakiness source, so the lock
// covers ONLY the heavy bring-up. Once healthy, the tests run fully
// concurrently against their own ports/tempdirs.
let spawn_guard = spawn_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = tempfile::tempdir().expect("create temp dir");
let bin = tidal_server_bin();
// Static plan: names, real ports, per-node data dirs (reused on restart).
let plans: Vec<RegionPlan> = (0..opts.regions)
.map(|i| {
let data_dir = tmp.path().join(format!("region-{i}"));
std::fs::create_dir_all(&data_dir).expect("create per-node data dir");
RegionPlan {
name: region_name(i),
grpc: free_addr(),
http: free_addr(),
data_dir,
shard_grpc: Vec::new(),
}
})
.collect();
let schema_path = write_schema(tmp.path());
let topology_paths: Vec<PathBuf> = (0..opts.regions)
.map(|i| {
write_topology_for(
tmp.path(),
&plans,
i,
&opts.rewrite,
opts.topology_extra.as_deref(),
)
})
.collect();
let mut harness = Self {
plans,
nodes: Vec::new(),
schema_path,
topology_paths,
rewrite: opts.rewrite,
log: opts.log,
extra_env: opts.extra_env,
client: reqwest::blocking::Client::builder()
.build()
.expect("build blocking client"),
shards: 1,
partial_placement: None,
host_group_counts: Vec::new(),
_tmp: tmp,
};
for i in 0..harness.plans.len() {
let child = harness.spawn_process(&bin, i, &[]);
let plan = &harness.plans[i];
harness.nodes.push(NodeHandle {
name: plan.name.clone(),
http: plan.http,
process: Some(child),
});
}
std::thread::sleep(SETTLE);
let deadline = Instant::now() + boot_budget();
for i in 0..harness.nodes.len() {
harness.wait_health_inner(i, deadline);
}
drop(spawn_guard);
harness
}
/// Spawn an `nodes`-process cluster of `shards` shard groups at FULL placement
/// (m11p6): every node replicates every group (RF = `nodes`), and group `s`'s
/// term-0 leader is node `s` (balanced placement). `topology_extra` is appended
/// to every per-process topology file (e.g. a fast-election `election:` block).
///
/// Each `(node, shard)` binds its own real loopback gRPC port, so a node hosts
/// `shards` `ShardReplica`s, each peering with the same group's replicas on the
/// other nodes. This is the harness the m11p6 exit gate runs on: kill any node
/// → only its shard-leaderships re-elect, the rest keep serving.
///
/// # Panics
///
/// Panics if `shards < 1`, `nodes < 2`, `shards > nodes` (balanced placement
/// needs a distinct preferred-leader node per group), files cannot be written,
/// or any process does not become healthy within [`boot_budget`].
#[must_use]
pub fn start_sharded(nodes: usize, shards: usize, topology_extra: Option<&str>) -> Self {
assert!(nodes >= 2, "need at least 2 nodes for a cluster");
assert!(shards >= 1, "need at least 1 shard group");
assert!(
shards <= nodes,
"balanced placement needs a distinct preferred-leader node per group \
(shards {shards} > nodes {nodes})"
);
let spawn_guard = spawn_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = tempfile::tempdir().expect("create temp dir");
let bin = tidal_server_bin();
// Per node: one HTTP gateway port + one real gRPC port per shard group it
// hosts (full placement ⇒ `shards` of them). `grpc` (the node base) is
// shard 0's port, matching the topology `regions[].grpc_addr` convention.
let plans: Vec<RegionPlan> = (0..nodes)
.map(|i| {
let data_dir = tmp.path().join(format!("region-{i}"));
std::fs::create_dir_all(&data_dir).expect("create per-node data dir");
let shard_grpc: Vec<SocketAddr> = (0..shards).map(|_| free_addr()).collect();
RegionPlan {
name: region_name(i),
grpc: shard_grpc[0],
http: free_addr(),
data_dir,
shard_grpc,
}
})
.collect();
let schema_path = write_schema(tmp.path());
let topology_paths: Vec<PathBuf> = (0..nodes)
.map(|i| write_sharded_topology_for(tmp.path(), &plans, i, shards, topology_extra))
.collect();
let mut harness = Self {
plans,
nodes: Vec::new(),
schema_path,
topology_paths,
rewrite: identity_rewrite(),
log: "warn".into(),
extra_env: HashMap::new(),
client: reqwest::blocking::Client::builder()
.build()
.expect("build blocking client"),
shards,
partial_placement: None,
host_group_counts: Vec::new(),
_tmp: tmp,
};
for i in 0..harness.plans.len() {
let child = harness.spawn_process(&bin, i, &[]);
let plan = &harness.plans[i];
harness.nodes.push(NodeHandle {
name: plan.name.clone(),
http: plan.http,
process: Some(child),
});
}
std::thread::sleep(SETTLE);
let deadline = Instant::now() + boot_budget();
for i in 0..harness.nodes.len() {
harness.wait_health_inner(i, deadline);
}
drop(spawn_guard);
harness
}
/// Spawn an `nodes`-process cluster of `placement.len()` shard groups at the
/// EXPLICIT, possibly PARTIAL placement `placement` (m12p4 cross-shard read
/// gate): `placement[g]` is the list of node indices that replicate group `g`,
/// with `placement[g][0]` its term-0 preferred leader. A node that appears in
/// some-but-not-all groups hosts a STRICT SUBSET of the corpus — exactly the
/// shape the L4 cross-shard read fan-out exists for.
///
/// Unlike [`start_sharded`](Self::start_sharded) (full placement, every node a
/// replica of every group), here each node binds a gRPC port ONLY for the
/// groups it actually hosts. The gateway on a partial node must fan a
/// corpus-wide `/feed` out to the groups it does not host to be complete.
///
/// # Panics
///
/// Panics if `placement` is empty, any group lists no replica, a group's
/// leader is not its first replica's owner, an index is out of range, files
/// cannot be written, or any process is not healthy within [`boot_budget`].
#[must_use]
pub fn start_sharded_partial(
nodes: usize,
placement: &[Vec<usize>],
topology_extra: Option<&str>,
) -> Self {
assert!(nodes >= 2, "need at least 2 nodes for a cluster");
assert!(!placement.is_empty(), "need at least 1 shard group");
let shards = placement.len();
for (g, replicas) in placement.iter().enumerate() {
assert!(!replicas.is_empty(), "group {g} lists no replica");
for &n in replicas {
assert!(n < nodes, "group {g} names node {n} but only {nodes} nodes");
}
}
let spawn_guard = spawn_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = tempfile::tempdir().expect("create temp dir");
let bin = tidal_server_bin();
// Each node allocates one gRPC port PER GROUP IT HOSTS (not per group in
// the cluster). `shard_grpc[g]` is bound only for hosted groups; a group
// the node does not host gets a placeholder it never binds (its port is
// never written into a `replicas:` entry, so nothing dials it).
let host_groups: Vec<Vec<usize>> = (0..nodes)
.map(|n| (0..shards).filter(|g| placement[*g].contains(&n)).collect())
.collect();
let plans: Vec<RegionPlan> = (0..nodes)
.map(|i| {
let data_dir = tmp.path().join(format!("region-{i}"));
std::fs::create_dir_all(&data_dir).expect("create per-node data dir");
// One real port per group; unhosted slots stay allocated but
// unused (keeps `shard_grpc[g]` index-stable across all groups).
let shard_grpc: Vec<SocketAddr> = (0..shards).map(|_| free_addr()).collect();
RegionPlan {
name: region_name(i),
grpc: shard_grpc[0],
http: free_addr(),
data_dir,
shard_grpc,
}
})
.collect();
let schema_path = write_schema(tmp.path());
let topology_paths: Vec<PathBuf> = (0..nodes)
.map(|i| {
write_partial_sharded_topology_for(tmp.path(), &plans, i, placement, topology_extra)
})
.collect();
let harness = Self {
plans,
nodes: Vec::new(),
schema_path,
topology_paths,
rewrite: identity_rewrite(),
log: "warn".into(),
extra_env: HashMap::new(),
client: reqwest::blocking::Client::builder()
.build()
.expect("build blocking client"),
shards,
partial_placement: Some(placement.to_vec()),
// Per-node hosted-group counts, so the partial-aware leader wait knows
// how many `shards[]` rows each node should report.
host_group_counts: host_groups.iter().map(Vec::len).collect(),
_tmp: tmp,
};
let mut harness = harness;
for i in 0..harness.plans.len() {
let child = harness.spawn_process(&bin, i, &[]);
let plan = &harness.plans[i];
harness.nodes.push(NodeHandle {
name: plan.name.clone(),
http: plan.http,
process: Some(child),
});
}
std::thread::sleep(SETTLE);
let deadline = Instant::now() + boot_budget();
for i in 0..harness.nodes.len() {
harness.wait_health_inner(i, deadline);
}
drop(spawn_guard);
harness
}
/// Spawn one region's process. `extra` is appended to the per-node env
/// (used by `restart` to inject overrides). The binary, schema, and this
/// region's topology file are stable across restarts.
fn spawn_process(&self, bin: &Path, idx: usize, extra: &[(String, String)]) -> Child {
let plan = &self.plans[idx];
let mut cmd = Command::new(bin);
cmd.arg("cluster")
.arg("--region")
.arg(&plan.name)
.arg("--listen")
.arg(plan.http.to_string())
.arg("--schema")
.arg(&self.schema_path)
.arg("--topology")
.arg(&self.topology_paths[idx])
.arg("--data-dir")
.arg(&plan.data_dir)
.env("TIDAL_ALLOW_EXPERIMENTAL_CLUSTER", "1")
.env("TIDAL_SERVER_LOG", &self.log);
// Node logs are discarded by default so parallel suites stay readable.
// `Stdio::null()`, NEVER `Stdio::piped()`: a piped-but-undrained pipe
// deadlocks the whole node once its 64KB kernel buffer fills — a
// sufficiently chatty log path (e.g. ship-retry WARNs during a long
// partition window) froze every logging thread in the leader,
// including request handlers, and surfaced as spurious 408s.
// `TIDAL_TEST_NODE_LOGS=inherit` streams them to the test's own
// stderr for debugging a failing scenario.
let inherit_logs = std::env::var("TIDAL_TEST_NODE_LOGS").is_ok_and(|v| v == "inherit");
if inherit_logs {
cmd.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
} else {
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
}
if let Some(env) = self.extra_env.get(&idx) {
for (k, v) in env {
cmd.env(k, v);
}
}
for (k, v) in extra {
cmd.env(k, v);
}
cmd.spawn()
.unwrap_or_else(|e| panic!("failed to spawn tidal-server region {}: {e}", plan.name))
}
// ── Accessors ────────────────────────────────────────────────────────────
/// This region's name (topology order).
#[must_use]
pub fn region_name(&self, idx: usize) -> &str {
&self.plans[idx].name
}
/// All region names in topology id order.
#[must_use]
pub fn region_names(&self) -> Vec<String> {
self.plans.iter().map(|p| p.name.clone()).collect()
}
/// Number of regions (= number of processes).
#[must_use]
pub const fn len(&self) -> usize {
self.plans.len()
}
/// Whether the cluster has no regions (always false in practice; satisfies
/// the clippy `len_without_is_empty` lint honestly).
#[must_use]
pub const fn is_empty(&self) -> bool {
self.plans.is_empty()
}
/// The HTTP base URL (`http://host:port`) for a node's process.
#[must_use]
pub fn node(&self, idx: usize) -> String {
format!("http://{}", self.plans[idx].http)
}
/// The shared blocking HTTP client (connection pooling across calls).
#[must_use]
pub const fn client(&self) -> &reqwest::blocking::Client {
&self.client
}
/// Total on-disk size (bytes) of node `idx`'s data dir — the engine's
/// recursive footprint after a snapshot install + catch-up. The exit-gate-2
/// "≤5 min @ 100k items" evidence run records this as the installed-artifact
/// proxy (a freshly seed-joined node's data dir IS the fetched snapshot plus
/// the streamed suffix). Best-effort: unreadable entries are skipped.
#[must_use]
pub fn data_dir_bytes(&self, idx: usize) -> u64 {
fn dir_bytes(path: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(path) else {
return 0;
};
entries
.flatten()
.map(|e| match e.file_type() {
Ok(ft) if ft.is_dir() => dir_bytes(&e.path()),
Ok(_) => e.metadata().map(|m| m.len()).unwrap_or(0),
Err(_) => 0,
})
.sum()
}
dir_bytes(&self.plans[idx].data_dir)
}
/// Node `idx`'s data directory (the `--data-dir` the process was spawned
/// with). Used by the m12p6 SIGTERM test to assert the deterministic shutdown
/// wrote its WAL checkpoint marker (`{data_dir}/wal/checkpoint.meta`) — the
/// on-disk proof that `TidalDb::shutdown_inner` ran on the SIGTERM path
/// (the same close that persists the HNSW graph).
#[must_use]
pub fn data_dir(&self, idx: usize) -> &Path {
&self.plans[idx].data_dir
}
// ── HTTP helpers ─────────────────────────────────────────────────────────
/// `GET {node(idx)}{path}`.
#[must_use]
pub fn get(&self, idx: usize, path: &str) -> reqwest::blocking::Response {
let url = format!("{}{path}", self.node(idx));
self.client
.get(&url)
.send()
.unwrap_or_else(|e| panic!("GET {url} failed: {e}"))
}
/// `GET {node(idx)}{path}` returning the parsed JSON body (panics on failure).
#[must_use]
pub fn get_json(&self, idx: usize, path: &str) -> serde_json::Value {
let resp = self.get(idx, path);
let status = resp.status();
resp.json()
.unwrap_or_else(|e| panic!("GET {path} on node {idx} ({status}) not JSON: {e}"))
}
/// `POST {node(idx)}{path}` with a JSON body.
#[must_use]
pub fn post(
&self,
idx: usize,
path: &str,
body: &serde_json::Value,
) -> reqwest::blocking::Response {
let url = format!("{}{path}", self.node(idx));
self.client
.post(&url)
.json(body)
.send()
.unwrap_or_else(|e| panic!("POST {url} failed: {e}"))
}
// ── Crash / restart ──────────────────────────────────────────────────────
/// SIGKILL the node at `idx` (a real crash — no graceful drain). After this,
/// the node is DOWN; `wait_converged_all` / `wait_leader_agreed` skip it.
///
/// # Panics
///
/// Panics if `idx` is out of range.
pub fn kill_hard(&mut self, idx: usize) {
assert!(idx < self.nodes.len(), "kill_hard: node {idx} out of range");
self.nodes[idx].sigkill();
}
/// Whether the node at `idx` is currently running (not killed / exited-on-Drop).
#[must_use]
pub fn is_alive(&self, idx: usize) -> bool {
self.nodes[idx].process.is_some()
}
/// Restart a previously-killed node on the SAME ports + data dir, with extra
/// env overrides (e.g. a new version tag, or an HLC skew). Reuses the region's
/// topology file, so it rejoins the same cluster. Waits for it to become
/// healthy before returning.
///
/// # Panics
///
/// Panics if `idx` is out of range or the restarted node does not become
/// healthy within [`boot_budget`].
/// Wipe a DEAD node's data dir (the operator "reseed" drill): the next
/// `restart` boots it as a genuinely fresh node that recovers the full
/// log via the catch-up stream. Panics if the process is still alive.
pub fn wipe_data_dir(&self, idx: usize) {
assert!(
!self.is_alive(idx),
"wipe_data_dir requires the node to be stopped"
);
let dir = &self.plans[idx].data_dir;
std::fs::remove_dir_all(dir).expect("wipe data dir");
std::fs::create_dir_all(dir).expect("recreate data dir");
}
pub fn restart(&mut self, idx: usize, env_overrides: &[(&str, &str)]) {
assert!(idx < self.nodes.len(), "restart: node {idx} out of range");
// Ensure any prior process is fully gone (idempotent if already killed).
self.nodes[idx].sigkill();
self.spawn_and_wait(idx, env_overrides);
}
/// Gracefully stop the node at `idx` with SIGTERM (the production shutdown
/// path: the process flips readiness, drains in-flight requests, checkpoints,
/// fsyncs the WAL, and joins its receiver before exiting — exactly what a
/// rolling-upgrade SIGTERM gives an operator), waiting up to its graceful
/// budget then hard-killing if it has not exited. After this the node is DOWN
/// until [`restart`](Self::restart) brings it back on the same data dir. This
/// is NOT [`kill_hard`](Self::kill_hard) (a crash with no drain) — the
/// rolling-upgrade scenario must prove a CLEAN handoff with zero data loss, so
/// it stops via SIGTERM and relies on WAL recovery only as a backstop.
///
/// # Panics
///
/// Panics if `idx` is out of range.
pub fn stop_graceful(&mut self, idx: usize) {
assert!(
idx < self.nodes.len(),
"stop_graceful: node {idx} out of range"
);
self.nodes[idx].graceful_stop();
}
/// Graceful rolling-upgrade restart: SIGTERM the running node (clean drain +
/// checkpoint + WAL fsync), then bring it back on the SAME ports + data dir
/// with `env_overrides` (e.g. a bumped `TIDAL_VERSION_TAG`). Reuses the
/// region's topology file so it rejoins the same cluster, and waits for health
/// before returning. This is the rolling-upgrade choreography's per-node step:
/// no crash, a real shutdown signal, then WAL-backed recovery of pre-restart
/// state.
///
/// # Panics
///
/// Panics if `idx` is out of range or the restarted node does not become
/// healthy within [`boot_budget`].
pub fn restart_graceful(&mut self, idx: usize, env_overrides: &[(&str, &str)]) {
assert!(
idx < self.nodes.len(),
"restart_graceful: node {idx} out of range"
);
self.nodes[idx].graceful_stop();
self.spawn_and_wait(idx, env_overrides);
}
/// Spawn `idx`'s process (under the spawn lock) with `env_overrides`, then wait
/// for it to report healthy. Shared by [`restart`] and [`restart_graceful`];
/// assumes the prior process is already stopped.
fn spawn_and_wait(&mut self, idx: usize, env_overrides: &[(&str, &str)]) {
let spawn_guard = spawn_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let bin = tidal_server_bin();
let extra: Vec<(String, String)> = env_overrides
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
let child = self.spawn_process(&bin, idx, &extra);
self.nodes[idx].process = Some(child);
drop(spawn_guard);
std::thread::sleep(SETTLE);
let deadline = Instant::now() + boot_budget();
self.wait_health_inner(idx, deadline);
}
// ── Elasticity: seed-join add / remove (m11p5 §3.4) ────────────────────────
/// Add a NEW node that seed-joins the cluster (m11p5 §3.4): allocate ports +
/// a data dir, write a knob-only topology variant (node `seed_of`'s topology
/// file — the joiner reads its `regions:` list for KNOBS only, the join
/// response is the roster), then spawn `cluster --region region-N
/// --seed http://<seed http_addr> --advertise-grpc <p> --advertise-http <p2>
/// --topology <knob file>`. Waits for `/health/startup` (the joiner reports
/// 503 until first-converged, then 200 — so health gates on convergence,
/// proving the snapshot+stream catch-up landed). Returns the new node's index.
///
/// The data dir is a SUBDIRECTORY of the harness tempdir (a mount root would
/// fail the §2.3 swap), so a snapshot install's rename siblings have a parent.
///
/// # Panics
///
/// Panics if `seed_of` is out of range, files cannot be written, the binary
/// cannot be spawned, or the joiner does not become healthy within
/// [`boot_budget`] (a join+install over loopback).
pub fn add_node(&mut self, seed_of: usize) -> usize {
assert!(
seed_of < self.plans.len(),
"add_node: seed {seed_of} out of range"
);
let spawn_guard = spawn_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let idx = self.plans.len();
let name = region_name(idx);
let grpc = free_addr();
let http = free_addr();
// The harness tempdir is the parent of every node's data dir (the `_tmp`
// field is underscore-held for Drop ordering; reach the path through an
// existing plan to avoid touching the underscore binding directly).
let tmp_root = self.plans[0]
.data_dir
.parent()
.expect("a node data dir always has the tempdir as its parent")
.to_path_buf();
let data_dir = tmp_root.join(format!("region-{idx}"));
std::fs::create_dir_all(&data_dir).expect("create joiner data dir");
// The knob source (§3.5): a topology file whose `regions:` is the INITIAL
// roster (NOT this joiner) — the joiner's own region is learned from the
// join response, not this file. Reusing the seed node's topology file is
// exactly the k8s shared-ConfigMap shape. We write a fresh copy so a
// concurrent restart of the seed never races this file handle.
let knob_path = {
let path = tmp_root.join(format!("topology-seed-{idx}.yaml"));
let body = std::fs::read_to_string(&self.topology_paths[seed_of])
.expect("read seed topology for knobs");
std::fs::write(&path, body).expect("write joiner knob topology");
path
};
let seed_http = self.plans[seed_of].http;
let bin = tidal_server_bin();
let mut cmd = Command::new(&bin);
cmd.arg("cluster")
.arg("--region")
.arg(&name)
.arg("--listen")
.arg(http.to_string())
.arg("--schema")
.arg(&self.schema_path)
.arg("--topology")
.arg(&knob_path)
.arg("--data-dir")
.arg(&data_dir)
.arg("--seed")
.arg(format!("http://{seed_http}"))
.arg("--advertise-grpc")
.arg(grpc.to_string())
.arg("--advertise-http")
.arg(http.to_string())
.env("TIDAL_ALLOW_EXPERIMENTAL_CLUSTER", "1")
.env("TIDAL_SERVER_LOG", &self.log)
// Bound the join + the (rare) install handshake inside the test budget
// rather than the production defaults.
.env("TIDAL_SEED_JOIN_MS", "60000")
.env("TIDAL_RESEED_HANDSHAKE_MS", "30000");
let inherit_logs = std::env::var("TIDAL_TEST_NODE_LOGS").is_ok_and(|v| v == "inherit");
if inherit_logs {
cmd.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
} else {
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
}
let child = cmd
.spawn()
.unwrap_or_else(|e| panic!("failed to spawn seed-join node {name}: {e}"));
// Register the new node in the harness tables (so `local_status`,
// convergence polls, and Drop all see it).
self.plans.push(RegionPlan {
name: name.clone(),
grpc,
http,
data_dir,
shard_grpc: Vec::new(),
});
self.topology_paths.push(knob_path);
self.nodes.push(NodeHandle {
name,
http,
process: Some(child),
});
drop(spawn_guard);
std::thread::sleep(SETTLE);
// The joiner's /health/startup is 503 until first-converged (§4 sticky
// readiness), so this wait proves the snapshot+stream catch-up landed.
// Give it the convergence budget on top of the boot budget (a join +
// possible install + catch-up).
let deadline = Instant::now() + boot_budget() + convergence_budget();
self.wait_health_inner(idx, deadline);
idx
}
/// Remove (decommission) a member via the `/cluster/members/remove` verb
/// (m11p5 §3.3): POST to the current leader (the verb forwards from a
/// follower, but targeting the leader avoids a hop). After this the leader
/// appends a `Removed` record; the removed node learns via the stream and
/// flips to readiness 503 + stops campaigning. Returns the leader's response
/// status code.
///
/// # Panics
///
/// Panics if no live leader can be found.
pub fn remove_node(&self, name: &str) -> u16 {
let leader_idx = self
.live_indices()
.into_iter()
.find(|&i| {
self.local_status(i)
.is_some_and(|s| s["is_leader"].as_bool() == Some(true))
})
.expect("remove_node: no live leader found to issue the remove verb");
let resp = self.post(
leader_idx,
"/cluster/members/remove",
&serde_json::json!({ "region": name }),
);
resp.status().as_u16()
}
// ── Health / convergence / leadership polling ──────────────────────────────
/// Block until node `idx` reports healthy, or [`boot_budget`] elapses.
///
/// # Panics
///
/// Panics on timeout.
pub fn wait_healthy(&self, idx: usize) {
let deadline = Instant::now() + boot_budget();
self.wait_health_inner(idx, deadline);
}
/// Poll `/health/startup` until 200, or `deadline`.
fn wait_health_inner(&self, idx: usize, deadline: Instant) {
let url = format!("{}/health/startup", self.node(idx));
loop {
if let Ok(resp) = self.client.get(&url).send()
&& resp.status().is_success()
{
return;
}
assert!(
Instant::now() <= deadline,
"node {idx} ({}) not healthy within budget: {}",
self.plans[idx].name,
self.drain_logs_hint(idx)
);
std::thread::sleep(POLL_INTERVAL);
}
}
/// Block until EVERY live follower's OWN `/cluster/status/local` reports zero
/// lag against the leader's high-water-mark, or `timeout`.
///
/// This is the exact gap the old harness could not close: it polls each
/// follower PROCESS directly (not the leader's aggregate view), so it proves
/// the segment crossed the process boundary and was applied on the follower.
/// The leader's own row is skipped (it writes directly, not via replication).
/// A killed node (`process == None`) is skipped — a partition test converges
/// only the reachable followers.
///
/// # Panics
///
/// Panics on timeout, dumping each lagging follower's last status row.
pub fn wait_converged_all(&self, timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
let leader_seq = self.leader_last_seq();
let mut pending: Vec<String> = Vec::new();
if let Some(target) = leader_seq {
for idx in self.live_follower_indices() {
match self.local_status(idx) {
Some(st) => {
let applied = st["applied_events"].as_u64().unwrap_or(0);
let lag = st["lag_events"].as_u64().unwrap_or(u64::MAX);
if !(lag == 0 && applied >= target) {
pending.push(format!(
"{}: applied={applied} lag={lag} (target {target})",
self.plans[idx].name
));
}
}
None => pending.push(format!(
"{}: status/local unreachable",
self.plans[idx].name
)),
}
}
if pending.is_empty() {
return;
}
} else {
pending.push("leader status/local unreachable".into());
}
assert!(
Instant::now() <= deadline,
"cluster did not converge within {timeout:?}; pending: {pending:?}"
);
std::thread::sleep(POLL_INTERVAL);
}
}
/// Block until every LIVE node's `/cluster/status/local` agrees the leader is
/// `expected`, or `timeout`.
///
/// # Panics
///
/// Panics on timeout, dumping each live node's reported leader.
pub fn wait_leader_agreed(&self, expected: &str, timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
let mut disagree: Vec<String> = Vec::new();
for idx in self.live_indices() {
match self.local_status(idx) {
Some(st) => {
let leader = st["leader"].as_str().unwrap_or("<none>");
if leader != expected {
disagree.push(format!("{}: leader={leader}", self.plans[idx].name));
}
}
None => disagree.push(format!("{}: unreachable", self.plans[idx].name)),
}
}
if disagree.is_empty() {
return;
}
assert!(
Instant::now() <= deadline,
"nodes did not agree leader={expected} within {timeout:?}; disagree: {disagree:?}"
);
std::thread::sleep(POLL_INTERVAL);
}
}
/// The leader's relay high-water-mark (`last_seq`) from its own
/// `/cluster/status/local`, or `None` if the leader is down/unreachable.
///
/// The leader is whichever LIVE node reports `is_leader: true` (so this works
/// after a promote moves leadership).
#[must_use]
pub fn leader_last_seq(&self) -> Option<u64> {
for idx in self.live_indices() {
if let Some(st) = self.local_status(idx)
&& st["is_leader"].as_bool() == Some(true)
{
return st["last_seq"].as_u64();
}
}
None
}
/// Fetch one node's `/cluster/status/local` (parsed JSON), or `None` on any
/// transport/parse failure (a killed or partitioned node).
#[must_use]
pub fn local_status(&self, idx: usize) -> Option<serde_json::Value> {
let url = format!("{}/cluster/status/local", self.node(idx));
let resp = self
.client
.get(&url)
.timeout(Duration::from_secs(2))
.send()
.ok()?;
if !resp.status().is_success() {
return None;
}
resp.json().ok()
}
/// Indices of nodes whose process is still running.
fn live_indices(&self) -> Vec<usize> {
(0..self.nodes.len())
.filter(|&i| self.nodes[i].process.is_some())
.collect()
}
/// Live nodes that are NOT the current leader (the convergence targets).
fn live_follower_indices(&self) -> Vec<usize> {
let leader = self.current_leader();
self.live_indices()
.into_iter()
.filter(|&i| Some(self.plans[i].name.as_str()) != leader.as_deref())
.collect()
}
/// The leader name as reported by the first live node that answers, or `None`.
fn current_leader(&self) -> Option<String> {
for idx in self.live_indices() {
if let Some(st) = self.local_status(idx)
&& let Some(leader) = st["leader"].as_str()
{
return Some(leader.to_string());
}
}
None
}
// ── Per-shard leadership (m11p6 sharded harness) ───────────────────────────
/// The shard-group count this harness booted (1 for the single-group harness).
#[must_use]
pub const fn shard_count(&self) -> usize {
self.shards
}
/// The leader EVERY live node agrees on for each shard group, or `None` if any
/// live node disagrees, reports a null leader, or is unreachable. The
/// agreed-leader read used to detect the converged steady state and to detect a
/// failover (the map changes for exactly the killed node's groups).
///
/// Reads the per-shard `shards[]` rows in `/cluster/status/local`: for each
/// group it collects every live node's reported leader and accepts the group
/// only when all of them are present and identical.
#[must_use]
pub fn agreed_shard_leaders(&self) -> Option<HashMap<u16, String>> {
let live: Vec<usize> = self.live_indices();
if live.is_empty() {
return None;
}
let mut per_shard: HashMap<u16, String> = HashMap::new();
for &idx in &live {
let st = self.local_status(idx)?;
let rows = st["shards"].as_array()?;
// `shards[]` is one row per group this node HOSTS — a constant under
// full placement (every node hosts every group), so a short array
// means the node is still booting/unreachable (its status raced the
// listener), not a leadership-convergence lag. Treating it as
// not-yet-agreed (wait) is correct: a just-restarted rejoiner reports
// a short/empty array until its groups open. (Partial placement would
// make this per-node; the exit gate is full placement.)
if rows.len() != self.shards {
return None;
}
for row in rows {
let shard = u16::try_from(row["shard"].as_u64()?).ok()?;
let leader = row["leader"].as_str()?.to_string();
match per_shard.get(&shard) {
Some(seen) if seen != &leader => return None, // disagreement
Some(_) => {}
None => {
per_shard.insert(shard, leader);
}
}
}
}
(per_shard.len() == self.shards).then_some(per_shard)
}
/// Block until every live node agrees on a (non-null) leader for every shard
/// group, returning the agreed `shard -> leader` map. The sharded analogue of
/// [`wait_leader_agreed`](Self::wait_leader_agreed).
///
/// # Panics
///
/// Panics on timeout, dumping the last partial view.
#[must_use]
pub fn wait_shard_leaders_agreed(&self, timeout: Duration) -> HashMap<u16, String> {
let deadline = Instant::now() + timeout;
loop {
if let Some(map) = self.agreed_shard_leaders() {
return map;
}
assert!(
Instant::now() <= deadline,
"shard leaders did not converge within {timeout:?}; last partial view: {:?}",
self.agreed_shard_leaders()
);
std::thread::sleep(POLL_INTERVAL);
}
}
/// m12p4 PARTIAL-placement analogue of [`agreed_shard_leaders`]. Each node
/// reports only the `shards[]` rows for the groups IT hosts (a node-local
/// count from `host_group_counts`), and a group is agreed once EVERY node that
/// hosts it reports the SAME (non-null) leader. A node hosting a strict subset
/// no longer trips the full-placement `rows.len() == self.shards` guard.
///
/// # Panics
///
/// Panics if called on a non-partial harness (use [`agreed_shard_leaders`]).
#[must_use]
fn agreed_shard_leaders_partial(&self) -> Option<HashMap<u16, String>> {
let placement = self
.partial_placement
.as_ref()
.expect("agreed_shard_leaders_partial needs a partial-placement harness");
let live: Vec<usize> = self.live_indices();
if live.is_empty() {
return None;
}
let mut per_shard: HashMap<u16, String> = HashMap::new();
for &idx in &live {
let st = self.local_status(idx)?;
let rows = st["shards"].as_array()?;
// The node must have opened EVERY group it hosts (else it is still
// booting — wait, do not mis-read as agreed).
if rows.len() != self.host_group_counts[idx] {
return None;
}
for row in rows {
let shard = u16::try_from(row["shard"].as_u64()?).ok()?;
let leader = row["leader"].as_str()?.to_string();
match per_shard.get(&shard) {
Some(seen) if seen != &leader => return None,
Some(_) => {}
None => {
per_shard.insert(shard, leader);
}
}
}
}
// Agreed only when every group has a reported leader (some node hosts it).
(per_shard.len() == placement.len()).then_some(per_shard)
}
/// Block until every group's hosting nodes agree on a leader, under PARTIAL
/// placement. The m12p4 analogue of [`wait_shard_leaders_agreed`].
///
/// # Panics
///
/// Panics on timeout (dumping the last partial view) or on a non-partial
/// harness.
#[must_use]
pub fn wait_shard_leaders_agreed_partial(&self, timeout: Duration) -> HashMap<u16, String> {
let deadline = Instant::now() + timeout;
loop {
if let Some(map) = self.agreed_shard_leaders_partial() {
return map;
}
assert!(
Instant::now() <= deadline,
"partial shard leaders did not converge within {timeout:?}; last view: {:?}",
self.agreed_shard_leaders_partial()
);
std::thread::sleep(POLL_INTERVAL);
}
}
/// Best-effort hint for a failing node, to make boot failures diagnosable in
/// the panic message. Node logs go to /dev/null by default (re-run with
/// `TIDAL_TEST_NODE_LOGS=inherit` to see them), so we surface the bind
/// address — the actionable detail when a port conflicts or a bind fails.
fn drain_logs_hint(&self, idx: usize) -> String {
format!("(node http={})", self.plans[idx].http)
}
}
// ── Shared seed / write helpers (the tier-3 suites' common data setup) ────────
/// Seed `1..=count` items (`title: "item {id}"`) plus deterministic 4-dim
/// embeddings (`[v, v+1, v+2, v+3]`, matching the shared schema's
/// `content_vector` slot) on the node at `leader_idx`.
///
/// m11p2: items and embeddings ride the one replicated log (kind-1/2 WAL
/// records) — there is no HTTP broadcast report to assert anymore. The 201 /
/// 204 assert leader durability (the record is fsynced into the stream);
/// follower visibility is asserted by each suite's convergence checks.
pub fn seed_items_and_embeddings(cluster: &MultiProcCluster, leader_idx: usize, count: u64) {
for entity_id in 1..=count {
let resp = cluster.post(
leader_idx,
"/items",
&serde_json::json!({
"entity_id": entity_id,
"metadata": { "title": format!("item {entity_id}") }
}),
);
assert_eq!(resp.status().as_u16(), 201, "leader /items must 201");
// A deterministic, entity-varying embedding so feed ordering is stable.
#[allow(clippy::cast_precision_loss)]
let v = entity_id as f32;
let resp = cluster.post(
leader_idx,
"/embeddings",
&serde_json::json!({
"entity_id": entity_id,
"values": [v, v + 1.0, v + 2.0, v + 3.0]
}),
);
assert_eq!(resp.status().as_u16(), 204, "leader /embeddings must 204");
}
}
/// POST one `view` signal for `entity_id` to the node at `leader_idx`,
/// asserting the leader-durable 204 contract.
pub fn write_view(cluster: &MultiProcCluster, leader_idx: usize, entity_id: u64, weight: f64) {
let resp = cluster.post(
leader_idx,
"/signals",
&serde_json::json!({ "entity_id": entity_id, "signal": "view", "weight": weight }),
);
assert_eq!(
resp.status().as_u16(),
204,
"leader /signals must 204 (leader-durable contract): {}",
resp.status()
);
}
// ── Topology + schema generation ──────────────────────────────────────────────
/// Write the topology file process `idx` will load. Its OWN region lists real
/// addrs (so it binds the harness-opened ports); every PEER lists the published
/// address from the rewrite hook.
///
/// The rewrite is invoked as `rewrite(observer_region, peer_region, kind, real)`:
/// the OBSERVER is `plans[idx]` (the process that will dial the published address)
/// and the PEER is `plans[i]` (the region it is reaching). Carrying the observer
/// lets a rewrite interpose a proxy on a SPECIFIC directed edge — the granularity
/// the follower↔follower partition test needs.
fn write_topology_for(
dir: &Path,
plans: &[RegionPlan],
idx: usize,
rewrite: &AddrRewrite,
extra: Option<&str>,
) -> PathBuf {
let path = dir.join(format!("topology-{idx}.yaml"));
let observer = &plans[idx].name;
let mut body = String::from("regions:\n");
for (i, plan) in plans.iter().enumerate() {
let (grpc, http) = if i == idx {
// Self: real bind addresses, never rewritten.
(plan.grpc.to_string(), plan.http.to_string())
} else {
(
rewrite(observer, &plan.name, AddrKind::Grpc, plan.grpc),
rewrite(observer, &plan.name, AddrKind::Http, plan.http),
)
};
let _ = writeln!(body, " - name: {}", plan.name);
let _ = writeln!(body, " grpc_addr: \"{grpc}\"");
let _ = writeln!(body, " http_addr: \"{http}\"");
}
// Region 0 is the initial leader (consistent with cluster_routes.rs).
let _ = writeln!(body, "leader: {}", plans[0].name);
if let Some(extra) = extra {
let _ = writeln!(body, "{extra}");
}
let mut f = std::fs::File::create(&path).expect("create topology file");
f.write_all(body.as_bytes()).expect("write topology file");
path
}
/// Write the sharded topology file process `idx` loads (m11p6 full placement):
/// a `regions:` list (every node) plus a `shards:` block declaring `shards`
/// groups, each replicated across every node and led (term-0) by node `s`.
///
/// Each `(node, shard)` replica entry carries its own real loopback `grpc_addr`
/// (`plans[node].shard_grpc[shard]`), so the engine binds a distinct socket per
/// hosted group — no derived-port collisions. With identity addressing every
/// process's file is byte-identical (the kill-node exit gate uses SIGKILL, not
/// TCP-proxy partitions, so no per-observer rewrite is needed). The legacy
/// top-level `leader:` is set but unused once `shards:` is present.
fn write_sharded_topology_for(
dir: &Path,
plans: &[RegionPlan],
idx: usize,
shards: usize,
extra: Option<&str>,
) -> PathBuf {
// Full placement: balanced leaders need a distinct node per group, and every
// node must have one bound port per group. Assert both up front so a future
// refactor that desyncs `shards` from the allocation fails loudly here, not as
// an out-of-bounds index mid-write or an opaque engine bind error.
assert!(
shards <= plans.len(),
"balanced placement needs nodes ({}) >= shards ({shards})",
plans.len()
);
for plan in plans {
assert!(
plan.shard_grpc.len() >= shards,
"node {} has {} shard ports, need {shards}",
plan.name,
plan.shard_grpc.len()
);
}
let path = dir.join(format!("topology-{idx}.yaml"));
let mut body = String::from("regions:\n");
for plan in plans {
// The node's base grpc_addr is shard 0's port (the topology convention);
// each group sets its own explicit grpc_addr in the `shards:` block below.
let _ = writeln!(body, " - name: {}", plan.name);
let _ = writeln!(body, " grpc_addr: \"{}\"", plan.shard_grpc[0]);
let _ = writeln!(body, " http_addr: \"{}\"", plan.http);
}
let _ = writeln!(body, "shards:");
for s in 0..shards {
let _ = writeln!(body, " - id: {s}");
// Balanced placement: group `s`'s term-0 leader is node `s`.
let _ = writeln!(body, " leader: {}", plans[s].name);
let _ = writeln!(body, " replicas:");
for plan in plans {
let _ = writeln!(
body,
" - {{ node: {}, grpc_addr: \"{}\" }}",
plan.name, plan.shard_grpc[s]
);
}
}
// Legacy field — required by the loader, ignored once `shards:` is present.
let _ = writeln!(body, "leader: {}", plans[0].name);
if let Some(extra) = extra {
let _ = writeln!(body, "{extra}");
}
let mut f = std::fs::File::create(&path).expect("create sharded topology file");
f.write_all(body.as_bytes())
.expect("write sharded topology file");
path
}
/// Write the PARTIAL-placement sharded topology file process `idx` loads (m12p4):
/// a `regions:` list (every node) plus a `shards:` block where each group `g`
/// declares ONLY the replica entries in `placement[g]` (a subset of nodes), led
/// (term-0) by `placement[g][0]`. A node absent from `placement[g]` never appears
/// in group `g`'s `replicas:`, so it does not host the group — its gateway must
/// cross-shard fan out to reach `g`. Identity addressing, so every process's file
/// is byte-identical (SIGKILL not TCP-proxy, no per-observer rewrite).
fn write_partial_sharded_topology_for(
dir: &Path,
plans: &[RegionPlan],
idx: usize,
placement: &[Vec<usize>],
extra: Option<&str>,
) -> PathBuf {
let path = dir.join(format!("topology-{idx}.yaml"));
let mut body = String::from("regions:\n");
for plan in plans {
let _ = writeln!(body, " - name: {}", plan.name);
let _ = writeln!(body, " grpc_addr: \"{}\"", plan.shard_grpc[0]);
let _ = writeln!(body, " http_addr: \"{}\"", plan.http);
}
let _ = writeln!(body, "shards:");
for (g, replicas) in placement.iter().enumerate() {
assert!(!replicas.is_empty(), "group {g} lists no replica");
let _ = writeln!(body, " - id: {g}");
// Term-0 leader = the group's first listed replica node.
let _ = writeln!(body, " leader: {}", plans[replicas[0]].name);
let _ = writeln!(body, " replicas:");
for &node in replicas {
let _ = writeln!(
body,
" - {{ node: {}, grpc_addr: \"{}\" }}",
plans[node].name, plans[node].shard_grpc[g]
);
}
}
// Legacy field — required by the loader, ignored once `shards:` is present.
let _ = writeln!(body, "leader: {}", plans[0].name);
if let Some(extra) = extra {
let _ = writeln!(body, "{extra}");
}
let mut f = std::fs::File::create(&path).expect("create partial sharded topology file");
f.write_all(body.as_bytes())
.expect("write partial sharded topology file");
path
}
/// Write the shared schema file. Matches the signal set the in-process route
/// tests use (`view` decayed, `like` decayed, `hide` permanent for `/hardnegs`)
/// so the harness asserts against identical engine behavior, plus a `title` text
/// field and a 4-dim embedding slot so `/items` metadata and `/embeddings` apply.
fn write_schema(dir: &Path) -> PathBuf {
let path = dir.join("schema.yaml");
std::fs::write(
&path,
r"signals:
- name: view
entity: item
decay:
exponential:
half_life_seconds: 604800
windows: [one_hour]
velocity: false
- name: like
entity: item
decay:
exponential:
half_life_seconds: 86400
windows: [one_hour]
velocity: false
- name: hide
entity: item
decay:
permanent: true
velocity: false
text_fields:
- name: title
kind: text
embedding_slots:
- name: content_vector
entity: item
dimensions: 4
",
)
.expect("write schema.yaml");
path
}
/// Region name for index `i`: a fixed roster (`us-east`, `eu-west`, `ap-south`,
/// …) matching the in-process route tests, then `region-N` past the roster.
fn region_name(i: usize) -> String {
const ROSTER: [&str; 3] = ["us-east", "eu-west", "ap-south"];
ROSTER
.get(i)
.map_or_else(|| format!("region-{i}"), |s| (*s).to_string())
}
// ── Process-global helpers (shared across harness instances) ────────────────────
/// Allocate a free loopback port by binding `:0` and reading the assigned port.
/// The listener is dropped immediately; the brief TOCTOU window is acceptable
/// for a localhost test harness. The spawn lock serializes allocation WITHIN
/// one test binary; suites in different binaries (cargo runs each `tests/*.rs`
/// as its own process) can still race, and a killed node's port could be handed
/// to another suite before `restart` reclaims it. Both manifest as a loud bind
/// failure at boot/restart — never silent corruption — and the ephemeral range
/// makes them rare enough to accept over a cross-process lock file.
fn free_addr() -> SocketAddr {
TcpListener::bind("127.0.0.1:0")
.expect("bind ephemeral port")
.local_addr()
.expect("read local_addr")
}
/// Serialize the heavy spawn phase (build + port alloc + process start) across
/// the parallel integration tests in a file. See [`MultiProcCluster::start_with`].
fn spawn_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
/// Resolve (and build once) the `tidal-server` binary, exactly like
/// `cluster_e2e.rs`. The `cargo build` is a no-op when the binary is current.
fn tidal_server_bin() -> PathBuf {
static BIN: OnceLock<PathBuf> = OnceLock::new();
BIN.get_or_init(|| {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let candidates = [
manifest_dir.parent(),
manifest_dir.parent().and_then(Path::parent),
];
let workspace_root = candidates
.into_iter()
.flatten()
.find(|root| root.join("target").is_dir() || root.join("Cargo.toml").is_file())
.or_else(|| manifest_dir.parent())
.expect("locate workspace root");
// Build with `fault-injection` so the spawned cluster processes carry the
// m11p9 WAL slow-fsync / disk-full hooks (inert unless a `TIDAL_FAULT_*`
// env var arms them — every non-fault suite spawns the same binary and is
// unaffected). Production never passes this feature, so the shipped image
// compiles the hooks out entirely.
let status = Command::new("cargo")
.arg("build")
.arg("-p")
.arg("tidal-server")
.arg("--features")
.arg("fault-injection")
.current_dir(workspace_root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("run cargo build");
assert!(
status.success(),
"cargo build -p tidal-server --features fault-injection failed"
);
let bin = workspace_root.join("target/debug/tidal-server");
assert!(
bin.exists(),
"tidal-server binary not found at {}",
bin.display()
);
bin
})
.clone()
}