//! Off-request-path replication shipping: per-peer windowed batch senders. //! //! m11p1 built this queue over the relay's in-memory log; m11p2 re-sources it //! from the **WAL's flushed-batch feed** ([`crate::wal::feed::WalShipFeed`]) — //! the one replicated log. A [`ShipQueue`] owns `window` dedicated OS threads //! per peer, each of which: //! //! 1. claims-and-collects the next contiguous run of already-encoded flushed //! batches from its [`ShipSource`] (up to `max_batch_events` / //! `max_batch_bytes`, never past the source's flushed frontier — an event //! the leader has not fsynced must never reach a follower); //! 2. ships the run's bytes verbatim with a single blocking `send_segment` //! call (byte-identical on the leader's disk, the wire, and the follower's //! apply path — no re-encode); //! 3. on success records the run and advances the peer's contiguous **acked //! frontier** (folding in the follower's reported applied seqno when the //! transport carries one); on a transient failure parks the run for retry //! after `retry_backoff`. //! //! With `window > 1`, up to `window` runs are in flight per peer //! concurrently; runs may complete out of order, which the receiver's //! gap-aware `apply_range` tolerates (ahead ranges park until the gap //! closes), and the retry queue guarantees every gap eventually closes — //! the "N batches outstanding, acks advance the window" protocol from the //! m11 roadmap. //! //! # Bounded tail + follower-pulled catch-up (m11p2) //! //! The feed's tail is bounded. When a peer's cursor falls behind the retained //! tail (long partition, long downtime), the source reports //! [`ShipCollect::Rotated`] and the sender SKIPS to the tail floor: the //! resulting receiver-side gap triggers the follower's own catch-up pull //! (`Transport::request_catchup` → `StreamSegments` over the leader's durable //! WAL segments). Push covers the hot path; pull covers history — the //! unbounded in-memory log dependency is gone. //! //! # Leadership activation //! //! Every cluster node spawns a queue at boot (its WAL feeds it regardless of //! role — a follower's feed entries are its replicated applies), but only an //! ACTIVE queue dispatches. Activation follows leadership: without the gate, a //! follower would echo its replicated applies back at its peers. //! //! # Threading contract //! //! Senders are plain `std::thread`s with no ambient tokio runtime — exactly //! what [`Transport::send_segment`]'s blocking contract requires (the gRPC //! implementation asserts it is not called from within a runtime). //! //! # Pause / resume (partition + heal verbs) //! //! `pause(peer)` stops new dispatch to a peer (in-flight sends complete); //! `resume(peer)` restarts dispatch, and `resume_from(peer, applied)` jumps //! the cursor past everything the follower reports applied — heal is then //! resume + (if the tail rotated) the follower's own pull. A **permanent** //! transport failure (TLS/auth/codec) auto-pauses the peer and logs loudly: //! retrying a permanent failure forever is a silent stall. use std::{ collections::BTreeMap, sync::{ Arc, Condvar, Mutex, PoisonError, Weak, atomic::{AtomicBool, AtomicU64, Ordering}, }, thread::JoinHandle, time::{Duration, Instant}, }; use super::{ commit::CommitIndex, relay::{SignalRelay, encode_run}, shard::ShardId, transport::{Transport, TransportError}, }; use crate::wal::feed::{FeedCollect, WalShipFeed}; #[cfg(feature = "metrics")] use crate::db::metrics::cluster::ClusterMetrics; /// Repeat-failure WARN cadence: the 1st failed attempt per outage WARNs, then /// every Nth, with the running count attached; everything between is `debug`. /// Recovery logs at INFO with the outage's total. Keeps a long partition from /// flooding the log (the unbounded per-retry WARN once filled an undrained /// 64KB stderr pipe and froze every logging thread in the process). const FAILURE_LOG_EVERY: u64 = 50; // ── Ship source ───────────────────────────────────────────────────────────── /// Result of a [`ShipSource::collect`] call. pub enum ShipCollect { /// Encoded batches covering exactly `[first_seq, last_seq]`, ready to /// ship verbatim. Run { bytes: Vec, first_seq: u64, last_seq: u64, event_count: u64, }, /// Nothing at or above the requested seqno is flushed yet. NothingNew, /// The requested seqno predates the retained data; dispatch must resume /// at `resume_from` and the skipped range becomes a receiver-side gap /// (closed by the follower's catch-up pull). Rotated { resume_from: u64 }, } /// A leader-side source of encoded, durably-flushed replication batches. /// /// Two implementations: [`WalFeedSource`] (the m11p2 production source — the /// WAL's flushed-batch feed) and [`SignalRelay`] (the in-process harness /// source, whose log never rotates). pub trait ShipSource: Send + Sync + 'static { /// The shard this stream ships under (stamped into every payload). fn source_shard(&self) -> ShardId; /// The contiguous flushed frontier: every seqno `<=` this is durable on /// the leader and eligible to ship. fn flushed_seq(&self) -> u64; /// Collect a contiguous run of encoded batches starting exactly at /// `from_seq`, bounded by `max_events` / `max_bytes`. /// /// # Errors /// /// A deterministic source fault (encode failure, log corruption): the /// caller pauses the peer loudly rather than retrying forever. fn collect( &self, from_seq: u64, max_events: u64, max_bytes: usize, ) -> crate::Result; /// Install a wake callback fired when new data becomes shippable. Sources /// without an internal flush signal (the relay) may ignore this; callers /// then rely on explicit [`ShipQueue::publish`] wakes. fn set_notify(&self, _notify: Box) {} } /// The m11p2 production [`ShipSource`]: the WAL writer's flushed-batch feed. pub struct WalFeedSource { shard: ShardId, feed: Arc, } impl WalFeedSource { /// Wrap a WAL ship feed as this shard's outbound stream source. #[must_use] pub const fn new(shard: ShardId, feed: Arc) -> Self { Self { shard, feed } } } impl ShipSource for WalFeedSource { fn source_shard(&self) -> ShardId { self.shard } fn flushed_seq(&self) -> u64 { self.feed.flushed_seq() } fn collect( &self, from_seq: u64, max_events: u64, max_bytes: usize, ) -> crate::Result { Ok(match self.feed.collect(from_seq, max_events, max_bytes) { FeedCollect::Run { bytes, first_seq, last_seq, event_count, } => ShipCollect::Run { bytes, first_seq, last_seq, event_count, }, FeedCollect::NothingNew => ShipCollect::NothingNew, FeedCollect::Rotated { tail_floor } => ShipCollect::Rotated { resume_from: tail_floor, }, }) } fn set_notify(&self, notify: Box) { self.feed.set_notify(notify); } } /// Harness compatibility: the m11p1 relay is a [`ShipSource`] whose in-memory /// log never rotates (so [`ShipCollect::Rotated`] is unreachable) and whose /// flushed frontier is the relay's durable frontier. impl ShipSource for SignalRelay { fn source_shard(&self) -> ShardId { Self::source_shard(self) } fn flushed_seq(&self) -> u64 { self.durable_seq() } fn collect( &self, from_seq: u64, max_events: u64, _max_bytes: usize, ) -> crate::Result { let max = usize::try_from(max_events.clamp(1, 256)).unwrap_or(256); let entries = self.snapshot_range(from_seq, max); if entries.is_empty() { return Ok(ShipCollect::NothingNew); } if entries[0].seqno != from_seq { // Unreachable for a contiguous relay log; deterministic if it // ever fires, so surface it as a source fault (peer pauses). return Err(crate::TidalError::internal( "ship_collect", format!( "relay log snapshot mismatch: requested run starts at {from_seq} but \ snapshot starts at {}", entries[0].seqno ), )); } let first_seq = entries[0].seqno; let last_seq = entries.last().map_or(first_seq, |e| e.seqno); let event_count = entries.len() as u64; let bytes = encode_run(&entries)?; Ok(ShipCollect::Run { bytes, first_seq, last_seq, event_count, }) } } // ── Queue configuration ───────────────────────────────────────────────────── /// Tuning for the per-peer batch senders. #[derive(Debug, Clone, Copy)] pub struct ShipQueueConfig { /// Maximum events coalesced into one shipped run. Clamped to the WAL wire /// format's per-batch ceiling (256) for the relay source; the feed source /// ships whole pre-encoded batches, so the cap bounds the run, not any /// single batch. pub max_batch_events: usize, /// Maximum bytes per shipped run (bounds blob-heavy runs; a single batch /// larger than this still ships alone). Defaults to 16 MiB — a quarter of /// the transport's 64 MiB payload ceiling. pub max_batch_bytes: usize, /// In-flight batches per peer (sender threads per peer). `1` = strictly /// in-order shipping; higher values pipeline across the peer RTT at the /// cost of out-of-order arrival (which the receiver parks gap-aware). pub window: usize, /// Delay before a transiently-failed run is retried. pub retry_backoff: Duration, /// This node's region id, stamped as `leader_region` on every outbound /// payload (m11p4 fencing). 0 for relay-path/test users without elections. pub leader_region: u16, } impl Default for ShipQueueConfig { fn default() -> Self { Self { max_batch_events: 256, max_batch_bytes: 16 * 1024 * 1024, window: 4, retry_backoff: Duration::from_millis(100), leader_region: 0, } } } // ── Per-peer state ────────────────────────────────────────────────────────── /// Per-peer dispatch state, guarded by one mutex per peer. struct PeerState { /// Next seqno to hand to a sender (everything below is dispatched, /// skipped-past-rotation, or covered by the follower's reported applied). dispatch_next: u64, /// Contiguous acked frontier: every seqno `<= acked` was accepted by the /// peer's transport (or reported applied by the follower itself). acked: u64, /// Completed-but-not-contiguous runs, keyed by first seqno → last seqno. completed: BTreeMap, /// Failed runs awaiting retry: first seqno → (last seqno, due time). retry: BTreeMap, /// Operator/permanent-failure pause: no new dispatch while set. paused: bool, /// Consecutive failed ship attempts since the last success, for /// transition-based logging (see [`FAILURE_LOG_EVERY`]). A partition /// window otherwise emits one WARN per retry attempt per sender — /// tens of lines per second, unbounded for the partition's duration. consecutive_failures: u64, } impl PeerState { /// Advance the contiguous acked frontier across completed runs and the /// follower's reported applied seqno, pruning state below it. fn advance_acked(&mut self, reported_applied: u64) { if reported_applied > self.acked { self.acked = reported_applied; } while let Some((&first, &last)) = self.completed.first_key_value() { if first <= self.acked + 1 { self.acked = self.acked.max(last); self.completed.remove(&first); } else { break; } } // Anything at or below the acked frontier no longer needs retrying: // the follower already has it. self.retry.retain(|_, (last, _)| *last > self.acked); } } /// One peer's shared dispatch cell. struct PeerShip { peer: ShardId, state: Mutex, cv: Condvar, /// Lock-free mirror of `state.acked` for status reads. acked_atomic: AtomicU64, } /// The region every outbound batch names as its leader (m11p4). Lives in the /// queue config so [`ShipQueue::spawn`]'s signature stays stable; defaults to /// 0 for relay-path/test users that never run elections. impl ShipQueueConfig { /// This node's region id, stamped as `leader_region` on every shipped /// payload alongside the term. #[must_use] pub const fn with_leader_region(mut self, region: u16) -> Self { self.leader_region = region; self } } impl PeerShip { fn lock(&self) -> std::sync::MutexGuard<'_, PeerState> { self.state.lock().unwrap_or_else(PoisonError::into_inner) } } /// Shared core of a [`ShipQueue`]. struct ShipShared { source: Arc, transport: Arc, peers: Vec>, config: ShipQueueConfig, shutdown: AtomicBool, /// Leadership gate: only an active queue dispatches (see module docs). active: AtomicBool, /// The leadership term every outbound batch is stamped with (m11p4 /// fencing). 0 = the topology era; set by [`ShipQueue::activate_from`] /// when this node leads an elected term. term: AtomicU64, /// Quorum commit index over the peers' durable marks (m11p3). Follows /// the queue's leadership gate: activated/deactivated with it. commit: Arc, #[cfg(feature = "metrics")] metrics: Option>, } impl ShipShared { fn peer(&self, peer: ShardId) -> Option<&Arc> { self.peers.iter().find(|p| p.peer == peer) } fn wake_all(&self) { for cell in &self.peers { cell.cv.notify_all(); } } } /// Per-peer windowed batch senders draining a [`ShipSource`]'s durable log. /// /// Spawn once per node ([`ShipQueue::spawn`]); the source's flush notify (or /// an explicit [`publish`](Self::publish)) wakes parked senders. Threads exit /// on [`shutdown`](Self::shutdown) (also called on `Drop`). pub struct ShipQueue { shared: Arc, threads: Vec>, } impl ShipQueue { /// Spawn `config.window` sender threads for every peer in `peers` /// (excluding the source's own shard). `active` gates dispatch: pass this /// node's at-boot leadership belief (an inactive queue parks until /// [`activate_from`](Self::activate_from)). /// /// # Panics /// /// Panics if a sender OS thread cannot be spawned — this runs once at /// node startup, so a thread-exhausted host fails loudly at boot. #[must_use] pub fn spawn( source: Arc, transport: Arc, peers: &[ShardId], config: ShipQueueConfig, active: bool, #[cfg(feature = "metrics")] metrics: Option>, ) -> Self { // Dispatch starts one past the source's flushed frontier: on a fresh // stream that is seqno 1; on a leader RESTART it means only new // writes push (a behind follower's gap triggers its catch-up pull // instead of a full-log re-ship). let start_next = source.flushed_seq() + 1; let peer_cells: Vec> = peers .iter() .filter(|&&p| p != source.source_shard()) .map(|&peer| { Arc::new(PeerShip { peer, state: Mutex::new(PeerState { dispatch_next: start_next, acked: 0, completed: BTreeMap::new(), retry: BTreeMap::new(), paused: false, consecutive_failures: 0, }), cv: Condvar::new(), acked_atomic: AtomicU64::new(0), }) }) .collect(); let commit = Arc::new(CommitIndex::new( &peer_cells.iter().map(|c| c.peer).collect::>(), active, )); let shared = Arc::new(ShipShared { source, transport, peers: peer_cells, config, shutdown: AtomicBool::new(false), active: AtomicBool::new(active), // Boot is the topology era (term 0); an elected leadership stamps // its term through activate_from. term: AtomicU64::new(0), commit, #[cfg(feature = "metrics")] metrics, }); // Wire the source's flush signal to the parked senders. A Weak breaks // the feed → notify-closure → shared → source → feed cycle, so the // queue's core frees as soon as the queue and the feed agree it is // gone. { let weak: Weak = Arc::downgrade(&shared); shared.source.set_notify(Box::new(move || { if let Some(shared) = weak.upgrade() { shared.wake_all(); } })); } let window = config.window.max(1); let mut threads = Vec::with_capacity(shared.peers.len() * window); for cell in &shared.peers { for i in 0..window { let shared = Arc::clone(&shared); let cell = Arc::clone(cell); let handle = std::thread::Builder::new() .name(format!("tidal-ship-{}-{i}", cell.peer.0)) .spawn(move || sender_loop(&shared, &cell)) .expect("spawn ship sender thread"); threads.push(handle); } } Self { shared, threads } } /// Wake parked senders: new entries are durable and shippable. /// Cheap (one notify per peer); the feed's flush signal normally does /// this automatically — explicit publish remains for relay-source users. pub fn publish(&self) { self.shared.wake_all(); } /// Whether this queue currently dispatches (leadership gate). #[must_use] pub fn is_active(&self) -> bool { self.shared.active.load(Ordering::Acquire) } /// The quorum commit index over this queue's peers (m11p3). Follows the /// queue's leadership gate; `ack=quorum` writes block on its /// [`wait_for`](CommitIndex::wait_for). #[must_use] pub fn commit_index(&self) -> Arc { Arc::clone(&self.shared.commit) } /// Activate dispatch with a fresh stream baseline: every peer's cursor /// jumps to `baseline + 1`, parked retries clear, and acked frontiers /// reset to the baseline. Called when this node becomes leader (promote /// or election win): `baseline` is its WAL flushed frontier at promotion — /// everything at or below it is pre-stream history that must NOT push to /// peers. `term` is the leadership term every outbound batch is stamped /// with and the commit index is scoped to (m11p4; 0 = the topology era). pub fn activate_from(&self, baseline: u64, term: u64) { for cell in &self.shared.peers { { let mut state = cell.lock(); state.dispatch_next = baseline + 1; state.acked = baseline; state.completed.clear(); state.retry.clear(); state.consecutive_failures = 0; } cell.acked_atomic.store(baseline, Ordering::Release); } self.shared.term.store(term, Ordering::Release); self.shared.commit.activate(baseline, term); self.shared.active.store(true, Ordering::Release); self.shared.wake_all(); tracing::info!(baseline, term, "ship queue activated (leadership)"); } /// Deactivate dispatch (this node stopped leading). In-flight sends /// complete; parked retries clear (the new leader's stream supersedes). pub fn deactivate(&self) { self.shared.active.store(false, Ordering::Release); self.shared.commit.deactivate(); for cell in &self.shared.peers { cell.lock().retry.clear(); cell.cv.notify_all(); } tracing::info!("ship queue deactivated (leadership moved)"); } /// Stop dispatching new batches to `peer` (in-flight sends complete). pub fn pause(&self, peer: ShardId) { if let Some(cell) = self.shared.peer(peer) { cell.lock().paused = true; cell.cv.notify_all(); } } /// Resume dispatch to `peer` (heal path). pub fn resume(&self, peer: ShardId) { if let Some(cell) = self.shared.peer(peer) { cell.lock().paused = false; cell.cv.notify_all(); } } /// Resume dispatch to `peer`, jumping its cursor past everything the /// follower reports applied (heal with a fresh follower status): retries /// at or below `applied` prune, and dispatch continues from /// `max(cursor, applied + 1)`. pub fn resume_from(&self, peer: ShardId, applied: u64) { if let Some(cell) = self.shared.peer(peer) { let mut state = cell.lock(); state.paused = false; state.advance_acked(applied); if state.dispatch_next <= applied { state.dispatch_next = applied + 1; } cell.acked_atomic.store(state.acked, Ordering::Release); drop(state); // The resume seqno is the follower's own durable report (fetched // from its status), so it counts toward quorum. self.shared.commit.update_peer(peer, applied); cell.cv.notify_all(); } } /// Whether dispatch to `peer` is currently paused. #[must_use] pub fn is_paused(&self, peer: ShardId) -> bool { self.shared.peer(peer).is_some_and(|c| c.lock().paused) } /// The peer's contiguous acked frontier: every seqno `<=` this was /// accepted by the peer's transport (or reported applied). Lock-free. #[must_use] pub fn acked_seqno(&self, peer: ShardId) -> u64 { self.shared .peer(peer) .map_or(0, |c| c.acked_atomic.load(Ordering::Acquire)) } /// Signal every sender to exit and join them. Idempotent. pub fn shutdown(&mut self) { self.shared.shutdown.store(true, Ordering::Release); // Quorum waiters must not sleep out their deadline against a queue // that will never ack again. self.shared.commit.deactivate(); self.shared.wake_all(); for handle in self.threads.drain(..) { if handle.join().is_err() { tracing::error!("ship sender thread panicked during shutdown"); } } } } impl Drop for ShipQueue { fn drop(&mut self) { self.shutdown(); } } // ── Sender machinery ──────────────────────────────────────────────────────── /// One collected unit of sender work: a contiguous seqno run plus its /// already-encoded bytes. struct ClaimedRun { first: u64, last: u64, event_count: u64, bytes: Vec, } /// Claim-and-collect the next unit of work for `cell`, or block on its /// condvar. /// /// Priority: due retries first (lowest first-seqno, so gaps close fastest), /// then fresh dispatch up to the source's flushed frontier. Collection runs /// under the peer lock so two windowed senders can never claim overlapping /// runs; the source's collect is an in-memory tail copy (bounded by the run /// caps). Returns `None` on shutdown. fn claim_and_collect(shared: &ShipShared, cell: &PeerShip) -> Option { let max_events = shared.config.max_batch_events.clamp(1, 65_536) as u64; let max_bytes = shared.config.max_batch_bytes.max(1); let mut state = cell.lock(); loop { if shared.shutdown.load(Ordering::Acquire) { return None; } if shared.active.load(Ordering::Acquire) && !state.paused { // Due retry first (lowest first seqno = closes receiver gaps fastest). let now = Instant::now(); if let Some((&first, &(last, due))) = state.retry.first_key_value() && due <= now { state.retry.remove(&first); // Cap the re-collect at the original run's seqno span so a // retry never grows past what was claimed (seqnos are dense, // so the span bounds the event count). let span = last.saturating_sub(first) + 1; match shared.source.collect(first, span, max_bytes) { Ok(ShipCollect::Run { bytes, first_seq, last_seq, event_count, }) => { return Some(ClaimedRun { first: first_seq, last: last_seq, event_count, bytes, }); } Ok(ShipCollect::Rotated { resume_from }) => { tracing::info!( peer = cell.peer.0, first, last, resume_from, "ship sender: retry run rotated out of the tail; dropping \ (the follower's catch-up pull covers the hole)" ); if state.dispatch_next < resume_from { state.dispatch_next = resume_from; } continue; } Ok(ShipCollect::NothingNew) => continue, Err(e) => { pause_on_source_fault(cell, &mut state, first, last, &e); continue; } } } // Fresh dispatch, bounded by the source's flushed frontier // (invariant: never ship an event the leader has not fsynced). let flushed = shared.source.flushed_seq(); if state.dispatch_next <= flushed { let from = state.dispatch_next; match shared.source.collect(from, max_events, max_bytes) { Ok(ShipCollect::Run { bytes, first_seq, last_seq, event_count, }) => { state.dispatch_next = last_seq + 1; return Some(ClaimedRun { first: first_seq, last: last_seq, event_count, bytes, }); } Ok(ShipCollect::Rotated { resume_from }) => { tracing::info!( peer = cell.peer.0, from, resume_from, "ship sender: peer cursor fell behind the retained tail; \ skipping ahead (the follower's catch-up pull covers the hole)" ); state.dispatch_next = resume_from; continue; } Ok(ShipCollect::NothingNew) => { // Raced a frontier read; fall through to park. } Err(e) => { pause_on_source_fault(cell, &mut state, from, flushed, &e); continue; } } } } // Park until publish/pause/resume/shutdown, or the earliest retry due // time (bounded so a due retry is never missed while parked). let wait = state .retry .values() .map(|&(_, due)| due.saturating_duration_since(Instant::now())) .min() .unwrap_or(Duration::from_secs(1)); let (guard, _timeout) = cell .cv .wait_timeout(state, wait.min(Duration::from_secs(1))) .unwrap_or_else(PoisonError::into_inner); state = guard; } } /// A deterministic source fault (encode failure / log corruption) repeats /// identically on every retry: pause the peer loudly instead of silently /// spinning, exactly like a permanent transport failure. fn pause_on_source_fault( cell: &PeerShip, state: &mut PeerState, first: u64, last: u64, err: &crate::TidalError, ) { state.paused = true; tracing::error!( peer = cell.peer.0, first, last, error = %err, "ship sender: SOURCE fault collecting a run; pausing peer \ (fix the fault then /cluster/heal to resume)" ); } /// Record a successful run and advance the contiguous acked frontier, folding /// in the follower's reported applied seqno when the transport carries one. /// Logs recovery (at INFO) when this success ends a failure streak. fn record_success(shared: &ShipShared, cell: &PeerShip, run: &ClaimedRun) { let reported = shared.transport.peer_applied_hint(cell.peer); // Quorum fold (m11p3): the hint is the follower's durably-applied mark // (the durable ship ack), the ONLY input that may advance the commit // index. Transport acceptance below advances `acked` for retry pruning // but never counts toward quorum. shared.commit.update_peer(cell.peer, reported); let recovered_after = { let mut state = cell.lock(); state.completed.insert(run.first, run.last); state.advance_acked(reported); cell.acked_atomic.store(state.acked, Ordering::Release); std::mem::take(&mut state.consecutive_failures) }; if recovered_after > 0 { tracing::info!( peer = cell.peer.0, failed_attempts = recovered_after, "ship sender: peer recovered; shipping resumed" ); } } /// Park a failed run for retry; auto-pause the peer on a permanent failure. /// Transition-based logging (see [`FAILURE_LOG_EVERY`]): the 1st and every /// Nth consecutive failure WARN, the rest are `debug`. fn record_failure(shared: &ShipShared, cell: &PeerShip, run: &ClaimedRun, err: &TransportError) { let permanent = matches!( err, TransportError::Permanent { .. } | TransportError::PayloadTooLarge { .. } ); let (newly_paused, failure_count) = { let mut state = cell.lock(); state.retry.insert( run.first, (run.last, Instant::now() + shared.config.retry_backoff), ); state.consecutive_failures += 1; let newly_paused = if permanent && !state.paused { state.paused = true; true } else { false }; (newly_paused, state.consecutive_failures) }; if newly_paused { tracing::error!( peer = cell.peer.0, first = run.first, last = run.last, error = %err, "ship sender: PERMANENT transport failure; pausing peer \ (fix config/peer then /cluster/heal to resume)" ); } else if failure_count == 1 || failure_count % FAILURE_LOG_EVERY == 0 { tracing::warn!( peer = cell.peer.0, first = run.first, last = run.last, consecutive_failures = failure_count, error = %err, "ship sender: batch ship failing; retrying every {:?}", shared.config.retry_backoff ); } else { tracing::debug!( peer = cell.peer.0, first = run.first, last = run.last, error = %err, "ship sender: batch ship failed; will retry" ); } } /// The sender thread body: claim-and-collect → ship → record. fn sender_loop(shared: &ShipShared, cell: &PeerShip) { while let Some(run) = claim_and_collect(shared, cell) { #[cfg(feature = "metrics")] let started = Instant::now(); let payload = crate::replication::relay::range_payload_with_term( shared.source.source_shard(), run.first, run.last, run.event_count, run.bytes.clone(), shared.term.load(Ordering::Acquire), shared.config.leader_region, ); match shared.transport.send_segment(cell.peer, payload) { Ok(()) => { record_success(shared, cell, &run); #[cfg(feature = "metrics")] if let Some(m) = &shared.metrics { m.observe_ship( cell.peer, started.elapsed(), run.event_count, cell.acked_atomic.load(Ordering::Acquire), shared.source.flushed_seq(), ); } } Err(e) => { // Logging lives in record_failure (transition-based, so a // partition window cannot flood the log with per-retry WARNs). #[cfg(feature = "metrics")] if let Some(m) = &shared.metrics { m.observe_ship_failure(cell.peer); } record_failure(shared, cell, &run, &e); } } } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use std::sync::Mutex as StdMutex; use super::*; use crate::{ replication::transport::WalSegmentPayload, schema::{DecaySpec, EntityId, EntityKind, Schema, SchemaBuilder, Timestamp, Window}, wal::feed::FlushedBatch, }; fn view_schema() -> Schema { let mut b = SchemaBuilder::new(); let _ = b .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: std::time::Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); b.build().expect("schema builds") } fn leader_db() -> crate::TidalDb { crate::TidalDb::builder() .ephemeral() .with_schema(view_schema()) .open() .expect("ephemeral leader db opens") } /// Gap-aware model receiver mirroring `ReplicationState::apply_range`: /// contiguous ranges advance, ahead ranges park, stale ranges no-op. #[derive(Default)] struct ModelReceiver { applied: u64, parked: BTreeMap, } impl ModelReceiver { fn apply_range(&mut self, first: u64, last: u64) { if last <= self.applied { return; // idempotent re-ship } if first <= self.applied + 1 { self.applied = self.applied.max(last); } else { self.parked.insert(first, last); } // Drain any parked runs now contiguous. while let Some((&f, &l)) = self.parked.first_key_value() { if f <= self.applied + 1 { self.applied = self.applied.max(l); self.parked.remove(&f); } else { break; } } } } /// Which error class the transport's injected failures produce. enum FailKind { Transient, Permanent, } /// A transport that drives a model receiver, optionally failing a /// configurable set of first-attempt seqno ranges, and recording every /// shipped batch (first, last, `event_count`). Reports the model /// receiver's applied seqno as its peer hint, exercising the /// follower-reported-acked fold. struct BatchTransport { local: ShardId, receiver: StdMutex, shipped: StdMutex>, /// Fail the Nth send (1-indexed) while the counter is in this set. fail_sends: StdMutex>, fail_kind: StdMutex, send_counter: AtomicU64, /// Catch-up pull requests observed (shard, `from_seqno`). pulls: StdMutex>, } impl BatchTransport { fn new() -> Self { Self { local: ShardId(1), receiver: StdMutex::new(ModelReceiver::default()), shipped: StdMutex::new(Vec::new()), fail_sends: StdMutex::new(std::collections::HashSet::new()), fail_kind: StdMutex::new(FailKind::Transient), send_counter: AtomicU64::new(0), pulls: StdMutex::new(Vec::new()), } } fn applied(&self) -> u64 { self.receiver.lock().unwrap().applied } } impl Transport for BatchTransport { fn send_segment( &self, _to: ShardId, payload: WalSegmentPayload, ) -> Result<(), TransportError> { let n = self.send_counter.fetch_add(1, Ordering::SeqCst) + 1; if self.fail_sends.lock().unwrap().remove(&n) { return match *self.fail_kind.lock().unwrap() { FailKind::Transient => Err(TransportError::Closed), FailKind::Permanent => Err(TransportError::Permanent { reason: "injected permanent failure".into(), }), }; } self.receiver .lock() .unwrap() .apply_range(payload.id.seqno, payload.leader_last_seq); self.shipped.lock().unwrap().push(( payload.id.seqno, payload.leader_last_seq, payload.event_count, )); Ok(()) } fn recv_segment(&self) -> Option { None } fn peer_applied_hint(&self, _peer: ShardId) -> u64 { self.applied() } fn request_catchup(&self, from_shard: ShardId, from_seqno: u64) { self.pulls.lock().unwrap().push((from_shard, from_seqno)); } fn local_shard(&self) -> ShardId { self.local } } fn write_n(relay: &SignalRelay, db: &crate::TidalDb, n: u64) { for i in 1..=n { let staged = relay .stage_write(db, 0, "view", EntityId::new(i), 1.0, Timestamp::now()) .expect("stage"); relay.complete_write(db, staged).expect("complete"); } } fn wait_for(deadline_ms: u64, mut check: impl FnMut() -> bool) -> bool { let deadline = Instant::now() + Duration::from_millis(deadline_ms); while Instant::now() < deadline { if check() { return true; } std::thread::sleep(Duration::from_millis(2)); } check() } fn spawn_queue( source: Arc, transport: &Arc, config: ShipQueueConfig, ) -> ShipQueue { ShipQueue::spawn( source, Arc::clone(transport) as Arc, &[ShardId(1)], config, true, #[cfg(feature = "metrics")] None, ) } /// Writes published to the queue are batched (fewer sends than events) and /// the model receiver converges to the relay's last seq. #[test] fn ships_batched_runs_and_converges() { let relay = Arc::new(SignalRelay::new(ShardId(0))); let db = leader_db(); let transport = Arc::new(BatchTransport::new()); let queue = spawn_queue( Arc::clone(&relay) as Arc, &transport, ShipQueueConfig { max_batch_events: 64, max_batch_bytes: 1 << 20, window: 1, retry_backoff: Duration::from_millis(10), leader_region: 0, }, ); write_n(&relay, &db, 100); queue.publish(); assert!( wait_for(5_000, || transport.applied() == 100), "receiver must converge to 100, got {}", transport.applied() ); assert!(wait_for(5_000, || queue.acked_seqno(ShardId(1)) == 100)); let shipped = transport.shipped.lock().unwrap().clone(); assert!( shipped.len() < 100, "100 events must coalesce into fewer batches, got {} sends", shipped.len() ); let total_events: u64 = shipped.iter().map(|s| s.2).sum(); assert_eq!(total_events, 100, "every event shipped exactly once"); } /// A transient failure parks the run; the retry closes the receiver-side /// gap even while later runs (window > 1) already landed ahead of it. #[test] fn transient_failure_retries_and_closes_gap() { let relay = Arc::new(SignalRelay::new(ShardId(0))); let db = leader_db(); let transport = Arc::new(BatchTransport::new()); // Fail the FIRST send: its run parks for retry while subsequent runs // land ahead (parked gap on the receiver). transport.fail_sends.lock().unwrap().insert(1); let queue = spawn_queue( Arc::clone(&relay) as Arc, &transport, ShipQueueConfig { max_batch_events: 8, max_batch_bytes: 1 << 20, window: 4, retry_backoff: Duration::from_millis(20), leader_region: 0, }, ); write_n(&relay, &db, 40); queue.publish(); assert!( wait_for(5_000, || transport.applied() == 40), "retry must close the gap; receiver at {}", transport.applied() ); assert!(wait_for(5_000, || queue.acked_seqno(ShardId(1)) == 40)); } /// Pause stops dispatch; resume catches the peer up from its acked /// frontier (the heal path with no operator redelivery needed). #[test] fn pause_holds_dispatch_and_resume_catches_up() { let relay = Arc::new(SignalRelay::new(ShardId(0))); let db = leader_db(); let transport = Arc::new(BatchTransport::new()); let queue = spawn_queue( Arc::clone(&relay) as Arc, &transport, ShipQueueConfig::default(), ); write_n(&relay, &db, 5); queue.publish(); assert!(wait_for(5_000, || transport.applied() == 5)); queue.pause(ShardId(1)); write_n(&relay, &db, 5); // seqnos 6..=10 while paused queue.publish(); // Give senders a moment: nothing new may ship while paused. std::thread::sleep(Duration::from_millis(50)); assert_eq!( transport.applied(), 5, "paused peer must not receive new batches" ); queue.resume(ShardId(1)); assert!( wait_for(5_000, || transport.applied() == 10), "resume must catch the peer up, got {}", transport.applied() ); } /// Senders never ship past the source's flushed frontier: staged-but-not- /// completed writes are invisible to peers. #[test] fn never_ships_past_durable_frontier() { let relay = Arc::new(SignalRelay::new(ShardId(0))); let db = leader_db(); let transport = Arc::new(BatchTransport::new()); let queue = spawn_queue( Arc::clone(&relay) as Arc, &transport, ShipQueueConfig::default(), ); let w1 = relay .stage_write(&db, 0, "view", EntityId::new(1), 1.0, Timestamp::now()) .expect("stage 1"); let w2 = relay .stage_write(&db, 0, "view", EntityId::new(2), 1.0, Timestamp::now()) .expect("stage 2"); queue.publish(); std::thread::sleep(Duration::from_millis(50)); assert_eq!( transport.applied(), 0, "staged-but-not-durable events must never ship" ); relay.complete_write(&db, w1).expect("complete 1"); queue.publish(); assert!(wait_for(5_000, || transport.applied() == 1)); relay.complete_write(&db, w2).expect("complete 2"); queue.publish(); assert!(wait_for(5_000, || transport.applied() == 2)); } /// A PERMANENT transport failure auto-pauses the peer (no silent infinite /// retry); `resume` (the heal verb) retries the parked run and recovers. #[test] fn permanent_failure_pauses_peer_and_resume_recovers() { let relay = Arc::new(SignalRelay::new(ShardId(0))); let db = leader_db(); let transport = Arc::new(BatchTransport::new()); // First send fails PERMANENTLY (e.g. TLS/codec class). transport.fail_sends.lock().unwrap().insert(1); *transport.fail_kind.lock().unwrap() = FailKind::Permanent; let queue = spawn_queue( Arc::clone(&relay) as Arc, &transport, ShipQueueConfig { max_batch_events: 8, max_batch_bytes: 1 << 20, window: 1, retry_backoff: Duration::from_millis(10), leader_region: 0, }, ); write_n(&relay, &db, 4); queue.publish(); assert!( wait_for(5_000, || queue.is_paused(ShardId(1))), "a permanent transport failure must auto-pause the peer" ); // Paused: nothing ships even though the run is parked for retry. std::thread::sleep(Duration::from_millis(50)); assert_eq!(transport.applied(), 0, "paused peer must not be retried"); // Heal: resume retries the parked run; the transport now succeeds. queue.resume(ShardId(1)); assert!( wait_for(5_000, || transport.applied() == 4), "resume must retry the parked run and converge, got {}", transport.applied() ); } /// An INACTIVE queue (follower) never dispatches; activation with a /// baseline ships only post-baseline writes. #[test] fn inactive_queue_holds_until_activated_with_baseline() { let relay = Arc::new(SignalRelay::new(ShardId(0))); let db = leader_db(); let transport = Arc::new(BatchTransport::new()); let queue = ShipQueue::spawn( Arc::clone(&relay) as Arc, Arc::clone(&transport) as Arc, &[ShardId(1)], ShipQueueConfig::default(), false, // follower at boot #[cfg(feature = "metrics")] None, ); write_n(&relay, &db, 5); queue.publish(); std::thread::sleep(Duration::from_millis(50)); assert_eq!( transport.applied(), 0, "an inactive (follower) queue must not dispatch" ); assert!(!queue.is_active()); // Promote with baseline 5: pre-promote history must NOT push. queue.activate_from(5, 1); write_n(&relay, &db, 3); // seqnos 6..=8 queue.publish(); assert!( wait_for(5_000, || { let shipped = transport.shipped.lock().unwrap().clone(); shipped.iter().map(|s| s.2).sum::() == 3 }), "only the 3 post-baseline writes may ship" ); let shipped = transport.shipped.lock().unwrap().clone(); assert!( shipped.iter().all(|&(first, _, _)| first >= 6), "no shipped run may start at or below the baseline: {shipped:?}" ); } /// The WAL-feed source ships pre-encoded batches verbatim, and a peer /// whose cursor falls behind the rotated tail SKIPS ahead (the receiver /// gap is the follower's pull trigger), never stalling dispatch. #[test] fn feed_source_ships_and_skips_rotated_tail() { use crate::wal::format::batch::{EventRecord, encode_batch}; // A tiny feed: 12-event cap so the third 6-event batch evicts the first. let feed = Arc::new(WalShipFeed::new(12, 1 << 20)); let make = |first: u64, n: u64| { let events: Vec = (0..n) .map(|i| EventRecord::signal(first + i, 0, 1.0, (first + i) * 1_000)) .collect(); let bytes = encode_batch(&events, first, 1).expect("encode"); FlushedBatch { bytes: Arc::new(bytes), first_seq: first, last_seq: first + n - 1, event_count: n, } }; let source = Arc::new(WalFeedSource::new(ShardId(0), Arc::clone(&feed))); let transport = Arc::new(BatchTransport::new()); // Pause the peer FIRST so the eviction below happens before any ship. let queue = ShipQueue::spawn( Arc::clone(&source) as Arc, Arc::clone(&transport) as Arc, &[ShardId(1)], ShipQueueConfig { max_batch_events: 64, max_batch_bytes: 1 << 20, window: 1, retry_backoff: Duration::from_millis(10), leader_region: 0, }, true, #[cfg(feature = "metrics")] None, ); queue.pause(ShardId(1)); feed.push(make(1, 6)); feed.push(make(7, 6)); // evicts [1..6] under the 12-event cap feed.push(make(13, 6)); // evicts [7..12] assert!(feed.tail_floor() > 1, "tail must have rotated"); queue.resume(ShardId(1)); // The peer's cursor (1) predates the tail: it must skip ahead and // ship the retained batches; the receiver parks them (gap below). assert!( wait_for(5_000, || !transport.shipped.lock().unwrap().is_empty()), "rotated tail must not stall dispatch" ); let shipped = transport.shipped.lock().unwrap().clone(); assert!( shipped.iter().all(|&(first, _, _)| first > 1), "the rotated-out prefix must never ship from the tail: {shipped:?}" ); // The receiver parked the ahead range — its applied stays 0, which is // exactly the state the follower-pull trigger fires on. assert_eq!(transport.applied(), 0, "gap must park on the receiver"); } /// Shutdown joins every sender thread (no leaks, no hangs). #[test] fn shutdown_joins_senders() { let relay = Arc::new(SignalRelay::new(ShardId(0))); let transport = Arc::new(BatchTransport::new()); let mut queue = spawn_queue( Arc::clone(&relay) as Arc, &transport, ShipQueueConfig::default(), ); queue.shutdown(); // Idempotent. queue.shutdown(); } }