- Eliminate the tidal/ self-contained doc mirror; docs now have two canonical homes (root *.md and docs/), with planning/specs/research/reviews moved up - Remove stale .agents/skills and .ai mirrors; canonicalize skills under .claude/ - Add pre-commit hook + scripts/check-docs.sh doc-guard + scripts/install-hooks.sh - Implement M0-M10 seven-dimension review findings across engine, net, server, and tidalctl (durability, replication, query, WAL, storage, CLI hardening)
261 lines
11 KiB
Rust
261 lines
11 KiB
Rust
//! 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 addresses (`shard_id` -> address).
|
||
pub peers: HashMap<ShardId, SocketAddr>,
|
||
/// 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,
|
||
}
|
||
|
||
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.
|
||
///
|
||
/// `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(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl Default for GrpcTransportConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
local_shard: ShardId::SINGLE,
|
||
// Default replication listener. Inside tidalDB's reserved dev band
|
||
// (59520–59529); 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),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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_circuit_breaker_reset_is_rejected() {
|
||
let cfg = GrpcTransportConfig {
|
||
circuit_breaker_reset: 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());
|
||
}
|
||
}
|