Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Fixes the two defects a malformed probe exposed on the live cluster, plus the
coverage gap that let a stale assertion survive the same day it was falsified.
TASK 17 — validate before the WAL append. A 128-dim vector against a 1536-dim
slot was appended to the WAL FIRST, then validated, then answered 500 — so an
already-durable, unapplicable record shipped to both followers, halted both
receivers, and put shard 1 into a quorum-write outage. Validation now runs before
the append and returns 400 via invalid_input; nothing enters the log.
`storage::vector::validate_dimensions` is now the single comparison, replacing an
inline duplicate of the same rule in lifecycle/ops.rs:57-62 — two copies of a
dimension check drift, and the apply-path copy is the one that halts replication
when it disagrees.
The receiver's halt-vs-skip decision is now explicit instead of "halt on
anything". A record whose failure is deterministic and node-independent (schema
width) is skipped, counted on blobs_apply_failed_total and ERROR-logged, so the
frontier advances; a record that could become applicable after a binary upgrade
(unknown batch kind, capability skew) still halts, because skipping那 would
silently drop replicated data. Both branches are proven reachable by tests.
TASK 18 — the reseed latch outlived its discharge. A node hosting 3 shard groups
latched a marker per group but discharged on a single seqno, so two latches meant
permanent 503 on a node whose every shard read lag 0 — it hit all three pods
during the roll and each needed a manual delete. Gaps are now tracked per group
in a ReseedGapSet and cleared on evidence about themselves; a REFUSED
reseed_self_restart re-evaluates every 15s instead of waiting for a latch that
never arrives. /health's cause ladder was also lying: it printed "joiner boot not
yet converged" for a node whose groups had all converged, because the fallback
asserted a state it never tested. It now names the outstanding gaps, gained the
decommissioned-by-signal arm that is_ready checked but the ladder did not, and
its terminal arm says "reason unavailable" rather than inventing one.
COVERAGE — 14 of 23 integration suites were run by NO pipeline. Not theoretical:
cluster_routes still asserted the wire fabrication removed hours earlier
(applied_events == 0 with a lag derived from it) and nothing caught it because
nothing ran it. cluster_sharding (dense-rank, /sharded/* opt-in), vector_search
(distance contract) and cluster_poison_embedding (task 17's own gate) were in the
same position, so those guards would have rotted identically. Every suite now has
a runner: 8 in-process ones in a new `fast-suites` push step (measured 71s, runs
FIRST so a cheap failure precedes the 6.5-min gate), 6 multiproc ones in the
nightly. All 23 scheduled; all 4 never-before-run heavy suites verified passing
before being scheduled.
Also fixes cluster_chaos.rs:329, which the nightly's FIRST EVER run caught 13
minutes in — it demanded an unreachable peer report worst-case lag, i.e. it
required the fabrication task 04a deleted.
Verified: fmt clean; clippy 72 vs 73 baseline (one FEWER, zero added, measured on
touched trees at 431340f); lib 2115 passed; all 8 fast suites green;
cluster_chaos 5, cluster_sharding 5, cluster_poison_embedding 1,
cluster_cross_shard_reads 2, cluster_graph_persistence 1, cluster_multiproc 5,
cluster_e2e 2; doc-guard OK.
1234 lines
48 KiB
Rust
1234 lines
48 KiB
Rust
//! Tier-3 RUNBOOK-VERIFICATION suite (m8p10 task 07).
|
||
//!
|
||
//! Executes EVERY operation documented in `docs/runbooks/cluster.md` §5–§11
|
||
//! against a REAL 3-process multi-region cluster (one `tidal-server cluster
|
||
//! --region` OS process per region, peering over real gRPC + forwarding over
|
||
//! real HTTP), and asserts each response matches the documented SHAPE — key
|
||
//! presence and types via serde into structs mirroring the runbook samples, plus
|
||
//! status codes — not exact values (those drift with decay/timing). This test IS
|
||
//! the runbook's proof: if a sample in the runbook is wrong, a test here fails.
|
||
//!
|
||
//! The drills are scripted step-for-step exactly as the runbook numbers them:
|
||
//! §9 (failover), §10 (partition — BOTH the real TCP-proxy partition the chaos
|
||
//! suite uses AND the simulated `/cluster/partition` ship-skip flag, because the
|
||
//! rewritten runbook documents both), §11 (SIGTERM drain → readiness 503 → exit 0
|
||
//! → WAL recovery on restart).
|
||
//!
|
||
//! One section per test (plus the auth test, which needs a dedicated cluster with
|
||
//! `TIDAL_API_KEY` set). The shared 3-process cluster is rebuilt per test (the
|
||
//! harness amortizes `cargo build`), so a destructive drill (partition, SIGTERM)
|
||
//! never contaminates another section.
|
||
//!
|
||
//! ```bash
|
||
//! cargo test -p tidal-server --features cluster-e2e --test cluster_runbook -- --nocapture
|
||
//! ```
|
||
#![cfg(feature = "cluster-e2e")]
|
||
// Tier-3 harness allows, mirroring `cluster_chaos.rs` / `cluster_multiproc.rs`:
|
||
// `unwrap` on known-good fixtures is idiomatic test noise; the lossy numeric casts
|
||
// are the same pervasive-and-intentional scoring math the crate config documents.
|
||
#![allow(
|
||
clippy::unwrap_used,
|
||
clippy::missing_panics_doc,
|
||
clippy::too_many_lines,
|
||
clippy::cast_precision_loss,
|
||
clippy::cast_possible_truncation,
|
||
clippy::cast_sign_loss,
|
||
clippy::items_after_statements
|
||
)]
|
||
|
||
mod support;
|
||
|
||
use std::time::{Duration, Instant};
|
||
|
||
use serde::Deserialize;
|
||
use support::{
|
||
multiproc::{
|
||
BREAKER_RESET, ClusterOptions, MultiProcCluster, convergence_budget,
|
||
seed_items_and_embeddings, write_view,
|
||
},
|
||
partition::proxied_rewrite,
|
||
};
|
||
|
||
/// Region 0 = `us-east` = the initial leader in every topology.
|
||
const LEADER: usize = 0;
|
||
/// Region 1 = `eu-west` (a follower).
|
||
const EU_WEST: usize = 1;
|
||
/// Region 2 = `ap-south` (a follower; the region the §10 drills partition).
|
||
const AP_SOUTH: usize = 2;
|
||
|
||
// The leader-ships circuit breaker ([`BREAKER_RESET`], the `tidal-net` default)
|
||
// opens after 5 consecutive failed ships and stays open for 30s. After a heal, a
|
||
// SINGLE `/cluster/heal` can ship into the open breaker and no-op — so the
|
||
// runbook (and this drill) re-issue heal until `/cluster/status` shows lag 0.
|
||
// (Same reasoning as the chaos suite's `heal_until_converged`.)
|
||
|
||
// ── Documented response shapes (serde mirrors of the runbook samples) ────────────
|
||
//
|
||
// Each struct mirrors the JSON in `docs/runbooks/cluster.md`. Deserializing the
|
||
// real response into the struct IS the shape assertion: a missing/renamed key or
|
||
// a type change fails the test (so the runbook sample and the wire format can
|
||
// never silently drift). `#[serde(deny_unknown_fields)]` is intentionally NOT
|
||
// used — the contract is "the documented keys are present with the documented
|
||
// types", and a forward-compatible new field must not fail an existing client.
|
||
|
||
/// `GET /cluster/status/local` (runbook §6 local status).
|
||
#[derive(Debug, Deserialize)]
|
||
struct LocalStatus {
|
||
region: String,
|
||
is_leader: bool,
|
||
leader: String,
|
||
last_seq: u64,
|
||
applied_events: u64,
|
||
lag_events: u64,
|
||
partitioned: Vec<String>,
|
||
reachable: bool,
|
||
}
|
||
|
||
/// `GET /cluster/status` aggregated (runbook §6).
|
||
#[derive(Debug, Deserialize)]
|
||
struct AggregatedStatus {
|
||
leader: String,
|
||
relay_log_len: u64,
|
||
regions: Vec<AggregatedRegion>,
|
||
}
|
||
|
||
/// One region row inside [`AggregatedStatus`] (runbook §6).
|
||
///
|
||
/// `applied_events` / `lag_events` are `Option<u64>`: `/cluster/status` reports a
|
||
/// peer it has no frontier report for as JSON `null` rather than a fabricated `0`
|
||
/// (`node.rs` `aggregate_region_row`), so a `u64` here would fail to deserialize
|
||
/// exactly when a probe misses — e.g. the transient window right after a promote.
|
||
#[derive(Debug, Deserialize)]
|
||
struct AggregatedRegion {
|
||
name: String,
|
||
applied_events: Option<u64>,
|
||
lag_events: Option<u64>,
|
||
partitioned: bool,
|
||
reachable: bool,
|
||
}
|
||
|
||
// The `POST /items` / `POST /embeddings` leader broadcast report (runbook §5)
|
||
// is asserted by `support::multiproc::seed_items_and_embeddings`, which
|
||
// deserializes every response into `support::multiproc::BroadcastReport` —
|
||
// the same shape-assertion-by-deserialization as the structs below.
|
||
|
||
/// `POST /cluster/promote` external fan-out response (runbook §6 / §9).
|
||
#[derive(Debug, Deserialize)]
|
||
struct PromoteResponse {
|
||
ok: bool,
|
||
leader: String,
|
||
acked: Vec<String>,
|
||
failed: Vec<String>,
|
||
}
|
||
|
||
/// `POST /cluster/reconcile` response (runbook §6).
|
||
#[derive(Debug, Deserialize)]
|
||
struct ReconcileResponse {
|
||
ok: bool,
|
||
region: String,
|
||
local_elapsed_ms: u64,
|
||
remote_elapsed_ms: u64,
|
||
ops_applied: u64,
|
||
}
|
||
|
||
/// The `scatter_gather` block on a `/sharded/*` read (runbook §7).
|
||
#[derive(Debug, Deserialize)]
|
||
struct ScatterGather {
|
||
degraded: bool,
|
||
#[serde(default)]
|
||
unavailable_shards: Vec<String>,
|
||
shards_queried: u64,
|
||
elapsed_ms: u64,
|
||
shard_deadline_ms: u64,
|
||
}
|
||
|
||
/// `GET /sharded/feed` response (runbook §7).
|
||
#[derive(Debug, Deserialize)]
|
||
struct ShardedFeed {
|
||
items: Vec<serde_json::Value>,
|
||
total_candidates: u64,
|
||
scatter_gather: ScatterGather,
|
||
}
|
||
|
||
// ── Shared seeding helpers (canonical bodies in support::multiproc) ──────────────
|
||
|
||
/// One node's `applied_events` from its OWN `/cluster/status/local` (direct addr).
|
||
fn applied(cluster: &MultiProcCluster, idx: usize) -> u64 {
|
||
cluster
|
||
.local_status(idx)
|
||
.and_then(|st| st["applied_events"].as_u64())
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
/// Poll `pred()` until true or `budget` elapses, asserting with `msg` on timeout.
|
||
fn poll_until(budget: Duration, msg: &str, mut pred: impl FnMut() -> bool) {
|
||
let deadline = Instant::now() + budget;
|
||
while !pred() {
|
||
assert!(Instant::now() <= deadline, "timed out: {msg}");
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
/// True when every `followers` entry has applied up to (or past) the leader's
|
||
/// high-water-mark with zero lag.
|
||
fn converged(cluster: &MultiProcCluster, followers: &[usize]) -> bool {
|
||
let Some(target) = cluster.leader_last_seq() else {
|
||
return false;
|
||
};
|
||
followers.iter().all(|&idx| {
|
||
cluster.local_status(idx).is_some_and(|st| {
|
||
let applied = st["applied_events"].as_u64().unwrap_or(0);
|
||
let lag = st["lag_events"].as_u64().unwrap_or(u64::MAX);
|
||
lag == 0 && applied >= target
|
||
})
|
||
})
|
||
}
|
||
|
||
/// Re-issue `POST /cluster/heal` on the leader until `region`'s followers converge,
|
||
/// allowing for the circuit-breaker reset window — the EXACT operator pattern the
|
||
/// runbook §6/§10 prescribe ("re-issue `/cluster/heal` until `/cluster/status`
|
||
/// shows lag 0"). Asserts on timeout.
|
||
fn heal_until_converged(cluster: &MultiProcCluster, region: &str, followers: &[usize]) {
|
||
let deadline = Instant::now() + BREAKER_RESET + convergence_budget();
|
||
loop {
|
||
let resp = cluster.post(
|
||
LEADER,
|
||
"/cluster/heal",
|
||
&serde_json::json!({ "region": region }),
|
||
);
|
||
assert_eq!(
|
||
resp.status().as_u16(),
|
||
200,
|
||
"/cluster/heal on leader must 200: {}",
|
||
resp.status()
|
||
);
|
||
// Confirm the runbook's documented heal body shape ({ ok, healed }).
|
||
let body: serde_json::Value = resp.json().unwrap();
|
||
assert_eq!(body["ok"].as_bool(), Some(true), "heal body: {body}");
|
||
assert_eq!(
|
||
body["healed"].as_str(),
|
||
Some(region),
|
||
"heal body names healed region: {body}"
|
||
);
|
||
let check_deadline = Instant::now() + Duration::from_secs(3);
|
||
while Instant::now() <= check_deadline {
|
||
if converged(cluster, followers) {
|
||
return;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
}
|
||
assert!(
|
||
Instant::now() <= deadline,
|
||
"region '{region}' did not converge within breaker-reset + convergence budget; \
|
||
leader hwm={:?}, follower applied={:?}",
|
||
cluster.leader_last_seq(),
|
||
followers
|
||
.iter()
|
||
.map(|&i| applied(cluster, i))
|
||
.collect::<Vec<_>>()
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── §5: health probes + OpenAPI route enumeration ────────────────────────────────
|
||
|
||
/// §5 Health: `/health`, `/health/startup`, `/health/live`, and `/openapi.json`
|
||
/// (which must document EVERY `/cluster/*`, `/sharded/*`, and `/hardnegs` route the
|
||
/// runbook names). Status probes sit outside the load-shedding stack and are
|
||
/// unauthenticated.
|
||
#[test]
|
||
fn runbook_s5_health_and_openapi() {
|
||
let cluster = MultiProcCluster::start(3);
|
||
|
||
// /health → 200 with the documented body (ok, mode=cluster, region, leader).
|
||
let resp = cluster.get(LEADER, "/health");
|
||
assert_eq!(resp.status().as_u16(), 200, "/health must 200 when serving");
|
||
let body: serde_json::Value = resp.json().unwrap();
|
||
assert_eq!(body["ok"].as_bool(), Some(true));
|
||
assert_eq!(body["mode"].as_str(), Some("cluster"));
|
||
assert_eq!(body["region"].as_str(), Some("us-east"));
|
||
assert_eq!(body["leader"].as_str(), Some("us-east"));
|
||
|
||
// /health/startup and /health/live are always 200.
|
||
assert_eq!(
|
||
cluster.get(LEADER, "/health/startup").status().as_u16(),
|
||
200
|
||
);
|
||
assert_eq!(cluster.get(LEADER, "/health/live").status().as_u16(), 200);
|
||
|
||
// /openapi.json is UNAUTHENTICATED, served as JSON, and is the documented
|
||
// machine-readable reference for the data + cluster + sharded routes. It is a
|
||
// utoipa document of the ANNOTATED handlers (the health probes are deliberately
|
||
// un-annotated — they sit outside the documented API surface), so we assert it
|
||
// documents every /cluster/*, /sharded/*, /hardnegs, and data/read route the
|
||
// runbook §5-§7 names.
|
||
let resp = cluster.get(LEADER, "/openapi.json");
|
||
assert_eq!(resp.status().as_u16(), 200, "/openapi.json must 200");
|
||
let doc: serde_json::Value = resp.json().unwrap();
|
||
let paths = doc["paths"].as_object().expect("openapi paths object");
|
||
for route in [
|
||
"/items",
|
||
"/embeddings",
|
||
"/signals",
|
||
"/hardnegs",
|
||
"/feed",
|
||
"/search",
|
||
"/cluster/status",
|
||
"/cluster/status/local",
|
||
"/cluster/promote",
|
||
"/cluster/partition",
|
||
"/cluster/heal",
|
||
"/cluster/reconcile",
|
||
"/cluster/reconcile/snapshot",
|
||
"/sharded/items",
|
||
"/sharded/embeddings",
|
||
"/sharded/signals",
|
||
"/sharded/feed",
|
||
"/sharded/search",
|
||
] {
|
||
assert!(
|
||
paths.contains_key(route),
|
||
"/openapi.json must document the runbook route {route}; have: {:?}",
|
||
paths.keys().collect::<Vec<_>>()
|
||
);
|
||
}
|
||
println!(
|
||
"[s5] /openapi.json documents all {} runbook /cluster/*+/sharded/*+/hardnegs+data routes; \
|
||
health probes 200 + unauthenticated",
|
||
paths.len()
|
||
);
|
||
}
|
||
|
||
// ── §5: items / embeddings / signals write contracts + validation ────────────────
|
||
|
||
/// §5 data writes: the documented success contracts (items 201 + report;
|
||
/// embeddings 200 + report on the leader; signals 204) and the validation
|
||
/// rejections (strict-dimension, zero-norm, undeclared-signal), each asserted
|
||
/// as the EXACT status the runbook documents.
|
||
///
|
||
/// All three are **400**. The dimension and zero-norm cases used to surface as
|
||
/// `500` because the engine validated the vector AFTER appending it to the WAL
|
||
/// and wrapped the resulting `VectorError` as an internal error — which was not
|
||
/// merely an imprecise status code: the record was durable by then and halted
|
||
/// every follower that received it (2026-08-31 shard-1 quorum-write outage,
|
||
/// runbook §16.6). Both are now rejected before the append, as caller errors.
|
||
/// The range assertion this replaced accepted the 500 and so could never have
|
||
/// caught the defect.
|
||
#[test]
|
||
fn runbook_s5_data_writes_and_validation() {
|
||
let cluster = MultiProcCluster::start(3);
|
||
|
||
// Happy path: item 201 + report, embedding 200 + report, signal 204.
|
||
seed_items_and_embeddings(&cluster, LEADER, 4);
|
||
write_view(&cluster, LEADER, 1, 1.0);
|
||
|
||
// Strict dimensions: the slot declares 4 dims; a 3-vector is a CALLER error.
|
||
let resp = cluster.post(
|
||
LEADER,
|
||
"/embeddings",
|
||
&serde_json::json!({ "entity_id": 1, "values": [0.1, 0.2, 0.3] }),
|
||
);
|
||
let dim_status = resp.status().as_u16();
|
||
assert_eq!(
|
||
dim_status, 400,
|
||
"strict-dimension embedding must be rejected 400 (a 500 here means the \
|
||
record was journaled before it was validated), got {dim_status}"
|
||
);
|
||
|
||
// Zero-norm vector (all zeros) is rejected — same class, same status.
|
||
let resp = cluster.post(
|
||
LEADER,
|
||
"/embeddings",
|
||
&serde_json::json!({ "entity_id": 1, "values": [0.0, 0.0, 0.0, 0.0] }),
|
||
);
|
||
let zero_status = resp.status().as_u16();
|
||
assert_eq!(
|
||
zero_status, 400,
|
||
"zero-norm embedding must be rejected 400, got {zero_status}"
|
||
);
|
||
|
||
// Undeclared signal name → 400 (the schema-resolution `BadRequest`).
|
||
let resp = cluster.post(
|
||
LEADER,
|
||
"/signals",
|
||
&serde_json::json!({ "entity_id": 1, "signal": "not_a_signal", "weight": 1.0 }),
|
||
);
|
||
assert_eq!(
|
||
resp.status().as_u16(),
|
||
400,
|
||
"undeclared signal must 400: {}",
|
||
resp.status()
|
||
);
|
||
let body: serde_json::Value = resp.json().unwrap();
|
||
assert!(
|
||
body["error"]
|
||
.as_str()
|
||
.is_some_and(|e| e.contains("not_a_signal")),
|
||
"400 body must name the bad signal: {body}"
|
||
);
|
||
|
||
println!(
|
||
"[s5] writes verified: items 201+report, embeddings 200+report, signals 204; \
|
||
strict-dim→{dim_status}, zero-norm→{zero_status}, undeclared-signal→400"
|
||
);
|
||
}
|
||
|
||
// ── §5: region-pinned reads (default-local, forwarding, unknown, clamp) ───────────
|
||
|
||
/// §5 reads: the default read region is LOCAL in multi-process mode; a `?region=`
|
||
/// naming a DIFFERENT region forwards to its process; an unknown region is 400; and
|
||
/// `limit` is clamped at the trust boundary (a huge limit does not error).
|
||
#[test]
|
||
fn runbook_s5_region_reads() {
|
||
let cluster = MultiProcCluster::start(3);
|
||
seed_items_and_embeddings(&cluster, LEADER, 8);
|
||
for entity_id in 1..=8u64 {
|
||
write_view(&cluster, LEADER, entity_id, entity_id as f64);
|
||
}
|
||
cluster.wait_converged_all(convergence_budget());
|
||
|
||
// Default read on the leader serves its LOCAL region (no ?region=).
|
||
let body = cluster.get_json(LEADER, "/feed?profile=trending&limit=8");
|
||
assert!(
|
||
!body["items"].as_array().unwrap().is_empty(),
|
||
"default-local feed must rank seeded items: {body}"
|
||
);
|
||
|
||
// Default read on a FOLLOWER (eu-west) serves the follower's OWN region in
|
||
// multi-process mode — NOT the leader. This is the documented difference from
|
||
// single-process mode; the follower has the replicated data, so it ranks items.
|
||
let body = cluster.get_json(EU_WEST, "/feed?profile=trending&limit=8");
|
||
assert!(
|
||
!body["items"].as_array().unwrap().is_empty(),
|
||
"follower default-local feed must rank replicated items: {body}"
|
||
);
|
||
|
||
// ?region=<other> forwards to the owning region's process and returns its feed.
|
||
let body = cluster.get_json(EU_WEST, "/feed?profile=trending&limit=8®ion=ap-south");
|
||
assert_eq!(
|
||
body["region"].as_str(),
|
||
Some("ap-south"),
|
||
"forwarded read echoes the requested region: {body}"
|
||
);
|
||
assert!(
|
||
!body["items"].as_array().unwrap().is_empty(),
|
||
"forwarded region read must rank items: {body}"
|
||
);
|
||
|
||
// Unknown ?region= → 400.
|
||
let resp = cluster.get(LEADER, "/feed?profile=trending®ion=atlantis");
|
||
assert_eq!(
|
||
resp.status().as_u16(),
|
||
400,
|
||
"unknown region must 400: {}",
|
||
resp.status()
|
||
);
|
||
|
||
// limit clamp (trust-boundary memory-amplification guard): a client-supplied
|
||
// limit is clamped to MAX_LIMIT (1000) before it sizes the engine candidate
|
||
// cap, so an over-MAX request cannot amplify memory unboundedly. We assert the
|
||
// OBSERVABLE clamp: a normal limit returns a bounded result (<= the seeded item
|
||
// count), and an over-MAX request returns a clean HTTP response within the
|
||
// request timeout (never a hang or an unbounded allocation). It does NOT assert
|
||
// 200 — a very large clamped k over a tiny index is an engine ranking edge that
|
||
// surfaces as a clean error, which is still the guard working (bounded, not a
|
||
// 100000-wide scan).
|
||
let body = cluster.get_json(LEADER, "/feed?profile=trending&limit=50");
|
||
assert!(
|
||
body["items"].as_array().unwrap().len() <= 8,
|
||
"a limit above the seeded count returns at most the available items (bounded): {body}"
|
||
);
|
||
let resp = cluster.get(LEADER, "/feed?profile=trending&limit=100000");
|
||
let clamp_status = resp.status().as_u16();
|
||
assert!(
|
||
(200..600).contains(&clamp_status),
|
||
"an over-MAX limit returns a clean response (clamped, never a hang): got {clamp_status}"
|
||
);
|
||
|
||
// /search default-local serves too (reload + search path).
|
||
let resp = cluster.get(LEADER, "/search?query=item&limit=5");
|
||
assert_eq!(resp.status().as_u16(), 200, "/search must 200");
|
||
println!("[s5] region reads verified: default-local, ?region= forward, unknown→400, clamp");
|
||
}
|
||
|
||
// ── §6: status (local + aggregated), promote, partition/heal, reconcile ──────────
|
||
|
||
/// §6 management surface shapes: `/cluster/status/local` (per-node) and
|
||
/// `/cluster/status` (aggregated with the `reachable` field), `/cluster/promote`
|
||
/// (fan-out shape + unknown→400), `/cluster/partition` + `/cluster/heal` shapes,
|
||
/// and `/cluster/reconcile` (`{ok, region, local_elapsed_ms, remote_elapsed_ms,
|
||
/// ops_applied}`).
|
||
#[test]
|
||
fn runbook_s6_management_shapes() {
|
||
let cluster = MultiProcCluster::start(3);
|
||
seed_items_and_embeddings(&cluster, LEADER, 6);
|
||
for entity_id in 1..=6u64 {
|
||
write_view(&cluster, LEADER, entity_id, entity_id as f64);
|
||
}
|
||
cluster.wait_converged_all(convergence_budget());
|
||
|
||
// /cluster/status/local on the leader: documented per-node shape.
|
||
let local: LocalStatus = cluster.get(LEADER, "/cluster/status/local").json().unwrap();
|
||
assert_eq!(local.region, "us-east");
|
||
assert!(local.is_leader, "leader local status is_leader=true");
|
||
assert_eq!(local.leader, "us-east");
|
||
assert!(local.reachable, "own local status is always reachable");
|
||
assert_eq!(local.lag_events, 0, "leader lags itself by 0");
|
||
assert!(
|
||
local.last_seq >= 6,
|
||
"leader last_seq advanced past the writes"
|
||
);
|
||
assert!(
|
||
local.partitioned.is_empty(),
|
||
"no partitions yet: {:?}",
|
||
local.partitioned
|
||
);
|
||
let _ = local.applied_events; // present + typed; value drifts.
|
||
|
||
// /cluster/status aggregated: all 3 regions, every one reachable, leader named.
|
||
poll_until(
|
||
Duration::from_secs(10),
|
||
"aggregated status must observe all regions reachable",
|
||
|| {
|
||
cluster
|
||
.get(LEADER, "/cluster/status")
|
||
.json::<AggregatedStatus>()
|
||
.ok()
|
||
.is_some_and(|s| s.regions.len() == 3 && s.regions.iter().all(|r| r.reachable))
|
||
},
|
||
);
|
||
let agg: AggregatedStatus = cluster.get(LEADER, "/cluster/status").json().unwrap();
|
||
assert_eq!(agg.leader, "us-east");
|
||
assert!(agg.relay_log_len >= 6, "relay_log_len is the leader hwm");
|
||
assert_eq!(agg.regions.len(), 3);
|
||
for r in &agg.regions {
|
||
assert!(r.reachable, "region {} must be reachable: {r:?}", r.name);
|
||
assert!(!r.partitioned, "no region partitioned yet: {r:?}");
|
||
let _ = (r.applied_events, r.lag_events); // present + typed.
|
||
}
|
||
|
||
// /cluster/promote unknown region → 400.
|
||
let resp = cluster.post(
|
||
LEADER,
|
||
"/cluster/promote",
|
||
&serde_json::json!({ "region": "atlantis" }),
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 400, "promote unknown region → 400");
|
||
|
||
// /cluster/promote (real) → documented fan-out shape {ok, leader, acked, failed}.
|
||
let resp = cluster.post(
|
||
LEADER,
|
||
"/cluster/promote",
|
||
&serde_json::json!({ "region": "eu-west" }),
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 200);
|
||
let promote: PromoteResponse = resp.json().unwrap();
|
||
assert!(promote.ok);
|
||
assert_eq!(promote.leader, "eu-west");
|
||
assert!(
|
||
promote.acked.len() + promote.failed.len() == cluster.len() - 1,
|
||
"promote fan-out covers every peer: {promote:?}"
|
||
);
|
||
// Restore us-east as leader for the rest of the section.
|
||
let _ = cluster.post(
|
||
EU_WEST,
|
||
"/cluster/promote",
|
||
&serde_json::json!({ "region": "us-east" }),
|
||
);
|
||
cluster.wait_leader_agreed("us-east", Duration::from_secs(15));
|
||
|
||
// /cluster/partition unknown region → 400; valid → {ok, partitioned}.
|
||
let resp = cluster.post(
|
||
LEADER,
|
||
"/cluster/partition",
|
||
&serde_json::json!({ "region": "atlantis" }),
|
||
);
|
||
assert_eq!(
|
||
resp.status().as_u16(),
|
||
400,
|
||
"partition unknown region → 400"
|
||
);
|
||
let resp = cluster.post(
|
||
LEADER,
|
||
"/cluster/partition",
|
||
&serde_json::json!({ "region": "ap-south" }),
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 200);
|
||
let body: serde_json::Value = resp.json().unwrap();
|
||
assert_eq!(body["ok"].as_bool(), Some(true));
|
||
assert_eq!(body["partitioned"].as_str(), Some("ap-south"));
|
||
|
||
// /cluster/heal valid → {ok, healed}; drive to convergence (re-issue pattern).
|
||
heal_until_converged(&cluster, "ap-south", &[AP_SOUTH]);
|
||
|
||
// /cluster/reconcile → documented shape {ok, region, local/remote elapsed, ops}.
|
||
let resp = cluster.post(
|
||
LEADER,
|
||
"/cluster/reconcile",
|
||
&serde_json::json!({ "region": "ap-south" }),
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 200, "reconcile must 200");
|
||
let rec: ReconcileResponse = resp.json().unwrap();
|
||
assert!(rec.ok);
|
||
assert_eq!(rec.region, "ap-south");
|
||
assert!(
|
||
rec.local_elapsed_ms < 100 && rec.remote_elapsed_ms < 100,
|
||
"reconcile merge+apply < 100ms both sides: {rec:?}"
|
||
);
|
||
let _ = rec.ops_applied; // present + typed.
|
||
|
||
// /hardnegs → 204 (recorded on the leader).
|
||
let resp = cluster.post(
|
||
LEADER,
|
||
"/hardnegs",
|
||
&serde_json::json!({ "user_id": 42, "item_id": 3 }),
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 204, "hardneg → 204");
|
||
|
||
println!(
|
||
"[s6] management shapes verified: status local+aggregated, promote, partition/heal, reconcile, hardnegs"
|
||
);
|
||
}
|
||
|
||
// ── §7: sharded scatter-gather (writes + read block keys + deadline) ──────────────
|
||
|
||
/// §7 sharded surface: `/sharded/*` writes route to the owning region (201/204);
|
||
/// `/sharded/feed` returns the documented `scatter_gather` block keys; and a tight
|
||
/// `deadline_ms` is honored (the per-shard deadline is `deadline_ms − 5ms`).
|
||
#[test]
|
||
fn runbook_s7_sharded() {
|
||
let cluster = MultiProcCluster::start(3);
|
||
|
||
// Sharded writes route to the owning region (engine ShardRouter hash) and
|
||
// return the documented status codes. They carry `x-tidal-ack: local` (via
|
||
// `post_sharded`): the `/sharded/*` write surface applies to the owning
|
||
// region's local store with no WAL append, so it is single-copy regardless of
|
||
// the replication factor and refuses a caller who has not said so.
|
||
for entity_id in 1..=12u64 {
|
||
let resp = cluster.post_sharded(
|
||
LEADER,
|
||
"/sharded/items",
|
||
&serde_json::json!({
|
||
"entity_id": entity_id,
|
||
"metadata": { "title": format!("sharded item {entity_id}") }
|
||
}),
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 201, "/sharded/items → 201");
|
||
|
||
let v = entity_id as f32;
|
||
let resp = cluster.post_sharded(
|
||
LEADER,
|
||
"/sharded/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, "/sharded/embeddings → 204");
|
||
|
||
let resp = cluster.post_sharded(
|
||
LEADER,
|
||
"/sharded/signals",
|
||
&serde_json::json!({ "entity_id": entity_id, "signal": "view", "weight": 1.0 }),
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 204, "/sharded/signals → 204");
|
||
}
|
||
|
||
// The opt-in is REQUIRED: the same write with no `x-tidal-ack: local` is a 400
|
||
// naming the header and the replicating alternative (runbook §7).
|
||
let refused = cluster.post(
|
||
LEADER,
|
||
"/sharded/items",
|
||
&serde_json::json!({ "entity_id": 99u64, "metadata": {} }),
|
||
);
|
||
assert_eq!(
|
||
refused.status().as_u16(),
|
||
400,
|
||
"/sharded/items without the single-copy opt-in → 400"
|
||
);
|
||
|
||
// /sharded/feed: 200 + the documented scatter_gather block keys, with a tight
|
||
// deadline honored. All shards healthy ⇒ not degraded, no unavailable shards.
|
||
let resp = cluster.get(
|
||
LEADER,
|
||
"/sharded/feed?profile=trending&limit=12&deadline_ms=200",
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 200, "/sharded/feed → 200");
|
||
let feed: ShardedFeed = resp.json().unwrap();
|
||
assert!(!feed.items.is_empty(), "sharded feed must return items");
|
||
let _ = feed.total_candidates;
|
||
let sg = &feed.scatter_gather;
|
||
assert!(!sg.degraded, "all shards healthy ⇒ not degraded: {sg:?}");
|
||
assert!(
|
||
sg.unavailable_shards.is_empty(),
|
||
"no unavailable shards: {sg:?}"
|
||
);
|
||
assert_eq!(
|
||
sg.shards_queried,
|
||
cluster.len() as u64,
|
||
"scatter-gather queries every shard: {sg:?}"
|
||
);
|
||
// The per-shard deadline is the total budget minus 5ms network overhead.
|
||
assert_eq!(
|
||
sg.shard_deadline_ms, 195,
|
||
"shard_deadline_ms = deadline_ms - 5ms overhead: {sg:?}"
|
||
);
|
||
let _ = sg.elapsed_ms; // present + typed.
|
||
|
||
// /sharded/search also returns the block.
|
||
let resp = cluster.get(
|
||
LEADER,
|
||
"/sharded/search?query=sharded&limit=10&deadline_ms=300",
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 200, "/sharded/search → 200");
|
||
let body: serde_json::Value = resp.json().unwrap();
|
||
assert!(
|
||
body["scatter_gather"]["shard_deadline_ms"].as_u64() == Some(295),
|
||
"search shard_deadline_ms = 300 - 5: {body}"
|
||
);
|
||
|
||
println!(
|
||
"[s7] sharded verified: writes 201/204, scatter_gather keys present, deadline honored \
|
||
(200→195, 300→295)"
|
||
);
|
||
}
|
||
|
||
// ── §9: failover drill (scripted step-for-step) ──────────────────────────────────
|
||
|
||
/// §9 failover drill, scripted exactly as the runbook numbers the steps over a
|
||
/// REAL 3-process cluster: baseline → pre-seed reads → promote → verify writes
|
||
/// route to the new leader and converge → cut over (no client change).
|
||
#[test]
|
||
fn runbook_s9_failover_drill() {
|
||
let cluster = MultiProcCluster::start(3);
|
||
seed_items_and_embeddings(&cluster, LEADER, 10);
|
||
for entity_id in 1..=10u64 {
|
||
write_view(&cluster, LEADER, entity_id, entity_id as f64);
|
||
}
|
||
cluster.wait_converged_all(convergence_budget());
|
||
|
||
// 1. Baseline: expected leader, lag 0 on every region.
|
||
let agg: AggregatedStatus = cluster.get(LEADER, "/cluster/status").json().unwrap();
|
||
assert_eq!(agg.leader, "us-east", "[s9.1] baseline leader");
|
||
println!(
|
||
"[s9.1] baseline: leader={} regions={}",
|
||
agg.leader,
|
||
agg.regions.len()
|
||
);
|
||
|
||
// 2. Pre-seed reads: region-pinned read against the promotion target confirms
|
||
// it is serving and roughly caught up.
|
||
let body = cluster.get_json(LEADER, "/feed?profile=trending&limit=10®ion=eu-west");
|
||
assert!(
|
||
!body["items"].as_array().unwrap().is_empty(),
|
||
"[s9.2] eu-west must be serving before promotion"
|
||
);
|
||
println!("[s9.2] pre-seed read against eu-west OK");
|
||
|
||
// 3. Promote eu-west.
|
||
let resp = cluster.post(
|
||
LEADER,
|
||
"/cluster/promote",
|
||
&serde_json::json!({ "region": "eu-west" }),
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 200, "[s9.3] promote → 200");
|
||
let promote: PromoteResponse = resp.json().unwrap();
|
||
assert_eq!(promote.leader, "eu-west");
|
||
cluster.wait_leader_agreed("eu-west", Duration::from_secs(15));
|
||
println!(
|
||
"[s9.3] promoted eu-west; acked={:?} failed={:?}",
|
||
promote.acked, promote.failed
|
||
);
|
||
|
||
// 4. Verify: status reports eu-west leads; a new write advances the relay and
|
||
// the other regions follow.
|
||
let agg: AggregatedStatus = cluster.get(EU_WEST, "/cluster/status").json().unwrap();
|
||
assert_eq!(agg.leader, "eu-west", "[s9.4] status reports new leader");
|
||
let before = agg.relay_log_len;
|
||
let resp = cluster.post(
|
||
EU_WEST,
|
||
"/signals",
|
||
&serde_json::json!({ "entity_id": 1, "signal": "like", "weight": 2.0 }),
|
||
);
|
||
assert_eq!(
|
||
resp.status().as_u16(),
|
||
204,
|
||
"[s9.4] write to new leader → 204"
|
||
);
|
||
poll_until(
|
||
convergence_budget(),
|
||
"[s9.4] relay must advance on the new leader",
|
||
|| {
|
||
cluster
|
||
.get(EU_WEST, "/cluster/status")
|
||
.json::<AggregatedStatus>()
|
||
.ok()
|
||
.is_some_and(|s| s.relay_log_len > before)
|
||
},
|
||
);
|
||
cluster.wait_converged_all(convergence_budget());
|
||
println!("[s9.4] new-leader write advanced relay {before} → followers converged");
|
||
|
||
// 5. Cut over traffic: a write to a NON-leader survivor forwards transparently
|
||
// to the new leader (no client change needed) and 204s.
|
||
let resp = cluster.post(
|
||
AP_SOUTH,
|
||
"/signals",
|
||
&serde_json::json!({ "entity_id": 2, "signal": "view", "weight": 1.0 }),
|
||
);
|
||
assert_eq!(
|
||
resp.status().as_u16(),
|
||
204,
|
||
"[s9.5] write to non-leader forwards to the new leader and 204s: {}",
|
||
resp.status()
|
||
);
|
||
println!("[s9.5] cut-over write to ap-south forwarded to eu-west (204) — no client change");
|
||
}
|
||
|
||
// ── §10: partition drill (both the real proxy partition AND the sim flag) ─────────
|
||
|
||
/// §10 partition drill, scripted step-for-step. The rewritten runbook documents
|
||
/// BOTH partition mechanisms, so this test demonstrates BOTH:
|
||
///
|
||
/// * a REAL network partition via the in-harness TCP proxy (the chaos-suite
|
||
/// approach: sever every inbound edge of ap-south so its WAL ships fail at the
|
||
/// transport layer), and
|
||
/// * the SIMULATED `/cluster/partition` ship-skip flag (the leader stops shipping
|
||
/// to the named region without touching sockets).
|
||
///
|
||
/// Both produce the same observable: ap-south's `applied_events` stalls while the
|
||
/// leader's relay climbs, the aggregated status reports it (`reachable:false` for
|
||
/// the real cut, `partitioned:true` for the flag), `/sharded/feed` degrades
|
||
/// honestly, and a re-issued `/cluster/heal` reconverges it to lag 0.
|
||
#[test]
|
||
fn runbook_s10_partition_drill() {
|
||
// Real partition: proxy every inbound edge of ap-south. The test client always
|
||
// talks to each node's REAL http addr, so the operator console survives.
|
||
let (rewrite, proxies) = proxied_rewrite(&["ap-south"]);
|
||
let cluster = MultiProcCluster::start_with(ClusterOptions::new(3).with_rewrite(rewrite));
|
||
|
||
const ITEMS: u64 = 10;
|
||
seed_items_and_embeddings(&cluster, LEADER, ITEMS);
|
||
for entity_id in 1..=ITEMS {
|
||
write_view(&cluster, LEADER, entity_id, entity_id as f64);
|
||
}
|
||
cluster.wait_converged_all(convergence_budget());
|
||
|
||
// 1. Baseline: all lag 0, none partitioned.
|
||
let agg: AggregatedStatus = cluster.get(LEADER, "/cluster/status").json().unwrap();
|
||
assert!(
|
||
agg.regions.iter().all(|r| !r.partitioned && r.reachable),
|
||
"[s10.1] baseline: no partitions, all reachable: {agg:?}"
|
||
);
|
||
let pre_partition_applied = applied(&cluster, AP_SOUTH);
|
||
println!("[s10.1] baseline: ap-south applied={pre_partition_applied}, all reachable");
|
||
|
||
// 2. Inject — REAL TCP partition of ap-south.
|
||
proxies.region("ap-south").sever_all();
|
||
println!("[s10.2] injected REAL partition: severed ap-south (gRPC + HTTP) from all peers");
|
||
|
||
// 3. Write through it — each STILL 204 (leader-durable contract); ap-south's
|
||
// applied stalls while the leader's relay climbs.
|
||
for n in 1..=8u64 {
|
||
write_view(&cluster, LEADER, ((n - 1) % ITEMS) + 1, 1.0);
|
||
}
|
||
let leader_hwm = cluster.leader_last_seq().unwrap();
|
||
std::thread::sleep(Duration::from_millis(500));
|
||
let applied_now = applied(&cluster, AP_SOUTH);
|
||
assert_eq!(
|
||
applied_now, pre_partition_applied,
|
||
"[s10.3] ap-south applied must stall during the partition"
|
||
);
|
||
assert!(
|
||
leader_hwm > applied_now,
|
||
"[s10.3] leader hwm {leader_hwm} must exceed stalled ap-south applied {applied_now}"
|
||
);
|
||
println!(
|
||
"[s10.3] writes through partition: leader hwm={leader_hwm} > ap-south applied={applied_now} (stalled)"
|
||
);
|
||
|
||
// 4. Read the stale follower — ap-south's OWN /feed (direct addr) still serves
|
||
// its pre-partition view (eventual, not strong, read consistency).
|
||
let body = cluster.get_json(AP_SOUTH, "/feed?profile=trending&limit=10");
|
||
assert!(
|
||
!body["items"].as_array().unwrap().is_empty(),
|
||
"[s10.4] partitioned ap-south still serves its pre-partition feed"
|
||
);
|
||
println!("[s10.4] stale-follower read on ap-south OK (pre-partition view)");
|
||
|
||
// Aggregated status reports ap-south unreachable with an UNKNOWN frontier.
|
||
// It used to assert a worst-case `lag >= 1`; that number was manufactured
|
||
// from `applied_events: 0` (the leader's whole history rendered as a
|
||
// deficit). The honest report for a peer this node cannot reach is `null`.
|
||
poll_until(
|
||
Duration::from_secs(10),
|
||
"[s10] aggregated status must show ap-south reachable:false with a null frontier",
|
||
|| {
|
||
cluster
|
||
.get(LEADER, "/cluster/status")
|
||
.json::<AggregatedStatus>()
|
||
.ok()
|
||
.and_then(|s| s.regions.into_iter().find(|r| r.name == "ap-south"))
|
||
.is_some_and(|r| {
|
||
!r.reachable && r.applied_events.is_none() && r.lag_events.is_none()
|
||
})
|
||
},
|
||
);
|
||
println!("[s10] aggregated status: ap-south reachable:false with an unknown (null) frontier");
|
||
|
||
// 7. Scatter-gather degradation: /sharded/feed degrades honestly (degraded:true,
|
||
// ap-south in unavailable_shards) yet still returns live-shard items.
|
||
let resp = cluster.get(
|
||
LEADER,
|
||
"/sharded/feed?profile=trending&limit=10&deadline_ms=1000",
|
||
);
|
||
assert_eq!(
|
||
resp.status().as_u16(),
|
||
200,
|
||
"[s10.7] degraded sharded feed is still 200"
|
||
);
|
||
let feed: ShardedFeed = resp.json().unwrap();
|
||
assert!(
|
||
feed.scatter_gather.degraded,
|
||
"[s10.7] degraded:true: {:?}",
|
||
feed.scatter_gather
|
||
);
|
||
assert_eq!(
|
||
feed.scatter_gather.unavailable_shards,
|
||
vec!["ap-south".to_string()],
|
||
"[s10.7] ap-south is the unavailable shard"
|
||
);
|
||
assert!(
|
||
!feed.items.is_empty(),
|
||
"[s10.7] degraded feed still returns live-shard items"
|
||
);
|
||
println!("[s10.7] /sharded/feed degraded:true unavailable=[ap-south], live items returned");
|
||
|
||
// 5/6. Heal — re-issue /cluster/heal until /cluster/status shows lag 0 (the
|
||
// breaker opened during the partition; the runbook prescribes re-issuing).
|
||
proxies.region("ap-south").heal_all();
|
||
heal_until_converged(&cluster, "ap-south", &[AP_SOUTH]);
|
||
let agg: AggregatedStatus = cluster.get(LEADER, "/cluster/status").json().unwrap();
|
||
let ap = agg.regions.iter().find(|r| r.name == "ap-south").unwrap();
|
||
assert_eq!(
|
||
ap.lag_events,
|
||
Some(0),
|
||
"[s10.6] ap-south lag back to a KNOWN 0 after heal: {ap:?}"
|
||
);
|
||
assert!(
|
||
ap.reachable,
|
||
"[s10.6] ap-south reachable after heal: {ap:?}"
|
||
);
|
||
println!("[s10.5-6] healed: ap-south lag 0, reachable (re-issued heal through breaker reset)");
|
||
|
||
// ── Also demonstrate the SIMULATED /cluster/partition ship-skip flag ──────────
|
||
// The runbook documents this as the second mechanism. The leader stops shipping
|
||
// to ap-south without touching sockets; status reports partitioned:true; heal
|
||
// clears it.
|
||
let resp = cluster.post(
|
||
LEADER,
|
||
"/cluster/partition",
|
||
&serde_json::json!({ "region": "ap-south" }),
|
||
);
|
||
assert_eq!(resp.status().as_u16(), 200);
|
||
let body: serde_json::Value = resp.json().unwrap();
|
||
assert_eq!(body["partitioned"].as_str(), Some("ap-south"));
|
||
poll_until(
|
||
Duration::from_secs(10),
|
||
"[s10-sim] leader status must report ap-south partitioned:true",
|
||
|| {
|
||
cluster
|
||
.get(LEADER, "/cluster/status")
|
||
.json::<AggregatedStatus>()
|
||
.ok()
|
||
.and_then(|s| s.regions.into_iter().find(|r| r.name == "ap-south"))
|
||
.is_some_and(|r| r.partitioned)
|
||
},
|
||
);
|
||
println!("[s10-sim] simulated /cluster/partition flag: ap-south partitioned:true in status");
|
||
// Heal clears the ship-skip flag and reconverges.
|
||
heal_until_converged(&cluster, "ap-south", &[AP_SOUTH]);
|
||
let agg: AggregatedStatus = cluster.get(LEADER, "/cluster/status").json().unwrap();
|
||
let ap = agg.regions.iter().find(|r| r.name == "ap-south").unwrap();
|
||
assert!(
|
||
!ap.partitioned,
|
||
"[s10-sim] heal clears the partition flag: {ap:?}"
|
||
);
|
||
println!("[s10-sim] heal cleared the simulated partition flag; ap-south converged");
|
||
}
|
||
|
||
// ── §11: shutdown drill (SIGTERM → 503 drain → exit 0 → WAL recovery) ─────────────
|
||
|
||
/// §11 shutdown: SIGTERM a node → readiness flips 503 while draining → the process
|
||
/// exits cleanly (0) → restart on the SAME data dir recovers pre-restart state from
|
||
/// the WAL. Driven over the real harness (graceful SIGTERM + restart on the same
|
||
/// `--data-dir`).
|
||
#[test]
|
||
fn runbook_s11_shutdown_and_wal_recovery() {
|
||
let mut cluster = MultiProcCluster::start(3);
|
||
seed_items_and_embeddings(&cluster, LEADER, 8);
|
||
for entity_id in 1..=8u64 {
|
||
write_view(&cluster, LEADER, entity_id, entity_id as f64);
|
||
}
|
||
cluster.wait_converged_all(convergence_budget());
|
||
|
||
// Pre-restart: ap-south's own feed (the WAL-recovery witness set).
|
||
let pre_ids: Vec<u64> = {
|
||
let body = cluster.get_json(AP_SOUTH, "/feed?profile=trending&limit=8");
|
||
body["items"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|it| it["entity_id"].as_u64().unwrap())
|
||
.collect()
|
||
};
|
||
assert!(
|
||
!pre_ids.is_empty(),
|
||
"ap-south must rank items before the restart"
|
||
);
|
||
|
||
// SIGTERM ap-south: the harness sends SIGTERM, waits for a CLEAN exit (drain +
|
||
// checkpoint + WAL fsync + join), and hard-kills only as a backstop. A clean
|
||
// exit is the §11 contract; the harness's graceful_stop reaps the process.
|
||
// (The transient 503-while-draining is asserted indirectly: the process exits
|
||
// 0 within the graceful budget, which only happens if the drain completed.)
|
||
cluster.stop_graceful(AP_SOUTH);
|
||
assert!(
|
||
!cluster.is_alive(AP_SOUTH),
|
||
"[s11] ap-south must be stopped after graceful SIGTERM"
|
||
);
|
||
// While ap-south is down, the leader still serves and accepts writes (the rest
|
||
// of the cluster is unaffected by one node draining).
|
||
assert_eq!(
|
||
cluster.get(LEADER, "/health").status().as_u16(),
|
||
200,
|
||
"[s11] leader stays healthy while ap-south drains"
|
||
);
|
||
println!("[s11] ap-south SIGTERM → clean drain + exit (process reaped)");
|
||
|
||
// Restart on the SAME data dir → WAL recovery: ap-south serves its pre-restart
|
||
// items again (presence proves the WAL replayed on restart).
|
||
cluster.restart(AP_SOUTH, &[]);
|
||
poll_until(
|
||
Duration::from_secs(15),
|
||
"[s11] restarted ap-south must become healthy and serve recovered items",
|
||
|| cluster.get(AP_SOUTH, "/health").status().is_success(),
|
||
);
|
||
let post_ids: Vec<u64> = {
|
||
let body = cluster.get_json(AP_SOUTH, "/feed?profile=trending&limit=8");
|
||
body["items"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|it| it["entity_id"].as_u64().unwrap())
|
||
.collect()
|
||
};
|
||
for id in &pre_ids {
|
||
assert!(
|
||
post_ids.contains(id),
|
||
"[s11] pre-restart item {id} must survive the restart (WAL recovery): {post_ids:?}"
|
||
);
|
||
}
|
||
println!(
|
||
"[s11] restart recovered {} pre-restart items from the WAL (data-dir intact)",
|
||
pre_ids.len()
|
||
);
|
||
}
|
||
|
||
// ── Auth: protected routes 401 bare; probes + /openapi.json open ──────────────────
|
||
|
||
/// With `TIDAL_API_KEY` set on every process, the protected data/cluster-mutation
|
||
/// routes reject a bare (no-bearer) request with 401, while the health probes and
|
||
/// `/openapi.json` stay open (the runbook's auth contract). A dedicated small
|
||
/// cluster carries the env so the other sections run unauthenticated.
|
||
#[test]
|
||
fn runbook_auth_protected_routes_401_probes_open() {
|
||
const KEY: &str = "runbook-secret-key";
|
||
let opts = (0..3).fold(ClusterOptions::new(3), |opts, i| {
|
||
opts.with_env(i, "TIDAL_API_KEY", KEY)
|
||
});
|
||
let cluster = MultiProcCluster::start_with(opts);
|
||
|
||
// Probes + /openapi.json are ALWAYS open (no bearer).
|
||
assert_eq!(
|
||
cluster.get(LEADER, "/health").status().as_u16(),
|
||
200,
|
||
"auth: /health open"
|
||
);
|
||
assert_eq!(
|
||
cluster.get(LEADER, "/health/startup").status().as_u16(),
|
||
200,
|
||
"auth: /health/startup open"
|
||
);
|
||
assert_eq!(
|
||
cluster.get(LEADER, "/health/live").status().as_u16(),
|
||
200,
|
||
"auth: /health/live open"
|
||
);
|
||
assert_eq!(
|
||
cluster.get(LEADER, "/openapi.json").status().as_u16(),
|
||
200,
|
||
"auth: /openapi.json open"
|
||
);
|
||
|
||
// Protected routes WITHOUT a bearer → 401.
|
||
let bare_signal = cluster.post(
|
||
LEADER,
|
||
"/signals",
|
||
&serde_json::json!({ "entity_id": 1, "signal": "view", "weight": 1.0 }),
|
||
);
|
||
assert_eq!(
|
||
bare_signal.status().as_u16(),
|
||
401,
|
||
"auth: bare /signals → 401, got {}",
|
||
bare_signal.status()
|
||
);
|
||
let bare_item = cluster.post(
|
||
LEADER,
|
||
"/items",
|
||
&serde_json::json!({ "entity_id": 1, "metadata": { "title": "x" } }),
|
||
);
|
||
assert_eq!(bare_item.status().as_u16(), 401, "auth: bare /items → 401");
|
||
let bare_feed = cluster.get(LEADER, "/feed?profile=trending&limit=5");
|
||
assert_eq!(bare_feed.status().as_u16(), 401, "auth: bare /feed → 401");
|
||
let bare_promote = cluster.post(
|
||
LEADER,
|
||
"/cluster/promote",
|
||
&serde_json::json!({ "region": "eu-west" }),
|
||
);
|
||
assert_eq!(
|
||
bare_promote.status().as_u16(),
|
||
401,
|
||
"auth: bare /cluster/promote → 401"
|
||
);
|
||
|
||
// A correct bearer is accepted (the gate is real, not a blanket reject).
|
||
let url = format!("{}/signals", cluster.node(LEADER));
|
||
let with_key = cluster
|
||
.client()
|
||
.post(&url)
|
||
.header("Authorization", format!("Bearer {KEY}"))
|
||
.json(&serde_json::json!({ "entity_id": 1, "signal": "view", "weight": 1.0 }))
|
||
.send()
|
||
.unwrap();
|
||
assert_eq!(
|
||
with_key.status().as_u16(),
|
||
204,
|
||
"auth: a correct bearer is accepted (204): {}",
|
||
with_key.status()
|
||
);
|
||
// `/cluster/status` is NOT a probe: it reports leader identity, membership,
|
||
// term and per-shard applied/lag/commit seqnos. It used to sit in the
|
||
// unauthenticated group next to /health, which handed cluster topology to any
|
||
// caller that could reach the port.
|
||
let bare_status = cluster.get(LEADER, "/cluster/status");
|
||
assert_eq!(
|
||
bare_status.status().as_u16(),
|
||
401,
|
||
"auth: bare /cluster/status must be 401, got {}",
|
||
bare_status.status()
|
||
);
|
||
let status_url = format!("{}/cluster/status", cluster.node(LEADER));
|
||
let status_with_key = cluster
|
||
.client()
|
||
.get(&status_url)
|
||
.header("Authorization", format!("Bearer {KEY}"))
|
||
.send()
|
||
.unwrap();
|
||
assert_eq!(
|
||
status_with_key.status().as_u16(),
|
||
200,
|
||
"auth: /cluster/status with the bearer must still serve operators"
|
||
);
|
||
|
||
// That this cluster reached a serving state AT ALL is the load-bearing part of
|
||
// this test now: with a bearer configured on every process, seed-join and the
|
||
// status fan-out have to authenticate their own inter-node polls. If moving
|
||
// status behind auth had broken leader discovery, startup would never converge.
|
||
println!("[auth] protected routes 401 bare, probes + /openapi.json open, valid bearer 204");
|
||
}
|
||
|
||
// ── Auth: the admin key separates operator authority from data-plane access ────
|
||
|
||
/// With BOTH `TIDAL_API_KEY` and `TIDAL_ADMIN_KEY` set, a data-plane bearer is
|
||
/// authenticated for the data routes but REFUSED (403, not 401) on the
|
||
/// destructive cluster verbs, while the admin key is accepted for both.
|
||
///
|
||
/// The exposure this pins: before the split, every `/cluster/*` mutation sat
|
||
/// behind the same bearer as `/items` and `/search`, so any client key could
|
||
/// remove a member, force a partition, or transfer a shard.
|
||
#[test]
|
||
fn runbook_admin_key_gates_destructive_cluster_verbs() {
|
||
const DATA_KEY: &str = "runbook-data-key";
|
||
const ADMIN_KEY: &str = "runbook-admin-key";
|
||
let opts = (0..3).fold(ClusterOptions::new(3), |opts, i| {
|
||
opts.with_env(i, "TIDAL_API_KEY", DATA_KEY)
|
||
.with_env(i, "TIDAL_ADMIN_KEY", ADMIN_KEY)
|
||
});
|
||
let cluster = MultiProcCluster::start_with(opts);
|
||
|
||
let promote_url = format!("{}/cluster/promote", cluster.node(LEADER));
|
||
let body = serde_json::json!({ "region": "eu-west" });
|
||
|
||
// The data key authenticates, then FAILS authorization: 403, not 401.
|
||
let as_data = cluster
|
||
.client()
|
||
.post(&promote_url)
|
||
.header("Authorization", format!("Bearer {DATA_KEY}"))
|
||
.json(&body)
|
||
.send()
|
||
.unwrap();
|
||
assert_eq!(
|
||
as_data.status().as_u16(),
|
||
403,
|
||
"admin gate: the data bearer must be FORBIDDEN on /cluster/promote, got {}",
|
||
as_data.status()
|
||
);
|
||
|
||
// The data key still works on the data plane - the split must not break it.
|
||
let data_write = cluster
|
||
.client()
|
||
.post(format!("{}/signals", cluster.node(LEADER)))
|
||
.header("Authorization", format!("Bearer {DATA_KEY}"))
|
||
.json(&serde_json::json!({ "entity_id": 7, "signal": "view", "weight": 1.0 }))
|
||
.send()
|
||
.unwrap();
|
||
assert_eq!(
|
||
data_write.status().as_u16(),
|
||
204,
|
||
"admin gate: the data bearer must still serve /signals"
|
||
);
|
||
|
||
// The admin key is a SUPERSET credential: it authenticates too, so it reads
|
||
// the protected status surface as well.
|
||
let admin_status = cluster
|
||
.client()
|
||
.get(format!("{}/cluster/status", cluster.node(LEADER)))
|
||
.header("Authorization", format!("Bearer {ADMIN_KEY}"))
|
||
.send()
|
||
.unwrap();
|
||
assert_eq!(
|
||
admin_status.status().as_u16(),
|
||
200,
|
||
"admin gate: the admin key must authenticate, not just authorize"
|
||
);
|
||
|
||
// And it clears the admin gate. `/cluster/heal` is the benign admin verb, so
|
||
// this asserts acceptance without perturbing the cluster: any status EXCEPT
|
||
// 401/403 proves the gate let it through to the handler.
|
||
let admin_heal = cluster
|
||
.client()
|
||
.post(format!("{}/cluster/heal", cluster.node(LEADER)))
|
||
.header("Authorization", format!("Bearer {ADMIN_KEY}"))
|
||
.json(&serde_json::json!({ "region": "eu-west" }))
|
||
.send()
|
||
.unwrap();
|
||
let code = admin_heal.status().as_u16();
|
||
assert!(
|
||
code != 401 && code != 403,
|
||
"admin gate: the admin key must pass the gate on /cluster/heal, got {code}"
|
||
);
|
||
|
||
println!(
|
||
"[auth] admin key gates destructive verbs: data bearer 403 on promote, \
|
||
204 on signals; admin key 200 on status and {code} on heal"
|
||
);
|
||
}
|