tidaldb/tidal-net/src/config.rs
jx12n 005e292cbb fix(m11): review remediation + tidal-stress perf sweep + perf wave 2
Resolve all BLOCKER/CRITICAL/WARNING findings from the m11p7/p8 review:
- tidalctl restore: safe_join path-traversal/Zip-Slip guard + fsync on write
- corrupt-WAL checkpoint_seq guard; PITR archive-before-delete
- cluster: x-tidal-relayed audit-dedup marker; forward_failures counts 5xx
- mTLS/HTTP-TLS handshake hardening; accept-loop EMFILE backoff
- per-principal rate-limit + node-token marker-pinning tests
- self-heal tier-3 coverage; 5 router-auth tests

tidal-stress: measurement-fidelity fixes (schedule-lag p99/max, exact
feed-over-SLO verdict, shed annotation) + typed Body, workload.next
184ns->68ns, RoundRobin len==1 short-circuit, HeaderValue cache;
new benches/hotpath.rs + lib.rs.

perf wave 2: signal_snapshot SmallVec/SignalKey carrier; one-get-per-type
ranking pre-pass.
2026-06-13 12:28:04 -06:00

458 lines
20 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.

//! Configuration for the gRPC transport.
use std::{collections::HashMap, net::SocketAddr, path::PathBuf, time::Duration};
use tidaldb::replication::shard::ShardId;
use crate::error::GrpcTransportError;
/// Maximum payload size in bytes (64 MiB).
///
/// **Source of truth:** the engine crate's `InProcessTransport::MAX_PAYLOAD_BYTES`
/// (`tidaldb`, `replication/in_process.rs`). Both transports must reject the same
/// over-size payloads so behavior does not diverge by which transport a deployment
/// uses. That engine constant is currently private (module-local in `in_process.rs`),
/// so it cannot be `use`d here directly; the value is mirrored and pinned by the
/// compile-time cross-check below. If the engine ever exposes it (e.g. as
/// `tidaldb::replication::MAX_PAYLOAD_BYTES`), replace this literal with a re-export
/// of that constant and delete the cross-check.
pub const MAX_PAYLOAD_BYTES: usize = 64 * 1024 * 1024;
/// Compile-time guard pinning [`MAX_PAYLOAD_BYTES`] to the engine's 64 MiB limit.
///
/// This catches accidental drift in the mirrored literal at build time (it is the
/// best available cross-check while the engine constant stays private). If this
/// fails to compile, the two crates' payload limits have diverged: reconcile them,
/// preferring to make the engine's constant the single referenced source of truth.
const _: () = assert!(
MAX_PAYLOAD_BYTES == 64 * 1024 * 1024,
"tidal-net MAX_PAYLOAD_BYTES must match the engine's InProcessTransport limit (64 MiB)"
);
/// Configuration for a single [`GrpcTransport`](crate::GrpcTransport) instance.
#[derive(Debug, Clone)]
pub struct GrpcTransportConfig {
/// This node's shard identity.
pub local_shard: ShardId,
/// Address to bind the gRPC server on.
pub listen_addr: SocketAddr,
/// Peer shard advertised addresses (`shard_id` -> `host:port`).
///
/// A bare `host:port` string, NOT a pre-resolved [`SocketAddr`]. This is
/// load-bearing for DNS peers (m11p5): [`PeerPool::new`] builds the tonic
/// URI from the string, so `Channel::from_shared("http://hostname:port")`
/// makes hyper re-resolve DNS on every (re)connect. A pre-resolved IP
/// literal would pin the peer to whatever address it had at boot — the
/// k8s pod-rescheduled-onto-a-new-IP case becomes structurally
/// unreachable until process restart. A literal IP `host:port` is still
/// accepted (and re-resolves trivially to itself), so every pre-m11p5
/// topology keeps working byte-for-byte.
///
/// [`PeerPool::new`]: crate::client::PeerPool::new
pub peers: HashMap<ShardId, String>,
/// TLS configuration. `None` requires `insecure = true`.
pub tls: Option<TlsConfig>,
/// Allow plaintext connections (no TLS).
pub insecure: bool,
/// Capacity of the inbound segment channel.
pub channel_capacity: usize,
/// Maximum payload size in bytes.
pub max_payload_bytes: usize,
/// Number of consecutive failures before the circuit breaker opens.
pub circuit_breaker_threshold: u32,
/// Duration the circuit breaker stays open before allowing a probe.
pub circuit_breaker_reset: Duration,
/// Maximum time to establish a TCP/TLS connection to a peer before the
/// attempt fails fast (and the failure trips the circuit breaker). Without
/// this, a peer whose host is up but whose port silently blackholes the
/// SYN would stall the single-threaded shipper indefinitely.
pub connect_timeout: Duration,
/// Maximum time for a single `ship_segment` RPC (connect + send + response)
/// before it is aborted as a transient failure. Bounds the per-call latency
/// the shipper can ever incur on a stalled peer.
pub request_timeout: Duration,
/// HTTP/2 PING keep-alive interval on the client channel. Idle and active
/// connections send a PING this often; a peer that stops responding is
/// detected within `keep_alive_interval + keep_alive_timeout` instead of
/// hanging on a half-open connection.
pub keep_alive_interval: Duration,
/// How long to wait for a keep-alive PING ACK before declaring the
/// connection dead and tearing it down.
pub keep_alive_timeout: Duration,
/// Delay before a FAILED catch-up pull is retried by the transport's
/// timer (m11p4). The event path alone (re-trigger on the next pushed
/// segment) deadlocks an IDLE cluster: a follower whose pull failed —
/// e.g. the leader's gRPC server was not yet ready during a rolling
/// restart — waited for a push that never came and stayed lagged
/// indefinitely (the 2026-06-11 p3 rollout incident). The timer is the
/// proactive wake-up alongside that event path; pulls stay single-flight
/// and rate-limited regardless of which path triggers them.
pub catchup_retry_interval: Duration,
/// How often the cert-rotation reloader polls the TLS files for a content
/// change (m11p7). Only spawned when TLS is configured; ignored on the
/// plaintext path. Content-hash polling (not inotify) is the robust signal
/// for Kubernetes secret rotation, which swaps a `..data` symlink atomically
/// — a mode inotify watchers routinely miss. Default 30s; tests set it low
/// to exercise rotation-under-load fast.
pub rotation_poll_interval: Duration,
/// Maximum wall-clock time a single INBOUND TLS handshake may take before it
/// is abandoned (m11p7 hardening). The TCP accept happens before any client
/// cert is verified, so a peer that completes the connect but stalls the TLS
/// `ClientHello` (a slowloris) would otherwise pin a task + socket
/// indefinitely, pre-auth. Default 10s; only used on the mTLS path.
pub handshake_timeout: Duration,
/// Maximum number of inbound TLS handshakes allowed in flight at once
/// (m11p7 hardening). Bounds the task/fd cost of a connection flood against
/// the replication port: when the limit is reached, a freshly-accepted
/// connection is dropped (load-shed) before its handshake is attempted,
/// rather than each spawning an unbounded task. Default 256; only the mTLS
/// path enforces it.
pub max_concurrent_handshakes: usize,
}
impl GrpcTransportConfig {
/// Reject a config whose numeric invariants would silently break the
/// transport rather than fail loudly.
///
/// # Errors
///
/// Returns [`GrpcTransportError::Internal`] if any of the following holds.
/// Each is a misconfiguration that, left unchecked, degrades into a silent
/// fault the operator cannot see at runtime:
///
/// - `channel_capacity == 0`: [`tokio::sync::mpsc::channel`] panics on a
/// zero capacity, so the transport would not even start — surface it as a
/// typed error here instead of a panic deep in [`GrpcTransport::new`].
/// - any timeout is `Duration::ZERO` (`connect_timeout`, `request_timeout`,
/// `keep_alive_interval`, `keep_alive_timeout`): a zero deadline aborts
/// every attempt instantly, turning the fail-fast design into a transport
/// that can never ship a single segment.
/// - `max_payload_bytes == 0` or `> MAX_PAYLOAD_BYTES`: a zero ceiling
/// rejects every segment; a ceiling above the engine's wire limit lets the
/// client/server codec accept a payload the peer will refuse, so both ends
/// must agree on the same in-range bound.
/// - `circuit_breaker_reset == Duration::ZERO`: a breaker that reopens with
/// no cool-down hammers a down peer with no backoff.
/// - any peer address is not a syntactic `host:port` (empty/malformed host
/// or a port outside `1..=65535`): a bad peer string surfaces only as
/// "invalid URI" deep in [`PeerPool::new`] otherwise — catch the config
/// typo here, naming the offending peer. The check is hostname-tolerant
/// (it mirrors the server crate's `validate_host_port`): a DNS name is a
/// valid peer address, an IP literal is too, but reachability is NOT
/// probed (siblings boot in any order).
///
/// `circuit_breaker_threshold == 0` is intentionally allowed: the breaker
/// treats a zero threshold as "open on the first failure" (fail-fast), a
/// legitimate—if aggressive—policy rather than a degenerate one.
///
/// [`GrpcTransport::new`]: crate::transport::GrpcTransport::new
pub fn validate(&self) -> Result<(), GrpcTransportError> {
if self.channel_capacity == 0 {
return Err(GrpcTransportError::Internal(
"channel_capacity must be > 0 (a zero-capacity mpsc channel panics)".into(),
));
}
for (name, d) in [
("connect_timeout", self.connect_timeout),
("request_timeout", self.request_timeout),
("keep_alive_interval", self.keep_alive_interval),
("keep_alive_timeout", self.keep_alive_timeout),
] {
if d.is_zero() {
return Err(GrpcTransportError::Internal(format!(
"{name} must be > 0 (a zero deadline aborts every attempt instantly)"
)));
}
}
if self.max_payload_bytes == 0 || self.max_payload_bytes > MAX_PAYLOAD_BYTES {
return Err(GrpcTransportError::Internal(format!(
"max_payload_bytes must be in 1..={MAX_PAYLOAD_BYTES}, got {}",
self.max_payload_bytes
)));
}
if self.circuit_breaker_reset.is_zero() {
return Err(GrpcTransportError::Internal(
"circuit_breaker_reset must be > 0 (a zero cool-down hammers a down peer)".into(),
));
}
if self.catchup_retry_interval.is_zero() {
return Err(GrpcTransportError::Internal(
"catchup_retry_interval must be > 0 (a zero delay turns the \
catch-up retry timer into a hot loop against the source)"
.into(),
));
}
if self.rotation_poll_interval.is_zero() {
return Err(GrpcTransportError::Internal(
"rotation_poll_interval must be > 0 (a zero delay turns the \
cert-rotation reloader into a hot loop re-reading the TLS files)"
.into(),
));
}
if self.handshake_timeout.is_zero() {
return Err(GrpcTransportError::Internal(
"handshake_timeout must be > 0 (a zero deadline rejects every \
inbound TLS handshake instantly)"
.into(),
));
}
if self.max_concurrent_handshakes == 0 {
return Err(GrpcTransportError::Internal(
"max_concurrent_handshakes must be > 0 (a zero bound load-sheds \
every inbound connection, refusing all peers)"
.into(),
));
}
for (shard, addr) in &self.peers {
validate_peer_addr(*shard, addr)?;
}
Ok(())
}
}
/// Syntactic check that a peer's advertised address is a `host:port` the tonic
/// URI builder ([`PeerPool::new`](crate::client::PeerPool::new)) can turn into
/// a valid endpoint, mirroring the server crate's `validate_host_port`:
///
/// - an optional `http://` / `https://` prefix is tolerated (the scheme the
/// pool prepends is derived from TLS, but a prefixed value must not be
/// silently doubled into `http://http://…`),
/// - the host must be non-empty and contain no whitespace or `/`,
/// - the port must parse as a non-zero `u16` (port 0 is never a peer-reachable
/// address).
///
/// A DNS name passes (the m11p5 point): reachability is NOT probed here —
/// siblings boot in any order, so an unresolvable-at-startup peer is normal.
fn validate_peer_addr(shard: ShardId, addr: &str) -> Result<(), GrpcTransportError> {
let bare = addr
.strip_prefix("http://")
.or_else(|| addr.strip_prefix("https://"))
.unwrap_or(addr)
.trim_end_matches('/');
let bad = |why: &str| {
Err(GrpcTransportError::Internal(format!(
"peer {shard:?} address '{addr}' is not a valid host:port ({why})"
)))
};
let Some((host, port)) = bare.rsplit_once(':') else {
return bad("missing ':port'");
};
if host.is_empty() || host.contains([' ', '/']) {
return bad("empty or malformed host");
}
match port.parse::<u16>() {
Ok(0) => bad("port 0 is not reachable by peers"),
Ok(_) => Ok(()),
Err(_) => bad("port must be 1-65535"),
}
}
impl Default for GrpcTransportConfig {
fn default() -> Self {
Self {
local_shard: ShardId::SINGLE,
// Default replication listener. Inside tidalDB's reserved dev band
// (5952059529); 59529 is the slot claimed for the gRPC transport.
listen_addr: "127.0.0.1:59529".parse().expect("valid default addr"),
peers: HashMap::new(),
tls: None,
insecure: true,
channel_capacity: 1024,
max_payload_bytes: MAX_PAYLOAD_BYTES,
circuit_breaker_threshold: 5,
circuit_breaker_reset: Duration::from_secs(30),
connect_timeout: Duration::from_secs(5),
request_timeout: Duration::from_secs(10),
keep_alive_interval: Duration::from_secs(10),
keep_alive_timeout: Duration::from_secs(5),
catchup_retry_interval: Duration::from_secs(30),
rotation_poll_interval: Duration::from_secs(30),
handshake_timeout: Duration::from_secs(10),
max_concurrent_handshakes: 256,
}
}
}
/// TLS certificate paths for mutual TLS.
#[derive(Debug, Clone)]
pub struct TlsConfig {
/// Path to the CA certificate PEM file.
pub ca_cert: PathBuf,
/// Path to the server certificate PEM file.
pub server_cert: PathBuf,
/// Path to the server private key PEM file.
pub server_key: PathBuf,
/// Path to the client certificate PEM file (for mTLS).
pub client_cert: Option<PathBuf>,
/// Path to the client private key PEM file (for mTLS).
pub client_key: Option<PathBuf>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_valid() {
// The shipped defaults must pass their own invariant check, otherwise
// every default-built transport would error at construction.
GrpcTransportConfig::default()
.validate()
.expect("default config must satisfy its own invariants");
}
#[test]
fn zero_channel_capacity_is_rejected() {
// A zero-capacity mpsc channel panics inside GrpcTransport::new; the
// typed error must catch it before that panic.
let cfg = GrpcTransportConfig {
channel_capacity: 0,
..GrpcTransportConfig::default()
};
let err = cfg
.validate()
.expect_err("zero channel_capacity must be rejected");
assert!(matches!(err, GrpcTransportError::Internal(_)));
}
#[test]
fn zero_timeouts_are_rejected() {
let mutators: [fn(&mut GrpcTransportConfig); 4] = [
|c| c.connect_timeout = Duration::ZERO,
|c| c.request_timeout = Duration::ZERO,
|c| c.keep_alive_interval = Duration::ZERO,
|c| c.keep_alive_timeout = Duration::ZERO,
];
for mutate in mutators {
let mut cfg = GrpcTransportConfig::default();
mutate(&mut cfg);
assert!(
cfg.validate().is_err(),
"a zero deadline must be rejected (it aborts every attempt instantly)"
);
}
}
#[test]
fn zero_handshake_bounds_are_rejected() {
// A zero handshake timeout rejects every inbound TLS handshake instantly;
// a zero concurrency bound load-sheds every connection — both refuse all
// peers, so the typed config check must catch them.
let mutators: [fn(&mut GrpcTransportConfig); 2] = [
|c| c.handshake_timeout = Duration::ZERO,
|c| c.max_concurrent_handshakes = 0,
];
for mutate in mutators {
let mut cfg = GrpcTransportConfig::default();
mutate(&mut cfg);
assert!(
cfg.validate().is_err(),
"a zero handshake bound must be rejected"
);
}
}
#[test]
fn zero_circuit_breaker_reset_is_rejected() {
let cfg = GrpcTransportConfig {
circuit_breaker_reset: Duration::ZERO,
..GrpcTransportConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn zero_catchup_retry_interval_is_rejected() {
// A zero retry delay turns the m11p4 catch-up timer into a hot loop
// that hammers the stream source.
let cfg = GrpcTransportConfig {
catchup_retry_interval: Duration::ZERO,
..GrpcTransportConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn payload_ceiling_out_of_range_is_rejected() {
// Zero rejects every segment; above the engine wire limit lets the codec
// accept a payload the peer will refuse.
for bytes in [0, MAX_PAYLOAD_BYTES + 1] {
let cfg = GrpcTransportConfig {
max_payload_bytes: bytes,
..GrpcTransportConfig::default()
};
assert!(
cfg.validate().is_err(),
"max_payload_bytes {bytes} is out of the 1..={MAX_PAYLOAD_BYTES} range"
);
}
}
#[test]
fn payload_ceiling_at_engine_limit_is_allowed() {
let cfg = GrpcTransportConfig {
max_payload_bytes: MAX_PAYLOAD_BYTES,
..GrpcTransportConfig::default()
};
assert!(cfg.validate().is_ok());
}
#[test]
fn zero_circuit_breaker_threshold_is_allowed() {
// A zero threshold means "open on the first failure" (fail-fast), a
// legitimate aggressive policy — not a degenerate config.
let cfg = GrpcTransportConfig {
circuit_breaker_threshold: 0,
..GrpcTransportConfig::default()
};
assert!(cfg.validate().is_ok());
}
#[test]
fn valid_peer_addresses_are_accepted() {
// IP literal, DNS name, fully-qualified k8s pod DNS, and a
// scheme-prefixed value must all pass — the m11p5 DNS-peer point is
// that hostnames are first-class peer addresses.
for addr in [
"127.0.0.1:9601",
"tidal-eu:9601",
"tidaldb-1.tidaldb-peers.tidaldb-cluster.svc.cluster.local:9601",
"http://127.0.0.1:9601",
"https://tidal-eu:9601",
] {
let cfg = GrpcTransportConfig {
peers: HashMap::from([(ShardId(1), addr.to_string())]),
..GrpcTransportConfig::default()
};
cfg.validate()
.unwrap_or_else(|e| panic!("peer addr '{addr}' must be accepted: {e:?}"));
}
}
#[test]
fn malformed_peer_addresses_are_rejected() {
// A bad peer string would otherwise surface only as an opaque
// "invalid URI" deep in PeerPool::new — the config check names the
// offending peer instead.
for (addr, why) in [
("127.0.0.1", "missing port"),
("127.0.0.1:0", "port 0"),
("127.0.0.1:99999", "port out of range"),
(":9601", "empty host"),
("10.0.0.1:port", "non-numeric port"),
] {
let cfg = GrpcTransportConfig {
peers: HashMap::from([(ShardId(1), addr.to_string())]),
..GrpcTransportConfig::default()
};
let Err(err) = cfg.validate() else {
panic!("peer addr '{addr}' ({why}) must be rejected");
};
assert!(
matches!(err, GrpcTransportError::Internal(_)),
"expected an Internal config error for '{addr}', got {err:?}"
);
}
}
}