- 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)
227 lines
9.3 KiB
Rust
227 lines
9.3 KiB
Rust
//! gRPC client connection pool for shipping WAL segments to peers.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use tidaldb::replication::{shard::ShardId, transport::WalSegmentPayload};
|
|
use tonic::transport::Channel;
|
|
|
|
use crate::{
|
|
circuit_breaker::CircuitBreaker,
|
|
config::GrpcTransportConfig,
|
|
error::GrpcTransportError,
|
|
proto::{ShipSegmentRequest, wal_shipping_client::WalShippingClient},
|
|
tls,
|
|
};
|
|
|
|
/// A connection to a single peer shard.
|
|
struct PeerConnection {
|
|
client: WalShippingClient<Channel>,
|
|
circuit_breaker: CircuitBreaker,
|
|
}
|
|
|
|
/// Connection pool managing one gRPC channel per peer shard.
|
|
pub struct PeerPool {
|
|
peers: HashMap<ShardId, PeerConnection>,
|
|
}
|
|
|
|
impl PeerPool {
|
|
/// Build a pool with lazy connections to all configured peers.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`GrpcTransportError`] if the config fails its numeric invariant
|
|
/// check ([`GrpcTransportConfig::validate`]), if TLS is unset but plaintext
|
|
/// was not explicitly opted into (`insecure == false`), if a peer URI is
|
|
/// invalid, or if the client TLS configuration cannot be built.
|
|
pub fn new(config: &GrpcTransportConfig) -> Result<Self, GrpcTransportError> {
|
|
config.validate()?;
|
|
|
|
// Refuse to build a PLAINTEXT channel unless plaintext was explicitly
|
|
// opted into. With no TLS config the scheme below falls back to `http`,
|
|
// so silently honoring `insecure == false` would ship WAL bytes in the
|
|
// clear while the operator believes the peer is authenticated. The server
|
|
// (server::start_server) rejects this same asymmetry; the client must
|
|
// match it so a half-configured deployment fails loudly on BOTH ends
|
|
// instead of one end quietly downgrading to plaintext. `TlsConfig` is the
|
|
// right variant: it is classified `is_permanent` (see error.rs), so the
|
|
// shipper quarantines rather than retrying a config that cannot self-heal.
|
|
if config.tls.is_none() && !config.insecure {
|
|
return Err(GrpcTransportError::TlsConfig(
|
|
"TLS not configured and insecure not set; refusing to ship WAL segments over \
|
|
plaintext"
|
|
.into(),
|
|
));
|
|
}
|
|
|
|
let mut peers = HashMap::new();
|
|
|
|
for (&shard_id, addr) in &config.peers {
|
|
let scheme = if config.tls.is_some() {
|
|
"https"
|
|
} else {
|
|
"http"
|
|
};
|
|
let uri = format!("{scheme}://{addr}");
|
|
|
|
// Build the endpoint with hard connect/request timeouts and HTTP/2
|
|
// keep-alive so a stalled peer fails fast (transient error → circuit
|
|
// breaker) instead of hanging the single-threaded WAL shipper. These
|
|
// apply on both the plaintext and TLS paths.
|
|
let mut endpoint = Channel::from_shared(uri)
|
|
.map_err(|e| GrpcTransportError::Internal(format!("invalid URI: {e}")))?
|
|
.connect_timeout(config.connect_timeout)
|
|
.timeout(config.request_timeout)
|
|
.tcp_keepalive(Some(config.keep_alive_interval))
|
|
.http2_keep_alive_interval(config.keep_alive_interval)
|
|
.keep_alive_timeout(config.keep_alive_timeout)
|
|
.keep_alive_while_idle(true);
|
|
|
|
// Configure client TLS if available.
|
|
if let Some(ref tls_config) = config.tls {
|
|
let client_tls = tls::client_tls_config(tls_config)?;
|
|
endpoint = endpoint
|
|
.tls_config(client_tls)
|
|
.map_err(GrpcTransportError::TonicTransport)?;
|
|
}
|
|
|
|
let channel = endpoint.connect_lazy();
|
|
// Raise tonic's default 4 MiB codec limits to the configured max
|
|
// payload on BOTH directions. The client is the ENCODER for the
|
|
// outbound ShipSegmentRequest (and the DECODER for any future
|
|
// server-streaming response). Without this the client encoder trips
|
|
// FIRST on a full-size WAL segment (16 MiB default, up to 64 MiB)
|
|
// before bytes ever leave the process, surfacing as a codec error
|
|
// the shipper would retry forever. Both ends must agree on the same
|
|
// ceiling the application already validates against.
|
|
let client = WalShippingClient::new(channel)
|
|
.max_encoding_message_size(config.max_payload_bytes)
|
|
.max_decoding_message_size(config.max_payload_bytes);
|
|
let circuit_breaker = CircuitBreaker::new(
|
|
config.circuit_breaker_threshold,
|
|
config.circuit_breaker_reset,
|
|
);
|
|
|
|
peers.insert(
|
|
shard_id,
|
|
PeerConnection {
|
|
client,
|
|
circuit_breaker,
|
|
},
|
|
);
|
|
}
|
|
|
|
Ok(Self { peers })
|
|
}
|
|
|
|
/// Send a WAL segment to a peer shard.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`GrpcTransportError`] if the peer is unknown, the circuit breaker
|
|
/// is open, the follower signals backpressure, or the gRPC call fails.
|
|
// `peer` is a plain `&PeerConnection` borrow into `self.peers` (no Drop of
|
|
// its own) used both before the await (clone the client) and after (record
|
|
// success/failure on its circuit breaker), so it legitimately spans the
|
|
// whole fn — significant_drop_tightening is a false positive for a borrow.
|
|
#[allow(clippy::significant_drop_tightening)]
|
|
pub async fn send_to(
|
|
&self,
|
|
shard: ShardId,
|
|
payload: WalSegmentPayload,
|
|
) -> Result<(), GrpcTransportError> {
|
|
let peer = self
|
|
.peers
|
|
.get(&shard)
|
|
.ok_or(GrpcTransportError::PeerUnreachable(shard))?;
|
|
|
|
// Check circuit breaker.
|
|
peer.circuit_breaker
|
|
.check()
|
|
.map_err(|_| GrpcTransportError::CircuitOpen(shard))?;
|
|
|
|
let request: ShipSegmentRequest = payload.into();
|
|
|
|
// Clone the client (tonic clients are cheap clones over a shared channel).
|
|
let mut client = peer.client.clone();
|
|
|
|
match client.ship_segment(request).await {
|
|
Ok(response) => {
|
|
// The follower signals backpressure with `accepted=false` when
|
|
// its inbound channel is full: the segment was NOT enqueued.
|
|
// This is normal flow control from a healthy-but-busy peer, not
|
|
// a connection failure — so it must NOT count toward opening the
|
|
// circuit breaker (that would convert transient congestion into
|
|
// a self-inflicted `circuit_breaker_reset` replication stall,
|
|
// amplifying follower lag exactly when the leader must keep
|
|
// shipping). We route it through `record_backpressure`, which
|
|
// leaves Closed/Open breaker state untouched (it only resolves an
|
|
// outstanding half-open probe), and return a transient
|
|
// `SegmentRejected` so the shipper does not advance its cursor
|
|
// and retries on the next poll. Only the genuine transport error
|
|
// arm below calls `record_failure`.
|
|
if response.into_inner().accepted {
|
|
peer.circuit_breaker.record_success();
|
|
Ok(())
|
|
} else {
|
|
peer.circuit_breaker.record_backpressure();
|
|
Err(GrpcTransportError::SegmentRejected(shard))
|
|
}
|
|
}
|
|
Err(status) => {
|
|
peer.circuit_breaker.record_failure();
|
|
Err(GrpcTransportError::Grpc(Box::new(status)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::GrpcTransportConfig;
|
|
|
|
#[test]
|
|
fn rejects_plaintext_when_insecure_unset() {
|
|
// insecure=false with no TLS config must error rather than silently
|
|
// building a plaintext channel — symmetric with the server's rejection.
|
|
// No peers means the early-return guard fires before any endpoint is
|
|
// built, so this needs no tokio reactor.
|
|
let config = GrpcTransportConfig {
|
|
tls: None,
|
|
insecure: false,
|
|
..GrpcTransportConfig::default()
|
|
};
|
|
// `let-else` instead of `expect_err` so the test does not require
|
|
// `PeerPool: Debug` (it holds tonic channel handles that are not Debug).
|
|
let Err(err) = PeerPool::new(&config) else {
|
|
panic!("plaintext without opt-in must be rejected");
|
|
};
|
|
assert!(
|
|
matches!(err, GrpcTransportError::TlsConfig(_)),
|
|
"expected a permanent TlsConfig error, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn allows_plaintext_when_insecure_set() {
|
|
// The explicit opt-in path must still succeed (no peers => no endpoints).
|
|
let config = GrpcTransportConfig {
|
|
tls: None,
|
|
insecure: true,
|
|
..GrpcTransportConfig::default()
|
|
};
|
|
assert!(PeerPool::new(&config).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_config() {
|
|
// PeerPool::new must run the config invariant check; a zero capacity is
|
|
// caught here instead of panicking later in the mpsc channel build.
|
|
let config = GrpcTransportConfig {
|
|
channel_capacity: 0,
|
|
..GrpcTransportConfig::default()
|
|
};
|
|
assert!(PeerPool::new(&config).is_err());
|
|
}
|
|
}
|