//! WAL segment shipper: polls for sealed segments and ships them to peers. //! //! The shipper runs in a background thread, polling the WAL directory for //! sealed segments (those with a newer segment after them) and sending each //! to all configured peer shards via the [`Transport`] trait. use std::{collections::HashMap, path::PathBuf, sync::Arc, thread::JoinHandle, time::Duration}; use crossbeam::channel::bounded; use dashmap::DashMap; use crate::{ replication::{ RegionId, WalSegmentId, shard::ShardId, transport::{Transport, TransportError, WalSegmentPayload}, }, wal::format::batch::{HEADER_SIZE, encode_batch_with_shard}, }; /// Configuration for the WAL shipper background thread. pub struct ShipperConfig { /// Directory containing WAL segment files. pub wal_dir: PathBuf, /// This node's shard identity. pub shard_id: ShardId, /// Peer shards to ship segments to. pub peer_shards: Vec, /// How often to poll for new sealed segments. pub poll_interval: Duration, /// When `true`, this shipper feeds a **community overlay** rather than a /// full replica: every [`SignalScope::Local`] event is stripped from each /// segment before it is shipped, enforcing the load-bearing M9 invariant /// that local-scoped signals never leave the node. Per-intent share /// filtering is applied earlier, at write time, via /// [`SharePolicy`](crate::governance::SharePolicy); the shipper only /// enforces the hard local/non-local boundary. /// /// `false` (the default) preserves M8 full-replication behavior: every /// event ships verbatim to the peer replica. /// /// [`SignalScope::Local`]: crate::governance::SignalScope::Local pub community_share_only: bool, } impl Default for ShipperConfig { fn default() -> Self { Self { wal_dir: PathBuf::from("wal"), shard_id: ShardId::SINGLE, peer_shards: vec![], poll_interval: Duration::from_secs(2), community_share_only: false, } } } /// Shared, operator-visible shipper state: per-peer high-water-mark and the set /// of quarantined peers. /// /// Previously these lived as local variables on the shipper thread's stack /// (`peer_hwm`, `quarantined`), so an operator could neither observe which peers /// were quarantined nor clear a quarantine without dropping and re-spawning the /// whole shipper. Hoisting them into an `Arc` shared with /// [`WalShipperHandle`] gives the control plane read accessors /// ([`quarantined_peers`](Self::quarantined_peers), [`peer_hwm`](Self::peer_hwm)) /// and a [`clear_quarantine`](Self::clear_quarantine) method an operator /// endpoint can call after fixing a peer's config — no shipper restart required. /// /// Both maps are [`DashMap`]s so the shipper thread and an operator thread can /// touch them concurrently without a coarse lock. The shipper advances a peer's /// HWM only on its own successful send and re-reads the quarantine set at the /// top of each peer's run, so a concurrent `clear_quarantine` takes effect on /// the next poll. #[derive(Debug, Default)] pub struct ShipperState { /// Per-peer high-water-mark: the highest seqno each peer has acknowledged. peer_hwm: DashMap, /// Peers quarantined after a PERMANENT send failure. Value is the seqno the /// peer was stuck on when quarantined (for operator diagnostics). quarantined: DashMap, } impl ShipperState { /// Build a fresh state with every configured peer at HWM 0 and no /// quarantines. #[must_use] fn new(peers: &[ShardId]) -> Self { let peer_hwm = DashMap::new(); for &p in peers { peer_hwm.insert(p, 0); } Self { peer_hwm, quarantined: DashMap::new(), } } /// The high-water-mark (last acknowledged seqno) for `peer`, or 0 if unknown. #[must_use] pub fn peer_hwm(&self, peer: ShardId) -> u64 { self.peer_hwm.get(&peer).map_or(0, |r| *r) } /// Snapshot of every tracked peer's high-water-mark. #[must_use] pub fn peer_hwms(&self) -> HashMap { self.peer_hwm .iter() .map(|r| (*r.key(), *r.value())) .collect() } /// `true` if `peer` is currently quarantined (permanently failed). #[must_use] pub fn is_quarantined(&self, peer: ShardId) -> bool { self.quarantined.contains_key(&peer) } /// The set of currently-quarantined peers, each with the seqno it was stuck /// on when quarantined. #[must_use] pub fn quarantined_peers(&self) -> HashMap { self.quarantined .iter() .map(|r| (*r.key(), *r.value())) .collect() } /// Clear `peer`'s quarantine so the shipper retries it on the next poll. /// /// Intended for the operator endpoint after a peer's config/TLS/CA has been /// fixed. Returns `true` if the peer was quarantined (and is now cleared), /// `false` if it was not quarantined. The peer resumes from its existing /// high-water-mark, so no segment is re-shipped that the peer already acked. pub fn clear_quarantine(&self, peer: ShardId) -> bool { let was = self.quarantined.remove(&peer).is_some(); if was { tracing::info!( peer = %peer, "shipper: quarantine cleared by operator; peer will be retried next poll" ); } was } // ── Internal mutators used by the shipper thread ──────────────────────── fn advance_hwm(&self, peer: ShardId, seqno: u64) { self.peer_hwm.insert(peer, seqno); } fn quarantine(&self, peer: ShardId, stuck_seqno: u64) { self.quarantined.insert(peer, stuck_seqno); } } /// Handle to a running WAL shipper thread. /// /// Call [`stop`](Self::stop) to signal shutdown and join the thread. pub struct WalShipperHandle { shutdown_tx: crossbeam::channel::Sender<()>, thread: Option>, /// Shared, operator-visible shipper state (per-peer HWM + quarantine set). state: Arc, } impl WalShipperHandle { /// Signal the shipper to stop and join the background thread. pub fn stop(mut self) { let _ = self.shutdown_tx.send(()); if let Some(handle) = self.thread.take() { let _ = handle.join(); } } /// The shared shipper state for operator/control-plane inspection and /// quarantine clearing. The returned `Arc` stays valid after [`stop`](Self::stop). #[must_use] pub fn state(&self) -> Arc { Arc::clone(&self.state) } /// Clear a peer's quarantine without restarting the shipper. /// /// Returns `true` if the peer was quarantined and is now cleared. See /// [`ShipperState::clear_quarantine`]. #[must_use] pub fn clear_quarantine(&self, peer: ShardId) -> bool { self.state.clear_quarantine(peer) } } /// Spawn a background thread that polls for sealed WAL segments and ships /// them to all peer shards. /// /// The shipper identifies sealed segments as those with a newer segment file /// after them in sequence order. The most recent segment is skipped because /// it may still be actively written. /// /// # Panics /// /// Panics if the OS fails to spawn the background thread. #[allow(clippy::too_many_lines)] // linear shipper setup + per-peer send/quarantine loop pub fn spawn_shipper( config: ShipperConfig, transport: Arc, ) -> WalShipperHandle { let (shutdown_tx, shutdown_rx) = bounded::<()>(1); // Shared, operator-visible per-peer HWM + quarantine set. Owned by both the // handle (for inspection / clear_quarantine) and the shipper thread. let state = Arc::new(ShipperState::new(&config.peer_shards)); let thread_state = Arc::clone(&state); let thread = std::thread::Builder::new() .name("tidaldb-wal-shipper".into()) .spawn(move || { // Per-peer high-water-mark and the quarantine set now live in the // shared `thread_state` (an `Arc`) instead of on this // thread's stack, so an operator can observe quarantined peers / HWMs // and clear a quarantine via the handle without restarting the // shipper. The shipper still advances a peer's cursor ONLY when ITS // own send succeeds, and re-reads the quarantine set at the top of // each peer's run so a concurrent clear takes effect next poll. // // A peer hits PERMANENT failure (bad TLS/CA, auth rejection, // malformed payload, unimplemented RPC) and is quarantined: skipped // on every subsequent poll, cursor frozen, until an operator clears // it. This trades a silent infinite retry stall for a loud, // actionable, bounded failure. Healthy peers are unaffected. let state = thread_state; loop { // Sleep for poll_interval, exit early on shutdown signal. match shutdown_rx.recv_timeout(config.poll_interval) { Ok(()) | Err(crossbeam::channel::RecvTimeoutError::Disconnected) => { tracing::debug!("wal shipper shutting down"); return; } Err(crossbeam::channel::RecvTimeoutError::Timeout) => {} } let segments = match crate::wal::segment::list_segments_for_shard( &config.wal_dir, config.shard_id, ) { Ok(s) => s, Err(e) => { tracing::warn!(error = %e, "shipper: failed to list WAL segments"); continue; } }; // Only ship segments that have a newer segment after them // (the last segment may still be actively written). let sealed_count = if segments.len() > 1 { segments.len() - 1 } else { 0 }; let sealed = &segments[..sealed_count]; // Cache segment bytes read this poll so peers at the same lag // don't each re-read the same file. Keyed by seqno. Each entry // carries the (possibly filtered) shippable bytes AND the // segment's authoritative last WAL seqno computed from the // ORIGINAL (pre-filter) bytes — the latter is what the receiver's // lag gauge needs so an all-local empty filtration still advances // the leader high-water-mark. let mut bytes_cache: HashMap, u64)> = HashMap::new(); // Ship each peer its own contiguous run of segments, in seqno // order, starting just past its high-water-mark. A peer is // advanced ONLY when its own send succeeds; on the first // failure we stop that peer for this poll so it receives a // gap-free, in-order stream (the receiver applies strictly by // seqno — a skipped segment would stall it). The peer resumes // from the same un-acked seqno on the next poll. for &peer in &config.peer_shards { // A quarantined peer hit a permanent failure earlier; never // re-attempt it (re-shipping would fail identically). It // stays skipped until an operator fixes the peer/config and // restarts the shipper. if state.is_quarantined(peer) { continue; } for (seqno, path) in sealed { if *seqno <= state.peer_hwm(peer) { continue; } // Read (and cache) the segment bytes lazily. In // community-overlay mode, strip local-scope events from // the segment before caching so no peer ever receives a // local signal. We compute the authoritative last WAL // seqno from the ORIGINAL bytes BEFORE filtering, so an // all-local segment (which filters to empty) still carries // the leader's true high-water-mark for the receiver's lag // gauge. let entry = match bytes_cache.get(seqno) { Some(b) => b, None => match std::fs::read(path) { Ok(original) => { let leader_last_seq = last_seq_in_segment(&original); let shippable = if config.community_share_only { filter_segment_drop_local(&original) } else { original }; bytes_cache .entry(*seqno) .or_insert((shippable, leader_last_seq)) } Err(e) => { tracing::warn!( error = %e, path = %path.display(), "shipper: failed to read segment" ); // Cannot ship this (or any later) segment to // this peer without the bytes; stop the // peer's run so it stays gap-free. break; } }, }; let (segment_bytes, leader_last_seq) = entry; let event_count = count_events_in_segment(segment_bytes); let payload = WalSegmentPayload { id: WalSegmentId::new(RegionId::SINGLE, config.shard_id, *seqno), bytes: segment_bytes.clone(), event_count, leader_last_seq: *leader_last_seq, stream_baseline: 0, term: 0, leader_region: 0, }; match transport.send_segment(peer, payload) { Ok(()) => { tracing::debug!( seqno = *seqno, peer = %peer, "shipped WAL segment" ); // Advance ONLY this peer's cursor on its own // successful send. state.advance_hwm(peer, *seqno); } Err(TransportError::Closed) => { // Transient (peer down, circuit open, or // follower backpressure): leave this peer's HWM // untouched and stop its run so the un-acked // segment is re-shipped, in order, next poll. tracing::warn!( peer = %peer, seqno = *seqno, "shipper: transport closed, will retry peer next poll" ); break; } Err(TransportError::Permanent { reason }) => { // PERMANENT: re-shipping this seqno will fail // identically forever (bad TLS/CA, auth // rejection, malformed payload, unimplemented // RPC). Quarantine the peer instead of stalling // on an unbounded silent retry: stop advancing // it, skip it on every future poll, and surface // a LOUD, actionable error. The peer's HWM is // left untouched so no segment is silently // skipped. The quarantine is now operator-clearable // via WalShipperHandle::clear_quarantine — no // shipper restart required once the peer/config is // fixed. state.quarantine(peer, *seqno); tracing::error!( peer = %peer, seqno = *seqno, reason = %reason, "shipper: PERMANENT transport failure — peer quarantined, \ replication to this peer is STOPPED until an operator clears \ the quarantine (clear_quarantine) after fixing peer/config; \ retrying without a fix would not help" ); break; } Err(e) => { // Other failures are also non-advancing: stop the // peer's run so we retry this seqno later without // creating a gap. tracing::warn!( error = %e, peer = %peer, seqno = *seqno, "shipper: send failed, will retry peer next poll" ); break; } } } } } }) .expect("failed to spawn wal shipper thread"); WalShipperHandle { shutdown_tx, thread: Some(thread), state, } } /// Strip every [`SignalScope::Local`] event from a WAL segment, re-encoding /// each batch with only its shippable (non-local) events. /// /// Used by community-overlay shippers ([`ShipperConfig::community_share_only`]) /// to enforce the invariant that local-scoped signals never leave the node. /// Each surviving batch preserves its `batch_timestamp` and shard/region /// identity; batches whose events are entirely local are dropped. A segment /// with no shippable events returns empty bytes, which the receiver applies as /// a no-op. /// /// # Preserving the WAL position (CRITICAL: idempotency + lag boundary) /// /// The receiver derives a batch's last WAL sequence number as /// `first_seq + event_count - 1` and uses it both as the per-segment /// idempotency boundary and as the value that advances /// [`ReplicationState`](crate::replication::ReplicationState) / /// [`ReplicationLagGauge`](crate::replication::ReplicationLagGauge). If we kept /// the original `first_seq` while shrinking `event_count`, the derived last seq /// would *under-report* the true WAL position by exactly the number of dropped /// events — silently lowering the high-water-mark, breaking the /// `lag = leader_seqno - applied_seqno` invariant, and letting a later replay /// re-apply the segment. /// /// To keep the boundary authoritative we **re-stamp `first_seq`** so that the /// re-encoded batch's last seq is identical to the original batch's last seq: /// /// ```text /// orig_last = orig_first_seq + orig_event_count - 1 /// new_first_seq = orig_last - (kept_count - 1) /// = orig_first_seq + orig_event_count - kept_count /// new_last = new_first_seq + kept_count - 1 == orig_last ✓ /// ``` /// /// The receiver never assigns per-event seqnos — it only reads the last-seq /// boundary — so collapsing the surviving (non-contiguous) events onto a fresh /// contiguous `first_seq..=orig_last` range is correct and keeps replay /// idempotent. /// /// Corrupt or trailing bytes that fail to decode terminate the scan (matching /// [`count_events_in_segment`]); already-decoded batches are preserved. /// /// [`SignalScope::Local`]: crate::governance::SignalScope::Local #[must_use] pub fn filter_segment_drop_local(bytes: &[u8]) -> Vec { use crate::governance::SignalScope; let mut out = Vec::with_capacity(bytes.len()); let mut offset = 0; while offset < bytes.len() { let remaining = &bytes[offset..]; if remaining.len() < HEADER_SIZE { break; } match crate::wal::format::decode_batch_payload(remaining) { Ok((header, payload)) => { let batch_size = HEADER_SIZE + header.payload_len as usize; offset += batch_size; // Blob (item-metadata / embedding) batches carry global config // data, not scoped signals: the M9 local-scope invariant does // not apply, so they pass through verbatim (byte-identical, // checksum intact). let events = match payload { crate::wal::format::BatchPayload::Signals(events) => events, crate::wal::format::BatchPayload::ItemMetadata(_) | crate::wal::format::BatchPayload::Embedding(_) | crate::wal::format::BatchPayload::TermMarker(_) => { out.extend_from_slice(&remaining[..batch_size]); continue; } }; // Authoritative last WAL seq of the *original* batch, before any // events are dropped. This is the value the receiver must end up // deriving so the high-water-mark never regresses. let orig_last_seq = header .first_seq .saturating_add(u64::from(header.event_count).saturating_sub(1)); let kept: Vec<_> = events .into_iter() .filter(|e| { // Unknown/corrupt discriminants are conservatively // dropped (never shipped) rather than panicking. SignalScope::from_discriminant(e.scope).is_ok_and(SignalScope::is_shippable) }) .collect(); if kept.is_empty() { continue; } // Re-stamp first_seq so first_seq + kept_count - 1 == orig_last_seq. // saturating_sub guards against a malformed header where // event_count under-counts the kept events (kept can never exceed // the decoded events, so this only clamps pathological input). let kept_count = kept.len() as u64; let new_first_seq = orig_last_seq.saturating_sub(kept_count.saturating_sub(1)); // Re-encode the kept events, carrying the WAL-position-preserving // first_seq. Encoding cannot fail here: `kept` is non-empty and // bounded by the original batch's event count // (<= MAX_EVENTS_PER_BATCH). if let Ok(encoded) = encode_batch_with_shard( &kept, new_first_seq, header.batch_timestamp, header.shard_id, header.region_id, ) { out.extend_from_slice(&encoded); } } Err(_) => break, } } out } /// Compute a WAL segment's authoritative last sequence number from its /// **original** (pre-filter) bytes: the maximum `first_seq + event_count - 1` /// across all decodable batches. /// /// This is the value the leader knows before any community-overlay filtering, /// and the value the receiver feeds to the replication lag gauge's leader /// high-water-mark. Computing it from the original bytes — not the filtered /// ones — is load-bearing: an all-local segment filters to empty and would /// otherwise report a leader seqno of 0, making the receiver's gauge /// under-report the leader's progress across that segment (obs-REPL-1). /// /// Returns `0` for an empty or fully-undecodable segment (the receiver treats /// `0` as "unknown" and falls back to the per-batch boundaries it decodes). /// Corrupt or trailing bytes terminate the scan (matching /// [`count_events_in_segment`]); the last seq of the already-decoded batches is /// returned. fn last_seq_in_segment(bytes: &[u8]) -> u64 { let mut offset = 0; let mut last_seq = 0u64; while offset < bytes.len() { let remaining = &bytes[offset..]; if remaining.len() < HEADER_SIZE { break; } // Kind-aware decode so blob (item-metadata / embedding) batches — // which consume one seqno each — keep advancing the boundary instead // of terminating the scan early. match crate::wal::format::decode_batch_payload(remaining) { Ok((header, _payload)) => { let batch_last_seq = header .first_seq .saturating_add(u64::from(header.event_count).saturating_sub(1)); last_seq = last_seq.max(batch_last_seq); let batch_size = HEADER_SIZE + header.payload_len as usize; offset += batch_size; } Err(_) => break, } } last_seq } /// Count the total number of events in a WAL segment by parsing all batches. fn count_events_in_segment(bytes: &[u8]) -> u64 { let mut offset = 0; let mut total = 0u64; while offset < bytes.len() { let remaining = &bytes[offset..]; if remaining.len() < HEADER_SIZE { break; } match crate::wal::format::decode_batch_payload(remaining) { Ok((header, _payload)) => { total += u64::from(header.event_count); let batch_size = HEADER_SIZE + header.payload_len as usize; offset += batch_size; } Err(_) => break, } } total } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use std::{collections::HashSet, sync::Mutex}; use super::*; use crate::wal::{ format::batch::{EventRecord, RECORD_TYPE_SIGNAL, decode_batch, encode_batch}, segment::segment_filename, }; use crate::governance::SignalScope; /// A community-scoped (shippable) event. fn community_event(id: u64) -> EventRecord { EventRecord::scoped( id, RECORD_TYPE_SIGNAL, 1.0, id * 100, SignalScope::Community(crate::governance::CommunityId(1)).discriminant(), 0, 1, 0, ) } #[test] fn count_events_empty() { assert_eq!(count_events_in_segment(&[]), 0); } #[test] fn drop_local_keeps_only_shippable() { // One local (dropped) + one community (kept) event in a batch. let events = vec![ EventRecord::signal(1, RECORD_TYPE_SIGNAL, 1.0, 100), community_event(2), ]; let bytes = encode_batch(&events, 10, 99).unwrap(); let filtered = filter_segment_drop_local(&bytes); assert_eq!(count_events_in_segment(&filtered), 1); // Verify the surviving event is the community one. let (header, kept) = decode_batch(&filtered).unwrap(); // CRITICAL fix: first_seq is re-stamped so the derived last seq // (first_seq + event_count - 1) still equals the ORIGINAL batch's last // seq (10 + 2 - 1 = 11) even though one event was dropped. let orig_last_seq = 10 + 2 - 1; let filtered_last_seq = header.first_seq + u64::from(header.event_count) - 1; assert_eq!( filtered_last_seq, orig_last_seq, "filtered batch must preserve the original WAL last-seq boundary" ); assert_eq!( header.first_seq, 11, "first_seq re-stamped to preserve last seq" ); assert_eq!(header.batch_timestamp, 99); assert_eq!(kept.len(), 1); assert_eq!(kept[0].entity_id, 2); assert_eq!( kept[0].scope, SignalScope::Community(crate::governance::CommunityId(1)).discriminant() ); } #[test] fn drop_local_all_local_yields_empty() { let events = vec![ EventRecord::signal(1, RECORD_TYPE_SIGNAL, 1.0, 100), EventRecord::signal(2, RECORD_TYPE_SIGNAL, 1.0, 200), ]; let bytes = encode_batch(&events, 1, 1).unwrap(); let filtered = filter_segment_drop_local(&bytes); assert!( filtered.is_empty(), "all-local segment must filter to empty" ); assert_eq!(count_events_in_segment(&filtered), 0); } #[test] fn drop_local_multi_batch_preserves_shippable() { // Batch 1: all local (dropped). Batch 2: mixed (one kept). let b1 = encode_batch( &[EventRecord::signal(1, RECORD_TYPE_SIGNAL, 1.0, 100)], 1, 1, ) .unwrap(); let b2 = encode_batch( &[ community_event(7), EventRecord::signal(8, RECORD_TYPE_SIGNAL, 1.0, 800), ], 2, 2, ) .unwrap(); let mut seg = b1; seg.extend(b2); let filtered = filter_segment_drop_local(&seg); assert_eq!(count_events_in_segment(&filtered), 1); let (header, kept) = decode_batch(&filtered).unwrap(); assert_eq!(kept[0].entity_id, 7); // b2 had first_seq=2, 2 events → orig last seq = 3. After dropping the // local event the surviving batch must still derive last seq 3. let last_seq = header.first_seq + u64::from(header.event_count) - 1; assert_eq!(last_seq, 3, "WAL last-seq boundary preserved across filter"); } #[test] fn drop_local_noop_when_all_shippable() { let events = vec![community_event(1), community_event(2)]; let bytes = encode_batch(&events, 5, 5).unwrap(); let filtered = filter_segment_drop_local(&bytes); assert_eq!(count_events_in_segment(&filtered), 2); } #[test] fn count_events_single_batch() { let events = vec![EventRecord::signal(1, RECORD_TYPE_SIGNAL, 1.0, 100)]; let bytes = encode_batch(&events, 1, 1).unwrap(); assert_eq!(count_events_in_segment(&bytes), 1); } #[test] fn count_events_multiple_batches() { let e1 = vec![EventRecord::signal(1, RECORD_TYPE_SIGNAL, 1.0, 100)]; let e2: Vec = (0..5) .map(|i| EventRecord::signal(i, RECORD_TYPE_SIGNAL, 1.0, 200)) .collect(); let mut bytes = encode_batch(&e1, 1, 1).unwrap(); bytes.extend(encode_batch(&e2, 2, 2).unwrap()); assert_eq!(count_events_in_segment(&bytes), 6); } #[test] fn count_events_truncated_segment() { let events = vec![EventRecord::signal(1, RECORD_TYPE_SIGNAL, 1.0, 100)]; let mut bytes = encode_batch(&events, 1, 1).unwrap(); // Append garbage that does not form a valid batch header. bytes.extend(&[0xFFu8; 10]); // Should still count the first valid batch. assert_eq!(count_events_in_segment(&bytes), 1); } #[test] fn shipper_config_default() { let config = ShipperConfig::default(); assert_eq!(config.shard_id, ShardId::SINGLE); assert!(config.peer_shards.is_empty()); assert_eq!(config.poll_interval, Duration::from_secs(2)); } // ── Per-peer high-water-mark behavior (REPL-2) ─────────────────────── /// A fault-injecting transport that records every (peer, seqno) it is /// asked to ship and fails sends to a configurable set of peers. /// /// Failing peers receive `TransportError::Closed` (the transient code the /// gRPC client returns for follower backpressure / unreachable peers), so /// the shipper must NOT advance their cursor. struct RecordingTransport { /// Peers whose sends should fail with `Closed`. failing: Mutex>, /// Peers whose sends should fail with `Permanent` (e.g. a bad TLS chain /// that never becomes shippable). The shipper must quarantine these /// rather than retry them forever. permanent: Mutex>, /// Per-peer ordered log of seqnos the shipper attempted to send. sent: Mutex>>, } impl RecordingTransport { fn new(failing: &[ShardId]) -> Self { Self { failing: Mutex::new(failing.iter().copied().collect()), permanent: Mutex::new(HashSet::new()), sent: Mutex::new(HashMap::new()), } } /// Build a transport whose sends to `permanent` peers fail with a /// non-retryable `TransportError::Permanent`. fn with_permanent(permanent: &[ShardId]) -> Self { Self { failing: Mutex::new(HashSet::new()), permanent: Mutex::new(permanent.iter().copied().collect()), sent: Mutex::new(HashMap::new()), } } fn seqnos_for(&self, peer: ShardId) -> Vec { self.sent .lock() .unwrap() .get(&peer) .cloned() .unwrap_or_default() } fn heal(&self, peer: ShardId) { self.failing.lock().unwrap().remove(&peer); } /// Clear a peer from the PERMANENT-failure set (the operator fixed the /// peer's config/TLS). Used together with `clear_quarantine` to prove a /// quarantined peer can recover without a shipper restart. fn heal_permanent(&self, peer: ShardId) { self.permanent.lock().unwrap().remove(&peer); } } impl Transport for RecordingTransport { fn send_segment( &self, to: ShardId, payload: WalSegmentPayload, ) -> Result<(), TransportError> { self.sent .lock() .unwrap() .entry(to) .or_default() .push(payload.id.seqno); if self.permanent.lock().unwrap().contains(&to) { Err(TransportError::Permanent { reason: "test: mutually-distrusted TLS chain".into(), }) } else if self.failing.lock().unwrap().contains(&to) { Err(TransportError::Closed) } else { Ok(()) } } fn recv_segment(&self) -> Option { None } fn local_shard(&self) -> ShardId { ShardId::SINGLE } } /// Write `count` sealed segments (seqnos 1..=count) plus one extra "active" /// segment so all `count` are considered sealed by the shipper. fn seed_sealed_segments(dir: &std::path::Path, count: u64) { for seq in 1..=count + 1 { let events = vec![EventRecord::signal(seq, RECORD_TYPE_SIGNAL, 1.0, seq * 100)]; let bytes = encode_batch(&events, seq, seq).unwrap(); let path = dir.join(segment_filename(ShardId::SINGLE, seq)); std::fs::write(path, bytes).unwrap(); } } /// Spin until `cond` is true or the deadline passes, polling cheaply. fn wait_until(deadline: Duration, mut cond: impl FnMut() -> bool) { let start = std::time::Instant::now(); while start.elapsed() < deadline { if cond() { return; } std::thread::sleep(Duration::from_millis(5)); } } #[test] fn failing_peer_does_not_skip_segments() { let dir = tempfile::tempdir().unwrap(); seed_sealed_segments(dir.path(), 3); let healthy = ShardId(1); let broken = ShardId(2); let transport = Arc::new(RecordingTransport::new(&[broken])); let config = ShipperConfig { wal_dir: dir.path().to_path_buf(), shard_id: ShardId::SINGLE, peer_shards: vec![healthy, broken], poll_interval: Duration::from_millis(10), community_share_only: false, }; let handle = spawn_shipper(config, Arc::clone(&transport)); // The healthy peer should receive all three sealed segments exactly once. wait_until(Duration::from_secs(2), || { transport.seqnos_for(healthy) == vec![1, 2, 3] }); assert_eq!( transport.seqnos_for(healthy), vec![1, 2, 3], "healthy peer should receive every sealed segment once" ); // The broken peer keeps being re-sent the lowest un-acked segment (1): // its cursor never advanced, so it never silently skipped 2 or 3. wait_until(Duration::from_secs(2), || { transport.seqnos_for(broken).len() >= 3 }); let broken_sent = transport.seqnos_for(broken); assert!( broken_sent.len() >= 3, "broken peer should be retried, got: {broken_sent:?}" ); assert!( broken_sent.iter().all(|&s| s == 1), "broken peer should keep re-receiving seqno 1 (the un-acked floor), got: {broken_sent:?}" ); handle.stop(); } #[test] fn healed_peer_catches_up() { let dir = tempfile::tempdir().unwrap(); seed_sealed_segments(dir.path(), 3); let broken = ShardId(2); let transport = Arc::new(RecordingTransport::new(&[broken])); let config = ShipperConfig { wal_dir: dir.path().to_path_buf(), shard_id: ShardId::SINGLE, peer_shards: vec![broken], poll_interval: Duration::from_millis(10), community_share_only: false, }; let handle = spawn_shipper(config, Arc::clone(&transport)); // While broken, the peer only ever sees seqno 1 retried. wait_until(Duration::from_secs(2), || { transport.seqnos_for(broken).len() >= 2 }); assert!( transport.seqnos_for(broken).iter().all(|&s| s == 1), "broken peer should only retry seqno 1 before healing" ); // Heal the peer; it must now advance through 1, 2, 3 in order. transport.heal(broken); wait_until(Duration::from_secs(2), || { let sent = transport.seqnos_for(broken); sent.contains(&1) && sent.contains(&2) && sent.contains(&3) }); let sent = transport.seqnos_for(broken); assert!( sent.contains(&1) && sent.contains(&2) && sent.contains(&3), "healed peer should catch up to all sealed segments, got: {sent:?}" ); handle.stop(); } /// A peer that returns a PERMANENT failure must be quarantined: the shipper /// attempts it once, then stops re-shipping the same seqno forever. This is /// the core fix — a permanent fault (bad TLS/CA, auth rejection, malformed /// payload, unimplemented RPC) used to map to the transient `Closed` /// variant and stall replication on an unbounded silent retry of seqno 1. #[test] fn permanent_failure_quarantines_peer_no_infinite_retry() { let dir = tempfile::tempdir().unwrap(); seed_sealed_segments(dir.path(), 3); let permanent_peer = ShardId(2); let transport = Arc::new(RecordingTransport::with_permanent(&[permanent_peer])); let config = ShipperConfig { wal_dir: dir.path().to_path_buf(), shard_id: ShardId::SINGLE, peer_shards: vec![permanent_peer], // Fast poll so several poll cycles elapse within the test window; // a non-quarantined peer would accumulate many retries of seqno 1. poll_interval: Duration::from_millis(5), community_share_only: false, }; let handle = spawn_shipper(config, Arc::clone(&transport)); // Wait for the first (and only) send attempt to land. wait_until(Duration::from_secs(2), || { !transport.seqnos_for(permanent_peer).is_empty() }); // Let MANY poll intervals elapse. A transient-`Closed` peer would by // now have re-shipped seqno 1 dozens of times; a quarantined peer must // not be re-attempted at all. std::thread::sleep(Duration::from_millis(150)); let sent = transport.seqnos_for(permanent_peer); assert_eq!( sent, vec![1], "permanent-failure peer must be quarantined after exactly one attempt \ (no infinite retry of seqno 1), got: {sent:?}" ); handle.stop(); } /// Quarantining a permanently-failing peer must NOT affect a healthy peer /// in the same shipper: the healthy peer still receives every sealed /// segment exactly once. #[test] fn permanent_failure_does_not_block_healthy_peer() { let dir = tempfile::tempdir().unwrap(); seed_sealed_segments(dir.path(), 3); let healthy = ShardId(1); let permanent_peer = ShardId(2); let transport = Arc::new(RecordingTransport::with_permanent(&[permanent_peer])); let config = ShipperConfig { wal_dir: dir.path().to_path_buf(), shard_id: ShardId::SINGLE, peer_shards: vec![healthy, permanent_peer], poll_interval: Duration::from_millis(5), community_share_only: false, }; let handle = spawn_shipper(config, Arc::clone(&transport)); // Healthy peer receives all three sealed segments exactly once. wait_until(Duration::from_secs(2), || { transport.seqnos_for(healthy) == vec![1, 2, 3] }); assert_eq!( transport.seqnos_for(healthy), vec![1, 2, 3], "healthy peer must receive every sealed segment despite a quarantined sibling" ); // The permanent peer was attempted exactly once and then quarantined. std::thread::sleep(Duration::from_millis(80)); assert_eq!( transport.seqnos_for(permanent_peer), vec![1], "permanent peer stays quarantined to one attempt" ); handle.stop(); } // ── Operator-visible quarantine/HWM state (Extensibility-W) ────────────── /// `last_seq_in_segment` returns the maximum `first_seq + event_count - 1` /// across all batches, computed from the ORIGINAL (pre-filter) bytes. #[test] fn last_seq_in_segment_is_max_batch_boundary() { // Batch A: first_seq=10, 1 event → last 10. Batch B: first_seq=11, 3 // events → last 13. Segment last seq = 13. let a = encode_batch( &[EventRecord::signal(1, RECORD_TYPE_SIGNAL, 1.0, 100)], 10, 10, ) .unwrap(); let b = encode_batch( &(0..3) .map(|i| EventRecord::signal(i, RECORD_TYPE_SIGNAL, 1.0, 200)) .collect::>(), 11, 11, ) .unwrap(); let mut seg = a; seg.extend(b); assert_eq!(last_seq_in_segment(&seg), 13); // Empty segment → 0 (unknown). assert_eq!(last_seq_in_segment(&[]), 0); } /// The shipper's quarantine and per-peer HWM are visible to an operator via /// the handle's shared `ShipperState`, and a quarantine can be cleared /// WITHOUT restarting the shipper — after which the (now-healed) peer is /// retried and catches up. #[test] fn quarantine_and_hwm_visible_and_clearable_without_restart() { let dir = tempfile::tempdir().unwrap(); seed_sealed_segments(dir.path(), 3); let healthy = ShardId(1); let permanent_peer = ShardId(2); let transport = Arc::new(RecordingTransport::with_permanent(&[permanent_peer])); let config = ShipperConfig { wal_dir: dir.path().to_path_buf(), shard_id: ShardId::SINGLE, peer_shards: vec![healthy, permanent_peer], poll_interval: Duration::from_millis(5), community_share_only: false, }; let handle = spawn_shipper(config, Arc::clone(&transport)); let state = handle.state(); // The healthy peer advances; its HWM becomes observable as 3. wait_until(Duration::from_secs(2), || state.peer_hwm(healthy) == 3); assert_eq!( state.peer_hwm(healthy), 3, "operator can read the healthy peer's high-water-mark" ); // The permanent peer becomes observably quarantined (stuck at seqno 1). wait_until(Duration::from_secs(2), || { state.is_quarantined(permanent_peer) }); let quarantined = state.quarantined_peers(); assert_eq!( quarantined.get(&permanent_peer).copied(), Some(1), "quarantine set must expose the peer and the seqno it stuck on, got: {quarantined:?}" ); // Operator fixes the peer's config, then clears the quarantine via the // handle — no shipper restart. transport.heal_permanent(permanent_peer); assert!( handle.clear_quarantine(permanent_peer), "clear_quarantine must report it cleared a live quarantine" ); assert!( !state.is_quarantined(permanent_peer), "peer must no longer be quarantined after clear" ); // The peer is now retried and catches up to all sealed segments. wait_until(Duration::from_secs(2), || { state.peer_hwm(permanent_peer) == 3 }); assert_eq!( state.peer_hwm(permanent_peer), 3, "cleared peer resumes shipping and catches up without a restart" ); // Clearing a non-quarantined peer reports false. assert!(!handle.clear_quarantine(ShardId(99))); handle.stop(); } /// In community-overlay mode an all-local segment ships empty bytes but the /// payload must still carry the authoritative `leader_last_seq` so the /// receiver's lag gauge does not under-report (obs-REPL-1). #[test] fn all_local_segment_ships_empty_with_authoritative_leader_last_seq() { use std::sync::Mutex as StdMutex; /// Captures the last payload's (`bytes_len`, `leader_last_seq`) per send. struct CapturingTransport { captured: StdMutex>, } impl Transport for CapturingTransport { fn send_segment( &self, _to: ShardId, payload: WalSegmentPayload, ) -> Result<(), TransportError> { self.captured .lock() .unwrap() .push((payload.bytes.len(), payload.leader_last_seq)); Ok(()) } fn recv_segment(&self) -> Option { None } fn local_shard(&self) -> ShardId { ShardId::SINGLE } } let dir = tempfile::tempdir().unwrap(); // Write one sealed all-local segment (file seqno 1) whose single batch // has first_seq=4 and 2 events → true last WAL seq = 5; plus a trailing // active segment so the first is considered sealed. let all_local = encode_batch( &[ EventRecord::signal(1, RECORD_TYPE_SIGNAL, 1.0, 100), EventRecord::signal(2, RECORD_TYPE_SIGNAL, 1.0, 200), ], 4, 4, ) .unwrap(); std::fs::write( dir.path().join(segment_filename(ShardId::SINGLE, 1)), all_local, ) .unwrap(); std::fs::write( dir.path().join(segment_filename(ShardId::SINGLE, 2)), encode_batch(&[community_event(9)], 6, 6).unwrap(), ) .unwrap(); let peer = ShardId(1); let transport = Arc::new(CapturingTransport { captured: StdMutex::new(Vec::new()), }); let config = ShipperConfig { wal_dir: dir.path().to_path_buf(), shard_id: ShardId::SINGLE, peer_shards: vec![peer], poll_interval: Duration::from_millis(5), community_share_only: true, }; let handle = spawn_shipper(config, Arc::clone(&transport)); wait_until(Duration::from_secs(2), || { !transport.captured.lock().unwrap().is_empty() }); let captured = transport.captured.lock().unwrap().clone(); handle.stop(); let (bytes_len, leader_last_seq) = captured[0]; assert_eq!(bytes_len, 0, "all-local segment filters to empty bytes"); assert_eq!( leader_last_seq, 5, "empty segment must still carry the leader's true last WAL seq (4 + 2 - 1)" ); } }