//! 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, /// TLS configuration. `None` requires `insecure = true`. pub tls: Option, /// 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, /// Path to the client private key PEM file (for mTLS). pub client_key: Option, } #[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()); } }