Kind-3 term markers in the WAL stream, STREAM-relative vote frontiers, heartbeat-only divergence detection + quarantine, and fenced promote. Elections converge in 0.6–1.0s; zero acked-write loss across all kill points. Closes G5 (leaderless recovery) from the v0.9 wave.
1215 lines
49 KiB
Rust
1215 lines
49 KiB
Rust
//! 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<ShardId>,
|
|
/// 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<ShipperState>` 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<ShardId, u64>,
|
|
/// Peers quarantined after a PERMANENT send failure. Value is the seqno the
|
|
/// peer was stuck on when quarantined (for operator diagnostics).
|
|
quarantined: DashMap<ShardId, u64>,
|
|
}
|
|
|
|
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<ShardId, u64> {
|
|
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<ShardId, u64> {
|
|
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<JoinHandle<()>>,
|
|
/// Shared, operator-visible shipper state (per-peer HWM + quarantine set).
|
|
state: Arc<ShipperState>,
|
|
}
|
|
|
|
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<ShipperState> {
|
|
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<T: Transport + ?Sized>(
|
|
config: ShipperConfig,
|
|
transport: Arc<T>,
|
|
) -> 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<ShipperState>`) 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, (Vec<u8>, 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<u8> {
|
|
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<EventRecord> = (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<HashSet<ShardId>>,
|
|
/// 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<HashSet<ShardId>>,
|
|
/// Per-peer ordered log of seqnos the shipper attempted to send.
|
|
sent: Mutex<HashMap<ShardId, Vec<u64>>>,
|
|
}
|
|
|
|
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<u64> {
|
|
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<WalSegmentPayload> {
|
|
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::<Vec<_>>(),
|
|
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<Vec<(usize, u64)>>,
|
|
}
|
|
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<WalSegmentPayload> {
|
|
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)"
|
|
);
|
|
}
|
|
}
|