tidaldb/tidal-server/src/cluster/join_boot.rs
jx12n 580142df49 feat(m12): election-divergence-fix + soak-eval streak + release tooling
Durable `leader_acked` frontier in `ShardReplica` tracks the highest seqno
acked under `ack=leader` (journal-only, un-replicated); `decide_join` now
quarantines on THIS node's own frontier rather than comparing stream numbers
across stream boundaries — eliminates false-quarantine churn on rolling
restarts. `SHUTDOWN_HANDOFF_WAIT` (3s) drains the leader's tail to quorum
before step-down so the next leader inherits a clean prefix. New
`load_leader_acked`/`persist_leader_acked` helpers; `cluster_reseed.rs` gains
the divergence-fix regression suite; `replication_ops.rs` threads the signal.

Soak-eval: `tidal_stress::soak_eval` + `soak-eval` binary implement the
30-night streak (ledger.tsv × restarts.tsv → streak.tsv); monitor and nightly
CronJob k8s YAMLs updated; phase-9 doc clarifies the dual-stream streak
definition (ledger PASS AND zero pod restarts in window). `run-reliability.sh`
gates the election-divergence suite before any k8s push.

Release tooling: `docker/release/` multi-stage Dockerfile + DR image;
`scripts/build-release.sh` single repeatable cross-compile+buildx path.
2026-06-18 13:08:53 -06:00

988 lines
41 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.

//! Seed-join boot (m11p5 §3.4§3.6): a node that is NOT declared in the local
//! topology joins an existing cluster by contacting a `--seed`.
//!
//! # The boot sequence (§2.7 / §3.4)
//!
//! For a FRESH data dir + `--seed`, on a dedicated boot thread:
//!
//! 1. **Discover a leader** over `(seeds MembershipStore cache)` HTTP status
//! endpoints (the cache wins precedence for discovery once it exists — a
//! restart-of-a-joiner knows its peers without the seed).
//! 2. **Join** via the standalone `JoinCluster` gRPC verb. Idempotent by name: a
//! re-join on retry returns the existing id/role, appends nothing. A
//! non-leader seed answers `accepted=false` with a leader hint → re-target.
//! 3. **Persist before act**: write the returned roster to the `MembershipStore`
//! cache AND the `ElectionStore {term}` (the joiner stamps the snapshot fetch
//! and stream pulls with this term).
//! 4. **FetchSnapshot** `from_seqno=1`: `needed=true` → staged install (reuse the
//! §2.3 staging/verify/swap machinery; a fresh dir makes the swap trivial);
//! `needed=false` → proceed empty (the small-cluster case where the stream
//! serves from 1).
//! 5. **Synthesize a topology** from the JOIN-RESPONSE roster (this node's
//! `--region` = its assigned id), carrying the local file's behavioral knob
//! blocks (§3.5). `ShardReplica::new` builds the transport/ship/election
//! peer tables from THIS, not the local topology's `regions:` list.
//!
//! A **restart of a joiner** (the data dir already has a WAL + a membership
//! cache) skips the seed loop: it boots from the cache roster (no leader
//! discovery, no fetch) and the runtime catch-up self-heal converges it. The
//! authoritative roster once the WAL opens is the recovered `ClusterMembership`
//! cell (§3.6 precedence, resolved inside `ShardReplica::new`); the cache
//! and the synthesized topology only feed the pre-open peer-table construction,
//! and the cache is rewritten from the cell after every applied record.
//!
//! # A joiner is a LEARNER
//!
//! The join response role is `Learner`. The synthesized topology marks the
//! joiner's role so `ShardReplica`'s era-0 view (when no cell exists yet)
//! does not mistake it for a voter — but in practice a snapshot install always
//! carries the leader's WAL with the kind-4 Learner record, so the view boots
//! `from_record` with the correct role. `ElectionState` boots with `self` NOT in
//! the voter set (campaign-gated) until a `Voter` record applies (the leader's
//! auto-promotion duty appends one once the joiner catches up).
use std::path::Path;
use std::time::{Duration, Instant};
use tidaldb::replication::{ElectionStore, HardState, MembershipSnapshot, MembershipStore};
use tidaldb::wal::format::{MemberEntry, MemberRole};
use super::reseed::{self, SeedInstallError};
use super::topology::{
ElectionSpec, GrpcTlsSpec, RegionSpec, ReplicationSpec, TimeoutsSpec, TopologySpec, WalSpec,
};
use crate::error::{Result, ServerError};
/// Per-status-poll HTTP timeout (the discovery loop iterates). Generous because
/// a COLD poll pays a full TLS handshake (rustls/aws-lc-rs) on top of DNS + TCP,
/// and on a real (TLS) cluster under CPU contention that handshake alone can blow
/// a sub-second budget — the joiner then times out every poll and burns the whole
/// 120 s discovery window despite the peer being reachable (TCP connects fine).
/// The in-process tests never saw this: plaintext loopback has no handshake cost.
/// Env-overridable via `TIDAL_SEED_STATUS_TIMEOUT_MS`.
fn status_poll_timeout() -> Duration {
std::env::var("TIDAL_SEED_STATUS_TIMEOUT_MS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&ms| ms > 0)
.map_or(Duration::from_secs(5), Duration::from_millis)
}
/// Per-join RPC timeout (the leader's bounded same-term commit wait is itself
/// bounded by `quorum_timeout`, so allow generous headroom).
const JOIN_RPC_TIMEOUT: Duration = Duration::from_secs(10);
/// Backoff bounds for the join loop.
const BACKOFF_MIN: Duration = Duration::from_millis(250);
const BACKOFF_MAX: Duration = Duration::from_secs(5);
/// Default join window before the boot gives up (env-overridable for tests via
/// `TIDAL_SEED_JOIN_MS`). A fresh joiner has nothing to fall back to, so on
/// timeout it returns an error and the process exits — the orchestrator restarts
/// it and the loop runs again (crash-loop is the orchestration's retry, not the
/// in-process mechanism). 120 s gives a slow loopback discover+join+fetch room.
const DEFAULT_JOIN_WINDOW_MS: u64 = 120_000;
/// The synthesized topology a seed-join boot constructs for
/// `ShardReplica::new` (§3.4).
///
/// The join-response roster becomes the `regions:` list, the local file's knob
/// blocks are carried verbatim, and this node is included at its assigned id.
pub struct SeedJoinBoot {
/// The synthesized topology (roster from the join, knobs from the local file).
pub topology: TopologySpec,
/// This node's region name (echoed for `ShardReplica::new`).
pub region: String,
}
/// Inputs to [`seed_join_boot`].
pub struct SeedJoinInput<'a> {
/// The `--seed` HTTP base URLs.
pub seeds: &'a [String],
/// The local topology file, parsed — the knob source (§3.5). Its `regions:`
/// list is IGNORED for the roster.
pub knobs: &'a TopologySpec,
/// This node's `--region` name.
pub region: &'a str,
/// This node's advertised gRPC `host:port`.
pub advertise_grpc: &'a str,
/// This node's advertised HTTP `host:port`.
pub advertise_http: &'a str,
/// This node's `--metrics` bind (seed-join's only metrics source, §3.5).
pub metrics: Option<&'a str>,
/// The data dir (already validated as a mount subdirectory for the swap).
pub data_dir: &'a Path,
/// The API key for the seed status/join HTTP/gRPC calls (forwarded verbatim).
pub api_key: Option<String>,
}
/// Run the seed-join boot (§3.4). See the module docs for the sequence.
///
/// # Errors
///
/// [`ServerError::Cluster`] when the join window elapses without a successful
/// join+install (a fresh joiner has nothing to fall back to), or for any
/// unrecoverable durable-persist / swap fault.
// One linear boot sequence (cache fast-path → discover → join → persist → fetch
// → synthesize); splitting it would scatter the §2.7 ordering it documents.
#[allow(clippy::too_many_lines)]
pub fn seed_join_boot(input: &SeedJoinInput<'_>) -> Result<SeedJoinBoot> {
let store = MembershipStore::new(input.data_dir);
// §3.6 precedence for the PRE-OPEN loop: a restart of a joiner whose data dir
// already carries a WAL boots from the cache WITHOUT the seed. The
// authoritative roster once the WAL opens is the recovered cell (resolved
// inside `ShardReplica::new`); this cache only seeds the peer tables.
let wal_exists = input
.data_dir
.join("wal")
.read_dir()
.map(|mut it| it.next().is_some())
.unwrap_or(false);
if wal_exists {
match store.load() {
Ok(Some(snap)) => {
tracing::info!(
region = input.region,
version = snap.version,
members = snap.members.len(),
"seed-join restart: a WAL + membership cache exist — booting from the cache \
roster without contacting a seed (§3.6; the WAL-recovered cell wins once \
opened, the runtime catch-up self-heal converges)"
);
// The restart path has no fresh join to name the current leader;
// the WAL cell wins at open and heartbeats correct the leader hint.
let topology = synthesize_topology(input, &snap.members, None)?;
return Ok(SeedJoinBoot {
topology,
region: input.region.to_string(),
});
}
Ok(None) => {
tracing::warn!(
region = input.region,
"seed-join restart: a WAL exists but the membership cache is missing — \
re-running the seed loop to recover the roster before opening"
);
}
Err(e) => {
// A corrupt cache must be DETECTED, never guessed around — but a
// re-join recovers the roster, so do not refuse boot: log loudly
// and fall through to the seed loop.
tracing::error!(
region = input.region,
error = %e,
"seed-join restart: the membership cache is corrupt — re-running the seed \
loop to recover the roster (the corrupt cache is overwritten on success)"
);
}
}
}
// The TLS posture the joiner advertises (from the knob file's own region
// block, when one names this region; else plaintext — the loopback/VPC
// default every shipped topology uses).
let tls = own_grpc_tls(input.knobs, input.region);
// m11p7: when the cluster runs inter-node TLS, the seed-join HTTP dials go
// `https://` (peer_url) and the blocking clients trust the cluster CA.
super::forward::set_inter_node_https(tls.is_some());
let join_window = join_window();
let deadline = Instant::now() + join_window;
let mut backoff = BACKOFF_MIN;
let status_client = build_join_client(status_poll_timeout(), tls.as_ref())?;
while Instant::now() < deadline {
// (1) Discover a leader over (seeds cache). The cache (when present)
// contributes its members' HTTP addresses so a restart-of-a-joiner that
// lost its cache-only fast path still finds the leader.
let candidates = discovery_candidates(input.seeds, &store);
let Some(leader) = discover_leader(&status_client, &candidates, input.api_key.as_deref())
else {
std::thread::sleep(backoff);
backoff = (backoff * 2).min(BACKOFF_MAX);
continue;
};
// (2) Join via the standalone gRPC verb (idempotent by name).
let join = match join_via_leader(
&leader,
input.region,
input.advertise_grpc,
input.advertise_http,
tls.as_ref(),
) {
Ok(JoinAttempt::Joined(resp)) => resp,
Ok(JoinAttempt::RetargetOrRetry(reason)) => {
tracing::info!(reason = %reason, "seed-join: join not accepted; re-discovering");
std::thread::sleep(backoff);
backoff = (backoff * 2).min(BACKOFF_MAX);
continue;
}
Err(e) => {
tracing::warn!(error = %e, "seed-join: join RPC failed; re-discovering");
std::thread::sleep(backoff);
backoff = (backoff * 2).min(BACKOFF_MAX);
continue;
}
};
// (3) Persist-before-act: the cache, then the term, BEFORE any fetch.
let snapshot = MembershipSnapshot {
version: join.membership_version,
term: join.term,
members: join.members.clone(),
};
if let Err(e) = store.persist(&snapshot) {
// A failed cache persist is non-fatal to the join itself (the cell
// installed via snapshot is the source of truth); log loudly and
// continue — the cache is rewritten from the cell after open.
tracing::error!(error = %e, "seed-join: persist membership cache failed (non-fatal)");
}
adopt_term(input.data_dir, join.term)?;
// (4) FetchSnapshot from_seqno=1: needed → install, else proceed empty.
match reseed::install_snapshot_from_leader(
input.data_dir,
&join.leader_grpc_addr,
join.leader_region_id,
join.term,
1,
tls.as_ref(),
) {
Ok(Some(seq)) => {
tracing::info!(
region = input.region,
assigned_id = join.assigned_id,
snapshot_seq = seq,
term = join.term,
"seed-join: snapshot installed; constructing the node from the join roster"
);
}
Ok(None) => {
tracing::info!(
region = input.region,
assigned_id = join.assigned_id,
term = join.term,
"seed-join: leader reports needed=false (the stream serves from 1) — \
proceeding empty; the post-open catch-up pull converges the joiner"
);
}
Err(SeedInstallError::Retry) => {
tracing::warn!("seed-join: snapshot fetch retryable; re-discovering the leader");
std::thread::sleep(backoff);
backoff = (backoff * 2).min(BACKOFF_MAX);
continue;
}
Err(SeedInstallError::Fatal(e)) => return Err(e),
}
// (5) Synthesize the topology from the join roster + the local knobs,
// naming the CURRENT leader (so the `needed=false` boot self-heal pulls
// from the live stream).
let topology = synthesize_topology(input, &join.members, Some(join.leader_region_id))?;
return Ok(SeedJoinBoot {
topology,
region: input.region.to_string(),
});
}
Err(ServerError::Cluster(format!(
"seed-join: could not join via any seed within {join_window:?} (a fresh joiner has \
nothing to fall back to). The orchestrator restarts this process and the loop runs \
again — crash-loop is the retry mechanism, never an in-process spin."
)))
}
/// The join window (env-overridable for tests).
fn join_window() -> Duration {
let ms = std::env::var("TIDAL_SEED_JOIN_MS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(DEFAULT_JOIN_WINDOW_MS);
Duration::from_millis(ms)
}
/// This region's advertised gRPC TLS material, when the knob file names this
/// Build a seed-join blocking HTTP client (m11p7): trusts the cluster CA when
/// `tls` is `Some` so an `https://` dial to a TLS-serving seed/leader verifies.
///
/// # Errors
///
/// Returns [`ServerError::Cluster`] if the CA cert cannot be read/parsed or the
/// client cannot be built.
fn build_join_client(
timeout: std::time::Duration,
tls: Option<&tidal_net::config::TlsConfig>,
) -> Result<reqwest::blocking::Client> {
// Separate connect timeout from the (generous) total timeout: a genuinely
// dead seed (SYN blackhole) fails fast at the connect stage instead of
// blocking the full `timeout` before the discovery loop tries the next seed,
// while a slow-but-live TLS handshake (the reason `timeout` is generous —
// see `status_poll_timeout`) still gets the full budget to complete.
let mut builder = reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(2))
.timeout(timeout);
if let Some(t) = tls {
let pem = std::fs::read(&t.ca_cert)
.map_err(|e| ServerError::Cluster(format!("seed-join: read CA cert: {e}")))?;
let ca = reqwest::Certificate::from_pem(&pem)
.map_err(|e| ServerError::Cluster(format!("seed-join: parse CA cert: {e}")))?;
builder = builder.add_root_certificate(ca);
}
builder
.build()
.map_err(|e| ServerError::Cluster(format!("seed-join: build HTTP client: {e}")))
}
/// region with a `grpc_tls` block (the seed dial uses the same posture).
fn own_grpc_tls(knobs: &TopologySpec, region: &str) -> Option<tidal_net::config::TlsConfig> {
grpc_tls_for(knobs, region).map(GrpcTlsSpec::to_tls_config)
}
/// The `grpc_tls` posture for `region`: its OWN block when the knob file names
/// it, ELSE ANY region's block.
///
/// A seed-joiner is NEVER in the shared-ConfigMap knob file — it learns its
/// identity from the join, so `regions:` lists only the bootstrap voters. But
/// every pod mounts the SAME cert Secret at the SAME paths, so any region's
/// `grpc_tls` block carries the correct CA/cert/key paths for the joiner too.
/// Returning `None` here (the prior behavior, premised on "the joiner's region
/// isn't named ⇒ plaintext loopback") silently dropped a TLS-cluster joiner to
/// PLAINTEXT: it then failed CA verification on the seed-discover HTTPS dial
/// (`invalid peer certificate: UnknownIssuer`, burning the whole 120 s window)
/// and could not mTLS-replicate with its TLS peers. The fallback is safe: with
/// no `grpc_tls` anywhere it is still `None` (the genuine plaintext case).
fn grpc_tls_for<'a>(knobs: &'a TopologySpec, region: &str) -> Option<&'a GrpcTlsSpec> {
knobs
.regions
.iter()
.find(|r| r.name == region)
.and_then(|r| r.grpc_tls.as_ref())
.or_else(|| knobs.regions.iter().find_map(|r| r.grpc_tls.as_ref()))
}
/// One discovery candidate: an HTTP status base + (when known) the gRPC address
/// the same node advertises.
#[derive(Debug, Clone)]
struct Candidate {
http_addr: String,
}
/// Build the discovery candidate set: the `--seed` URLs plus the cache's
/// members' HTTP addresses (deduplicated). Seeds first (the operator-named
/// bootstrap), then the cache (a restart-of-a-joiner's known peers).
fn discovery_candidates(seeds: &[String], store: &MembershipStore) -> Vec<Candidate> {
let mut out: Vec<Candidate> = Vec::new();
let mut seen = std::collections::HashSet::new();
for s in seeds {
if seen.insert(s.clone()) {
out.push(Candidate {
http_addr: s.clone(),
});
}
}
if let Ok(Some(snap)) = store.load() {
for m in &snap.members {
if m.role != MemberRole::Removed
&& !m.http_addr.is_empty()
&& seen.insert(m.http_addr.clone())
{
out.push(Candidate {
http_addr: m.http_addr.clone(),
});
}
}
}
out
}
/// What the discovery loop learned (§2.7): the current leader's advertised gRPC
/// address + its term + region id, so a join can dial it directly.
#[derive(Debug, Clone)]
struct LeaderInfo {
http_addr: String,
}
/// Poll every candidate's `/cluster/status/local`; return the first that either
/// reports ITSELF the leader OR names a leader (whose HTTP address the roster
/// resolves). The join RPC is forwarded by a non-leader anyway, so any reachable
/// member is a usable join target — but preferring a leader avoids an extra hop.
fn discover_leader(
client: &reqwest::blocking::Client,
candidates: &[Candidate],
api_key: Option<&str>,
) -> Option<LeaderInfo> {
// First pass: a candidate that reports itself the leader (direct target).
let mut any_reachable: Option<LeaderInfo> = None;
for cand in candidates {
let url = super::forward::peer_url(&cand.http_addr, "/cluster/status/local");
let mut req = client.get(&url);
if let Some(key) = api_key {
req = req.bearer_auth(key);
}
// Log every failure mode at debug — a silent discovery loop turned a
// too-tight TLS poll timeout into an opaque "could not join within 120s".
let resp = match req.send() {
Ok(resp) => resp,
Err(e) => {
// Walk the std::error source chain so the ROOT transport/TLS
// cause (hidden behind reqwest's terse Display) is visible.
let mut chain = format!("{e}");
let mut src = std::error::Error::source(&e);
while let Some(s) = src {
chain.push_str(" -> ");
chain.push_str(&s.to_string());
src = s.source();
}
tracing::warn!(%url, error = %chain, "seed-join discover: status poll failed (transport/TLS/timeout)");
continue;
}
};
if !resp.status().is_success() {
tracing::debug!(%url, status = %resp.status(), "seed-join discover: status poll non-2xx");
continue;
}
let Ok(json) = resp.json::<serde_json::Value>() else {
tracing::debug!(%url, "seed-join discover: status body was not JSON");
continue;
};
// Any reachable member is a fallback join target (it forwards to the
// leader); a self-reported leader is preferred (one fewer hop).
any_reachable.get_or_insert_with(|| LeaderInfo {
http_addr: cand.http_addr.clone(),
});
if json.get("is_leader").and_then(serde_json::Value::as_bool) == Some(true) {
return Some(LeaderInfo {
http_addr: cand.http_addr.clone(),
});
}
}
any_reachable
}
/// A successful join: the assigned id/role + the roster + the leader's
/// dial-able addresses + term.
struct Joined {
assigned_id: u16,
term: u64,
membership_version: u64,
leader_grpc_addr: String,
leader_region_id: u16,
members: Vec<MemberEntry>,
}
/// The outcome of one join attempt.
enum JoinAttempt {
Joined(Joined),
/// Not accepted (a non-leader seed, a held conf-change): re-discover.
RetargetOrRetry(String),
}
/// Join via the candidate over the HTTP `/cluster/join` verb — which wraps the
/// same leader-side logic the gRPC `JoinCluster` uses and is FORWARDED by a
/// non-leader, so the joiner never needs a peer's gRPC address for the join
/// itself (it learns the snapshot-source gRPC address from the returned roster).
/// Idempotent by name.
///
/// gRPC TLS is NOT used here (the HTTP gateway terminates its own TLS); the
/// snapshot fetch that follows dials the returned `leader_grpc_addr` with the
/// joiner's gRPC TLS posture.
fn join_via_leader(
leader: &LeaderInfo,
region: &str,
advertise_grpc: &str,
advertise_http: &str,
tls: Option<&tidal_net::config::TlsConfig>,
) -> Result<JoinAttempt> {
let client = build_join_client(JOIN_RPC_TIMEOUT, tls)?;
let url = super::forward::peer_url(&leader.http_addr, "/cluster/join");
let body = serde_json::json!({
"name": region,
"grpc_addr": advertise_grpc,
"http_addr": advertise_http,
});
let mut req = client.post(&url).json(&body);
if let Ok(key) = std::env::var("TIDAL_API_KEY") {
req = req.bearer_auth(key);
}
let resp = req
.send()
.map_err(|e| ServerError::Cluster(format!("seed-join: POST /cluster/join: {e}")))?;
let status = resp.status();
if !status.is_success() {
// A 503 (not leader / conf-change held) is RETRYABLE; surface the body.
let body = resp.text().unwrap_or_default();
return Ok(JoinAttempt::RetargetOrRetry(format!(
"/cluster/join returned {status}: {body}"
)));
}
let json: serde_json::Value = resp
.json()
.map_err(|e| ServerError::Cluster(format!("seed-join: /cluster/join body: {e}")))?;
let assigned_id = u16::try_from(json["assigned_id"].as_u64().unwrap_or(0)).unwrap_or(0);
let term = json["term"].as_u64().unwrap_or(0);
let membership_version = json["membership_version"].as_u64().unwrap_or(0);
// The HTTP /cluster/join body is intentionally lean (it does not echo the
// full roster). Fetch the full roster from /cluster/members on the same
// gateway (it serves the effective roster from any node) so the joiner can
// build its peer tables.
let members = fetch_roster(&client, &leader.http_addr)?;
// The snapshot SOURCE must be the CURRENT leader — not just any voter — so
// the post-install catch-up resumes from the live stream (a re-election may
// have moved leadership since the topology declared region 0 the leader). The
// gateway's /cluster/status/local names the current leader; resolve its gRPC
// address + region from the roster. Fall back to the lowest-id voter only if
// the leader is momentarily unknown (mid-election) — the install retries.
let (leader_grpc_addr, leader_region_id) =
current_leader_dial_target(&client, &leader.http_addr, &members)
.unwrap_or_else(|| leader_dial_target(&members));
Ok(JoinAttempt::Joined(Joined {
assigned_id,
term,
membership_version,
leader_grpc_addr,
leader_region_id,
members,
}))
}
/// Resolve the CURRENT leader's gRPC dial target from the gateway's
/// `/cluster/status/local` (the `leader` field names it) + the roster (resolves
/// its advertised gRPC address + id). `None` when the leader is unknown
/// (mid-election) or not in the roster — the caller falls back to a voter and the
/// install retries.
fn current_leader_dial_target(
client: &reqwest::blocking::Client,
gateway_http: &str,
members: &[MemberEntry],
) -> Option<(String, u16)> {
let url = super::forward::peer_url(gateway_http, "/cluster/status/local");
let mut req = client.get(&url);
if let Ok(key) = std::env::var("TIDAL_API_KEY") {
req = req.bearer_auth(key);
}
let json: serde_json::Value = req.send().ok()?.json().ok()?;
let leader_name = json.get("leader").and_then(serde_json::Value::as_str)?;
members
.iter()
.find(|m| m.name == leader_name && m.role != MemberRole::Removed)
.map(|m| (m.grpc_addr.clone(), m.id))
}
/// Fetch the effective roster from `/cluster/members` (served from any node).
fn fetch_roster(client: &reqwest::blocking::Client, http_addr: &str) -> Result<Vec<MemberEntry>> {
let url = super::forward::peer_url(http_addr, "/cluster/members");
let mut req = client.get(&url);
if let Ok(key) = std::env::var("TIDAL_API_KEY") {
req = req.bearer_auth(key);
}
let resp = req
.send()
.map_err(|e| ServerError::Cluster(format!("seed-join: GET /cluster/members: {e}")))?;
if !resp.status().is_success() {
return Err(ServerError::Cluster(format!(
"seed-join: /cluster/members returned {}",
resp.status()
)));
}
let json: serde_json::Value = resp
.json()
.map_err(|e| ServerError::Cluster(format!("seed-join: /cluster/members body: {e}")))?;
let arr = json["members"].as_array().ok_or_else(|| {
ServerError::Cluster("seed-join: /cluster/members has no members array".into())
})?;
let mut members = Vec::with_capacity(arr.len());
for m in arr {
let id = u16::try_from(m["id"].as_u64().unwrap_or(0)).unwrap_or(0);
let name = m["name"].as_str().unwrap_or_default().to_string();
let grpc_addr = m["grpc_addr"].as_str().unwrap_or_default().to_string();
let http_addr_m = m["http_addr"].as_str().unwrap_or_default().to_string();
let role = match m["role"].as_str() {
Some("learner") => MemberRole::Learner,
Some("removed") => MemberRole::Removed,
_ => MemberRole::Voter,
};
members.push(MemberEntry {
id,
name,
grpc_addr,
http_addr: http_addr_m,
role,
});
}
if members.is_empty() {
return Err(ServerError::Cluster(
"seed-join: /cluster/members returned an empty roster".into(),
));
}
Ok(members)
}
/// The snapshot-fetch target: a current VOTER's advertised gRPC address + its
/// region id (the snapshot source is the leader; any voter serves it, and the
/// `FetchSnapshot` handler resolves its own shard regardless of the requested
/// id). Prefers the lowest-id voter for determinism.
fn leader_dial_target(members: &[MemberEntry]) -> (String, u16) {
members
.iter()
.filter(|m| m.role == MemberRole::Voter)
.min_by_key(|m| m.id)
.map(|m| (m.grpc_addr.clone(), m.id))
.unwrap_or_default()
}
/// Adopt the discovered leadership term durably BEFORE pulling (§2.7
/// persist-before-act). A fresh joiner has no `election_state`; a restart-of-a-
/// joiner adopts only a HIGHER term (never regresses).
fn adopt_term(data_dir: &Path, term: u64) -> Result<()> {
let store = ElectionStore::new(data_dir);
let wal_exists = data_dir
.join("wal")
.read_dir()
.map(|mut it| it.next().is_some())
.unwrap_or(false);
let durable = match store.load(wal_exists) {
Ok(tidaldb::replication::BootState::Rejoin(h)) => h.current_term,
Ok(_) => 0,
Err(e) => {
return Err(ServerError::Cluster(format!(
"seed-join: refusing to adopt a term — corrupt election_state ({e}); a join \
does not restore term knowledge (the m11p4 corrupt-state rule stands, §2.5)"
)));
}
};
if term > durable {
store
.persist(HardState {
current_term: term,
voted_for: None,
})
.map_err(|e| {
ServerError::Cluster(format!(
"seed-join: persist discovered term {term} (persist-before-act): {e}"
))
})?;
}
Ok(())
}
/// Synthesize the topology `ShardReplica::new` consumes (§3.4): the
/// join-response roster as `regions:` (in id order so the positional `RegionId`
/// MATCHES the assigned member id — the seam every durable id depends on), this
/// node's `--region` carried, the local file's knob blocks copied verbatim
/// (§3.5), this node's `metrics_addr` from `--metrics`.
///
/// Removed tombstones are EXCLUDED (they are not live peers) — but excluding a
/// tombstone whose id is below a live member's would shift positional ids. The
/// 1:1 region↔shard mapping (`shard_of_region`) requires the positional index to
/// equal the member id, so we keep the roster id-DENSE by emitting a placeholder
/// region for each burned id below the max live id. A placeholder declares the
/// tombstone's name + addresses but is never dialed (it is excluded from the
/// live peer set by the membership view's `from_record` once the WAL opens, and
/// the era-0 synthesized view treats it as present-but-addressless).
fn synthesize_topology(
input: &SeedJoinInput<'_>,
members: &[MemberEntry],
current_leader_region: Option<u16>,
) -> Result<TopologySpec> {
// The roster's id range. RegionId = positional index, and
// `shard_of_region(RegionId(n)) = ShardId(n)`, so the regions list must be
// dense from 0..=max_id with each member at its own id index.
let max_id = members.iter().map(|m| m.id).max().unwrap_or(0);
let mut by_id: std::collections::HashMap<u16, &MemberEntry> = std::collections::HashMap::new();
for m in members {
by_id.insert(m.id, m);
}
let mut regions: Vec<RegionSpec> = Vec::with_capacity(usize::from(max_id) + 1);
for id in 0..=max_id {
if let Some(m) = by_id.get(&id) {
// This node's own entry carries its real advertised addresses (the
// roster already holds them from the join, but be explicit for self).
let is_self = m.name == input.region;
let grpc = if is_self {
input.advertise_grpc.to_string()
} else {
m.grpc_addr.clone()
};
let http = if is_self {
input.advertise_http.to_string()
} else {
m.http_addr.clone()
};
regions.push(RegionSpec {
name: m.name.clone(),
grpc_addr: Some(grpc),
grpc_bind: None,
http_addr: Some(http),
grpc_tls: self_tls_spec(input.knobs, &m.name),
metrics_addr: if is_self {
input.metrics.map(str::to_string)
} else {
None
},
zone: None,
});
} else {
// A burned id with no member entry at all (a gap that should not
// happen — ids are assigned densely as max+1). Emit a placeholder so
// the positional index stays aligned; it is never a live peer.
regions.push(RegionSpec {
name: format!("__burned-{id}"),
grpc_addr: Some(format!("0.0.0.0:{}", 1u16.wrapping_add(id))),
grpc_bind: None,
http_addr: Some(format!("http://0.0.0.0:{}", 1u16.wrapping_add(id))),
grpc_tls: None,
metrics_addr: None,
zone: None,
});
}
}
// The leader for the synthesized topology: the CURRENT leader the join
// resolved (so the `needed=false` boot self-heal pulls from the live stream,
// not a stale lowest-voter — the catch-up targets `shard_of_region(leader)`).
// Fall back to the lowest-id voter when the join could not resolve a leader
// (mid-election). A joiner is never the term-0 leader, so it must not name
// itself here; the durable election state + heartbeats correct the hint.
let leader = current_leader_region
.and_then(|id| {
members
.iter()
.find(|m| m.id == id && m.role != MemberRole::Removed)
.map(|m| m.name.clone())
})
.filter(|name| name != input.region)
.or_else(|| {
members
.iter()
.filter(|m| m.role == MemberRole::Voter)
.min_by_key(|m| m.id)
.map(|m| m.name.clone())
})
.ok_or_else(|| {
ServerError::Cluster("seed-join: the join roster has no voter to name as leader".into())
})?;
// Carry the local file's knob blocks verbatim (§3.5).
Ok(TopologySpec {
regions,
leader,
write_workers: input.knobs.write_workers,
timeouts: clone_timeouts(&input.knobs.timeouts),
replication: clone_replication(&input.knobs.replication),
wal: clone_wal(&input.knobs.wal),
election: clone_election(&input.knobs.election),
// Seed-join synthesizes the legacy single group (the joiner learns its
// shard assignment from the membership log, not the synthesized file).
shards: None,
})
}
/// This region's TLS spec for the synthesized topology. The joiner's own region
/// is NOT in the shared-ConfigMap knob file, so this falls back to ANY region's
/// `grpc_tls` ([`grpc_tls_for`]) — every pod mounts the same cert files at the
/// same paths. Without the fallback a TLS-cluster joiner synthesized a PLAINTEXT
/// gRPC posture and could not mTLS-replicate with its peers.
fn self_tls_spec(knobs: &TopologySpec, name: &str) -> Option<GrpcTlsSpec> {
grpc_tls_for(knobs, name).map(|t| GrpcTlsSpec {
ca_cert: t.ca_cert.clone(),
server_cert: t.server_cert.clone(),
server_key: t.server_key.clone(),
client_cert: t.client_cert.clone(),
client_key: t.client_key.clone(),
})
}
// The knob blocks have no `Clone`; rebuild them field-by-field from the loaded
// file so the synthesized topology carries the operator's exact knobs (§3.5).
const fn clone_timeouts(t: &TimeoutsSpec) -> TimeoutsSpec {
TimeoutsSpec {
broadcast_peer_secs: t.broadcast_peer_secs,
}
}
fn clone_replication(r: &ReplicationSpec) -> ReplicationSpec {
ReplicationSpec {
batch_max_events: r.batch_max_events,
window: r.window,
retry_ms: r.retry_ms,
ack: r.ack.clone(),
quorum_timeout_ms: r.quorum_timeout_ms,
catchup_retry_ms: r.catchup_retry_ms,
snapshot_artifact_ttl_ms: r.snapshot_artifact_ttl_ms,
reseed_self_restart: r.reseed_self_restart,
learner_promote_lag: r.learner_promote_lag,
}
}
fn clone_wal(w: &WalSpec) -> WalSpec {
WalSpec {
batch_size: w.batch_size,
batch_timeout_ms: w.batch_timeout_ms,
archive_dir: w.archive_dir.clone(),
}
}
const fn clone_election(e: &ElectionSpec) -> ElectionSpec {
ElectionSpec {
heartbeat_interval_ms: e.heartbeat_interval_ms,
election_timeout_min_ms: e.election_timeout_min_ms,
election_timeout_max_ms: e.election_timeout_max_ms,
leader_lease_ms: e.leader_lease_ms,
auto_election: e.auto_election,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn knobs_with(ack: &str) -> TopologySpec {
TopologySpec {
// The knob file's regions are IGNORED for the roster — a single
// declared region proves we do not read them.
regions: vec![RegionSpec {
name: "us-east".into(),
grpc_addr: Some("127.0.0.1:9000".into()),
grpc_bind: None,
http_addr: Some("127.0.0.1:9001".into()),
grpc_tls: None,
metrics_addr: None,
zone: None,
}],
leader: "us-east".into(),
write_workers: None,
timeouts: TimeoutsSpec::default(),
replication: ReplicationSpec {
ack: Some(ack.to_string()),
quorum_timeout_ms: Some(1500),
..ReplicationSpec::default()
},
wal: WalSpec::default(),
election: ElectionSpec::default(),
shards: None,
}
}
fn member(id: u16, name: &str, role: MemberRole) -> MemberEntry {
MemberEntry {
id,
name: name.to_string(),
grpc_addr: format!("{name}.svc:9500"),
http_addr: format!("http://{name}.svc:9501"),
role,
}
}
/// The synthesized topology carries the join roster as `regions:` (id-dense,
/// positional index == member id), this node's advertised addresses for self,
/// the knob file's behavioral blocks (NOT its regions), and the metrics flag.
#[test]
fn synthesize_topology_uses_roster_and_local_knobs() {
let tmp = tempfile::tempdir().unwrap();
let knobs = knobs_with("quorum");
let members = vec![
member(0, "us-east", MemberRole::Voter),
member(1, "eu-west", MemberRole::Voter),
member(3, "joiner", MemberRole::Learner), // id 2 was burned
];
let input = SeedJoinInput {
seeds: &["http://us-east.svc:9501".to_string()],
knobs: &knobs,
region: "joiner",
advertise_grpc: "joiner.real:9500",
advertise_http: "http://joiner.real:9501",
metrics: Some("0.0.0.0:9091"),
data_dir: tmp.path(),
api_key: None,
};
// Name the current leader region 0 (us-east) explicitly.
let synth = synthesize_topology(&input, &members, Some(0)).unwrap();
// The regions list is id-dense 0..=3 (a burned id 2 gets a placeholder),
// so the positional RegionId equals the assigned member id.
assert_eq!(synth.regions.len(), 4);
assert_eq!(synth.regions[0].name, "us-east");
assert_eq!(synth.regions[1].name, "eu-west");
assert!(
synth.regions[2].name.starts_with("__burned-"),
"id 2 burned"
);
assert_eq!(synth.regions[3].name, "joiner");
// Self carries its REAL advertised addresses + the --metrics bind.
assert_eq!(
synth.regions[3].grpc_addr.as_deref(),
Some("joiner.real:9500")
);
assert_eq!(
synth.regions[3].http_addr.as_deref(),
Some("http://joiner.real:9501")
);
assert_eq!(
synth.regions[3].metrics_addr.as_deref(),
Some("0.0.0.0:9091")
);
// The knob file's behavioral block is carried, NOT its single region.
assert_eq!(synth.replication.ack.as_deref(), Some("quorum"));
assert_eq!(synth.replication.quorum_timeout_ms, Some(1500));
// The leader is the lowest-id voter (a joiner never names itself).
assert_eq!(synth.leader, "us-east");
}
/// `discovery_candidates` lists seeds first, then the cache's live members'
/// HTTP addresses, deduplicated.
#[test]
fn discovery_candidates_merges_seeds_and_cache() {
let tmp = tempfile::tempdir().unwrap();
let store = MembershipStore::new(tmp.path());
store
.persist(&MembershipSnapshot {
version: 2,
term: 1,
members: vec![
member(0, "us-east", MemberRole::Voter),
member(1, "eu-west", MemberRole::Voter),
member(2, "gone", MemberRole::Removed),
],
})
.unwrap();
let seeds = vec![
"http://seed-a:9501".to_string(),
"http://us-east.svc:9501".to_string(),
];
let cands = discovery_candidates(&seeds, &store);
let addrs: Vec<&str> = cands.iter().map(|c| c.http_addr.as_str()).collect();
// Seeds first.
assert_eq!(addrs[0], "http://seed-a:9501");
// us-east is in both; it appears once (seed wins position).
assert_eq!(
addrs
.iter()
.filter(|a| **a == "http://us-east.svc:9501")
.count(),
1
);
// eu-west comes from the cache.
assert!(addrs.contains(&"http://eu-west.svc:9501"));
// The removed tombstone is excluded.
assert!(!addrs.iter().any(|a| a.contains("gone")));
}
/// `leader_dial_target` is the lowest-id voter's gRPC address (the snapshot
/// source); learners/tombstones are not dial targets.
#[test]
fn leader_dial_target_is_lowest_id_voter() {
let members = vec![
member(0, "us-east", MemberRole::Voter),
member(1, "eu-west", MemberRole::Voter),
member(3, "joiner", MemberRole::Learner),
];
let (grpc, id) = leader_dial_target(&members);
assert_eq!(grpc, "us-east.svc:9500");
assert_eq!(id, 0);
}
}