tidaldb/tidal/src/replication/receiver.rs

1939 lines
84 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Segment receiver: consumes WAL segments from the transport and applies
//! them to the local signal ledger.
//!
//! The receiver runs in a background thread, blocking on
//! [`Transport::recv_segment`] and replaying each batch into the shared
//! [`SignalLedger`] via `apply_replicated_events`. Idempotent replay is ensured
//! by checking the per-shard high-water-mark in [`ReplicationState`].
//!
//! # Durability (BLOCKER 6)
//!
//! Each accepted segment is applied **WAL-first** on the follower:
//! `apply_replicated_events` appends every event to the follower's *own* WAL
//! and awaits the group-commit fsync once for the whole staged batch (m11p1:
//! one shared fsync, not one solo `batch_timeout` per event) before any
//! in-memory state mutates. The
//! follower's normal WAL replay on open therefore reconstructs all replicated
//! state — without this, every replicated event between the last checkpoint and
//! a follower crash was silently lost. The leader-seqno high-water-mark is
//! persisted separately by the periodic checkpoint thread (a re-shipped segment
//! whose last seq is at or below the restored high-water-mark is an idempotent
//! no-op).
//!
//! # Error handling
//!
//! If a batch fails BLAKE3 verification or structural decode, `apply_payload`
//! returns [`WalError::Corruption`]. If the follower's own WAL append fails (a
//! disk fault), it returns [`WalError::Io`]. In both cases the receiver thread
//! propagates the error immediately (it does **not** skip the payload) so that
//! [`SegmentReceiverHandle::join`] can surface it to the operator — the
//! follower must never silently acknowledge an event it failed to durably
//! record.
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread::JoinHandle,
};
use crate::{
replication::{
lag::ReplicationLagGauge, shard::ShardId, state::ReplicationState, transport::Transport,
},
schema::{EntityId, Timestamp},
signals::{SignalLedger, SignalTypeId},
wal::{
error::WalError,
format::batch::{BatchPayload, BlobRecord, HEADER_SIZE, decode_batch_payload},
},
};
/// Applier for replicated blob (kind-1/2) records on a follower.
///
/// Implemented by `TidalDb` (via a `Weak` adapter — see
/// `start_replication_with_blobs` — so the receiver thread's handle cannot
/// keep the database alive past its owner). Routes each record through the
/// SAME WAL-first write path the leader used: the follower re-journals the
/// blob in its own WAL (so follower recovery rebuilds it) and then applies it
/// to storage as an idempotent upsert.
pub trait ReplicatedBlobApplier: Send + Sync {
/// Apply one apply-round's replicated blob records, in order, as a
/// BATCH: the implementation stages every record's WAL append before
/// waiting, so a whole round shares group-commit fsyncs (m11p3 — a
/// record-at-a-time apply pays one solo fsync per item, capping item
/// apply throughput at the fsync floor, ~100/s on macOS). Records are
/// handed over by value so the engine can share them with its WAL
/// writer by refcount instead of deep-cloning every payload.
///
/// # Errors
///
/// Any engine error; the receiver HALTS on it (a follower must never
/// silently acknowledge an item it failed to durably record). Blobs are
/// idempotent upserts, so redelivery after a mid-batch halt is safe.
fn apply_blobs(&self, records: Vec<BlobRecord>) -> crate::Result<()>;
}
/// Handle to a running segment receiver thread.
///
/// Call [`join`](Self::join) to block until the thread exits and retrieve any
/// corruption error that caused it to stop. While the node is still open, poll
/// [`died`](Self::died) (or [`is_finished`](Self::is_finished)) to observe a
/// receiver that halted on an error mid-life — without it, a dead receiver is
/// indistinguishable from a healthy one until shutdown calls `join` (C11).
pub struct SegmentReceiverHandle {
thread: Option<JoinHandle<Result<(), WalError>>>,
/// Latched `true` exactly when the receiver thread halts on an apply error
/// (corrupt segment / follower-WAL IO fault) or an unexpected exit — i.e. a
/// silent replication stall. A clean `recv_segment() == None` shutdown leaves
/// this `false`, so it distinguishes "the follower stopped applying because
/// something broke" from "the follower was asked to shut down" (C11). Shared
/// with the thread, which sets it before returning the error.
died: Arc<AtomicBool>,
}
impl SegmentReceiverHandle {
/// Block until the receiver thread exits.
///
/// # Errors
///
/// Returns `Err(WalError::Corruption { .. })` if a corrupt batch was
/// received. Returns `Err(WalError::Corruption { .. })` if the thread
/// panicked unexpectedly.
pub fn join(mut self) -> Result<(), WalError> {
self.thread.take().map_or(Ok(()), |handle| {
handle.join().unwrap_or_else(|_| {
Err(WalError::Corruption {
message: "segment receiver thread panicked".into(),
})
})
})
}
/// Whether the receiver thread has halted on an error (corrupt segment,
/// follower-WAL IO fault, or an unexpected exit) while the node is still
/// open.
///
/// `true` means replication has silently stopped applying and the follower
/// is frozen — health checks must report degraded. A clean shutdown
/// (`recv_segment() == None`) does **not** set this, so it does not
/// false-positive on intentional teardown (C11). Cheap, lock-free, and safe
/// to poll from `health_check` while the handle is still parked in its mutex.
#[must_use]
pub fn died(&self) -> bool {
self.died.load(Ordering::Acquire)
}
/// Whether the receiver thread's join handle has finished.
///
/// Mirrors [`std::thread::JoinHandle::is_finished`]. Unlike [`died`](Self::died)
/// this is also `true` after a clean shutdown, so health checks should prefer
/// `died` to avoid flagging an intentional teardown as a fault.
#[must_use]
pub fn is_finished(&self) -> bool {
self.thread
.as_ref()
.is_some_and(std::thread::JoinHandle::is_finished)
}
}
/// Spawn a background thread that receives WAL segments and replays them
/// into the signal ledger.
///
/// The thread exits when `transport.recv_segment()` returns `None` (transport
/// closed / shutdown) **or** when a corrupt batch is detected.
///
/// When `lag_gauge` is `Some`, every received segment advances the gauge's
/// leader high-water-mark to the highest WAL sequence the leader has shipped
/// (obs-REPL-1). The gauge's `applied_seqno` comes from the same
/// `ReplicationState` this receiver advances, so `lag_segments()` reflects real
/// follower backpressure (`leader_seqno applied_seqno`).
///
/// # Panics
///
/// Panics if the OS fails to spawn the background thread.
/// Max segments drained into one coalesced apply round.
const MAX_COALESCED_SEGMENTS: usize = 64;
/// Max events (by payload metadata) drained into one coalesced apply round.
///
/// A SOFT cap: the drain loop checks it before accepting the next payload, so
/// the final accepted payload may overshoot it by up to one payload's events
/// (≤ the wire format's 256/batch for honest peers). The cap bounds the
/// halting blast radius of one apply round, not memory — overshooting by one
/// payload is harmless and cheaper than splitting it.
const MAX_COALESCED_EVENTS: u64 = 2048;
pub fn spawn_receiver<T: Transport + ?Sized>(
transport: Arc<T>,
ledger: Arc<SignalLedger>,
replication_state: Arc<ReplicationState>,
lag_gauge: Option<Arc<ReplicationLagGauge>>,
blob_applier: Option<Arc<dyn ReplicatedBlobApplier>>,
) -> SegmentReceiverHandle {
// Liveness latch (C11): set true iff the thread halts on an apply error.
// Shared with the handle so health_check can observe a silently-dead
// receiver while the node is still open. A clean shutdown leaves it false.
let died = Arc::new(AtomicBool::new(false));
let thread_died = Arc::clone(&died);
let thread = std::thread::Builder::new()
.name("tidaldb-segment-receiver".into())
.spawn(move || -> Result<(), WalError> {
// Per-source-shard floor of the last frontier handed to
// `notify_applied`: an apply round that did not advance a shard's
// frontier re-notifies nothing (the transport dedups too, but
// skipping here saves its lock acquisition per quiet round).
let mut last_notified: std::collections::HashMap<ShardId, u64> =
std::collections::HashMap::new();
loop {
let Some(first) = transport.recv_segment() else {
// Clean shutdown: the transport closed / shutdown was
// requested. Leave the `died` latch false so health_check
// does not flag an intentional teardown as a fault (C11).
tracing::debug!("segment receiver: transport closed, shutting down");
return Ok(());
};
// ── Coalesce (m11p1): drain the immediately-available backlog ──
//
// Every applied segment pays one follower group-commit wait
// (fsync is ms-scale on real volumes), so applying segments
// one-at-a-time caps follower throughput at
// events-per-segment / fsync-cost — the live ramp measured
// ~12-event segments × ~6.5ms fsync ≈ 1.8k events/s while the
// leader wrote 2.7k+/s, growing lag without bound. Draining
// the backlog and staging it through ONE shared group commit
// amortizes the fsync across everything queued.
let mut pending = vec![first];
let mut drained_events = pending[0].event_count;
while pending.len() < MAX_COALESCED_SEGMENTS
&& drained_events < MAX_COALESCED_EVENTS
{
let Some(next) = transport.try_recv_segment() else {
break;
};
drained_events += next.event_count;
pending.push(next);
}
// Per-source-shard maxima of this round's shipped seqnos,
// captured before `pending` moves: the gap detector below
// compares them against the post-apply contiguous frontier.
let mut shard_maxima: Vec<(ShardId, u64)> = Vec::new();
for p in &pending {
let last = p.leader_last_seq.max(p.id.seqno);
if last == 0 {
continue;
}
match shard_maxima.iter_mut().find(|(s, _)| *s == p.id.shard_id) {
Some((_, m)) => *m = (*m).max(last),
None => shard_maxima.push((p.id.shard_id, last)),
}
}
if let Err(e) = apply_drained(
pending,
&ledger,
&replication_state,
lag_gauge.as_deref(),
blob_applier.as_deref(),
) {
// A corrupt segment / follower-WAL fault halts the receiver
// thread. Latch `died` BEFORE returning so a stalled replica
// is observable via SegmentReceiverHandle::died() while the
// node is still open — not just at shutdown via join(). Log at
// the failure site too -- mirrors wal::reader's per-corruption
// logging, except replication cannot skip-and-continue (a gap
// would diverge the replica), so we halt and surface.
thread_died.store(true, Ordering::Release);
tracing::error!(
error = %e,
"replication apply failed; receiver halting (health degraded)"
);
return Err(e);
}
// ── Gap detection → follower-pulled catch-up (m11p2) ──
//
// If this round left a shard's contiguous frontier short of
// the highest seqno we were shipped, the round parked a range
// ahead of a hole: data older than the leader's in-memory
// ship tail. The leader cannot push it (the tail rotated);
// the FOLLOWER pulls it via the transport's catch-up stream
// (`StreamSegments` from the durable WAL). The transport
// rate-limits and de-duplicates in-flight pulls per shard;
// the default (in-process) implementation is a no-op.
for (shard, max_last) in shard_maxima {
let applied = replication_state.applied_seqno(shard).unwrap_or(0);
// Durable-ack release (m11p3): everything this round
// applied is durably folded (storage upserts + this
// node's own WAL fsync precede the frontier advance), so
// ship acks parked on seqnos <= applied may now answer
// with a true durable mark. Only an ADVANCED frontier is
// worth a notification.
if applied > 0 {
let notified = last_notified.entry(shard).or_insert(0);
if applied > *notified {
*notified = applied;
transport.notify_applied(shard, applied);
}
}
if applied < max_last {
transport.request_catchup(shard, applied + 1);
}
}
}
})
.expect("failed to spawn segment receiver thread");
SegmentReceiverHandle {
thread: Some(thread),
died,
}
}
/// Whether two payloads' authoritative seqno ranges overlap (same source
/// shard). A payload with an unknown range (`id.seqno == 0` or
/// `leader_last_seq < id.seqno`) is conservatively treated as overlapping
/// everything from its shard, so it is always applied alone.
fn ranges_overlap(
a: &crate::replication::WalSegmentPayload,
b: &crate::replication::WalSegmentPayload,
) -> bool {
if a.id.shard_id != b.id.shard_id {
return false;
}
let known = |p: &crate::replication::WalSegmentPayload| {
p.id.seqno > 0 && p.leader_last_seq >= p.id.seqno
};
if !known(a) || !known(b) {
return true;
}
!(a.leader_last_seq < b.id.seqno || b.leader_last_seq < a.id.seqno)
}
/// Apply a drained backlog of payloads in range-disjoint groups.
///
/// # Why grouping is load-bearing (no within-group double-fold)
///
/// The idempotency gate (`range_applied`) reads `ReplicationState`, which only
/// advances at COMMIT time. Two payloads covering the same seqnos (a sender
/// retry duplicate; a heal single-event re-ship inside an already-queued
/// batched run) would BOTH pass a gate taken before either committed, folding
/// the shared events twice. Within one group, ranges are therefore disjoint by
/// construction; an overlapping payload is deferred to a later group, where it
/// gates against the now-advanced state exactly like the sequential path.
fn apply_drained(
mut pending: Vec<crate::replication::WalSegmentPayload>,
ledger: &SignalLedger,
state: &ReplicationState,
lag_gauge: Option<&ReplicationLagGauge>,
blob_applier: Option<&dyn ReplicatedBlobApplier>,
) -> Result<(), WalError> {
while !pending.is_empty() {
let mut group: Vec<crate::replication::WalSegmentPayload> = Vec::new();
let mut deferred: Vec<crate::replication::WalSegmentPayload> = Vec::new();
for payload in pending {
if group.iter().any(|g| ranges_overlap(g, &payload)) {
deferred.push(payload);
} else {
group.push(payload);
}
}
pending = deferred;
// Prepare (decode + validate + gauge + gate) every payload in the
// group, then commit the survivors through one shared group commit.
let mut prepared = Vec::with_capacity(group.len());
for payload in &group {
// Stream-baseline jump (m11p2): a catch-up chunk from a promoted
// leader announces where its stream STARTED. Seqnos at or below
// the baseline are pre-stream history — data this follower
// already holds via the previous leader's stream — so the
// frontier jumps to the baseline instead of waiting forever for
// a gap that is not data.
if payload.stream_baseline > 0 {
let applied = state.applied_seqno(payload.id.shard_id).unwrap_or(0);
if applied < payload.stream_baseline {
tracing::info!(
shard = %payload.id.shard_id,
baseline = payload.stream_baseline,
applied,
"replication: stream baseline announced; jumping frontier \
past pre-stream history"
);
state.advance(payload.id.shard_id, payload.stream_baseline);
}
}
let leader_seqno = (payload.leader_last_seq > 0).then_some(payload.leader_last_seq);
let segment_first = (payload.id.seqno > 0).then_some(payload.id.seqno);
if let Some(seg) = prepare_segment(
&payload.bytes,
payload.id.shard_id,
state,
lag_gauge,
segment_first,
leader_seqno,
)? {
prepared.push(seg);
}
}
commit_segments(&mut prepared, ledger, state, blob_applier)?;
}
Ok(())
}
/// Apply all batches in a WAL segment payload to the signal ledger.
///
/// Idempotent: batches whose last sequence number is at or below the
/// replication state's high-water-mark for the source shard are skipped.
///
/// # Atomic payload application (WARNING fix)
///
/// The payload is applied **all-or-nothing**. We first decode and validate
/// *every* batch (BLAKE3, structural decode, and the `first_seq + event_count`
/// overflow check) into a staged list, advancing only the lag gauge's *leader*
/// high-water-mark as boundaries are observed. Only after the whole payload
/// validates do we apply staged events to the follower ledger (WAL-first) and
/// advance the *applied* high-water-mark to the payload's final boundary in one
/// step.
///
/// This means a corrupt batch *anywhere* in the payload causes the function to
/// return `Err` **before** any event is applied or any applied-seqno is
/// advanced, so the follower's ledger and idempotency boundary never reflect a
/// half-applied payload. The leader can cleanly re-ship the same un-acked
/// segment and it replays from a single consistent resume point. (Without this,
/// a later corrupt batch left earlier batches applied with the applied-seqno
/// stuck mid-payload, with no clean point to resume from.)
///
/// `leader_seqno` is the segment's authoritative last WAL seqno as known by the
/// leader *before* community-overlay filtering (see [`WalSegmentPayload`]). It
/// feeds the lag gauge's leader high-water-mark regardless of how many batches
/// survived filtering — an all-local segment ships zero batches yet still
/// advances the leader HWM so the gauge does not under-report (obs-REPL-1,
/// WARNING fix). `None` falls back to the per-batch boundaries (legacy callers
/// / tests with no authoritative value).
///
/// When `lag_gauge` is `Some`, the gauge's applied seqno tracks
/// `state.applied_seqno`, so `lag_segments()` is non-zero whenever a payload has
/// been received from the leader but not yet applied, and returns to 0 once the
/// follower catches up.
///
/// # Errors
///
/// Returns `WalError::Corruption` on any BLAKE3 or structural decode failure
/// (or a wrapped seqno boundary). The offset of the first corrupt batch is
/// included in the error message. On error, no state has been mutated.
pub fn apply_payload(
bytes: &[u8],
from_shard: ShardId,
ledger: &SignalLedger,
state: &ReplicationState,
lag_gauge: Option<&ReplicationLagGauge>,
) -> Result<(), WalError> {
apply_payload_with_leader_seq(bytes, from_shard, ledger, state, lag_gauge, None)
}
/// Like [`apply_payload`] but carrying the segment's authoritative leader
/// high-water-mark (the true last WAL seqno before community-overlay filtering).
///
/// See [`apply_payload`] for the atomicity and leader-seqno contract. The
/// `leader_seqno` is fed to the lag gauge independently of how many batches
/// survive filtering, so an all-local (empty) segment still advances the leader
/// HWM. The segment's FIRST seqno is taken from the decoded batches' minimum
/// `first_seq` (see [`apply_segment`] to pass the leader's authoritative,
/// pre-filter segment-first instead — required to gap-gate a filtered segment
/// whose surviving batches were re-stamped to a higher `first_seq`).
///
/// # Errors
///
/// Returns `WalError::Corruption` on any decode/checksum/overflow failure,
/// before any state is mutated.
pub fn apply_payload_with_leader_seq(
bytes: &[u8],
from_shard: ShardId,
ledger: &SignalLedger,
state: &ReplicationState,
lag_gauge: Option<&ReplicationLagGauge>,
leader_seqno: Option<u64>,
) -> Result<(), WalError> {
apply_segment(
bytes,
from_shard,
ledger,
state,
lag_gauge,
None,
leader_seqno,
)
}
/// Apply a WAL segment payload, gap-gating the WHOLE segment's seqno range.
///
/// `segment_first_seq` is the segment's authoritative FIRST WAL seqno as known
/// by the leader *before* community-overlay filtering (the payload's
/// `id.seqno`); `leader_seqno` is its authoritative LAST. Together they are the
/// segment's true `[first, last]` coverage range — the unit the gap-aware
/// [`ReplicationState::apply_range`] gates on. Passing the leader's pre-filter
/// `segment_first_seq` is load-bearing for filtered segments: a filtered batch
/// is re-stamped to a higher `first_seq` (the dropped local events vacate the
/// front of the range), so the surviving batches' headers no longer reveal the
/// segment's true start; without the explicit value the receiver would mistake
/// the re-stamp for a gap and stall. When `None` (legacy/test callers) the range
/// falls back to the decoded batches' min `first_seq` / max `last_seq`.
///
/// # Why one range per SEGMENT, not per batch or per event
///
/// Within a segment the leader assigns seqnos contiguously and filtering only
/// DROPS events (never reorders), so the whole `[first, last]` span is one
/// atomic unit of seqno coverage on this follower: interior drops (filtered
/// local events) are legitimately covered, never gaps. A genuine gap is a hole
/// BETWEEN segments — an out-of-order eager ship whose `first > frontier + 1`.
/// `apply_range` refuses to advance the contiguous frontier across such a hole
/// (parking the segment instead), so heal/anti-entropy redelivery — gated on
/// `seqno > applied_seqno` (the contiguous frontier) — still backfills the
/// missing prefix in order. This is the lost-update / rolling-upgrade-divergence
/// fix: the old monotonic-max advance jumped the frontier past the gap, after
/// which redelivery saw the gap seqnos as already-applied and never re-shipped
/// them, silently dropping acknowledged events (spec §criterion 7).
///
/// # Errors
///
/// Returns `WalError::Corruption` on any decode/checksum/overflow failure,
/// before any state is mutated.
#[allow(clippy::too_many_arguments)]
pub fn apply_segment(
bytes: &[u8],
from_shard: ShardId,
ledger: &SignalLedger,
state: &ReplicationState,
lag_gauge: Option<&ReplicationLagGauge>,
segment_first_seq: Option<u64>,
leader_seqno: Option<u64>,
) -> Result<(), WalError> {
let prepared = prepare_segment(
bytes,
from_shard,
state,
lag_gauge,
segment_first_seq,
leader_seqno,
)?;
match prepared {
// No blob applier on this legacy/test call shape: a blob batch
// reaching it is a wiring error and halts loudly in commit_segments.
Some(mut seg) => commit_segments(std::slice::from_mut(&mut seg), ledger, state, None),
None => Ok(()),
}
}
/// A validated, gated, not-yet-committed segment: the decoded events plus the
/// authoritative `[first, last]` seqno range, ready for a (possibly shared)
/// group-commit fold via [`commit_segments`].
struct PreparedSegment {
from_shard: ShardId,
folded: Vec<(SignalTypeId, EntityId, f64, Timestamp)>,
/// Blob (kind-1/2) records in batch (seqno) order, applied via the
/// [`ReplicatedBlobApplier`] BEFORE the signal fold (blobs are idempotent
/// upserts, so a halt after blob apply but before the fold redelivers
/// safely; the reverse order would double-fold signals on redelivery).
blobs: Vec<BlobRecord>,
first: u64,
last: u64,
}
/// Phase 1 of [`apply_segment`]: decode + validate the WHOLE payload, advance
/// the lag gauge's leader high-water-mark, and gate against the idempotency
/// boundary. Returns `None` for segments with nothing to commit (empty /
/// all-filtered / already-applied). Mutates NO replication state.
fn prepare_segment(
bytes: &[u8],
from_shard: ShardId,
state: &ReplicationState,
lag_gauge: Option<&ReplicationLagGauge>,
segment_first_seq: Option<u64>,
leader_seqno: Option<u64>,
) -> Result<Option<PreparedSegment>, WalError> {
// ── Decode + validate the WHOLE payload before mutating state ──
//
// Collect every surviving event and the segment's seqno range. If any batch
// fails decode/checksum/overflow we return here, with neither the follower
// ledger nor the applied high-water-mark touched.
let mut folded: Vec<(SignalTypeId, EntityId, f64, Timestamp)> = Vec::new();
let mut blobs: Vec<BlobRecord> = Vec::new();
// The lowest first-seq / highest last-seq across all decoded batches, the
// fallback segment range when no authoritative leader value is supplied.
let mut min_batch_first_seq: Option<u64> = None;
let mut max_batch_last_seq: u64 = 0;
let mut offset = 0;
while offset < bytes.len() {
let remaining = &bytes[offset..];
if remaining.len() < HEADER_SIZE {
break;
}
let (header, payload) =
decode_batch_payload(remaining).map_err(|e| WalError::Corruption {
message: format!("corrupt batch at payload offset {offset}: {e}"),
})?;
// The last WAL seq is `first_seq + event_count - 1`. Both operands
// originate from peer- or disk-controlled header bytes, so a
// corrupt/hostile `first_seq` near `u64::MAX` could wrap. A wrapped
// seqno would corrupt the idempotency boundary and the lag gauge, so we
// reject the batch as corruption rather than applying it.
// (`decode_batch` rejects `event_count == 0` as corruption, so the
// zero-count arm below is defensive only — an EMPTY SEGMENT is zero
// batches in the payload, handled by the `segment_last == 0` early
// return after this loop.)
let batch_last_seq = if header.event_count > 0 {
header
.first_seq
.checked_add(u64::from(header.event_count) - 1)
.ok_or_else(|| WalError::Corruption {
message: format!(
"WAL seq overflow at payload offset {offset}: \
first_seq={} + event_count={} would wrap u64",
header.first_seq, header.event_count
),
})?
} else {
header.first_seq
};
max_batch_last_seq = max_batch_last_seq.max(batch_last_seq);
min_batch_first_seq =
Some(min_batch_first_seq.map_or(header.first_seq, |m: u64| m.min(header.first_seq)));
match payload {
BatchPayload::Signals(events) => {
for event in &events {
folded.push((
SignalTypeId::new(u16::from(event.signal_type)),
EntityId::new(event.entity_id),
f64::from(event.weight),
Timestamp::from_nanos(event.timestamp_nanos),
));
}
}
BatchPayload::ItemMetadata(record) => blobs.push(BlobRecord::ItemMetadata(record)),
BatchPayload::Embedding(record) => blobs.push(BlobRecord::Embedding(record)),
// m11p4: a replicated term marker rides the blob path — the
// applier journals it into THIS node's WAL (advancing its durable
// tail term) and folds the term-mark cell instead of storage.
BatchPayload::TermMarker(record) => blobs.push(BlobRecord::TermMarker(record)),
// m11p5: a replicated membership record rides the same blob path —
// journaled into THIS node's WAL, then folded into the roster cell
// (no storage effect).
BatchPayload::Membership(record) => blobs.push(BlobRecord::Membership(record)),
}
let batch_size = HEADER_SIZE + header.payload_len as usize;
offset += batch_size;
}
// Record the leader high-water-mark. Prefer the authoritative segment-level
// value (known before filtering, so an all-local segment that decoded zero
// batches still advances it); otherwise fall back to the highest per-batch
// boundary we observed. The leader's seqno is known the moment the segment
// arrives, independent of how many batches survived filtering or the
// idempotency check, so `lag_segments() = leader_seqno applied_seqno` is
// the real follower lag.
let segment_last = leader_seqno
.filter(|&s| s > 0)
.unwrap_or(max_batch_last_seq);
if let Some(gauge) = lag_gauge
&& segment_last > 0
{
// Per-source-shard HWM (BUG 1): the status endpoint computes lag against
// the CURRENT leader's shard, so the HWM must be keyed by the source
// shard this segment came from, not folded into one scalar that a
// leadership change would strand. This also advances the single-scalar
// metric HWM when `from_shard` is this node's tracked shard.
gauge.update_leader_seqno_for(from_shard, segment_last);
}
// An empty segment (all-local, filtered to nothing, or a zero-event batch)
// covers no seqno range we can apply; it has already advanced the leader-HWM
// gauge above, which is all an empty segment must do. Return before gating.
if segment_last == 0 {
return Ok(None);
}
// The segment's authoritative FIRST seqno (pre-filter). Prefer the explicit
// leader value; fall back to the decoded min. Clamp to <= segment_last so a
// single-event segment (`first == last`) and a malformed pairing are both
// well-formed for `apply_range`'s `first <= last` contract.
let segment_first = segment_first_seq
.or(min_batch_first_seq)
.unwrap_or(segment_last)
.min(segment_last);
// ── Phase 2: gap-gate the segment range, FOLD, then advance the frontier ──
//
// The order is load-bearing: the contiguous frontier (`applied_seqno`) must
// never be advanced past a seqno whose event is not yet folded into the
// aggregate. `applied_seqno` is the value the convergence check and the
// replication-latency poller read to decide "this segment is applied", so an
// advance-before-fold opens a window where a reader sees `applied >= n` while
// the n-th event is still pending — reading a count short by one (a transient
// same-process race, distinct from the cross-restart lost-update). We
// therefore:
//
// 1. GATE with `range_applied` (does NOT mutate): skip a re-ship whose whole
// range is at/below the contiguous frontier or is an exact parked range.
// 2. FOLD the surviving events WAL-first, as ONE staged batch
// (`apply_replicated_events`): every event is submitted to the
// follower's WAL first, durability is awaited once for the whole set
// (they share group-commit batches instead of each paying a solo
// batch-timeout + fsync — without this the follower's apply ceiling is
// ~1/batch_timeout events/s and it lags unboundedly behind an m11p1
// leader), and only then are the events folded into the aggregate. A
// WAL failure returns `Err` and halts the receiver BEFORE the frontier
// advances, so the follower never acknowledges (via `applied_seqno`)
// an event it could not durably record — the re-shipped segment
// replays in full from one consistent point on the next open.
// 3. COMMIT with `apply_range`: advance the contiguous frontier (or park the
// range ahead of an open gap, draining any parked contiguous run). Because
// the gate in step 1 already excluded the already-applied cases, a fresh
// range is always recorded; a `false` return would mean only the
// bounded-buffer refusal of a never-closing gap (its WARN fires), which on
// the live ship path is unreachable for any real window. If it ever DOES
// fire, the receiver halts with an error (below) instead of tearing the
// fold/record invariant — a torn range would double-apply on redelivery.
//
// The single receiver thread is the only writer of this shard's state, so the
// gate→fold→commit window cannot race another applier. (Coalesced commits
// preserve this: `apply_drained` keeps every group's ranges disjoint, so a
// gate taken here at prepare time cannot be invalidated by a groupmate.)
if state.range_applied(from_shard, segment_first, segment_last) {
// Already applied (re-ship at/below the frontier, or an exact parked
// range): do NOT fold this segment again.
return Ok(None);
}
// Partial-overlap guard (m11p2): a range whose PREFIX is already inside
// the contiguous frontier would double-fold those events if applied. The
// protocol never produces partial overlaps (every stream position —
// applied frontier, dispatch cursor, baseline — is a batch boundary by
// construction), so this is a defensive refusal: skip with a WARN and let
// the follower's pull-based catch-up realign at a clean boundary, turning
// a would-be silent double-count into a self-healing skip.
let frontier = state.applied_seqno(from_shard).unwrap_or(0);
if segment_first <= frontier && frontier < segment_last {
tracing::warn!(
shard = %from_shard,
first = segment_first,
last = segment_last,
frontier,
"replication: partially-applied segment range refused; awaiting \
boundary-aligned redelivery (catch-up stream)"
);
return Ok(None);
}
Ok(Some(PreparedSegment {
from_shard,
folded,
blobs,
first: segment_first,
last: segment_last,
}))
}
/// Phase 2 of [`apply_segment`], shared by the sequential and coalesced paths:
/// fold every prepared segment's events WAL-first through ONE staged
/// group-commit batch, then advance each segment's range in order.
fn commit_segments(
prepared: &mut [PreparedSegment],
ledger: &SignalLedger,
state: &ReplicationState,
blob_applier: Option<&dyn ReplicatedBlobApplier>,
) -> Result<(), WalError> {
let all_events: Vec<(SignalTypeId, EntityId, f64, Timestamp)> = prepared
.iter()
.flat_map(|seg| seg.folded.iter().copied())
.collect();
if all_events.is_empty() && prepared.is_empty() {
return Ok(());
}
// ── Blob (item-metadata / embedding) records apply FIRST ──
//
// The applier routes the whole group's records through the engine's
// WAL-first item write path as ONE batch (stage all, wait all — group-
// commit fsyncs; m11p3), then upserts storage. Blobs are idempotent, so
// a halt AFTER blob apply but BEFORE the signal fold redelivers safely;
// folding signals first would double-count them when a later blob
// failure forces redelivery of the same range. The records are DRAINED
// out of the prepared segments (ownership moves into the applier — no
// deep clone); on failure the receiver halts and redelivery rebuilds
// them from the segment bytes, so nothing here needs them back.
let group_blobs: Vec<BlobRecord> = prepared
.iter_mut()
.flat_map(|seg| seg.blobs.drain(..))
.collect();
if !group_blobs.is_empty() {
let Some(applier) = blob_applier else {
return Err(WalError::Io(std::io::Error::other(format!(
"received {} blob record(s) but no blob applier is wired on \
this receiver; halting rather than silently dropping \
replicated items",
group_blobs.len()
))));
};
let blob_count = group_blobs.len();
if let Err(e) = applier.apply_blobs(group_blobs) {
return Err(WalError::Io(std::io::Error::other(format!(
"replicated blob batch apply failed ({blob_count} records): {e}"
))));
}
}
if !all_events.is_empty() {
ledger.apply_replicated_events(&all_events).map_err(|e| {
WalError::Io(std::io::Error::other(format!(
"follower WAL append failed applying replicated segment group \
({} segments, {} events): {e}",
prepared.len(),
all_events.len()
)))
})?;
}
// Durable now: advance each contiguous frontier (or park ranges ahead of a
// gap). The prepare-time gates excluded already-applied cases, and group
// ranges are disjoint, so each records a genuinely new range.
for seg in prepared {
let committed = state.apply_range(seg.from_shard, seg.first, seg.last);
if !committed && !state.range_applied(seg.from_shard, seg.first, seg.last) {
// The range was refused (ahead-buffer overflow on a never-closing
// gap, or an unknown shard) AFTER its events were durably folded.
// Returning `Ok` here would leave a torn state: the events are in
// the aggregate, but the range is unrecorded — the next redelivery
// of this segment passes the `range_applied` gate and folds the
// same events AGAIN, silently double-counting. Halt the receiver
// instead; the `died` latch surfaces the halt, and
// heal/anti-entropy redelivery closes the gap in order from one
// consistent point on the next open.
tracing::error!(
shard = %seg.from_shard,
first = seg.first,
last = seg.last,
"folded segment's range could not be recorded (ahead-of-frontier \
buffer overflow or unknown shard); halting receiver to prevent \
double-apply on redelivery — close the gap via heal/anti-entropy \
redelivery and reopen"
);
return Err(WalError::Io(std::io::Error::other(format!(
"replication state refused range [{}, {}] for shard {} after \
its events were folded; receiver halted to prevent \
double-apply on redelivery",
seg.first, seg.last, seg.from_shard
))));
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::time::Duration;
use super::*;
use crate::{
replication::{segment_id::WalSegmentId, shard::RegionId},
schema::{DecaySpec, SchemaBuilder, Window},
signals::NoopWalWriter,
wal::format::batch::{EventRecord, encode_batch},
};
fn make_schema() -> crate::schema::Schema {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
crate::schema::EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
builder.build().unwrap()
}
fn make_event(entity_id: u64, signal_type: u8, ts_ns: u64) -> EventRecord {
EventRecord::signal(entity_id, signal_type, 1.0, ts_ns)
}
/// Ship one single-event segment for `entity` at `seqno` (the eager/redeliver
/// payload shape: `id.seqno == leader_last_seq == seqno`).
fn ship_single(
ledger: &SignalLedger,
state: &ReplicationState,
type_id: u8,
entity: u64,
seqno: u64,
) -> Result<(), WalError> {
let bytes =
encode_batch(&[make_event(entity, type_id, seqno * 1_000_000)], seqno, 1).unwrap();
apply_segment(
&bytes,
ShardId::SINGLE,
ledger,
state,
None,
Some(seqno),
Some(seqno),
)
}
/// REGRESSION (torn fold/record): when `apply_range` refuses to record a
/// range AFTER its events were durably folded (unknown shard here; the
/// ahead-buffer overflow takes the same path in production), `apply_segment`
/// must halt with an error rather than return `Ok` — an `Ok` would let the
/// next redelivery of the same segment pass the `range_applied` gate and
/// fold the same events again, silently double-counting.
#[test]
fn apply_segment_halts_when_folded_range_cannot_be_recorded() {
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = ReplicationState::single();
let tid = ledger.resolve_signal_type("view").unwrap().as_u16() as u8;
// A shard the state does not track: `apply_range` returns `false` and
// `range_applied` stays `false` — the refused-after-fold torn state.
let untracked = ShardId(99);
let bytes = encode_batch(&[make_event(10, tid, 1_000_000)], 1, 1).unwrap();
let err = apply_segment(&bytes, untracked, &ledger, &state, None, Some(1), Some(1))
.expect_err("a folded range that cannot be recorded must halt the receiver");
assert!(
err.to_string().contains("halted"),
"halt error must explain the refusal: {err}"
);
}
/// REGRESSION (rolling-upgrade divergence, lost acknowledged events): an
/// out-of-order eager ship that skips ahead of the contiguous frontier must
/// NOT advance the frontier across the gap, and the gap MUST be backfillable
/// by heal redelivery (gated on `seqno > applied_seqno`) without losing or
/// double-counting any event.
///
/// Before the fix, `apply_range` was a monotonic max: the ahead ship for
/// seqno 8 jumped the frontier to 8, swallowing 6,7. Heal redelivery then
/// gated on `seqno > applied` (now 8) and skipped 6,7, so those acknowledged
/// events were lost from the follower's aggregate forever — the exact 0.5-vs-
/// 1.0/0.0 trending divergence the m8p10 UAT surfaced (a one-event-per-item
/// hole the rank-relative normalize blew up). This test models that interleave
/// at the engine level (no OS processes) and proves the follower converges to
/// the leader's per-entity counts.
#[test]
fn out_of_order_ship_then_heal_loses_no_events() {
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = ReplicationState::single();
let tid = ledger.resolve_signal_type("view").unwrap().as_u16() as u8;
// Leader's logical stream: seqno -> entity. Each event is one `view` on
// its entity. The leader applied all 8 (its own contiguous WAL).
let stream: [(u64, u64); 8] = [
(1, 10),
(2, 11),
(3, 12),
(4, 13),
(5, 14),
(6, 12), // the events the buggy version lost (entities 12,13 lose one)
(7, 13),
(8, 15),
];
// In-order eager ships for 1..=5 land while the follower is up.
for &(seq, entity) in &stream[..5] {
ship_single(&ledger, &state, tid, entity, seq).unwrap();
}
assert_eq!(state.applied_seqno(ShardId::SINGLE), Some(5));
// The follower "restarts": seqnos 6,7 were shipped while it was down and
// FAILED (breaker). After it returns, an eager ship for the NEXT write
// (seqno 8) arrives FIRST — out of order, ahead of the 6,7 gap.
ship_single(&ledger, &state, tid, 15, 8).unwrap();
assert_eq!(
state.applied_seqno(ShardId::SINGLE),
Some(5),
"the out-of-order ship for 8 must NOT advance the frontier across the 6,7 gap"
);
// Heal redelivery re-ships everything above the contiguous frontier (5) in
// order: 6, 7, 8. (8 is a re-ship of the parked range — must not double-
// apply.) An operator-style redelivery would gate on applied_seqno=5.
for &(seq, entity) in &stream[5..] {
ship_single(&ledger, &state, tid, entity, seq).unwrap();
}
assert_eq!(
state.applied_seqno(ShardId::SINGLE),
Some(8),
"heal redelivery must close the gap and reach the leader's last seq"
);
// The follower's per-entity `view` counts must equal the leader's logical
// stream: entity 12 got events at seqs 3 AND 6 (count 2), entity 13 at 4
// AND 7 (count 2), entity 15 at 8 (count 1, NOT double-counted by the
// re-shipped seqno 8). The lost-update bug would leave 12 and 13 at count
// 1 each (the 6,7 events dropped).
let count = |e: u64| {
ledger
.read_windowed_count(EntityId::new(e), "view", Window::AllTime)
.unwrap()
};
assert_eq!(count(10), 1);
assert_eq!(count(11), 1);
assert_eq!(
count(12),
2,
"entity 12 must keep BOTH events (seqs 3 and 6)"
);
assert_eq!(
count(13),
2,
"entity 13 must keep BOTH events (seqs 4 and 7)"
);
assert_eq!(count(14), 1);
assert_eq!(
count(15),
1,
"entity 15's seqno-8 event must be applied EXACTLY once (no double-count from the re-ship)"
);
}
#[test]
fn apply_payload_updates_ledger() {
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId::SINGLE]));
// Resolve the signal type for "view" to get the correct type id.
let type_id = ledger.resolve_signal_type("view").unwrap();
let events = vec![make_event(42, type_id.as_u16() as u8, 1_000_000_000)];
let bytes = encode_batch(&events, 1, 1).unwrap();
apply_payload(&bytes, ShardId::SINGLE, &ledger, &state, None).unwrap();
// Verify the ledger was updated.
assert!(ledger.entries().contains_key(&(EntityId::new(42), type_id)));
assert_eq!(state.applied_seqno(ShardId::SINGLE), Some(1));
}
#[test]
fn apply_payload_idempotent() {
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId::SINGLE]));
let type_id = ledger.resolve_signal_type("view").unwrap();
let events = vec![make_event(42, type_id.as_u16() as u8, 1_000_000_000)];
let bytes = encode_batch(&events, 1, 1).unwrap();
// Apply once.
apply_payload(&bytes, ShardId::SINGLE, &ledger, &state, None).unwrap();
// Apply again -- should be idempotent.
apply_payload(&bytes, ShardId::SINGLE, &ledger, &state, None).unwrap();
assert_eq!(state.applied_seqno(ShardId::SINGLE), Some(1));
}
#[test]
fn apply_payload_multiple_batches() {
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId::SINGLE]));
let type_id = ledger.resolve_signal_type("view").unwrap();
let e1 = vec![make_event(1, type_id.as_u16() as u8, 100)];
let e2 = vec![make_event(2, type_id.as_u16() as u8, 200)];
let mut bytes = encode_batch(&e1, 1, 100).unwrap();
bytes.extend(encode_batch(&e2, 2, 200).unwrap());
apply_payload(&bytes, ShardId::SINGLE, &ledger, &state, None).unwrap();
assert!(ledger.entries().contains_key(&(EntityId::new(1), type_id)));
assert!(ledger.entries().contains_key(&(EntityId::new(2), type_id)));
assert_eq!(state.applied_seqno(ShardId::SINGLE), Some(2));
}
/// CRITICAL (REPL): a community-overlay filter must not move the WAL
/// high-water-mark. Applying a filtered segment must advance `applied_seqno`
/// to the *same* boundary as applying the original (unfiltered) segment,
/// even though the filtered batch carries fewer events. This is the
/// load-bearing invariant for both per-segment idempotency and the
/// `lag = leader_seqno - applied_seqno` gauge.
#[test]
fn filtered_segment_advances_to_same_boundary_as_unfiltered() {
use crate::governance::{CommunityId, SignalScope};
use crate::replication::shipper::filter_segment_drop_local;
let schema = make_schema();
let community = |entity: u64, ts: u64| {
EventRecord::scoped(
entity,
crate::wal::format::batch::RECORD_TYPE_SIGNAL,
1.0,
ts,
SignalScope::Community(CommunityId(1)).discriminant(),
0,
0,
0,
)
};
let local = |entity: u64, ts: u64| make_event(entity, 1, ts);
// A single batch starting at first_seq=10 with 4 events (true last seq
// = 13); the middle two are local and will be stripped by the filter.
let events = vec![
community(100, 1_000_000_000),
local(101, 1_100_000_000),
local(102, 1_200_000_000),
community(103, 1_300_000_000),
];
let original = encode_batch(&events, 10, 1).unwrap();
let filtered = filter_segment_drop_local(&original);
assert!(
!filtered.is_empty(),
"two community events survive the filter"
);
// Apply each via `apply_segment` carrying the segment's AUTHORITATIVE
// range `[10, 13]` (the leader knows this before filtering; the real
// receiver reads it from the payload's `id.seqno` + `leader_last_seq`).
// Passing it is what lets the filtered segment — whose surviving batch is
// re-stamped to a higher first_seq — gate on its TRUE range rather than
// be mistaken for a gap. Both states start at frontier 0 and the segment
// begins at seqno 10 > frontier+1, so this also exercises the gap-aware
// path: the very first segment a node ever applies establishes its
// baseline (the leader ships from the follower's applied+1, so seqno 10
// here is the contiguous start for this follower).
let ledger_a = Arc::new(SignalLedger::new(schema.clone(), Box::new(NoopWalWriter)));
let state_a = ReplicationState::new(&[ShardId::SINGLE]);
// Frontier starts at 9 so the segment `[10,13]` is contiguous (the
// follower has applied through 9 from an earlier in-order stream).
state_a.advance(ShardId::SINGLE, 9);
apply_segment(
&original,
ShardId::SINGLE,
&ledger_a,
&state_a,
None,
Some(10),
Some(13),
)
.unwrap();
// Apply the filtered segment to a fresh state at the same frontier.
let ledger_b = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state_b = ReplicationState::new(&[ShardId::SINGLE]);
state_b.advance(ShardId::SINGLE, 9);
apply_segment(
&filtered,
ShardId::SINGLE,
&ledger_b,
&state_b,
None,
Some(10),
Some(13),
)
.unwrap();
assert_eq!(
state_a.applied_seqno(ShardId::SINGLE),
Some(13),
"unfiltered segment advances to its true last seq (10 + 4 - 1)"
);
assert_eq!(
state_b.applied_seqno(ShardId::SINGLE),
state_a.applied_seqno(ShardId::SINGLE),
"filtered segment must advance to the SAME boundary as unfiltered"
);
}
/// CRITICAL (REPL): the filtered-vs-unfiltered boundary equivalence must
/// hold for arbitrary mixes of local/community events across multiple
/// batches — not just the single hand-picked case above.
///
/// Scope of the invariant: the per-batch re-stamp guarantees a surviving
/// batch preserves *its own* last seq. A segment's overall boundary is the
/// last seq of its last surviving batch, so the equivalence holds whenever
/// the trailing batch survives the filter. We therefore force the final
/// batch to carry a community event (its boundary must survive); the
/// interior batches are arbitrary local/community mixes. (A trailing
/// all-local batch legitimately produces no shippable bytes, so its seqno
/// range cannot — and should not — be reconstructed by the receiver.)
#[test]
fn prop_filtered_segment_preserves_boundary() {
use proptest::prelude::*;
use crate::governance::{CommunityId, SignalScope};
use crate::replication::shipper::filter_segment_drop_local;
// Each event is (is_community, entity_id). Batches are non-empty.
let event_strategy = (any::<bool>(), 1u64..1000);
let batch_strategy = proptest::collection::vec(event_strategy, 1..6);
let batches_strategy = proptest::collection::vec(batch_strategy, 1..4);
let mut runner = proptest::test_runner::TestRunner::new(proptest::test_runner::Config {
cases: 128,
..Default::default()
});
runner
.run(&batches_strategy, |batches| {
let schema = make_schema();
let community = |entity: u64| {
EventRecord::scoped(
entity,
crate::wal::format::batch::RECORD_TYPE_SIGNAL,
1.0,
1_000_000_000,
SignalScope::Community(CommunityId(1)).discriminant(),
0,
0,
0,
)
};
// Lay out batches with contiguous WAL seqnos: batch i gets
// first_seq = next, occupying `len` slots.
let mut original = Vec::new();
let mut next_seq = 1u64;
let last_idx = batches.len() - 1;
for (i, batch) in batches.iter().enumerate() {
let mut recs: Vec<EventRecord> = batch
.iter()
.map(|&(is_comm, entity)| {
if is_comm {
community(entity)
} else {
make_event(entity, 1, 1_000_000_000)
}
})
.collect();
// Force the LAST event of the LAST batch to be community so
// the segment's trailing boundary always survives the filter.
if i == last_idx {
let last = recs.len() - 1;
recs[last] = community(9999);
}
original.extend(encode_batch(&recs, next_seq, 1).unwrap());
next_seq += batch.len() as u64;
}
let true_last_seq = next_seq - 1;
let filtered = filter_segment_drop_local(&original);
// Apply via `apply_segment` with the segment's authoritative range
// `[1, true_last_seq]` (seqnos laid out contiguously from 1, so the
// segment is frontier-contiguous on a fresh node). The filtered
// version's surviving batches are re-stamped to higher first_seqs,
// but the explicit segment-first keeps both gating on the SAME true
// range — the receiver contract the real `spawn_receiver` uses.
let ledger_a = Arc::new(SignalLedger::new(schema.clone(), Box::new(NoopWalWriter)));
let state_a = ReplicationState::new(&[ShardId::SINGLE]);
apply_segment(
&original,
ShardId::SINGLE,
&ledger_a,
&state_a,
None,
Some(1),
Some(true_last_seq),
)
.unwrap();
let ledger_b = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state_b = ReplicationState::new(&[ShardId::SINGLE]);
apply_segment(
&filtered,
ShardId::SINGLE,
&ledger_b,
&state_b,
None,
Some(1),
Some(true_last_seq),
)
.unwrap();
prop_assert_eq!(state_a.applied_seqno(ShardId::SINGLE), Some(true_last_seq));
prop_assert_eq!(
state_b.applied_seqno(ShardId::SINGLE),
state_a.applied_seqno(ShardId::SINGLE),
"filtered segment must reach the same boundary as unfiltered"
);
Ok(())
})
.unwrap();
}
/// WARNING (REPL): a corrupt header whose `first_seq + event_count` wraps
/// u64 must be rejected as corruption, not silently wrapped into a bogus
/// (small) high-water-mark.
#[test]
fn apply_payload_seq_overflow_is_corruption() {
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = ReplicationState::new(&[ShardId::SINGLE]);
let type_id = ledger.resolve_signal_type("view").unwrap();
// Encode a structurally-valid 2-event batch with first_seq = u64::MAX.
// The checksum is computed over these bytes, so decode succeeds and the
// batch reaches the seq arithmetic, where first_seq + (2 - 1) wraps.
let e1 = make_event(1, type_id.as_u16() as u8, 100);
let e2 = make_event(2, type_id.as_u16() as u8, 200);
let bytes = encode_batch(&[e1, e2], u64::MAX, 1).unwrap();
let result = apply_payload(&bytes, ShardId::SINGLE, &ledger, &state, None);
assert!(
matches!(result, Err(WalError::Corruption { .. })),
"first_seq=u64::MAX with 2 events must overflow → Corruption, got {result:?}"
);
assert_eq!(
state.applied_seqno(ShardId::SINGLE),
Some(0),
"overflowing batch must not advance the high-water-mark"
);
}
#[test]
fn apply_payload_corrupt_returns_error() {
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId::SINGLE]));
// Build a valid batch then corrupt the checksum region.
let type_id = ledger.resolve_signal_type("view").unwrap();
let events = vec![make_event(7, type_id.as_u16() as u8, 500)];
let mut bytes = encode_batch(&events, 1, 1).unwrap();
// Flip bytes in the checksum region (bytes 32-63) to break BLAKE3.
for b in &mut bytes[32..64] {
*b = b.wrapping_add(1);
}
let result = apply_payload(&bytes, ShardId::SINGLE, &ledger, &state, None);
assert!(
matches!(result, Err(WalError::Corruption { .. })),
"expected Corruption, got {result:?}"
);
// Ledger must NOT have been updated.
assert!(!ledger.entries().contains_key(&(EntityId::new(7), type_id)));
}
/// WARNING (REPL): a payload whose LATER batch is corrupt must apply
/// **none** of its batches and must NOT advance the applied high-water-mark.
/// The earlier (valid) batch must not be half-applied — the leader can then
/// cleanly re-ship the same un-acked segment and replay it from one
/// consistent resume point.
#[test]
fn apply_payload_atomic_on_mid_payload_corruption() {
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = ReplicationState::new(&[ShardId::SINGLE]);
let type_id = ledger.resolve_signal_type("view").unwrap();
// First batch (seqs 1..=1): valid, entity 100. Second batch (seqs
// 2..=2): valid then corrupted so decode fails AFTER the first batch.
let good = vec![make_event(100, type_id.as_u16() as u8, 100)];
let bad = vec![make_event(200, type_id.as_u16() as u8, 200)];
let mut bytes = encode_batch(&good, 1, 100).unwrap();
let good_len = bytes.len();
bytes.extend(encode_batch(&bad, 2, 200).unwrap());
// Corrupt the SECOND batch's checksum region (bytes 32..64 of it).
for b in &mut bytes[good_len + 32..good_len + 64] {
*b = b.wrapping_add(1);
}
let result = apply_payload(&bytes, ShardId::SINGLE, &ledger, &state, None);
assert!(
matches!(result, Err(WalError::Corruption { .. })),
"mid-payload corruption must return Corruption, got {result:?}"
);
// NEITHER batch may be applied: the first (valid) batch must not have
// been committed before the second batch failed to decode.
assert!(
!ledger
.entries()
.contains_key(&(EntityId::new(100), type_id)),
"earlier valid batch must NOT be applied when a later batch is corrupt"
);
assert!(
!ledger
.entries()
.contains_key(&(EntityId::new(200), type_id)),
"corrupt batch must not be applied"
);
// The applied high-water-mark must NOT have advanced past 0.
assert_eq!(
state.applied_seqno(ShardId::SINGLE),
Some(0),
"half-applied payload must not advance the applied high-water-mark"
);
}
/// WARNING (REPL): in `community_share_only` mode a segment whose events are
/// all local filters to empty bytes that decode to ZERO batches. The
/// receiver must still advance the lag gauge's leader high-water-mark from
/// the segment's authoritative `leader_last_seq` (known before filtering),
/// so the gauge does not under-report the leader's progress across the
/// all-local segment.
#[test]
fn lag_gauge_tracks_leader_hwm_across_all_local_empty_segment() {
use crate::replication::lag::ReplicationLagGauge;
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId::SINGLE]));
let gauge = Arc::new(ReplicationLagGauge::new(
ShardId::SINGLE,
Arc::clone(&state),
));
// An all-local segment filters to empty bytes: the leader's TRUE last
// WAL seqno for it is 5 (e.g. a batch first_seq=4 with 2 events), but the
// shipped bytes are empty.
let empty_bytes: Vec<u8> = Vec::new();
apply_payload_with_leader_seq(
&empty_bytes,
ShardId::SINGLE,
&ledger,
&state,
Some(&gauge),
Some(5),
)
.unwrap();
assert_eq!(
gauge.leader_seqno(),
5,
"leader HWM must advance from the authoritative leader_last_seq even \
when the filtered segment decodes zero batches"
);
// No events were applied (empty segment), so the applied seqno stays 0.
assert_eq!(state.applied_seqno(ShardId::SINGLE), Some(0));
// The gauge therefore reports the leader being ahead of the follower.
assert_eq!(
gauge.lag_segments(),
5,
"leader at 5, applied 0 → 5 segments behind across the empty segment"
);
}
/// A minimal transport that returns one payload then signals shutdown.
struct OneShot {
rx: crossbeam::channel::Receiver<crate::replication::WalSegmentPayload>,
}
impl Transport for OneShot {
fn send_segment(
&self,
_to: ShardId,
_payload: crate::replication::WalSegmentPayload,
) -> Result<(), crate::replication::TransportError> {
Ok(())
}
fn recv_segment(&self) -> Option<crate::replication::WalSegmentPayload> {
self.rx.recv().ok()
}
fn try_recv_segment(&self) -> Option<crate::replication::WalSegmentPayload> {
self.rx.try_recv().ok()
}
fn local_shard(&self) -> ShardId {
ShardId(1)
}
}
/// REGRESSION (m11p1 coalescing, within-batch double-fold): a drained
/// backlog may carry the SAME seqnos twice — a sender-retry duplicate of a
/// whole run, and a heal-path single-event re-ship INSIDE an already-queued
/// batched run. The idempotency gate reads `ReplicationState`, which only
/// advances at commit time, so a naive coalesced apply would gate both
/// copies before either committed and fold the shared events twice.
/// `apply_drained` must keep each group's ranges disjoint and defer
/// overlapping payloads to a later group (where the advanced state gates
/// them out) — every entity's count must equal the leader's logical stream.
#[test]
fn coalesced_backlog_with_duplicates_and_subsets_folds_once() {
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId::SINGLE]));
let tid = ledger.resolve_signal_type("view").unwrap().as_u16() as u8;
// The leader's logical stream: seqnos 1..=6, one view per entity 1..=6,
// shipped as two batched runs [1,3] and [4,6].
let run = |first: u64, entities: &[u64]| {
let events: Vec<EventRecord> = entities
.iter()
.enumerate()
.map(|(i, &e)| make_event(e, tid, (first + i as u64) * 1_000_000))
.collect();
let last = first + entities.len() as u64 - 1;
crate::replication::WalSegmentPayload {
id: WalSegmentId::new(RegionId::SINGLE, ShardId::SINGLE, first),
bytes: encode_batch(&events, first, 1).unwrap(),
event_count: entities.len() as u64,
leader_last_seq: last,
stream_baseline: 0,
term: 0,
leader_region: 0,
}
};
let backlog = vec![
run(1, &[1, 2, 3]),
run(1, &[1, 2, 3]), // exact retry duplicate of [1,3]
run(4, &[4, 5, 6]),
run(2, &[2]), // heal single-event re-ship INSIDE the [1,3] run
run(5, &[5]), // heal single inside [4,6]
];
apply_drained(backlog, &ledger, &state, None, None).unwrap();
assert_eq!(
state.applied_seqno(ShardId::SINGLE),
Some(6),
"the full stream must be applied"
);
for entity in 1..=6u64 {
let count = ledger
.read_windowed_count(EntityId::new(entity), "view", Window::AllTime)
.unwrap();
assert_eq!(
count, 1,
"entity {entity} must fold EXACTLY once despite the duplicate \
run and the overlapping heal singles"
);
}
}
#[test]
fn receiver_thread_exits_on_transport_close() {
let (tx, rx) = crossbeam::channel::bounded(4);
let transport = Arc::new(OneShot { rx });
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId(0)]));
let handle = spawn_receiver(
Arc::clone(&transport),
Arc::clone(&ledger),
Arc::clone(&state),
None,
None,
);
// Send one segment.
let type_id = ledger.resolve_signal_type("view").unwrap();
let events = vec![make_event(99, type_id.as_u16() as u8, 100)];
let payload_bytes = encode_batch(&events, 1, 1).unwrap();
tx.send(crate::replication::WalSegmentPayload {
id: WalSegmentId::new(RegionId::SINGLE, ShardId(0), 1),
bytes: payload_bytes,
event_count: 1,
leader_last_seq: 1,
stream_baseline: 0,
term: 0,
leader_region: 0,
})
.unwrap();
// Give the receiver a moment to process.
std::thread::sleep(Duration::from_millis(50));
// Drop sender -- receiver's recv will return None.
drop(tx);
// The receiver should exit gracefully.
handle.join().unwrap();
// Verify the segment was applied.
assert!(ledger.entries().contains_key(&(EntityId::new(99), type_id)));
assert_eq!(state.applied_seqno(ShardId(0)), Some(1));
}
#[test]
fn receiver_thread_exits_on_corrupt_payload() {
let (tx, rx) = crossbeam::channel::bounded(4);
let transport = Arc::new(OneShot { rx });
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId(0)]));
let handle = spawn_receiver(
Arc::clone(&transport),
Arc::clone(&ledger),
Arc::clone(&state),
None,
None,
);
// Build a valid batch then corrupt it.
let type_id = ledger.resolve_signal_type("view").unwrap();
let events = vec![make_event(55, type_id.as_u16() as u8, 100)];
let mut corrupt_bytes = encode_batch(&events, 1, 1).unwrap();
for b in &mut corrupt_bytes[32..64] {
*b = b.wrapping_add(1);
}
tx.send(crate::replication::WalSegmentPayload {
id: WalSegmentId::new(RegionId::SINGLE, ShardId(0), 1),
bytes: corrupt_bytes,
event_count: 1,
leader_last_seq: 1,
stream_baseline: 0,
term: 0,
leader_region: 0,
})
.unwrap();
// Give thread time to process.
std::thread::sleep(Duration::from_millis(50));
drop(tx);
let result = handle.join();
assert!(
matches!(result, Err(WalError::Corruption { .. })),
"expected Corruption from join, got {result:?}"
);
// Entity 55 must NOT have been applied.
assert!(!ledger.entries().contains_key(&(EntityId::new(55), type_id)));
}
/// C11: a receiver that halts on a corrupt segment must flip its `died`
/// liveness latch WITHOUT the owner ever calling `join()` — so `health_check`
/// can observe a silently-stalled follower while the node is still open,
/// instead of the death being visible only at shutdown.
#[test]
fn receiver_died_latch_set_on_corrupt_without_join() {
let (tx, rx) = crossbeam::channel::bounded(4);
let transport = Arc::new(OneShot { rx });
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId(0)]));
let handle = spawn_receiver(
Arc::clone(&transport),
Arc::clone(&ledger),
Arc::clone(&state),
None,
None,
);
// Healthy until something breaks.
assert!(!handle.died(), "fresh receiver must not report died");
// Feed one corrupt segment.
let type_id = ledger.resolve_signal_type("view").unwrap();
let events = vec![make_event(55, type_id.as_u16() as u8, 100)];
let mut corrupt_bytes = encode_batch(&events, 1, 1).unwrap();
for b in &mut corrupt_bytes[32..64] {
*b = b.wrapping_add(1);
}
tx.send(crate::replication::WalSegmentPayload {
id: WalSegmentId::new(RegionId::SINGLE, ShardId(0), 1),
bytes: corrupt_bytes,
event_count: 1,
leader_last_seq: 1,
stream_baseline: 0,
term: 0,
leader_region: 0,
})
.unwrap();
// Poll the latch (NOT join): the receiver must halt and set `died` while
// the channel/transport stays open and the owner never calls join().
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !handle.died() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(
handle.died(),
"a corrupt segment must flip the receiver's died latch without join()"
);
// The transport is still open (tx not dropped) — the death is observable
// purely from the latch, exactly what health_check reads.
drop(tx);
}
/// C11: a CLEAN shutdown (transport closed via `recv_segment()` == None) must
/// NOT set the `died` latch — otherwise `health_check` would flag an
/// intentional teardown as a fault.
#[test]
fn receiver_died_latch_unset_on_clean_shutdown() {
let (tx, rx) = crossbeam::channel::bounded(4);
let transport = Arc::new(OneShot { rx });
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId(0)]));
let handle = spawn_receiver(
Arc::clone(&transport),
Arc::clone(&ledger),
Arc::clone(&state),
None,
None,
);
// Clean shutdown: drop the sender so recv_segment() returns None.
drop(tx);
// The thread exits Ok; the latch must stay false.
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !handle.is_finished() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(
handle.is_finished(),
"clean shutdown must let the thread exit"
);
assert!(
!handle.died(),
"a clean shutdown must NOT set the died latch (no false-positive fault)"
);
handle.join().unwrap();
}
/// obs-REPL-1: a follower that has received batches from the leader but not
/// yet applied all of them reports a NON-ZERO replication lag; the lag
/// returns to 0 once the follower catches up. This proves the receiver
/// feeds the lag gauge's leader high-water-mark from the real apply path,
/// not just from test-only `update_leader_seqno` calls.
#[test]
fn apply_payload_feeds_lag_gauge_until_caught_up() {
use crate::replication::lag::ReplicationLagGauge;
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId::SINGLE]));
let gauge = Arc::new(ReplicationLagGauge::new(
ShardId::SINGLE,
Arc::clone(&state),
));
let type_id = ledger.resolve_signal_type("view").unwrap();
// First segment carries WAL seqs 1..=2 (leader high-water = 2).
let e1 = vec![make_event(1, type_id.as_u16() as u8, 100)];
let e2 = vec![make_event(2, type_id.as_u16() as u8, 200)];
let mut first = encode_batch(&e1, 1, 100).unwrap();
first.extend(encode_batch(&e2, 2, 200).unwrap());
// Apply the first segment: leader HWM 2, applied 2 → caught up.
apply_payload(&first, ShardId::SINGLE, &ledger, &state, Some(&gauge)).unwrap();
assert_eq!(gauge.leader_seqno(), 2);
assert_eq!(gauge.applied_seqno(), 2);
assert_eq!(gauge.lag_segments(), 0, "fully applied → no lag");
// Simulate received-but-not-yet-applied backpressure: the leader has
// shipped up through seq 5, but the follower has only applied 2.
// Recording the leader HWM ahead of application is exactly what the
// receiver does for each decoded batch before the apply step.
gauge.update_leader_seqno(5);
assert_eq!(
gauge.lag_segments(),
3,
"leader at 5, applied 2 → 3 segments behind"
);
// Deliver the missing segment (WAL seqs 3..=5) and apply it: caught up.
let e3 = vec![make_event(3, type_id.as_u16() as u8, 300)];
let e4 = vec![make_event(4, type_id.as_u16() as u8, 400)];
let e5 = vec![make_event(5, type_id.as_u16() as u8, 500)];
let mut catchup = encode_batch(&e3, 3, 300).unwrap();
catchup.extend(encode_batch(&e4, 4, 400).unwrap());
catchup.extend(encode_batch(&e5, 5, 500).unwrap());
apply_payload(&catchup, ShardId::SINGLE, &ledger, &state, Some(&gauge)).unwrap();
assert_eq!(gauge.leader_seqno(), 5);
assert_eq!(gauge.applied_seqno(), 5);
assert_eq!(gauge.lag_segments(), 0, "caught up → lag back to 0");
}
/// m11p2: a payload mixing a kind-1 item blob, a kind-0 signal batch, and
/// a kind-2 embedding blob applies atomically — blobs through the applier
/// (in seqno order, BEFORE the signal fold), signals into the ledger — and
/// advances the contiguous frontier across the whole range.
#[test]
fn mixed_kind_payload_applies_blobs_and_signals() {
use std::sync::Mutex as StdMutex;
use crate::replication::shard::RegionId;
use crate::wal::format::batch::{
EmbeddingRecord, ItemMetadataRecord, encode_embedding_batch, encode_item_metadata_batch,
};
#[derive(Default)]
struct RecordingApplier {
applied: StdMutex<Vec<String>>,
}
impl ReplicatedBlobApplier for RecordingApplier {
fn apply_blobs(&self, records: Vec<BlobRecord>) -> crate::Result<()> {
let lines: Vec<String> = records
.iter()
.map(|record| match record {
BlobRecord::ItemMetadata(r) => {
format!("item:{}:{}", r.entity_id, r.metadata_bytes.len())
}
BlobRecord::Embedding(r) => {
format!("emb:{}:{}", r.entity_id, r.values.len())
}
BlobRecord::TermMarker(r) => format!("term:{}", r.term),
BlobRecord::Membership(r) => {
format!("membership:v{}:{}", r.version, r.members.len())
}
})
.collect();
self.applied.lock().unwrap().extend(lines);
Ok(())
}
}
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = ReplicationState::new(&[ShardId::SINGLE]);
let tid = ledger.resolve_signal_type("view").unwrap().as_u16() as u8;
// seq 1: item blob; seqs 2-3: two signal events; seq 4: embedding blob.
let mut bytes = encode_item_metadata_batch(
&ItemMetadataRecord {
entity_id: 9,
metadata_bytes: vec![0xAA; 16],
},
1,
1,
ShardId::SINGLE,
RegionId::SINGLE,
)
.unwrap();
bytes.extend(
encode_batch(&[make_event(10, tid, 100), make_event(11, tid, 200)], 2, 1).unwrap(),
);
bytes.extend(
encode_embedding_batch(
&EmbeddingRecord {
entity_id: 9,
values: vec![0.5, 0.5],
},
4,
1,
ShardId::SINGLE,
RegionId::SINGLE,
)
.unwrap(),
);
let applier = RecordingApplier::default();
let payload = crate::replication::WalSegmentPayload {
id: crate::replication::WalSegmentId::new(RegionId::SINGLE, ShardId::SINGLE, 1),
bytes,
event_count: 4,
leader_last_seq: 4,
stream_baseline: 0,
term: 0,
leader_region: 0,
};
apply_drained(vec![payload], &ledger, &state, None, Some(&applier)).unwrap();
assert_eq!(
state.applied_seqno(ShardId::SINGLE),
Some(4),
"the whole mixed range advances the frontier"
);
let applied = applier.applied.lock().unwrap().clone();
assert_eq!(
applied,
vec!["item:9:16".to_string(), "emb:9:2".to_string()],
"blobs apply in seqno order"
);
let count = |e: u64| {
ledger
.read_windowed_count(EntityId::new(e), "view", Window::AllTime)
.unwrap()
};
assert_eq!(count(10), 1, "signal events folded");
assert_eq!(count(11), 1);
}
/// m11p2: a blob batch arriving with NO applier wired halts the receiver
/// loudly instead of silently dropping a replicated item.
#[test]
fn blob_payload_without_applier_halts() {
use crate::replication::shard::RegionId;
use crate::wal::format::batch::{ItemMetadataRecord, encode_item_metadata_batch};
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = ReplicationState::new(&[ShardId::SINGLE]);
let bytes = encode_item_metadata_batch(
&ItemMetadataRecord {
entity_id: 1,
metadata_bytes: vec![1],
},
1,
1,
ShardId::SINGLE,
RegionId::SINGLE,
)
.unwrap();
let payload = crate::replication::WalSegmentPayload {
id: crate::replication::WalSegmentId::new(RegionId::SINGLE, ShardId::SINGLE, 1),
bytes,
event_count: 1,
leader_last_seq: 1,
stream_baseline: 0,
term: 0,
leader_region: 0,
};
let err = apply_drained(vec![payload], &ledger, &state, None, None)
.expect_err("a blob with no applier must halt");
assert!(err.to_string().contains("no blob"), "got: {err}");
assert_eq!(
state.applied_seqno(ShardId::SINGLE),
Some(0),
"nothing recorded on the halt path"
);
}
/// m11p2: a catch-up chunk announcing a stream baseline jumps the frontier
/// past pre-stream history, so the chunk's own range applies contiguously
/// instead of parking on a phantom gap.
#[test]
fn stream_baseline_jumps_frontier_past_prestream_history() {
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = ReplicationState::new(&[ShardId::SINGLE]);
let tid = ledger.resolve_signal_type("view").unwrap().as_u16() as u8;
// A promoted leader's first chunk: range [11, 12] with baseline 10.
let bytes =
encode_batch(&[make_event(1, tid, 100), make_event(2, tid, 200)], 11, 1).unwrap();
let payload = crate::replication::WalSegmentPayload {
id: crate::replication::WalSegmentId::new(
crate::replication::shard::RegionId::SINGLE,
ShardId::SINGLE,
11,
),
bytes,
event_count: 2,
leader_last_seq: 12,
stream_baseline: 10,
term: 0,
leader_region: 0,
};
apply_drained(vec![payload], &ledger, &state, None, None).unwrap();
assert_eq!(
state.applied_seqno(ShardId::SINGLE),
Some(12),
"baseline 10 + chunk [11,12] must land contiguously at 12"
);
}
}