tidaldb/tidal/src/replication/in_process.rs
jx12n b55ad70141 fix: M0-M10 third-pass remediation — durability, replication, and CLI hardening
Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across
the engine, server, net, and CLI surfaces:

- WAL/session-journal durability, checkpoint format, and crash-recovery hardening
- Replication shipper/receiver, tenant isolation, and migration paths
- Cluster scatter-gather, router, standalone server + health/offload endpoints
- tidalctl refactored into command modules with JSON output and WAL-state tooling
- Cohort, governance, signal-ledger, and vector-registry correctness fixes
- Expanded UAT/integration/durability test coverage across all milestones
2026-06-08 10:28:34 -06:00

270 lines
9.1 KiB
Rust

//! In-process transport for WAL segment shipping via crossbeam channels.
//!
//! Used for integration tests and single-process multi-shard deployments.
//! Each shard gets a unique receiver and a clone of all senders, enabling
//! any-to-any communication within the process.
use std::collections::HashMap;
use crossbeam::channel::{Receiver, Sender, bounded};
use crate::replication::{
shard::ShardId,
transport::{Transport, TransportError, WalSegmentPayload},
};
/// Maximum payload size in bytes (64 MB). Segments larger than this are
/// rejected to prevent unbounded memory growth in the channel buffer.
const MAX_PAYLOAD_BYTES: usize = 64 * 1024 * 1024;
/// Per-shard channel wiring: a map from each local shard to its
/// `(all_senders, own_receiver)` pair. One entry per shard; the sender map
/// inside each pair carries a clone of every shard's sender so an endpoint can
/// ship to any peer.
pub(crate) type ChannelEndpoints<P> = HashMap<ShardId, (HashMap<ShardId, Sender<P>>, Receiver<P>)>;
/// Build the channel wiring for a set of any-to-any in-process transport
/// endpoints: one bounded channel per shard, where every endpoint owns a clone
/// of *all* senders (so it can ship to any peer) and its own unique receiver.
///
/// Returns, per shard, the `(all_senders, own_receiver)` pair from which a
/// concrete endpoint struct is built. Shared by
/// [`InProcessTransportFactory::build`] (WAL segments) and
/// [`super::session_bridge::InProcessSessionTransportFactory::build`] (session
/// payloads) so the wiring lives in one place; each factory only differs in its
/// payload type `P` and the endpoint struct it wraps the parts in.
pub(crate) fn build_channel_endpoints<P>(
shards: &[ShardId],
capacity: usize,
) -> ChannelEndpoints<P> {
let mut senders: HashMap<ShardId, Sender<P>> = HashMap::new();
let mut receivers: HashMap<ShardId, Receiver<P>> = HashMap::new();
for &shard_id in shards {
let (tx, rx) = bounded(capacity);
senders.insert(shard_id, tx);
receivers.insert(shard_id, rx);
}
shards
.iter()
.map(|&local_shard| {
// Each receiver is consumed exactly once (the map was populated from
// the same shard list), so `remove` is always `Some`.
let receiver = receivers
.remove(&local_shard)
.expect("receiver missing for shard that was just created");
(local_shard, (senders.clone(), receiver))
})
.collect()
}
/// Factory for building a set of [`InProcessTransport`] endpoints, one per shard.
///
/// Each transport owns all senders (one per shard) and its own unique receiver.
/// The channel capacity is 64 segments per shard.
pub struct InProcessTransportFactory {
shards: Vec<ShardId>,
}
impl InProcessTransportFactory {
/// Create a factory for the given shard IDs.
#[must_use]
pub fn new(shards: &[ShardId]) -> Self {
Self {
shards: shards.to_vec(),
}
}
/// Build one `InProcessTransport` per shard.
///
/// Each transport can send to any shard and receives on its own channel.
///
/// # Panics
///
/// Panics if a receiver is missing for a shard -- this is unreachable
/// because the receiver map is populated from the same shard list.
#[must_use]
pub fn build(self) -> HashMap<ShardId, InProcessTransport> {
build_channel_endpoints::<WalSegmentPayload>(&self.shards, 64)
.into_iter()
.map(|(local_shard, (senders, receiver))| {
(
local_shard,
InProcessTransport {
local_shard,
senders,
receiver,
},
)
})
.collect()
}
}
/// An in-process transport endpoint backed by crossbeam bounded channels.
///
/// Thread-safe: `Sender` and `Receiver` from crossbeam are `Send + Sync`.
pub struct InProcessTransport {
local_shard: ShardId,
senders: HashMap<ShardId, Sender<WalSegmentPayload>>,
receiver: Receiver<WalSegmentPayload>,
}
impl Transport for InProcessTransport {
fn send_segment(&self, to: ShardId, payload: WalSegmentPayload) -> Result<(), TransportError> {
if payload.bytes.len() > MAX_PAYLOAD_BYTES {
return Err(TransportError::PayloadTooLarge {
size: payload.bytes.len(),
max: MAX_PAYLOAD_BYTES,
});
}
let sender = self
.senders
.get(&to)
.ok_or(TransportError::UnknownPeer(to))?;
sender.send(payload).map_err(|_| TransportError::Closed)
}
fn recv_segment(&self) -> Option<WalSegmentPayload> {
self.receiver.recv().ok()
}
fn local_shard(&self) -> ShardId {
self.local_shard
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::replication::{segment_id::WalSegmentId, shard::RegionId};
fn make_payload(shard: ShardId, seqno: u64) -> WalSegmentPayload {
WalSegmentPayload {
id: WalSegmentId::new(RegionId::SINGLE, shard, seqno),
bytes: vec![0xAB; 100],
event_count: 5,
leader_last_seq: seqno,
}
}
#[test]
fn factory_creates_one_transport_per_shard() {
let shards = [ShardId(0), ShardId(1), ShardId(2)];
let factory = InProcessTransportFactory::new(&shards);
let transports = factory.build();
assert_eq!(transports.len(), 3);
assert!(transports.contains_key(&ShardId(0)));
assert!(transports.contains_key(&ShardId(1)));
assert!(transports.contains_key(&ShardId(2)));
}
#[test]
fn send_and_receive_between_shards() {
let shards = [ShardId(0), ShardId(1)];
let factory = InProcessTransportFactory::new(&shards);
let transports = factory.build();
let t0 = &transports[&ShardId(0)];
let t1 = &transports[&ShardId(1)];
// Shard 0 sends to Shard 1.
let payload = make_payload(ShardId(0), 42);
t0.send_segment(ShardId(1), payload).unwrap();
// Shard 1 receives it.
let received = t1.recv_segment().unwrap();
assert_eq!(received.id.seqno, 42);
assert_eq!(received.event_count, 5);
assert_eq!(received.bytes.len(), 100);
}
#[test]
fn send_to_unknown_peer_fails() {
let shards = [ShardId(0)];
let factory = InProcessTransportFactory::new(&shards);
let transports = factory.build();
let t0 = &transports[&ShardId(0)];
let result = t0.send_segment(ShardId(99), make_payload(ShardId(0), 1));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, TransportError::UnknownPeer(ShardId(99))));
}
#[test]
fn payload_too_large_rejected() {
let shards = [ShardId(0), ShardId(1)];
let factory = InProcessTransportFactory::new(&shards);
let transports = factory.build();
let t0 = &transports[&ShardId(0)];
let payload = WalSegmentPayload {
id: WalSegmentId::single_node(1),
bytes: vec![0u8; MAX_PAYLOAD_BYTES + 1],
event_count: 0,
leader_last_seq: 0,
};
let result = t0.send_segment(ShardId(1), payload);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
TransportError::PayloadTooLarge { .. }
));
}
#[test]
fn local_shard_returns_correct_id() {
let shards = [ShardId(7), ShardId(8)];
let factory = InProcessTransportFactory::new(&shards);
let transports = factory.build();
assert_eq!(transports[&ShardId(7)].local_shard(), ShardId(7));
assert_eq!(transports[&ShardId(8)].local_shard(), ShardId(8));
}
#[test]
fn recv_returns_none_when_all_transports_dropped() {
let shards = [ShardId(0), ShardId(1)];
let factory = InProcessTransportFactory::new(&shards);
let transports = factory.build();
// Extract shard 1's receiver before dropping everything.
// We need to drop ALL transports (including t1 itself) because
// each transport holds clones of all senders -- including senders
// to its own channel.
let t1_receiver = {
let t1 = &transports[&ShardId(1)];
t1.receiver.clone()
};
// Drop all transports -- this drops all senders.
drop(transports);
// With all senders dropped, recv should return None.
assert!(t1_receiver.recv().is_err());
}
#[test]
fn multiple_segments_fifo_order() {
let shards = [ShardId(0), ShardId(1)];
let factory = InProcessTransportFactory::new(&shards);
let transports = factory.build();
let t0 = &transports[&ShardId(0)];
let t1 = &transports[&ShardId(1)];
for seq in 1..=5u64 {
t0.send_segment(ShardId(1), make_payload(ShardId(0), seq))
.unwrap();
}
for expected_seq in 1..=5u64 {
let received = t1.recv_segment().unwrap();
assert_eq!(received.id.seqno, expected_seq);
}
}
}