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

479 lines
19 KiB
Rust

//! `PartitionProxy`: a root-free, in-harness TCP relay for REAL network-partition
//! injection between OS processes (m8p10 task 05).
//!
//! # Why a proxy and not iptables/pfctl
//!
//! The ROADMAP sanctions iptables/pfctl *or* a proxy ("toxiproxy or similar").
//! `iptables`/`pfctl` require root and differ between Linux and macOS; a developer
//! laptop cannot run them unprivileged. A user-space TCP relay the test owns is
//! identical on every platform, needs no privileges, and severs connections at the
//! genuine OS transport layer: established streams are `shutdown(Both)` (the peer's
//! in-flight gRPC `ShipSegment` returns a real transport error, the circuit breaker
//! opens, HTTP fan-outs time out), and new connections are accepted-then-dropped so
//! a fresh dial through a severed proxy fails its HTTP/2 handshake. That is a true
//! network partition between the two processes — not an engine flag.
//!
//! # How it interposes (the harness rewrite hook)
//!
//! [`MultiProcCluster`](super::multiproc::MultiProcCluster) writes a per-process
//! topology where a process's OWN region lists its REAL bind addresses but every
//! PEER region lists a *published* address from the
//! [`AddrRewrite`](super::multiproc::AddrRewrite) hook. A `PartitionProxy` listens
//! on a free loopback port and relays to a peer's real target; pointing the peers'
//! published address at the proxy means peer↔peer traffic flows through it while
//! each process still self-binds its real port. Severing the proxy therefore cuts
//! ONLY the path peers use to reach that region — the test client still talks to
//! every node's real HTTP address directly (the operator's console survives the
//! partition, matching the runbook drill's "read the stale follower" step).
//!
//! # Threading / cleanup discipline
//!
//! Pure `std::net` + `std::thread`; no tokio. One acceptor thread per proxy plus
//! two pump threads per live connection (each direction). Every live downstream and
//! upstream `TcpStream` is tracked (a clone) so `sever()` can `shutdown(Both)` it.
//! [`Drop`] latches a stop flag, shuts every tracked stream, unblocks the acceptor
//! by dialing its own listener, and joins the acceptor; pump threads observe the
//! shutdown via a read/write error and exit on their own (they are detached, never
//! leaked past process exit because a closed socket unblocks their blocking I/O).
#![allow(dead_code)]
use std::{
io::{Read, Write},
net::{Shutdown, SocketAddr, TcpListener, TcpStream},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::Duration,
};
/// A user-space TCP relay between peers and a region's real listener, with a latch
/// the test flips to inject / heal a real network partition.
pub struct PartitionProxy {
/// The loopback port peers dial (published in their topology for the target).
listen: SocketAddr,
/// The region's real grpc/http listener this relay forwards to.
target: SocketAddr,
/// Latched true by [`sever`](Self::sever): the acceptor accept-and-drops, and
/// every live stream is `shutdown(Both)`. Cleared by [`heal_link`](Self::heal_link).
severed: Arc<AtomicBool>,
/// Latched true on [`Drop`]: the acceptor exits and stops spawning pumps.
stopped: Arc<AtomicBool>,
/// Live client (downstream) + target (upstream) stream clones, so `sever()` can
/// `shutdown(Both)` every in-flight connection. Cleared lazily on sever.
conns: Arc<Mutex<Vec<TcpStream>>>,
/// The acceptor thread handle, joined on [`Drop`].
acceptor: Option<JoinHandle<()>>,
}
impl PartitionProxy {
/// Start a relay on a free loopback port forwarding to `target`. Returns once
/// the listener is bound (so the published address is dialable immediately).
///
/// # Panics
///
/// Panics if a loopback listener cannot be bound or its address read.
#[must_use]
pub fn start(target: SocketAddr) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind proxy listener");
let listen = listener.local_addr().expect("read proxy local_addr");
let severed = Arc::new(AtomicBool::new(false));
let stopped = Arc::new(AtomicBool::new(false));
let conns: Arc<Mutex<Vec<TcpStream>>> = Arc::new(Mutex::new(Vec::new()));
let acceptor = {
let severed = Arc::clone(&severed);
let stopped = Arc::clone(&stopped);
let conns = Arc::clone(&conns);
thread::spawn(move || accept_loop(&listener, target, &severed, &stopped, &conns))
};
Self {
listen,
target,
severed,
stopped,
conns,
acceptor: Some(acceptor),
}
}
/// The address peers should dial (published in their topology for the target).
#[must_use]
pub const fn addr(&self) -> SocketAddr {
self.listen
}
/// The real target this relay forwards to.
#[must_use]
pub const fn target(&self) -> SocketAddr {
self.target
}
/// Whether the link is currently severed.
#[must_use]
pub fn is_severed(&self) -> bool {
self.severed.load(Ordering::Acquire)
}
/// Inject a partition: refuse new connections (accept→drop) AND `shutdown(Both)`
/// every live stream so in-flight peer traffic fails for real. Idempotent.
pub fn sever(&self) {
self.severed.store(true, Ordering::Release);
// Tear down every tracked stream in BOTH directions. A pump blocked in
// `read`/`write` on a shut stream returns an error and exits; the partner
// pump's next I/O then also fails. We drain the vec — new connections after
// a heal repopulate it.
let mut guard = self
.conns
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for stream in guard.drain(..) {
let _ = stream.shutdown(Shutdown::Both);
}
}
/// Heal the link: clear the latch so new connections flow again. Existing
/// streams stay severed (gRPC/HTTP reconnect on their own — tonic re-dials, the
/// blocking reqwest client opens a fresh connection). Idempotent.
pub fn heal_link(&self) {
self.severed.store(false, Ordering::Release);
}
}
impl Drop for PartitionProxy {
fn drop(&mut self) {
// Stop the acceptor and tear down every live stream so pump threads unblock.
self.stopped.store(true, Ordering::Release);
self.severed.store(true, Ordering::Release);
{
let mut guard = self
.conns
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for stream in guard.drain(..) {
let _ = stream.shutdown(Shutdown::Both);
}
}
// Unblock the acceptor's blocking `accept()` by dialing our own listener
// (it will observe `stopped` and exit). A failed dial is fine — the thread
// also re-checks `stopped` on its accept-error path.
let _ = TcpStream::connect_timeout(&self.listen, Duration::from_millis(200));
if let Some(handle) = self.acceptor.take() {
let _ = handle.join();
}
}
}
/// The acceptor loop: for each inbound connection, dial the target and start two
/// pump threads (downstream→upstream, upstream→downstream). On `severed`, accept
/// the connection and immediately drop it (a real connection-refused-equivalent at
/// the application layer: the peer's HTTP/2 handshake never completes).
fn accept_loop(
listener: &TcpListener,
target: SocketAddr,
severed: &Arc<AtomicBool>,
stopped: &Arc<AtomicBool>,
conns: &Arc<Mutex<Vec<TcpStream>>>,
) {
for incoming in listener.incoming() {
if stopped.load(Ordering::Acquire) {
return;
}
let Ok(downstream) = incoming else {
// A transient accept error: bail only if we are stopping.
if stopped.load(Ordering::Acquire) {
return;
}
continue;
};
if severed.load(Ordering::Acquire) {
// Severed: drop the just-accepted connection. The peer sees its
// connection die immediately (no relay to the target).
let _ = downstream.shutdown(Shutdown::Both);
continue;
}
// Dial the real target. A failed dial drops the downstream (peer sees a
// dead connection) — never blocks the acceptor for other peers.
let Ok(upstream) = TcpStream::connect_timeout(&target, Duration::from_secs(2)) else {
let _ = downstream.shutdown(Shutdown::Both);
continue;
};
// Disable Nagle so the gRPC/HTTP framing flows promptly through the relay.
let _ = downstream.set_nodelay(true);
let _ = upstream.set_nodelay(true);
// Track clones of both ends so `sever()` can shut them down.
register_conn(conns, &downstream);
register_conn(conns, &upstream);
spawn_pump(&downstream, &upstream);
spawn_pump(&upstream, &downstream);
}
}
/// Register a clone of `stream` in the live-connection set (best-effort; a failed
/// clone just means `sever()` cannot reach this end, but its partner pump will
/// still tear the pair down when ITS end is shut).
fn register_conn(conns: &Arc<Mutex<Vec<TcpStream>>>, stream: &TcpStream) {
if let Ok(clone) = stream.try_clone() {
conns
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(clone);
}
}
// ── Harness glue: per-directed-edge proxies via the rewrite hook ──────────────
use std::collections::HashMap;
use super::multiproc::{AddrKind, AddrRewrite};
/// The directed edge a proxy interposes: the OBSERVER region (which dials the
/// published address) reaching the PEER region over one link `kind`. One
/// `PartitionProxy` sits on each `(observer → peer, kind)` edge, so the harness
/// can sever a SINGLE direction (cut eu-west→ap-south while us-east→ap-south stays
/// up — the follower↔follower partition the ROADMAP names) or every inbound edge
/// of a region at once (full node isolation).
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
struct EdgeKey {
observer: String,
peer: String,
kind: AddrKind,
}
/// The two link proxies (gRPC + HTTP) for ONE directed `observer → peer` edge.
///
/// Severing BOTH = that observer cannot ship segments OR forward/aggregate over
/// HTTP to the peer; severing only `grpc` cuts replication while leaving the HTTP
/// control/forward plane intact (a narrower fault worth exercising on its own).
pub struct EdgeProxies {
/// The gRPC relay for this directed edge (the observer's published `grpc_addr`).
pub grpc: Arc<PartitionProxy>,
/// The HTTP relay for this directed edge (the observer's published `http_addr`).
pub http: Arc<PartitionProxy>,
}
impl EdgeProxies {
/// Sever BOTH links of this directed edge.
pub fn sever_all(&self) {
self.grpc.sever();
self.http.sever();
}
/// Sever ONLY the gRPC (replication) link, leaving HTTP control/forward up.
pub fn sever_grpc(&self) {
self.grpc.sever();
}
/// Heal BOTH links of this directed edge.
pub fn heal_all(&self) {
self.grpc.heal_link();
self.http.heal_link();
}
}
/// Every inbound link proxy of ONE region, across all observers — severing the set
/// is a complete network partition of the region from EVERY peer (no peer can ship
/// to it, forward to it, or aggregate its status). This is the "isolate one node"
/// fault used by UAT steps 3/4.
pub struct RegionProxies {
/// All `(observer → region)` link proxies (both kinds, every observer).
links: Vec<Arc<PartitionProxy>>,
/// Just the gRPC link proxies, for a replication-only sever.
grpc_links: Vec<Arc<PartitionProxy>>,
}
impl RegionProxies {
/// Sever EVERY inbound link (gRPC + HTTP, every peer): full node isolation.
pub fn sever_all(&self) {
for p in &self.links {
p.sever();
}
}
/// Sever only the inbound gRPC (replication) links, every peer — HTTP stays up
/// (status aggregation / forwards still reach the region; only WAL ships fail).
pub fn sever_grpc(&self) {
for p in &self.grpc_links {
p.sever();
}
}
/// Heal EVERY inbound link.
pub fn heal_all(&self) {
for p in &self.links {
p.heal_link();
}
}
}
/// Collects the [`PartitionProxy`] instances an [`AddrRewrite`] hook creates while
/// the harness generates per-process topology files, keyed by directed edge.
///
/// Build one with [`proxied_rewrite`], pass the rewrite into
/// `ClusterOptions::with_rewrite`, start the cluster, then call [`region`] or
/// [`edge`] to recover the handles and sever/heal each link.
///
/// [`region`]: Self::region
/// [`edge`]: Self::edge
#[derive(Clone)]
pub struct ProxyController {
/// `(observer, peer, kind) → proxy`. The rewrite closure inserts one proxy per
/// directed edge as the per-process topology files are generated (each peer
/// process's file declares its OWN published address for every other region).
proxies: Arc<Mutex<HashMap<EdgeKey, Arc<PartitionProxy>>>>,
/// The peer regions we proxy. A peer NOT in this set rewrites to identity (its
/// real address) on every edge, so only edges INTO a chosen region are
/// interposed.
targets: Arc<Vec<String>>,
}
impl ProxyController {
/// Recover ALL inbound link proxies for one proxied region (every observer,
/// both kinds), so the test can isolate the whole region from its peers.
///
/// # Panics
///
/// Panics if the region was never proxied (no inbound edge recorded).
#[must_use]
pub fn region(&self, region: &str) -> RegionProxies {
// Snapshot every inbound-edge proxy for `region` in a single locked
// expression so the `MutexGuard` is dropped the moment the collect finishes
// (keeps the significant-`Drop` guard's scope minimal).
let matching: Vec<(AddrKind, Arc<PartitionProxy>)> = self
.proxies
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.filter(|(key, _)| key.peer == region)
.map(|(key, proxy)| (key.kind, Arc::clone(proxy)))
.collect();
assert!(
!matching.is_empty(),
"no proxies for inbound edges to region '{region}' (was it proxied?)"
);
let grpc_links = matching
.iter()
.filter(|(kind, _)| *kind == AddrKind::Grpc)
.map(|(_, proxy)| Arc::clone(proxy))
.collect();
let links = matching.into_iter().map(|(_, proxy)| proxy).collect();
RegionProxies { links, grpc_links }
}
/// Recover the gRPC + HTTP proxies for a SINGLE directed `observer → peer` edge,
/// so the test can sever exactly one direction (the follower↔follower case).
///
/// # Panics
///
/// Panics if either link of the edge was never published (the peer was not
/// proxied, or the observer does not dial the peer — e.g. observer == peer).
#[must_use]
pub fn edge(&self, observer: &str, peer: &str) -> EdgeProxies {
let map = self
.proxies
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let get = |kind: AddrKind| {
map.get(&EdgeKey {
observer: observer.to_string(),
peer: peer.to_string(),
kind,
})
.cloned()
.unwrap_or_else(|| {
panic!("no {kind:?} proxy for edge '{observer}' -> '{peer}' (was it proxied?)")
})
};
EdgeProxies {
grpc: get(AddrKind::Grpc),
http: get(AddrKind::Http),
}
}
}
/// Build an [`AddrRewrite`] that interposes a [`PartitionProxy`] on every directed
/// edge whose PEER (target region) is in `proxied_regions`, plus a
/// [`ProxyController`] the test uses to sever/heal those links after start.
///
/// Edges whose peer is NOT listed rewrite to identity (the real address), so only
/// traffic INTO a chosen region is interposed. The rewrite is invoked once per
/// `(observer, peer, kind)` during per-process topology generation; each invocation
/// starts (or reuses) the proxy for that exact directed edge and returns its listen
/// address. Because each observer's topology file is written independently, every
/// directed edge gets its OWN relay — severing one never touches another.
#[must_use]
pub fn proxied_rewrite(proxied_regions: &[&str]) -> (AddrRewrite, ProxyController) {
let proxies: Arc<Mutex<HashMap<EdgeKey, Arc<PartitionProxy>>>> =
Arc::new(Mutex::new(HashMap::new()));
let targets: Arc<Vec<String>> =
Arc::new(proxied_regions.iter().map(|s| (*s).to_string()).collect());
let controller = ProxyController {
proxies: Arc::clone(&proxies),
targets: Arc::clone(&targets),
};
let rewrite_proxies = Arc::clone(&proxies);
let rewrite_targets = Arc::clone(&targets);
let rewrite: AddrRewrite = Box::new(
move |observer: &str, peer: &str, kind: AddrKind, real: SocketAddr| {
if !rewrite_targets.iter().any(|t| t == peer) {
return real.to_string(); // peer not proxied: identity
}
let key = EdgeKey {
observer: observer.to_string(),
peer: peer.to_string(),
kind,
};
// Resolve (or lazily start) this edge's proxy and read its listen addr
// under the lock, releasing the guard before the closure returns.
let addr = {
let mut map = rewrite_proxies
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.entry(key)
.or_insert_with(|| Arc::new(PartitionProxy::start(real)))
.addr()
};
addr.to_string()
},
);
(rewrite, controller)
}
/// Spawn one detached unidirectional pump `from → to`. Exits when either end errors
/// (EOF, reset, or a `sever()`-driven `shutdown`), shutting the write side so the
/// partner pump unblocks. Detached: a closed socket always unblocks its blocking
/// `read`, so the thread terminates without an explicit join (and never outlives
/// the process — the streams are owned here and dropped on exit).
fn spawn_pump(from: &TcpStream, to: &TcpStream) {
let (Ok(mut from), Ok(mut to)) = (from.try_clone(), to.try_clone()) else {
return;
};
thread::spawn(move || {
let mut buf = [0u8; 16 * 1024];
loop {
match from.read(&mut buf) {
Ok(n) if n > 0 => {
if to.write_all(&buf[..n]).is_err() {
break;
}
}
// `Ok(0)` is a clean EOF; `Err(_)` is a reset / shutdown / severed
// stream. Either way the pump retires.
Ok(_) | Err(_) => break,
}
}
// Unblock the partner pump: shut the direction we write to.
let _ = to.shutdown(Shutdown::Both);
let _ = from.shutdown(Shutdown::Both);
});
}