- fault-injection cargo feature (compiled OUT of prod): slow-fsync + disk-full WAL hooks in tidal/src/fault.rs, inert until armed, tier-3 builds with feature - first-class invariant checkers (tests/support/invariants.rs): AckLedger no-acked-loss (now consumed by m11p3 gate), feed parity, single-leader-per-term, monotonic frontiers - cluster_faults.rs tier-3 suite 4/4: disk-full degrade+recover, slow-fsync lag+converge, both-slow quorum 503, asymmetric partition no-split-brain - tidal-stress soak gates: --json-summary + --max-p99-ms/--max-error-pct/ --fail-on-knee → non-zero exit on regression - Woodpecker cron nightly flow (chaos + gated soak), event-routed, not GH Actions - guarantee-traceability.md: roadmap §2 guarantees → named tests (closes G-C apparatus; 30-day-green is a calendar criterion)
1193 lines
47 KiB
Rust
1193 lines
47 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,
|
|
}
|
|
|
|
/// 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,
|
|
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"),
|
|
_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);
|
|
// 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)
|
|
}
|
|
|
|
// ── 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,
|
|
});
|
|
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
|
|
}
|
|
|
|
/// 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 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()
|
|
}
|