tidaldb/tidal-net/src/config.rs
jx12n ad4134e280 chore: doc consolidation, seven-dimension review fixes, and commit hooks
- 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)
2026-06-08 22:46:28 -06:00

261 lines
11 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 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
// (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),
}
}
}
/// 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());
}
}