tidaldb/tidal-server/tests/support/multiproc.rs
jx12n 8a0950260f feat(m8p10): multi-process cluster mode — scatter-gather, reconcile relay, chaos/UAT suites
Splits monolithic cluster.rs into tidal-server/src/cluster/ modules. Adds redeliver-missed
relay, bounded HLC drift, lag tracking, and reconcile idempotence. Five new tier-3 test suites
(chaos, lifecycle, multiproc, region, routes, runbook) all green. Docs, CHANGELOG, and ROADMAP
updated with G4/G5/G6 known gaps.
2026-06-10 14:07:33 -06:00

972 lines
37 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.

//! `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 `RegionClusterState`), 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())
}
/// 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,
}
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(),
}
}
/// 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,
}
/// 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,
/// 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,
}
})
.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))
.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"),
_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 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)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
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
}
// ── 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`].
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);
}
// ── 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
}
/// Best-effort hint for a failing node, to make boot failures diagnosable in
/// the panic message. stderr is piped; reading it here would block (the
/// process is still alive on a boot-poll timeout), so we surface the bind
/// address instead — the actionable detail when a port conflicts or a bind
/// fails (those show up in the process's own exit/stderr).
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) ────────
/// One leader broadcast report (`{"replicated_to": n, "failed": [..]}`).
#[derive(Debug, serde::Deserialize)]
pub struct BroadcastReport {
/// Peers that acknowledged the marked broadcast (2xx).
pub replicated_to: u64,
/// Peer region names that were unreachable or returned non-2xx.
pub failed: Vec<String>,
}
/// 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`, asserting the leader
/// broadcast contract every tier-3 suite shares: `/items` → 201 and
/// `/embeddings` → 200 (the 200 exists because the broadcast report needs a
/// body; the forwarded/internal path stays 204), each report covering every
/// peer (`replicated_to + failed == regions 1` — true even mid-crash or
/// mid-partition, when the missing peers land in `failed`). Returns the
/// per-item `(items_report, embeddings_report)` pairs so a suite can layer
/// scenario-specific assertions (e.g. WHICH peer failed) on top.
pub fn seed_items_and_embeddings(
cluster: &MultiProcCluster,
leader_idx: usize,
count: u64,
) -> Vec<(BroadcastReport, BroadcastReport)> {
let peers = cluster.len() as u64 - 1;
let mut reports = Vec::with_capacity(usize::try_from(count).unwrap_or(0));
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");
let items: BroadcastReport = resp.json().expect("/items broadcast report");
assert_eq!(
items.replicated_to + items.failed.len() as u64,
peers,
"items replicated_to + failed must cover every peer: {items:?}"
);
// 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]
}),
);
// The leader broadcast path returns 200 WITH the report body (NOT 204 —
// a 204 cannot carry one). The forwarded/internal path stays 204.
assert_eq!(resp.status().as_u16(), 200, "leader /embeddings must 200");
let embeddings: BroadcastReport = resp.json().expect("/embeddings broadcast report");
assert_eq!(
embeddings.replicated_to + embeddings.failed.len() as u64,
peers,
"embeddings replicated_to + failed must cover every peer: {embeddings:?}"
);
reports.push((items, embeddings));
}
reports
}
/// 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,
) -> 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);
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 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");
let status = Command::new("cargo")
.arg("build")
.arg("-p")
.arg("tidal-server")
.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 failed");
let bin = workspace_root.join("target/debug/tidal-server");
assert!(
bin.exists(),
"tidal-server binary not found at {}",
bin.display()
);
bin
})
.clone()
}