Skip to main content

reddb_server/replication/
logical.rs

1//! Logical replication helpers shared by replica apply and point-in-time restore.
2
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Condvar, Mutex};
5
6use crate::api::{RedDBError, RedDBResult};
7use crate::application::entity::metadata_from_json;
8use crate::replication::cdc::{
9    change_record_from_entity, wire_json_to_server_json, ChangeOperation, ChangeRecord,
10    RangeAdmitError, RangeAuthority,
11};
12use crate::replication::fence::{FenceVerdict, MemoryTermStore, TermFence};
13use crate::storage::{EntityId, EntityKind, RedDB, UnifiedStore};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ApplyMode {
17    Replica,
18    Restore,
19}
20
21/// PLAN.md Phase 11.5 — counters the replica apply loop bumps when an
22/// invariant breaks. Surfaced via `reddb_replica_apply_errors_total`.
23/// Decode errors aren't strictly apply errors but they share the same
24/// observability lane so dashboards alert on "replica is ingesting
25/// trash from primary regardless of cause".
26#[derive(Debug, Default)]
27pub struct ReplicaApplyMetrics {
28    pub gap_total: std::sync::atomic::AtomicU64,
29    pub divergence_total: std::sync::atomic::AtomicU64,
30    pub apply_error_total: std::sync::atomic::AtomicU64,
31    pub decode_error_total: std::sync::atomic::AtomicU64,
32    /// Issue #814 — a delete (or other apply) that found no target on the
33    /// replica: a missing collection or a missing entity. Non-fatal (the
34    /// LSN chain still advances so idempotent re-pull converges, see
35    /// #813), but recorded so a missed delete that drives collection-count
36    /// drift leaves a trail instead of being swallowed by `let _ =`.
37    pub apply_miss_total: std::sync::atomic::AtomicU64,
38    /// Issue #835 — a record carrying a term *behind* the replica's current
39    /// term was fenced at the apply boundary (a returning ex-primary on a
40    /// stale term). Fail-closed: the record is rejected and the LSN/term
41    /// chain does not advance, so the deposed primary cannot move any
42    /// watermark until it re-syncs under the new term.
43    pub fenced_total: std::sync::atomic::AtomicU64,
44    /// Issue #1242 — entity-payload bytes from successfully applied WAL
45    /// records (entity_bytes for insert/update, refresh payload for
46    /// refresh; deletes carry no payload and contribute 0). Monotonic
47    /// within one process lifetime; reset-aware for readers after restart.
48    /// Surfaced via `reddb_replication_apply_bytes_total`.
49    pub bytes_applied_total: std::sync::atomic::AtomicU64,
50    /// Issue #1242 — count of WAL records successfully applied (Applied
51    /// outcome only; Idempotent and Skipped are excluded). Monotonic
52    /// within one process lifetime. Surfaced via
53    /// `reddb_replication_apply_records_total`.
54    pub records_applied_total: std::sync::atomic::AtomicU64,
55}
56
57impl ReplicaApplyMetrics {
58    pub fn record(&self, kind: ApplyErrorKind) {
59        use std::sync::atomic::Ordering::Relaxed;
60        match kind {
61            ApplyErrorKind::Gap => {
62                self.gap_total.fetch_add(1, Relaxed);
63            }
64            ApplyErrorKind::Divergence => {
65                self.divergence_total.fetch_add(1, Relaxed);
66            }
67            ApplyErrorKind::Apply => {
68                self.apply_error_total.fetch_add(1, Relaxed);
69            }
70            ApplyErrorKind::Decode => {
71                self.decode_error_total.fetch_add(1, Relaxed);
72            }
73            ApplyErrorKind::Miss => {
74                self.apply_miss_total.fetch_add(1, Relaxed);
75            }
76            ApplyErrorKind::Fenced => {
77                self.fenced_total.fetch_add(1, Relaxed);
78            }
79        }
80    }
81
82    pub fn snapshot(&self) -> [(ApplyErrorKind, u64); 6] {
83        use std::sync::atomic::Ordering::Relaxed;
84        [
85            (ApplyErrorKind::Gap, self.gap_total.load(Relaxed)),
86            (
87                ApplyErrorKind::Divergence,
88                self.divergence_total.load(Relaxed),
89            ),
90            (ApplyErrorKind::Apply, self.apply_error_total.load(Relaxed)),
91            (
92                ApplyErrorKind::Decode,
93                self.decode_error_total.load(Relaxed),
94            ),
95            (ApplyErrorKind::Miss, self.apply_miss_total.load(Relaxed)),
96            (ApplyErrorKind::Fenced, self.fenced_total.load(Relaxed)),
97        ]
98    }
99
100    /// Issue #1242 — throughput snapshot: `(bytes_applied_total,
101    /// records_applied_total)`. Both are monotonic within a process
102    /// lifetime and zero after restart, so callers that track rate must
103    /// detect a reset (new value < prior value) and treat it as a restart.
104    pub fn snapshot_throughput(&self) -> (u64, u64) {
105        use std::sync::atomic::Ordering::Relaxed;
106        (
107            self.bytes_applied_total.load(Relaxed),
108            self.records_applied_total.load(Relaxed),
109        )
110    }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum ApplyErrorKind {
115    Gap,
116    Divergence,
117    Apply,
118    Decode,
119    /// Issue #814 — apply ran against a missing target (delete on an
120    /// absent collection/entity). Non-fatal divergence signal.
121    Miss,
122    /// Issue #835 — a record from a term behind the replica's current term
123    /// was fenced at the apply boundary (a stale ex-primary). Fail-closed.
124    Fenced,
125}
126
127impl ApplyErrorKind {
128    pub fn label(self) -> &'static str {
129        match self {
130            Self::Gap => "gap",
131            Self::Divergence => "divergence",
132            Self::Apply => "apply",
133            Self::Decode => "decode",
134            Self::Miss => "apply_miss",
135            Self::Fenced => "fenced",
136        }
137    }
138}
139
140impl LogicalApplyError {
141    pub fn kind(&self) -> ApplyErrorKind {
142        match self {
143            Self::Gap { .. } => ApplyErrorKind::Gap,
144            Self::Divergence { .. } => ApplyErrorKind::Divergence,
145            Self::Apply { .. } => ApplyErrorKind::Apply,
146            Self::StaleTermFenced { .. } => ApplyErrorKind::Fenced,
147            Self::RangeFenced { .. } => ApplyErrorKind::Fenced,
148        }
149    }
150}
151
152/// Outcome of a single `apply` call. `Applied` advances the chain;
153/// `Idempotent` and `Skipped` are no-ops (we already saw an
154/// equal-or-newer LSN). `Gap` and `Divergence` (returned via
155/// `LogicalApplyError`) are fail-closed — callers (replica fetcher,
156/// restore loop) should mark the instance unhealthy and stop applying.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum ApplyOutcome {
159    /// Normal monotonic advance.
160    Applied,
161    /// Same LSN as last applied with same payload hash — log + skip.
162    Idempotent,
163    /// Older LSN than what we already have — log + skip.
164    Skipped,
165}
166
167#[derive(Debug)]
168pub enum LogicalApplyError {
169    Gap {
170        last: u64,
171        next: u64,
172    },
173    Divergence {
174        expected_term: u64,
175        got_term: u64,
176        lsn: u64,
177        expected: String,
178        got: String,
179    },
180    Apply {
181        lsn: u64,
182        source: RedDBError,
183    },
184    /// Issue #835 — the record's term is behind the replica's current
185    /// term: a returning ex-primary streaming on a stale, superseded term.
186    /// Rejected at the apply boundary so the deposed primary cannot apply
187    /// or advance any watermark until it re-syncs under the new term.
188    StaleTermFenced {
189        record_term: u64,
190        current_term: u64,
191        lsn: u64,
192    },
193    /// Issue #991 — the record is stamped for a range whose authority
194    /// watermark has moved past it: a write from a deposed range owner
195    /// (stale ownership epoch) or a superseded timeline (stale term) for the
196    /// target range. Rejected at the apply boundary before the LSN state
197    /// machine runs — fail-closed, so a stale owner cannot apply or advance
198    /// the chain/watermark for that range. Shares the `Fenced` metrics lane
199    /// with the global stale-term fence.
200    RangeFenced {
201        range_id: u64,
202        lsn: u64,
203        reason: RangeAdmitError,
204    },
205}
206
207impl std::fmt::Display for LogicalApplyError {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        match self {
210            Self::Gap { last, next } => write!(f, "LSN gap on apply: last={last} next={next}"),
211            Self::StaleTermFenced {
212                record_term,
213                current_term,
214                lsn,
215            } => write!(
216                f,
217                "stale-term record fenced at lsn={lsn}: record term {record_term} is behind current term {current_term}"
218            ),
219            Self::RangeFenced {
220                range_id,
221                lsn,
222                reason,
223            } => match reason {
224                RangeAdmitError::StaleTerm {
225                    record_term,
226                    accepted_term,
227                } => write!(
228                    f,
229                    "range-stale record fenced at lsn={lsn} for range {range_id}: record term {record_term} is behind accepted term {accepted_term}"
230                ),
231                RangeAdmitError::StaleOwnershipEpoch {
232                    record_epoch,
233                    accepted_epoch,
234                } => write!(
235                    f,
236                    "range-stale record fenced at lsn={lsn} for range {range_id}: ownership epoch {record_epoch} is behind accepted epoch {accepted_epoch}"
237                ),
238            },
239            Self::Divergence {
240                expected_term,
241                got_term,
242                lsn,
243                expected,
244                got,
245            } => write!(
246                f,
247                "LSN divergence on apply at term/lsn=({got_term},{lsn}): expected term {expected_term} payload hash {expected}, got {got}"
248            ),
249            Self::Apply { lsn, source } => write!(f, "apply error at lsn={lsn}: {source}"),
250        }
251    }
252}
253
254impl std::error::Error for LogicalApplyError {}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum BookmarkWaitError {
258    Timeout { target_lsn: u64, applied_lsn: u64 },
259    TermMismatch { target_term: u64, applied_term: u64 },
260}
261
262impl BookmarkWaitError {
263    pub fn is_timeout(&self) -> bool {
264        matches!(self, Self::Timeout { .. })
265    }
266}
267
268impl std::fmt::Display for BookmarkWaitError {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        match self {
271            Self::Timeout {
272                target_lsn,
273                applied_lsn,
274            } => write!(
275                f,
276                "timed out waiting for causal bookmark lsn {target_lsn}; applied={applied_lsn}"
277            ),
278            Self::TermMismatch {
279                target_term,
280                applied_term,
281            } => write!(
282                f,
283                "causal bookmark term mismatch: target={target_term} applied={applied_term}"
284            ),
285        }
286    }
287}
288
289impl std::error::Error for BookmarkWaitError {}
290
291/// Shared logical change applier so replica replay and PITR converge on the
292/// same semantics. Stateful (PLAN.md Phase 11.5): tracks the last applied
293/// LSN + payload hash so duplicates / older LSNs / gaps / divergences are
294/// detected explicitly.
295pub struct LogicalChangeApplier {
296    last_applied_term: AtomicU64,
297    term_fence: TermFence<MemoryTermStore>,
298    last_applied_lsn: AtomicU64,
299    received_frontier_lsn: AtomicU64,
300    last_payload_hash: Mutex<Option<[u8; 32]>>,
301    apply_wait: (Mutex<()>, Condvar),
302    apply_lock: Mutex<()>,
303    /// Issue #814 — metrics the apply path bumps when a record runs
304    /// against a missing target. The production replica loop shares the
305    /// runtime's `ReplicaApplyMetrics` here so `/metrics` surfaces misses;
306    /// other callers (PITR, tests) get a private default that no one reads.
307    metrics: std::sync::Arc<ReplicaApplyMetrics>,
308}
309
310impl LogicalChangeApplier {
311    /// Build a fresh applier. `starting_lsn` is the LSN already
312    /// covered by the snapshot (or `0` for an empty replica). The
313    /// next acceptable record is any positive LSN; from there the
314    /// chain advances by 1.
315    pub fn new(starting_lsn: u64) -> Self {
316        Self::with_metrics(
317            starting_lsn,
318            std::sync::Arc::new(ReplicaApplyMetrics::default()),
319        )
320    }
321
322    /// Build an applier that records apply misses / errors into a shared
323    /// `ReplicaApplyMetrics` (issue #814). The production replica loop
324    /// passes the runtime's metrics so a swallowed delete leaves a trail
325    /// on `reddb_replica_apply_errors_total{kind="apply_miss"}`.
326    pub fn with_metrics(starting_lsn: u64, metrics: std::sync::Arc<ReplicaApplyMetrics>) -> Self {
327        Self {
328            last_applied_term: AtomicU64::new(crate::replication::DEFAULT_REPLICATION_TERM),
329            term_fence: TermFence::new(MemoryTermStore::new()),
330            last_applied_lsn: AtomicU64::new(starting_lsn),
331            received_frontier_lsn: AtomicU64::new(starting_lsn),
332            last_payload_hash: Mutex::new(None),
333            apply_wait: (Mutex::new(()), Condvar::new()),
334            apply_lock: Mutex::new(()),
335            metrics,
336        }
337    }
338
339    /// The metrics handle this applier records misses/errors into.
340    pub fn metrics(&self) -> &std::sync::Arc<ReplicaApplyMetrics> {
341        &self.metrics
342    }
343
344    pub fn last_applied_lsn(&self) -> u64 {
345        self.last_applied_lsn.load(Ordering::Acquire)
346    }
347
348    pub fn received_frontier_lsn(&self) -> u64 {
349        self.received_frontier_lsn.load(Ordering::Acquire)
350    }
351
352    pub fn last_applied_term(&self) -> u64 {
353        self.last_applied_term.load(Ordering::Acquire)
354    }
355
356    pub fn wait_for_bookmark(
357        &self,
358        bookmark: &crate::replication::CausalBookmark,
359        timeout: std::time::Duration,
360    ) -> Result<(), BookmarkWaitError> {
361        let deadline = std::time::Instant::now() + timeout;
362        let target_lsn = bookmark.commit_lsn();
363        let target_term = bookmark.term();
364
365        let mut guard = self.apply_wait.0.lock().expect("apply wait mutex");
366        loop {
367            let applied_lsn = self.last_applied_lsn();
368            let applied_term = self.last_applied_term();
369            if applied_lsn >= target_lsn {
370                if applied_term == target_term {
371                    return Ok(());
372                }
373                return Err(BookmarkWaitError::TermMismatch {
374                    target_term,
375                    applied_term,
376                });
377            }
378
379            let now = std::time::Instant::now();
380            if now >= deadline {
381                return Err(BookmarkWaitError::Timeout {
382                    target_lsn,
383                    applied_lsn,
384                });
385            }
386            let remaining = deadline.saturating_duration_since(now);
387            let (next_guard, wait_result) = self
388                .apply_wait
389                .1
390                .wait_timeout(guard, remaining)
391                .expect("apply wait condvar");
392            guard = next_guard;
393            if wait_result.timed_out() {
394                return Err(BookmarkWaitError::Timeout {
395                    target_lsn,
396                    applied_lsn: self.last_applied_lsn(),
397                });
398            }
399        }
400    }
401
402    /// Apply one logical change record. The state machine:
403    /// - first record after `starting_lsn == 0` → apply, anchor.
404    /// - `lsn == last + 1` → apply, advance.
405    /// - `lsn == last` && payload hash equal → idempotent skip.
406    /// - `lsn == last` && payload hash differs → `Divergence` (fail closed).
407    /// - `lsn < last` → older replay, skip with debug log.
408    /// - `lsn > last + 1` → `Gap` (fail closed; caller marks unhealthy).
409    pub fn apply(
410        &self,
411        db: &RedDB,
412        record: &ChangeRecord,
413        mode: ApplyMode,
414    ) -> Result<ApplyOutcome, LogicalApplyError> {
415        self.apply_fenced(db, record, mode, None)
416    }
417
418    /// Apply one record, first gating it against the target range's authority
419    /// watermark (issue #991). When `range_fence` is `Some`, a record stamped
420    /// for that range whose term or ownership epoch is behind the watermark is
421    /// rejected before the global term fence and the LSN state machine run —
422    /// fail-closed, so a deposed range owner cannot advance anything. Records
423    /// for a different range, or with no range identity (legacy /
424    /// non-range-replicated), pass the range fence untouched. `apply` is the
425    /// unfenced shorthand for callers that do not yet hold range authority.
426    pub fn apply_fenced(
427        &self,
428        db: &RedDB,
429        record: &ChangeRecord,
430        mode: ApplyMode,
431        range_fence: Option<&RangeAuthority>,
432    ) -> Result<ApplyOutcome, LogicalApplyError> {
433        // Serialize validation, publication and frontier advancement. Keep this
434        // separate from bookmark waits so their deadlines still work during I/O.
435        let _apply = self.apply_lock.lock().expect("apply mutex");
436        let last = self.last_applied_lsn.load(Ordering::Acquire);
437        let last_term = self.last_applied_term.load(Ordering::Acquire);
438
439        // Per-range authority fence (issue #991). Runs before the global
440        // term fence so a stale ownership epoch is rejected even when the
441        // record's term is otherwise current. Only records stamped for the
442        // fence's range are gated; the rest fall through.
443        if let Some(fence) = range_fence {
444            if let Err(reason) = fence.admit(record) {
445                self.metrics.record(ApplyErrorKind::Fenced);
446                return Err(LogicalApplyError::RangeFenced {
447                    range_id: fence.range_id,
448                    lsn: record.lsn,
449                    reason,
450                });
451            }
452        }
453
454        // Stale-term fence (issue #835, ADR 0030). Run the shared primitive
455        // before the LSN state machine so a stale ex-primary can neither
456        // apply nor advance the chain/watermark. TermFence also adopts a
457        // higher term before admitting its record.
458        if let FenceVerdict::Fenced(rejection) = self
459            .term_fence
460            .admit_record(record.term)
461            .expect("in-memory term store")
462        {
463            self.metrics.record(ApplyErrorKind::Fenced);
464            return Err(LogicalApplyError::StaleTermFenced {
465                record_term: rejection.incoming_term,
466                current_term: rejection.current_term,
467                lsn: record.lsn,
468            });
469        }
470
471        let payload_hash = record_payload_hash(record);
472        self.received_frontier_lsn
473            .fetch_max(record.lsn, Ordering::AcqRel);
474
475        if last == 0 && record.lsn > 0 {
476            self.do_apply(db, record, mode)?;
477            self.publish_applied(record, payload_hash);
478            return Ok(ApplyOutcome::Applied);
479        }
480
481        if record.lsn == last {
482            let prior = *self.last_payload_hash.lock().expect("payload hash mutex");
483            return match prior {
484                Some(p) if p == payload_hash => Ok(ApplyOutcome::Idempotent),
485                Some(p) => Err(LogicalApplyError::Divergence {
486                    expected_term: last_term,
487                    got_term: record.term,
488                    lsn: record.lsn,
489                    expected: hex_digest(&p),
490                    got: hex_digest(&payload_hash),
491                }),
492                None => Ok(ApplyOutcome::Idempotent),
493            };
494        }
495        if record.lsn < last {
496            return Ok(ApplyOutcome::Skipped);
497        }
498        if record.lsn > last + 1 {
499            return Err(LogicalApplyError::Gap {
500                last,
501                next: record.lsn,
502            });
503        }
504
505        self.do_apply(db, record, mode)?;
506        self.publish_applied(record, payload_hash);
507        Ok(ApplyOutcome::Applied)
508    }
509
510    fn publish_applied(&self, record: &ChangeRecord, payload_hash: [u8; 32]) {
511        // Coordinate the predicate and notification with Condvar::wait_timeout.
512        let _wait = self.apply_wait.0.lock().expect("apply wait mutex");
513        self.last_applied_term.store(record.term, Ordering::Release);
514        self.last_applied_lsn.store(record.lsn, Ordering::Release);
515        *self.last_payload_hash.lock().expect("payload hash mutex") = Some(payload_hash);
516        self.apply_wait.1.notify_all();
517    }
518
519    fn do_apply(
520        &self,
521        db: &RedDB,
522        record: &ChangeRecord,
523        mode: ApplyMode,
524    ) -> Result<(), LogicalApplyError> {
525        Self::apply_record_with_metrics(db, record, mode, &self.metrics).map_err(|err| {
526            LogicalApplyError::Apply {
527                lsn: record.lsn,
528                source: err,
529            }
530        })
531    }
532
533    /// Stateless apply — applies the record without monotonicity
534    /// checks. Kept for callers that don't yet thread the stateful
535    /// applier through. New code should prefer
536    /// `LogicalChangeApplier::new()` + `apply()`. Apply misses (delete
537    /// against a missing target) are recorded into a throwaway metrics
538    /// handle; use [`apply_record_with_metrics`] to surface them.
539    pub fn apply_record(db: &RedDB, record: &ChangeRecord, mode: ApplyMode) -> RedDBResult<()> {
540        Self::apply_record_with_metrics(db, record, mode, &ReplicaApplyMetrics::default())
541    }
542
543    /// Stateless apply that records apply misses (issue #814) into
544    /// `metrics`. A delete against a missing collection or a missing
545    /// entity is a non-fatal divergence signal: it bumps
546    /// `ApplyErrorKind::Miss` and emits a structured warn line, but still
547    /// returns `Ok(())` so the LSN chain advances and idempotent re-pull
548    /// (#813) converges. A genuine (non-missing-target) store error on a
549    /// delete propagates as a real apply error — counted, fail-closed —
550    /// rather than being swallowed by the old `let _ =`.
551    pub fn apply_record_with_metrics(
552        db: &RedDB,
553        record: &ChangeRecord,
554        _mode: ApplyMode,
555        metrics: &ReplicaApplyMetrics,
556    ) -> RedDBResult<()> {
557        if db.options().read_only {
558            return Err(RedDBError::InvalidOperation(
559                "cannot apply logical changes to a read-only database".into(),
560            ));
561        }
562        // Issue #1242 — compute payload bytes before the apply so we can
563        // increment the throughput counter unconditionally at the end (early
564        // `return Err` paths skip the counter, correctly counting only
565        // successful applies).
566        let payload_bytes: u64 = match record.operation {
567            ChangeOperation::Insert | ChangeOperation::Update => record
568                .entity_bytes
569                .as_ref()
570                .map(|b| b.len() as u64)
571                .unwrap_or(0),
572            ChangeOperation::Refresh => record
573                .refresh_records
574                .as_ref()
575                .map(|recs| recs.iter().map(|r| r.len() as u64).sum())
576                .unwrap_or(0),
577            ChangeOperation::Delete => 0,
578        };
579        let store = db.store();
580        let runtime = db
581            .runtime
582            .get()
583            .and_then(crate::runtime::WeakRuntime::upgrade);
584        let indexes = runtime.as_ref().map(|runtime| runtime.index_store_ref());
585        let _publication = indexes.map(|indexes| indexes.begin_indexed_mutation());
586        let topology = indexes.map(|indexes| indexes.collection_topology_lock(&record.collection));
587        let _topology = topology.as_ref().map(|lock| lock.read());
588        let _storage_publication = store.vault_publication_lock.lock();
589        // Decode before changing either data or indexes. Collect only affected
590        // physical versions, except Refresh which replaces the whole collection.
591        let incoming = record
592            .entity_bytes
593            .as_ref()
594            .map(|bytes| {
595                UnifiedStore::deserialize_entity(bytes, store.format_version())
596                    .map_err(|err| RedDBError::Internal(err.to_string()))
597            })
598            .transpose()?;
599        if incoming
600            .as_ref()
601            .is_some_and(|entity| entity.id.raw() != record.entity_id)
602        {
603            return Err(RedDBError::Internal(
604                "replication entity identity does not match its payload".into(),
605            ));
606        }
607        let metadata = record
608            .metadata
609            .as_ref()
610            .map(|json| metadata_from_json(&wire_json_to_server_json(json)))
611            .transpose()?;
612        let mut previous = match record.operation {
613            ChangeOperation::Refresh => store
614                .get_collection(&record.collection)
615                .map(|manager| manager.query_all(|_| true))
616                .unwrap_or_default(),
617            _ => store
618                .get(&record.collection, EntityId::new(record.entity_id))
619                .into_iter()
620                .collect(),
621        };
622        if let Some(entity) = &incoming {
623            if matches!(entity.kind, EntityKind::TableRow { .. }) {
624                previous.extend(
625                    store
626                        .table_row_versions_by_logical_id(&record.collection, entity.logical_id())
627                        .into_iter()
628                        .filter(|version| version.id != entity.id && version.xmax == 0),
629                );
630            }
631        }
632        let capture = (!UnifiedStore::deferred_store_wal_capture_active())
633            .then(|| crate::storage::unified::store::StoreWalCaptureGuard::begin(None));
634        let mut replayed_xid = 0;
635        match record.operation {
636            ChangeOperation::Delete => {
637                // Runtime readers may still hold snapshots of this table row.
638                // Retain a durable tombstone until normal MVCC reclamation.
639                let deletion = if let (Some(runtime), Some(entity)) = (
640                    runtime.as_ref(),
641                    previous
642                        .first()
643                        .filter(|entity| matches!(entity.kind, EntityKind::TableRow { .. })),
644                ) {
645                    let mut tombstone = entity.clone();
646                    if tombstone.xmax == 0 {
647                        tombstone.set_xmax(runtime.snapshot_manager().allocate_committed_xid());
648                        let manager = store
649                            .get_collection(&record.collection)
650                            .expect("existing row collection");
651                        manager
652                            .update(tombstone.clone())
653                            .map_err(|error| RedDBError::Internal(error.to_string()))?;
654                        store
655                            .persist_entities_to_pager(&record.collection, &[tombstone])
656                            .map_err(|error| RedDBError::Internal(error.to_string()))?;
657                    }
658                    Ok(true)
659                } else {
660                    store.delete(&record.collection, EntityId::new(record.entity_id))
661                };
662                match deletion {
663                    Ok(true) => {}
664                    Ok(false) => {
665                        // Target collection existed but no such entity —
666                        // the delete found nothing to remove.
667                        metrics.record(ApplyErrorKind::Miss);
668                        tracing::warn!(
669                            target: "reddb::replication::apply",
670                            lsn = record.lsn,
671                            collection = %record.collection,
672                            entity_id = record.entity_id,
673                            "replica delete found no matching entity; recorded apply miss (non-fatal divergence signal)"
674                        );
675                    }
676                    Err(crate::storage::StoreError::CollectionNotFound(name)) => {
677                        // The whole collection is absent on the replica —
678                        // a missed delete that can drive count drift.
679                        metrics.record(ApplyErrorKind::Miss);
680                        tracing::warn!(
681                            target: "reddb::replication::apply",
682                            lsn = record.lsn,
683                            collection = %name,
684                            entity_id = record.entity_id,
685                            "replica delete against missing collection; recorded apply miss (non-fatal divergence signal)"
686                        );
687                    }
688                    Err(err) => {
689                        // A real store error is a genuine apply failure:
690                        // surface it instead of discarding it so the
691                        // caller counts it and the replica fails closed.
692                        return Err(RedDBError::Internal(err.to_string()));
693                    }
694                }
695            }
696            ChangeOperation::Refresh => {
697                // Issue #596 slice 9d — replica replay of
698                // `REFRESH MATERIALIZED VIEW v`. The primary
699                // emitted the serialized backing-collection
700                // contents in `refresh_records`; apply the
701                // atomic swap on the replica's local store
702                // (which also persists a `RefreshCollection`
703                // WAL action so the post-swap contents survive
704                // a replica restart).
705                let records = record.refresh_records.clone().ok_or_else(|| {
706                    RedDBError::Internal(
707                        "replication refresh record missing refresh_records payload".to_string(),
708                    )
709                })?;
710                for bytes in &records {
711                    let (entity, _) =
712                        UnifiedStore::deserialize_entity_record(bytes, store.format_version())
713                            .map_err(|err| RedDBError::Internal(err.to_string()))?;
714                    replayed_xid = replayed_xid.max(entity.xmin).max(entity.xmax);
715                }
716                store
717                    .refresh_collection_from_records(&record.collection, records)
718                    .map_err(|err| RedDBError::Internal(err.to_string()))?;
719            }
720            ChangeOperation::Insert | ChangeOperation::Update => {
721                let mut entity = incoming.clone().ok_or_else(|| {
722                    RedDBError::Internal("replication record missing entity payload".to_string())
723                })?;
724                if matches!(entity.kind, EntityKind::TableRow { .. }) && entity.xmax == 0 {
725                    if let Some(runtime) = &runtime {
726                        // Source and replica allocators are independent. A source
727                        // xid already in a local snapshot's past must not make a
728                        // newly arriving physical version visible retroactively.
729                        if let Some(existing) = previous.iter().find(|old| old.id == entity.id) {
730                            entity.set_xmin(existing.xmin);
731                        } else {
732                            entity.set_xmin(
733                                entity
734                                    .xmin
735                                    .max(runtime.snapshot_manager().allocate_committed_xid()),
736                            );
737                        }
738                    }
739                }
740
741                // Issue #813 — MVCC table-row supersession on the replica.
742                //
743                // A table-row UPDATE on the primary installs a NEW physical
744                // version (fresh `EntityId`) that shares the row's stable
745                // `logical_id`, and marks the prior version superseded
746                // (`xmax != 0`) so snapshot reads skip it. Only the new
747                // version travels on the wire — the prior version's `xmax`
748                // bump is implicit. Without reproducing it here the replica
749                // leaves every prior version LIVE, so each update to a row
750                // accumulates a stale live duplicate and a full re-pull
751                // replays them all (the observed 22× inflation). Before
752                // upserting the incoming version, mark any *other* live
753                // version of the same logical id superseded — mirroring
754                // `install_versioned_table_row_update` on the primary. This
755                // is idempotent under re-pull: re-applying a record updates
756                // its version in place (resetting its `xmax` from the
757                // serialized bytes), and the last writer per logical id in
758                // LSN order wins, converging on the primary's live set.
759                if matches!(entity.kind, EntityKind::TableRow { .. }) {
760                    let logical = entity.logical_id();
761                    let new_id = entity.id;
762                    let superseding_xid = if entity.xmin != 0 { entity.xmin } else { 1 };
763                    let stale: Vec<_> = store
764                        .table_row_versions_by_logical_id(&record.collection, logical)
765                        .into_iter()
766                        .filter(|version| version.id != new_id && version.xmax == 0)
767                        .collect();
768                    if !stale.is_empty() {
769                        let manager = store
770                            .get_collection(&record.collection)
771                            .ok_or_else(|| RedDBError::NotFound(record.collection.clone()))?;
772                        let mut changed = Vec::with_capacity(stale.len());
773                        for mut version in stale {
774                            version.set_xmax(superseding_xid);
775                            manager
776                                .update(version.clone())
777                                .map_err(|err| RedDBError::Internal(err.to_string()))?;
778                            changed.push(version);
779                        }
780                        store
781                            .persist_entities_to_pager(&record.collection, &changed)
782                            .map_err(|err| RedDBError::Internal(err.to_string()))?;
783                    }
784                }
785
786                let exists = store
787                    .get(&record.collection, EntityId::new(record.entity_id))
788                    .is_some();
789                if exists {
790                    let manager = store
791                        .get_collection(&record.collection)
792                        .ok_or_else(|| RedDBError::NotFound(record.collection.clone()))?;
793                    manager
794                        .update(entity.clone())
795                        .map_err(|err| RedDBError::Internal(err.to_string()))?;
796                    store
797                        .persist_entities_to_pager(
798                            &record.collection,
799                            std::slice::from_ref(&entity),
800                        )
801                        .map_err(|err| RedDBError::Internal(err.to_string()))?;
802                } else {
803                    store
804                        .insert_auto(&record.collection, entity.clone())
805                        .map_err(|err| RedDBError::Internal(err.to_string()))?;
806                }
807                if let Some(metadata) = metadata {
808                    store
809                        .set_metadata(&record.collection, entity.id, metadata)
810                        .map_err(|err| RedDBError::Internal(err.to_string()))?;
811                }
812                store
813                    .context_index()
814                    .index_entity(&record.collection, &entity);
815                // Publish the visibility floor only after this record's WAL
816                // batch and metadata have become durable.
817                replayed_xid = entity.xmin.max(entity.xmax);
818            }
819        }
820        if let Some(capture) = capture {
821            store
822                .append_deferred_store_wal_actions(capture.finish())
823                .map_err(|error| RedDBError::Internal(error.to_string()))?;
824        }
825        if let Some(manager) = store.snapshot_manager.get() {
826            manager.observe_committed_xid(replayed_xid);
827        }
828        if let Some(indexes) = indexes {
829            let publish = (|| -> Result<(), String> {
830                for entity in &previous {
831                    indexes.index_entity_delete(
832                        &record.collection,
833                        entity.id,
834                        &crate::application::ports::entity_row_fields_snapshot(entity),
835                    )?;
836                }
837                let current = match record.operation {
838                    ChangeOperation::Refresh => store
839                        .get_collection(&record.collection)
840                        .map(|manager| manager.query_all(|entity| entity.xmax == 0))
841                        .unwrap_or_default(),
842                    ChangeOperation::Insert | ChangeOperation::Update => incoming
843                        .into_iter()
844                        .filter(|entity| entity.xmax == 0)
845                        .collect(),
846                    ChangeOperation::Delete => Vec::new(),
847                };
848                for entity in &current {
849                    indexes.index_entity_insert(
850                        &record.collection,
851                        entity.id,
852                        &crate::application::ports::entity_row_fields_snapshot(entity),
853                    )?;
854                }
855                Ok(())
856            })();
857            if let Some(runtime) = &runtime {
858                runtime.invalidate_result_cache_for_table(&record.collection);
859            }
860            if let Err(error) = publish {
861                // A partially repaired index must never answer subsequent
862                // queries. Keep durable declarations for rebuild on restart.
863                for index in indexes.list_indices(&record.collection) {
864                    indexes.drop_index(&index.name, &record.collection);
865                }
866                return Err(RedDBError::Internal(error));
867            }
868        }
869        // Issue #1242 — only reached on successful apply (all early `return
870        // Err` paths skip this). Fenced and error records never reach here.
871        metrics
872            .bytes_applied_total
873            .fetch_add(payload_bytes, Ordering::Relaxed);
874        metrics
875            .records_applied_total
876            .fetch_add(1, Ordering::Relaxed);
877        Ok(())
878    }
879}
880
881fn record_payload_hash(record: &ChangeRecord) -> [u8; 32] {
882    let mut hasher = crate::crypto::sha256::Sha256::new();
883    hasher.update(&record.term.to_le_bytes());
884    hasher.update(&record.lsn.to_le_bytes());
885    hasher.update(&[record.operation as u8]);
886    hasher.update(record.collection.as_bytes());
887    hasher.update(&record.entity_id.to_le_bytes());
888    // Issue #991 — range authority participates in the payload hash so two
889    // records at the same LSN that differ only in range identity or ownership
890    // epoch are flagged divergent rather than silently treated as idempotent.
891    // `u64::MAX` stands in for an absent field (a value real epochs/ids never
892    // reach) so `None` and `Some(MAX)` stay distinguishable.
893    hasher.update(&record.range_id.unwrap_or(u64::MAX).to_le_bytes());
894    hasher.update(&record.ownership_epoch.unwrap_or(u64::MAX).to_le_bytes());
895    if let Some(bytes) = &record.entity_bytes {
896        hasher.update(bytes);
897    }
898    // Issue #596 slice 9d — refresh payload participates in the
899    // payload-hash so the same-LSN-idempotent / different-payload-
900    // divergence state machine works for Refresh records too.
901    if let Some(records) = &record.refresh_records {
902        hasher.update(&(records.len() as u64).to_le_bytes());
903        for r in records {
904            hasher.update(&(r.len() as u64).to_le_bytes());
905            hasher.update(r);
906        }
907    }
908    hasher.finalize()
909}
910
911fn hex_digest(bytes: &[u8; 32]) -> String {
912    crate::utils::to_hex(bytes)
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918    use crate::replication::cdc::ChangeOperation;
919    use crate::storage::{EntityData, EntityId, EntityKind, RedDB, RowData, UnifiedEntity};
920    use reddb_types::Value;
921    use std::sync::Arc;
922
923    fn open_db() -> (RedDB, std::path::PathBuf) {
924        let path = std::env::temp_dir().join(format!(
925            "reddb_logical_apply_{}_{}",
926            std::process::id(),
927            std::time::SystemTime::now()
928                .duration_since(std::time::UNIX_EPOCH)
929                .unwrap()
930                .as_nanos()
931        ));
932        let _ = std::fs::remove_file(&path);
933        let db = RedDB::open(&path).unwrap();
934        (db, path)
935    }
936
937    fn record(lsn: u64, payload: &[u8]) -> ChangeRecord {
938        let timestamp = 100 + lsn;
939        let mut entity = UnifiedEntity::new(
940            EntityId::new(lsn),
941            EntityKind::TableRow {
942                table: Arc::from("users"),
943                row_id: lsn,
944            },
945            EntityData::Row(RowData::with_names(
946                vec![Value::UnsignedInteger(lsn), Value::Blob(payload.to_vec())],
947                vec!["id".to_string(), "payload".to_string()],
948            )),
949        );
950        entity.created_at = timestamp;
951        entity.updated_at = timestamp;
952        entity.sequence_id = lsn;
953        change_record_from_entity(
954            lsn,
955            timestamp,
956            ChangeOperation::Insert,
957            "users",
958            "row",
959            &entity,
960            crate::api::REDDB_FORMAT_VERSION,
961            None,
962        )
963    }
964
965    fn delete_record(lsn: u64, collection: &str, entity_id: u64) -> ChangeRecord {
966        ChangeRecord {
967            term: crate::replication::DEFAULT_REPLICATION_TERM,
968            lsn,
969            timestamp: 100 + lsn,
970            operation: ChangeOperation::Delete,
971            collection: collection.to_string(),
972            entity_id,
973            entity_kind: "row".to_string(),
974            entity_bytes: None,
975            metadata: None,
976            refresh_records: None,
977            range_id: None,
978            ownership_epoch: None,
979        }
980    }
981
982    fn table_row_entity(id: u64) -> UnifiedEntity {
983        let mut entity = UnifiedEntity::new(
984            EntityId::new(id),
985            EntityKind::TableRow {
986                table: Arc::from("users"),
987                row_id: id,
988            },
989            EntityData::Row(RowData::with_names(
990                vec![Value::UnsignedInteger(id)],
991                vec!["id".to_string()],
992            )),
993        );
994        entity.created_at = 100 + id;
995        entity.updated_at = 100 + id;
996        entity.sequence_id = id;
997        entity
998    }
999
1000    // Issue #814 — a delete against a missing collection must record an
1001    // apply miss (not a silent no-op) and still return Ok so the LSN
1002    // chain advances (idempotent re-pull, #813).
1003    #[test]
1004    fn delete_against_missing_collection_records_apply_miss() {
1005        let (db, path) = open_db();
1006        let metrics = ReplicaApplyMetrics::default();
1007        let before = metrics.apply_miss_total.load(Ordering::Relaxed);
1008
1009        LogicalChangeApplier::apply_record_with_metrics(
1010            &db,
1011            &delete_record(1, "no_such_collection", 42),
1012            ApplyMode::Replica,
1013            &metrics,
1014        )
1015        .expect("missing-target delete is non-fatal");
1016
1017        assert_eq!(
1018            metrics.apply_miss_total.load(Ordering::Relaxed),
1019            before + 1,
1020            "delete against a missing collection must bump the apply-miss signal"
1021        );
1022        let _ = std::fs::remove_file(path);
1023    }
1024
1025    // Issue #814 — a delete against an existing collection but absent
1026    // entity is likewise a recorded miss, not a swallowed no-op.
1027    #[test]
1028    fn delete_against_missing_entity_records_apply_miss() {
1029        let (db, path) = open_db();
1030        let _ = db.store().get_or_create_collection("users");
1031        let metrics = ReplicaApplyMetrics::default();
1032
1033        LogicalChangeApplier::apply_record_with_metrics(
1034            &db,
1035            &delete_record(1, "users", 9999),
1036            ApplyMode::Replica,
1037            &metrics,
1038        )
1039        .expect("missing-entity delete is non-fatal");
1040
1041        assert_eq!(
1042            metrics.apply_miss_total.load(Ordering::Relaxed),
1043            1,
1044            "delete of an absent entity must bump the apply-miss signal"
1045        );
1046        let _ = std::fs::remove_file(path);
1047    }
1048
1049    // Issue #814 — the normal path (target present) deletes without
1050    // firing the miss signal. No behavioral regression.
1051    #[test]
1052    fn delete_of_present_target_records_no_apply_miss() {
1053        let (db, path) = open_db();
1054        let store = db.store();
1055        let _ = store.get_or_create_collection("users");
1056        let id = store
1057            .insert_auto("users", table_row_entity(1))
1058            .expect("insert entity");
1059        let metrics = ReplicaApplyMetrics::default();
1060
1061        LogicalChangeApplier::apply_record_with_metrics(
1062            &db,
1063            &delete_record(1, "users", id.raw()),
1064            ApplyMode::Replica,
1065            &metrics,
1066        )
1067        .expect("present-target delete applies");
1068
1069        assert_eq!(
1070            metrics.apply_miss_total.load(Ordering::Relaxed),
1071            0,
1072            "deleting a present target must not fire the apply-miss signal"
1073        );
1074        assert!(
1075            store.get("users", id).is_none(),
1076            "the entity must actually be removed on the normal path"
1077        );
1078        let _ = std::fs::remove_file(path);
1079    }
1080
1081    // Issue #814 — the shared-metrics handle on the stateful applier
1082    // surfaces the miss so `/metrics` (which reads the same Arc) sees it.
1083    #[test]
1084    fn stateful_apply_surfaces_delete_miss_via_metrics_handle() {
1085        let (db, path) = open_db();
1086        let applier =
1087            LogicalChangeApplier::with_metrics(0, Arc::new(ReplicaApplyMetrics::default()));
1088
1089        applier
1090            .apply(&db, &delete_record(1, "ghost", 7), ApplyMode::Replica)
1091            .expect("missing-target delete advances the chain");
1092
1093        assert_eq!(
1094            applier.metrics().apply_miss_total.load(Ordering::Relaxed),
1095            1,
1096            "the applier's shared metrics handle must record the miss"
1097        );
1098        assert_eq!(
1099            applier.last_applied_lsn(),
1100            1,
1101            "a non-fatal miss still advances the LSN chain"
1102        );
1103        let _ = std::fs::remove_file(path);
1104    }
1105
1106    // Issue #1242 — bytes_applied_total and records_applied_total must
1107    // reflect observed apply throughput: Applied increments both, Idempotent
1108    // and Skipped increment neither, and deletes contribute 0 bytes but 1
1109    // record.
1110    #[test]
1111    fn throughput_counters_reflect_applied_bytes_and_records() {
1112        let (db, path) = open_db();
1113        let metrics = Arc::new(ReplicaApplyMetrics::default());
1114        let applier = LogicalChangeApplier::with_metrics(0, Arc::clone(&metrics));
1115
1116        // Three insert records with payloads of different sizes.
1117        let r1 = record(1, b"hello");
1118        let r2 = record(2, b"world-longer-payload");
1119        let r3 = record(3, b"x");
1120
1121        // Compute expected bytes from the serialized entity_bytes (not the raw
1122        // payload, which goes through entity serialization before it lands in
1123        // entity_bytes).
1124        let expected_bytes: u64 = [&r1, &r2, &r3]
1125            .iter()
1126            .map(|r| r.entity_bytes.as_ref().map(|b| b.len() as u64).unwrap_or(0))
1127            .sum();
1128        assert!(expected_bytes > 0, "test records must carry entity payload");
1129
1130        for r in [&r1, &r2, &r3] {
1131            assert_eq!(
1132                applier.apply(&db, r, ApplyMode::Replica).unwrap(),
1133                ApplyOutcome::Applied
1134            );
1135        }
1136
1137        assert_eq!(
1138            metrics.records_applied_total.load(Ordering::Relaxed),
1139            3,
1140            "three applied records must increment records counter by 3"
1141        );
1142        assert_eq!(
1143            metrics.bytes_applied_total.load(Ordering::Relaxed),
1144            expected_bytes,
1145            "bytes counter must match the sum of entity_bytes across applied records"
1146        );
1147
1148        // Idempotent replay must not increment either counter.
1149        assert_eq!(
1150            applier.apply(&db, &r3, ApplyMode::Replica).unwrap(),
1151            ApplyOutcome::Idempotent
1152        );
1153        assert_eq!(
1154            metrics.records_applied_total.load(Ordering::Relaxed),
1155            3,
1156            "idempotent replay must not increment the records counter"
1157        );
1158        assert_eq!(
1159            metrics.bytes_applied_total.load(Ordering::Relaxed),
1160            expected_bytes,
1161            "idempotent replay must not increment the bytes counter"
1162        );
1163
1164        // A delete-miss (non-fatal) increments records by 1 but contributes
1165        // 0 bytes — deletes carry no entity payload.
1166        let del = delete_record(4, "ghost_collection", 999);
1167        applier
1168            .apply(&db, &del, ApplyMode::Replica)
1169            .expect("delete miss is non-fatal and still advances the chain");
1170        assert_eq!(
1171            metrics.records_applied_total.load(Ordering::Relaxed),
1172            4,
1173            "a delete (including a miss) must increment the records counter"
1174        );
1175        assert_eq!(
1176            metrics.bytes_applied_total.load(Ordering::Relaxed),
1177            expected_bytes,
1178            "a delete contributes 0 bytes to the bytes counter"
1179        );
1180
1181        let _ = std::fs::remove_file(path);
1182    }
1183
1184    #[test]
1185    fn apply_advances_on_monotonic_lsn() {
1186        let (db, path) = open_db();
1187        let applier = LogicalChangeApplier::new(0);
1188        assert_eq!(
1189            applier
1190                .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1191                .unwrap(),
1192            ApplyOutcome::Applied
1193        );
1194        assert_eq!(applier.last_applied_lsn(), 1);
1195        assert_eq!(
1196            applier
1197                .apply(&db, &record(2, b"b"), ApplyMode::Replica)
1198                .unwrap(),
1199            ApplyOutcome::Applied
1200        );
1201        assert_eq!(applier.last_applied_lsn(), 2);
1202        let _ = std::fs::remove_file(path);
1203    }
1204
1205    #[test]
1206    fn bookmark_timeout_does_not_wait_for_inflight_apply_lock() {
1207        let applier = std::sync::Arc::new(LogicalChangeApplier::new(0));
1208        let apply = applier.apply_lock.lock().expect("inflight apply");
1209        let reader = std::sync::Arc::clone(&applier);
1210        let (send, receive) = std::sync::mpsc::channel();
1211        let thread = std::thread::spawn(move || {
1212            send.send(reader.wait_for_bookmark(
1213                &crate::replication::CausalBookmark::new(1, 1),
1214                std::time::Duration::from_millis(20),
1215            ))
1216            .expect("send result");
1217        });
1218        let result = receive.recv_timeout(std::time::Duration::from_secs(2));
1219        drop(apply);
1220        thread.join().expect("waiter");
1221        assert!(matches!(
1222            result.expect("bookmark respects its deadline during apply"),
1223            Err(BookmarkWaitError::Timeout { .. })
1224        ));
1225    }
1226
1227    #[test]
1228    fn apply_idempotent_on_duplicate_lsn_same_payload() {
1229        let (db, path) = open_db();
1230        let applier = LogicalChangeApplier::new(0);
1231        let r = record(5, b"same");
1232        applier.apply(&db, &r, ApplyMode::Replica).unwrap();
1233        assert_eq!(
1234            applier.apply(&db, &r, ApplyMode::Replica).unwrap(),
1235            ApplyOutcome::Idempotent
1236        );
1237        assert_eq!(applier.last_applied_lsn(), 5);
1238        let _ = std::fs::remove_file(path);
1239    }
1240
1241    #[test]
1242    fn apply_fails_closed_on_lsn_collision_diff_payload() {
1243        let (db, path) = open_db();
1244        let applier = LogicalChangeApplier::new(0);
1245        applier
1246            .apply(&db, &record(7, b"first"), ApplyMode::Replica)
1247            .unwrap();
1248        let err = applier
1249            .apply(&db, &record(7, b"different"), ApplyMode::Replica)
1250            .unwrap_err();
1251        assert!(
1252            matches!(err, LogicalApplyError::Divergence { lsn: 7, .. }),
1253            "got {err:?}"
1254        );
1255        let _ = std::fs::remove_file(path);
1256    }
1257
1258    #[test]
1259    fn apply_fails_closed_on_same_lsn_different_term() {
1260        let (db, path) = open_db();
1261        let applier = LogicalChangeApplier::new(0);
1262        applier
1263            .apply(&db, &record(7, b"same").with_term(1), ApplyMode::Replica)
1264            .unwrap();
1265        let err = applier
1266            .apply(&db, &record(7, b"same").with_term(2), ApplyMode::Replica)
1267            .unwrap_err();
1268        assert!(
1269            matches!(
1270                err,
1271                LogicalApplyError::Divergence {
1272                    lsn: 7,
1273                    expected_term: 1,
1274                    got_term: 2,
1275                    ..
1276                }
1277            ),
1278            "got {err:?}"
1279        );
1280        assert_eq!(applier.last_applied_term(), 1);
1281        assert_eq!(applier.last_applied_lsn(), 7);
1282        let _ = std::fs::remove_file(path);
1283    }
1284
1285    // Issue #835 — a record from a term behind the replica's adopted term
1286    // is fenced at the apply boundary: rejected, counted, and crucially the
1287    // LSN/term chain does NOT advance, so a returning ex-primary on a stale
1288    // term cannot move any watermark.
1289    #[test]
1290    fn apply_fences_stale_term_record() {
1291        let (db, path) = open_db();
1292        let applier = LogicalChangeApplier::new(0);
1293
1294        // Replica adopts term 5 from the legitimate primary at lsn 1.
1295        applier
1296            .apply(&db, &record(1, b"a").with_term(5), ApplyMode::Replica)
1297            .unwrap();
1298        assert_eq!(applier.last_applied_term(), 5);
1299        assert_eq!(applier.last_applied_lsn(), 1);
1300
1301        // A returning ex-primary streams the next record on the old term 4.
1302        let before = applier.metrics().fenced_total.load(Ordering::Relaxed);
1303        let err = applier
1304            .apply(&db, &record(2, b"b").with_term(4), ApplyMode::Replica)
1305            .unwrap_err();
1306        assert!(
1307            matches!(
1308                err,
1309                LogicalApplyError::StaleTermFenced {
1310                    record_term: 4,
1311                    current_term: 5,
1312                    lsn: 2,
1313                }
1314            ),
1315            "got {err:?}"
1316        );
1317        assert_eq!(err.kind(), ApplyErrorKind::Fenced);
1318        assert_eq!(
1319            applier.metrics().fenced_total.load(Ordering::Relaxed),
1320            before + 1,
1321            "the fence must leave a metrics trail"
1322        );
1323        // The fenced record advanced nothing — no apply, no watermark move.
1324        assert_eq!(applier.last_applied_lsn(), 1, "watermark must not advance");
1325        assert_eq!(applier.last_applied_term(), 5);
1326        assert_eq!(
1327            applier.received_frontier_lsn(),
1328            1,
1329            "a fenced record must not even advance the received frontier"
1330        );
1331        let _ = std::fs::remove_file(path);
1332    }
1333
1334    // The fence only bites a *behind* term. A record on the same term
1335    // applies normally, and a record on a higher term is the new primary's
1336    // timeline — adopted on apply.
1337    #[test]
1338    fn apply_admits_same_term_and_adopts_higher_term() {
1339        let (db, path) = open_db();
1340        let applier = LogicalChangeApplier::new(0);
1341
1342        applier
1343            .apply(&db, &record(1, b"a").with_term(3), ApplyMode::Replica)
1344            .unwrap();
1345        // Same term → admitted.
1346        applier
1347            .apply(&db, &record(2, b"b").with_term(3), ApplyMode::Replica)
1348            .unwrap();
1349        assert_eq!(applier.last_applied_term(), 3);
1350        // Higher term → adopted.
1351        applier
1352            .apply(&db, &record(3, b"c").with_term(7), ApplyMode::Replica)
1353            .unwrap();
1354        assert_eq!(applier.last_applied_term(), 7);
1355        assert_eq!(applier.last_applied_lsn(), 3);
1356
1357        // Now a record back on term 3 is fenced.
1358        let err = applier
1359            .apply(&db, &record(4, b"d").with_term(3), ApplyMode::Replica)
1360            .unwrap_err();
1361        assert!(
1362            matches!(err, LogicalApplyError::StaleTermFenced { .. }),
1363            "got {err:?}"
1364        );
1365        let _ = std::fs::remove_file(path);
1366    }
1367
1368    // Issue #991 — a record stamped for the target range but carrying an
1369    // ownership epoch behind the range's accepted epoch is a write from a
1370    // deposed owner. It is fenced at the apply boundary: rejected, counted on
1371    // the Fenced lane, and the LSN/term chain does not advance.
1372    #[test]
1373    fn apply_fenced_rejects_stale_ownership_epoch() {
1374        let (db, path) = open_db();
1375        let applier = LogicalChangeApplier::new(0);
1376        let fence = RangeAuthority {
1377            range_id: 7,
1378            min_term: 1,
1379            min_ownership_epoch: 5,
1380        };
1381
1382        let stale = record(1, b"a").with_range_authority(7, 4);
1383        let before = applier.metrics().fenced_total.load(Ordering::Relaxed);
1384        let err = applier
1385            .apply_fenced(&db, &stale, ApplyMode::Replica, Some(&fence))
1386            .unwrap_err();
1387        assert!(
1388            matches!(
1389                err,
1390                LogicalApplyError::RangeFenced {
1391                    range_id: 7,
1392                    lsn: 1,
1393                    reason: RangeAdmitError::StaleOwnershipEpoch {
1394                        record_epoch: 4,
1395                        accepted_epoch: 5,
1396                    },
1397                }
1398            ),
1399            "got {err:?}"
1400        );
1401        assert_eq!(err.kind(), ApplyErrorKind::Fenced);
1402        assert_eq!(
1403            applier.metrics().fenced_total.load(Ordering::Relaxed),
1404            before + 1
1405        );
1406        assert_eq!(applier.last_applied_lsn(), 0, "watermark must not advance");
1407        assert_eq!(
1408            applier.received_frontier_lsn(),
1409            0,
1410            "a range-fenced record must not advance the received frontier"
1411        );
1412        let _ = std::fs::remove_file(path);
1413    }
1414
1415    // Issue #991 — the same fence applies on the recovery/restore path: a
1416    // record on a stale term for the target range is rejected there too.
1417    #[test]
1418    fn apply_fenced_rejects_stale_range_term_on_restore() {
1419        let (db, path) = open_db();
1420        let applier = LogicalChangeApplier::new(0);
1421        let fence = RangeAuthority {
1422            range_id: 3,
1423            min_term: 6,
1424            min_ownership_epoch: 1,
1425        };
1426
1427        let stale = record(1, b"a").with_term(4).with_range_authority(3, 9);
1428        let err = applier
1429            .apply_fenced(&db, &stale, ApplyMode::Restore, Some(&fence))
1430            .unwrap_err();
1431        assert!(
1432            matches!(
1433                err,
1434                LogicalApplyError::RangeFenced {
1435                    range_id: 3,
1436                    reason: RangeAdmitError::StaleTerm {
1437                        record_term: 4,
1438                        accepted_term: 6,
1439                    },
1440                    ..
1441                }
1442            ),
1443            "got {err:?}"
1444        );
1445        let _ = std::fs::remove_file(path);
1446    }
1447
1448    // Issue #991 — a record current for the range applies through the fence,
1449    // and a record for a *different* range is not gated by this fence.
1450    #[test]
1451    fn apply_fenced_admits_current_and_ignores_other_ranges() {
1452        let (db, path) = open_db();
1453        let applier = LogicalChangeApplier::new(0);
1454        let fence = RangeAuthority {
1455            range_id: 7,
1456            min_term: 1,
1457            min_ownership_epoch: 5,
1458        };
1459
1460        // Current epoch for the fenced range → applies and advances.
1461        applier
1462            .apply_fenced(
1463                &db,
1464                &record(1, b"a").with_range_authority(7, 5),
1465                ApplyMode::Replica,
1466                Some(&fence),
1467            )
1468            .expect("current record applies");
1469        assert_eq!(applier.last_applied_lsn(), 1);
1470
1471        // A record stamped for a different (stale-looking) range is not this
1472        // fence's concern and still applies.
1473        applier
1474            .apply_fenced(
1475                &db,
1476                &record(2, b"b").with_range_authority(99, 1),
1477                ApplyMode::Replica,
1478                Some(&fence),
1479            )
1480            .expect("other-range record bypasses this fence");
1481        assert_eq!(applier.last_applied_lsn(), 2);
1482        let _ = std::fs::remove_file(path);
1483    }
1484
1485    // Issue #992 — end-to-end range-indexed catch-up over the single physical
1486    // WAL: a follower plans its range's work out of the shared stream, then
1487    // applies exactly that work through the same `apply_fenced` gate. The other
1488    // range's records and a deposed-owner write never reach apply.
1489    #[test]
1490    fn range_catchup_plan_applies_only_its_range_through_the_fence() {
1491        use crate::replication::cdc::{plan_range_catchup, RangeStreamPosition, RangeStreamReject};
1492
1493        let (db, path) = open_db();
1494        let applier = LogicalChangeApplier::new(0);
1495
1496        // One sequential WAL slice: range 7 at LSN 1..3 (epoch 5), range 9 at
1497        // 4..5, and a returning ex-owner of range 7 at LSN 6 on a stale epoch.
1498        let stream = vec![
1499            record(1, b"a").with_range_authority(7, 5),
1500            record(2, b"b").with_range_authority(7, 5),
1501            record(3, b"c").with_range_authority(7, 5),
1502            record(4, b"d").with_range_authority(9, 2),
1503            record(5, b"e").with_range_authority(9, 2),
1504            record(6, b"f").with_range_authority(7, 4),
1505        ];
1506
1507        // Range-7 follower resumes from origin, already knowing owner epoch 5.
1508        let position = RangeStreamPosition::new(7, 0, 1, 5);
1509        let plan = plan_range_catchup(&position, &stream);
1510
1511        // Only range 7's current records were selected; the stale-epoch write
1512        // is rejected, not selected.
1513        assert_eq!(plan.apply, vec![0, 1, 2]);
1514        assert_eq!(
1515            plan.rejected,
1516            vec![RangeStreamReject {
1517                lsn: 6,
1518                error: RangeAdmitError::StaleOwnershipEpoch {
1519                    record_epoch: 4,
1520                    accepted_epoch: 5,
1521                },
1522            }]
1523        );
1524        assert_eq!(plan.resume.applied_lsn, 3);
1525
1526        // Apply exactly the planned records through the per-range fence.
1527        let fence = position.authority();
1528        for index in &plan.apply {
1529            applier
1530                .apply_fenced(&db, &stream[*index], ApplyMode::Replica, Some(&fence))
1531                .expect("planned record applies through the fence");
1532        }
1533        assert_eq!(applier.last_applied_lsn(), 3);
1534
1535        // The deposed-owner record the plan refused is exactly what the apply
1536        // fence would also reject, never advancing the chain.
1537        let stale = &stream[5];
1538        let err = applier
1539            .apply_fenced(&db, stale, ApplyMode::Replica, Some(&fence))
1540            .unwrap_err();
1541        assert!(
1542            matches!(err, LogicalApplyError::RangeFenced { range_id: 7, .. }),
1543            "got {err:?}"
1544        );
1545        assert_eq!(
1546            applier.last_applied_lsn(),
1547            3,
1548            "stale write must not advance"
1549        );
1550        let _ = std::fs::remove_file(path);
1551    }
1552
1553    #[test]
1554    fn apply_skips_older_lsn() {
1555        let (db, path) = open_db();
1556        let applier = LogicalChangeApplier::new(0);
1557        applier
1558            .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1559            .unwrap();
1560        applier
1561            .apply(&db, &record(2, b"b"), ApplyMode::Replica)
1562            .unwrap();
1563        assert_eq!(
1564            applier
1565                .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1566                .unwrap(),
1567            ApplyOutcome::Skipped
1568        );
1569        assert_eq!(applier.last_applied_lsn(), 2);
1570        let _ = std::fs::remove_file(path);
1571    }
1572
1573    #[test]
1574    fn apply_returns_gap_on_future_lsn() {
1575        let (db, path) = open_db();
1576        let applier = LogicalChangeApplier::new(0);
1577        applier
1578            .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1579            .unwrap();
1580        let err = applier
1581            .apply(&db, &record(5, b"e"), ApplyMode::Replica)
1582            .unwrap_err();
1583        assert!(
1584            matches!(err, LogicalApplyError::Gap { last: 1, next: 5 }),
1585            "got {err:?}"
1586        );
1587        assert_eq!(applier.last_applied_lsn(), 1);
1588        let _ = std::fs::remove_file(path);
1589    }
1590}