tidaldb/tidal-server/tests/cluster_e2e.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

470 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

//! Tier-3 SINGLE-PROCESS-cluster smoke suite over the real HTTP binary.
//!
//! # What these tests verify
//!
//! Each test spawns real `tidal-server cluster` OS processes (the **single-process**
//! mode — no `--region`), connects over HTTP, and exercises the full binary boot
//! path: config/topology/schema parsing, the experimental-cluster opt-in gate,
//! router wiring, the gRPC transport bring-up, and the data/cluster-management
//! routes end-to-end against a real process (not an in-test `ClusterState`).
//!
//! # Scope: single-process mode, by design
//!
//! These tests drive the **single-process** cluster fabric: each spawned
//! `tidal-server cluster` process holds ALL regions, and replication between
//! regions traverses real gRPC on the loopback interface *within that one
//! process*. They therefore drive only `nodes[0]` and prove the single-process
//! binary boots, serves, and replicates intra-process. They do not (and are not
//! meant to) cross a process boundary.
//!
//! # The multi-process surface is covered elsewhere (m8p10, RESOLVED)
//!
//! True multi-process HA — one `tidal-server cluster --region` process per region,
//! peering over real gRPC to sibling addresses, with cross-process HTTP routing and
//! real network-partition injection — shipped in m8p10 and is verified by the
//! sibling tier-3 suites (all `--features cluster-e2e`):
//!
//! * [`cluster_multiproc`](cluster_multiproc) — cross-process replication, leader
//! crash + failover, region-routing flip, write forwarding, status aggregation;
//! * [`cluster_chaos`](cluster_chaos) — REAL network partitions via an in-harness
//! TCP relay proxy (degraded query, heal/reconcile, follower↔follower partition);
//! * [`cluster_lifecycle`](cluster_lifecycle) — clock skew + rolling upgrade;
//! * [`cluster_runbook`](cluster_runbook) — every `docs/runbooks/cluster.md` §5§11
//! operation against a real 3-process cluster.
//!
//! This file is the single-process smoke complement to those — the former
//! "KNOWN GAP" (ROADMAP.md G1 / m8p10) is RESOLVED.
//!
//! Gated behind `--features cluster-e2e` (disabled by default; these tests
//! are slow and require a built binary).
//!
//! ```bash
//! cargo test -p tidal-server --features cluster-e2e --test cluster_e2e -- --nocapture
//! ```
#![cfg(feature = "cluster-e2e")]
// Tier-3 E2E harness: unwrap on known-good fixtures is idiomatic test noise.
#![allow(clippy::unwrap_used)]
use std::{
io::Write,
net::{SocketAddr, TcpListener},
path::{Path, PathBuf},
process::{Child, Command},
time::{Duration, Instant},
};
/// A running tidal-server cluster node.
struct NodeHandle {
#[allow(dead_code)]
name: String,
http_addr: SocketAddr,
process: Child,
}
impl Drop for NodeHandle {
fn drop(&mut self) {
// Best-effort graceful shutdown.
#[cfg(unix)]
{
// Send SIGTERM via the `kill` binary so this crate stays
// `unsafe_code = forbid` — no `libc::kill` FFI needed.
let _ = Command::new("kill")
.args(["-TERM", &self.process.id().to_string()])
.status();
// Wait up to 2s for graceful exit.
let deadline = Instant::now() + Duration::from_secs(2);
loop {
match self.process.try_wait() {
Ok(Some(_)) => break,
_ if Instant::now() > deadline => {
let _ = self.process.kill();
break;
}
_ => std::thread::sleep(Duration::from_millis(50)),
}
}
}
#[cfg(not(unix))]
{
let _ = self.process.kill();
}
let _ = self.process.wait();
}
}
/// Multi-process cluster test harness.
struct ClusterHarness {
nodes: Vec<NodeHandle>,
_config_dir: tempfile::TempDir,
}
impl ClusterHarness {
/// Spawn `n` `tidal-server cluster` processes, each a FULL in-process
/// cluster.
///
/// Allocates dynamic HTTP ports, generates topology + schema YAML, spawns
/// the processes, and waits for all to become healthy. Note (see module
/// docs): every spawned process internally holds ALL `n` regions, so the
/// tests only meaningfully exercise `nodes[0]`. The extra processes prove
/// the binary boots independently on its own port, not cross-process
/// replication.
fn start(n: usize) -> Self {
assert!(n >= 2, "need at least 2 nodes for a cluster");
let region_names: Vec<String> = (0..n).map(|i| format!("region-{i}")).collect();
let http_addrs: Vec<SocketAddr> = (0..n).map(|_| free_addr()).collect();
// Write config files.
let config_dir = tempfile::tempdir().expect("create temp dir");
let topology = Self::write_topology(config_dir.path(), &region_names);
let schema = Self::write_schema(config_dir.path());
// Find the tidal-server binary.
let bin = tidal_server_bin();
// Spawn processes.
let mut nodes = Vec::with_capacity(n);
for i in 0..n {
let child = Command::new(&bin)
.arg("cluster")
.arg("--listen")
.arg(http_addrs[i].to_string())
.arg("--schema")
.arg(&schema)
.arg("--topology")
.arg(&topology)
// Cluster mode is experimental (regions replicate over real
// gRPC but share one process — no host/process isolation) and
// refuses to start without this opt-in.
.env("TIDAL_ALLOW_EXPERIMENTAL_CLUSTER", "1")
.env("TIDAL_SERVER_LOG", "warn")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap_or_else(|e| panic!("failed to spawn tidal-server: {e}"));
nodes.push(NodeHandle {
name: region_names[i].clone(),
http_addr: http_addrs[i],
process: child,
});
}
let harness = Self {
nodes,
_config_dir: config_dir,
};
// Wait for all nodes to become healthy. Each node boots a real gRPC
// transport (its own tokio runtime + server) per follower region on top
// of opening its tidalDB instances, and the suite may start several
// such processes concurrently, so allow a generous tier-3 budget.
harness.wait_all_healthy(Duration::from_secs(30));
harness
}
fn write_topology(dir: &Path, regions: &[String]) -> PathBuf {
let path = dir.join("topology.yaml");
let mut f = std::fs::File::create(&path).expect("create topology.yaml");
writeln!(f, "regions:").unwrap();
for name in regions {
writeln!(f, " - name: {name}").unwrap();
}
writeln!(f, "leader: {}", regions[0]).unwrap();
path
}
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
text_fields:
- name: title
kind: text
embedding_slots:
- name: content_vector
entity: item
dimensions: 4
",
)
.expect("write schema.yaml");
path
}
fn wait_all_healthy(&self, timeout: Duration) {
let deadline = Instant::now() + timeout;
for node in &self.nodes {
wait_for_health(&node.http_addr, deadline);
}
}
fn http_get(&self, node_idx: usize, path: &str) -> reqwest::blocking::Response {
let url = format!("http://{}{path}", self.nodes[node_idx].http_addr);
reqwest::blocking::get(&url).unwrap_or_else(|e| panic!("GET {url} failed: {e}"))
}
fn http_post_json(
&self,
node_idx: usize,
path: &str,
body: &serde_json::Value,
) -> reqwest::blocking::Response {
let url = format!("http://{}{path}", self.nodes[node_idx].http_addr);
let client = reqwest::blocking::Client::new();
client
.post(&url)
.json(body)
.send()
.unwrap_or_else(|e| panic!("POST {url} failed: {e}"))
}
/// Poll `/cluster/status` until all follower regions show `lag_events == 0`, or timeout.
///
/// The leader is excluded because it writes directly (not via replication)
/// and its `applied_events` counter tracks replication from other shards.
fn wait_converged(&self, timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
assert!(
Instant::now() <= deadline,
"convergence timeout after {timeout:?}; cluster did not converge"
);
let resp = self.http_get(0, "/cluster/status");
if resp.status().is_success() {
let body: serde_json::Value = resp.json().unwrap();
let leader = body["leader"].as_str().unwrap_or("");
if let Some(regions) = body["regions"].as_array() {
let followers_converged = regions
.iter()
.filter(|r| r["name"].as_str().unwrap_or("") != leader)
.all(|r| r["lag_events"].as_u64().unwrap_or(u64::MAX) == 0);
if followers_converged {
return;
}
}
}
std::thread::sleep(Duration::from_millis(200));
}
}
}
/// Allocate a free port by binding to :0 and recording the assigned port.
fn free_addr() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap()
}
/// Find the tidal-server binary. Build it first if needed.
fn tidal_server_bin() -> PathBuf {
// Resolve the workspace root that owns the cargo `target/` dir. tidal-server
// is a workspace member at `<workspace>/tidal-server`, so the workspace root
// is the parent of `CARGO_MANIFEST_DIR`. Probe the parent first, then the
// grandparent, so the harness keeps working regardless of how the crate is
// nested in a workspace.
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let candidates = [
manifest_dir.parent(),
manifest_dir.parent().and_then(std::path::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("workspace root");
// Build the binary to make sure it's up to date.
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("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
}
/// Poll a node's `/health/startup` until it returns 200.
fn wait_for_health(addr: &SocketAddr, deadline: Instant) {
let url = format!("http://{addr}/health/startup");
loop {
assert!(
Instant::now() <= deadline,
"health check timeout for {addr}"
);
match reqwest::blocking::get(&url) {
Ok(resp) if resp.status().is_success() => return,
_ => std::thread::sleep(Duration::from_millis(100)),
}
}
}
// ── E2E Tests ─────────────────────────────────────────────────────────────
/// Single-process-cluster smoke test: boot a real `tidal-server cluster`
/// process, write data over HTTP, and verify it converges within its OWN
/// in-process replication fabric.
///
/// This is NOT a cross-process convergence proof: node 0 holds all regions
/// internally, so `wait_converged` polls node 0's own `/cluster/status`. The
/// cross-process proof lives in `cluster_multiproc`/`cluster_chaos`/
/// `cluster_lifecycle`/`cluster_runbook` (see the module docs).
#[test]
fn single_process_cluster_smoke() {
let harness = ClusterHarness::start(3);
// 1. Health check.
let resp = harness.http_get(0, "/health");
assert!(resp.status().is_success());
let body: serde_json::Value = resp.json().unwrap();
assert_eq!(body["mode"], "cluster");
// 2. Cluster status — all regions healthy, leader is region-0.
let resp = harness.http_get(0, "/cluster/status");
assert!(resp.status().is_success());
let status: serde_json::Value = resp.json().unwrap();
assert_eq!(status["leader"], "region-0");
assert_eq!(status["regions"].as_array().unwrap().len(), 3);
// 3. Write items.
for i in 1..=5u64 {
let resp = harness.http_post_json(
0,
"/items",
&serde_json::json!({
"entity_id": i,
"metadata": { "title": format!("item {i}") }
}),
);
assert!(
resp.status().is_success(),
"POST /items failed: {}",
resp.status()
);
}
// 4. Write signals.
for i in 1..=5u64 {
let resp = harness.http_post_json(
0,
"/signals",
&serde_json::json!({
"entity_id": i,
"signal": "view",
"weight": 1.0
}),
);
assert!(
resp.status().is_success(),
"POST /signals failed: {}",
resp.status()
);
}
// 5. Wait for convergence.
harness.wait_converged(Duration::from_secs(5));
// 6. Verify follower regions show zero lag.
let resp = harness.http_get(0, "/cluster/status");
let status: serde_json::Value = resp.json().unwrap();
let leader = status["leader"].as_str().unwrap();
for region in status["regions"].as_array().unwrap() {
if region["name"].as_str().unwrap() == leader {
continue; // Leader writes directly, not via replication.
}
assert_eq!(
region["lag_events"].as_u64().unwrap(),
0,
"follower {} has lag",
region["name"]
);
}
// 7. Retrieve from a follower region.
let resp = harness.http_get(0, "/feed?profile=trending&limit=5&region=region-1");
assert!(resp.status().is_success());
let feed: serde_json::Value = resp.json().unwrap();
assert!(
!feed["items"].as_array().unwrap().is_empty(),
"follower region should have items"
);
}
/// Promote a follower within a single process's in-process cluster and verify
/// subsequent writes route to the new leader.
///
/// Like [`single_process_cluster_smoke`], this exercises one process's own
/// fabric (node 0 holds all regions); cross-process promotion is proven by
/// `cluster_multiproc::mp_uat_step2_leader_crash_failover_under_10s`.
#[test]
fn single_process_cluster_promote_leader() {
let harness = ClusterHarness::start(3);
// Write some initial data.
harness.http_post_json(
0,
"/items",
&serde_json::json!({ "entity_id": 1, "metadata": { "title": "before promote" } }),
);
harness.http_post_json(
0,
"/signals",
&serde_json::json!({ "entity_id": 1, "signal": "view", "weight": 1.0 }),
);
harness.wait_converged(Duration::from_secs(5));
// Promote region-1 to leader.
let resp = harness.http_post_json(
0,
"/cluster/promote",
&serde_json::json!({ "region": "region-1" }),
);
assert!(resp.status().is_success());
// Verify status shows new leader.
let resp = harness.http_get(0, "/cluster/status");
let status: serde_json::Value = resp.json().unwrap();
assert_eq!(status["leader"], "region-1");
// Write more signals — should route to new leader.
let resp = harness.http_post_json(
0,
"/signals",
&serde_json::json!({ "entity_id": 1, "signal": "like", "weight": 2.0 }),
);
assert!(
resp.status().is_success(),
"POST /signals after promote failed: {}",
resp.status()
);
// Note: this single-process smoke does not assert post-promotion convergence.
// Per-source-shard lag tracking across a promotion is verified over real
// processes by `cluster_lifecycle`/`cluster_chaos` (the m8p10 lag-gauge fix).
}