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    /// Issue #814 — metrics the apply path bumps when a record runs
303    /// against a missing target. The production replica loop shares the
304    /// runtime's `ReplicaApplyMetrics` here so `/metrics` surfaces misses;
305    /// other callers (PITR, tests) get a private default that no one reads.
306    metrics: std::sync::Arc<ReplicaApplyMetrics>,
307}
308
309impl LogicalChangeApplier {
310    /// Build a fresh applier. `starting_lsn` is the LSN already
311    /// covered by the snapshot (or `0` for an empty replica). The
312    /// next acceptable record is any positive LSN; from there the
313    /// chain advances by 1.
314    pub fn new(starting_lsn: u64) -> Self {
315        Self::with_metrics(
316            starting_lsn,
317            std::sync::Arc::new(ReplicaApplyMetrics::default()),
318        )
319    }
320
321    /// Build an applier that records apply misses / errors into a shared
322    /// `ReplicaApplyMetrics` (issue #814). The production replica loop
323    /// passes the runtime's metrics so a swallowed delete leaves a trail
324    /// on `reddb_replica_apply_errors_total{kind="apply_miss"}`.
325    pub fn with_metrics(starting_lsn: u64, metrics: std::sync::Arc<ReplicaApplyMetrics>) -> Self {
326        Self {
327            last_applied_term: AtomicU64::new(crate::replication::DEFAULT_REPLICATION_TERM),
328            term_fence: TermFence::new(MemoryTermStore::new()),
329            last_applied_lsn: AtomicU64::new(starting_lsn),
330            received_frontier_lsn: AtomicU64::new(starting_lsn),
331            last_payload_hash: Mutex::new(None),
332            apply_wait: (Mutex::new(()), Condvar::new()),
333            metrics,
334        }
335    }
336
337    /// The metrics handle this applier records misses/errors into.
338    pub fn metrics(&self) -> &std::sync::Arc<ReplicaApplyMetrics> {
339        &self.metrics
340    }
341
342    pub fn last_applied_lsn(&self) -> u64 {
343        self.last_applied_lsn.load(Ordering::Acquire)
344    }
345
346    pub fn received_frontier_lsn(&self) -> u64 {
347        self.received_frontier_lsn.load(Ordering::Acquire)
348    }
349
350    pub fn last_applied_term(&self) -> u64 {
351        self.last_applied_term.load(Ordering::Acquire)
352    }
353
354    pub fn wait_for_bookmark(
355        &self,
356        bookmark: &crate::replication::CausalBookmark,
357        timeout: std::time::Duration,
358    ) -> Result<(), BookmarkWaitError> {
359        let deadline = std::time::Instant::now() + timeout;
360        let target_lsn = bookmark.commit_lsn();
361        let target_term = bookmark.term();
362
363        let mut guard = self.apply_wait.0.lock().expect("apply wait mutex");
364        loop {
365            let applied_lsn = self.last_applied_lsn();
366            let applied_term = self.last_applied_term();
367            if applied_lsn >= target_lsn {
368                if applied_term == target_term {
369                    return Ok(());
370                }
371                return Err(BookmarkWaitError::TermMismatch {
372                    target_term,
373                    applied_term,
374                });
375            }
376
377            let now = std::time::Instant::now();
378            if now >= deadline {
379                return Err(BookmarkWaitError::Timeout {
380                    target_lsn,
381                    applied_lsn,
382                });
383            }
384            let remaining = deadline.saturating_duration_since(now);
385            let (next_guard, wait_result) = self
386                .apply_wait
387                .1
388                .wait_timeout(guard, remaining)
389                .expect("apply wait condvar");
390            guard = next_guard;
391            if wait_result.timed_out() {
392                return Err(BookmarkWaitError::Timeout {
393                    target_lsn,
394                    applied_lsn: self.last_applied_lsn(),
395                });
396            }
397        }
398    }
399
400    /// Apply one logical change record. The state machine:
401    /// - first record after `starting_lsn == 0` → apply, anchor.
402    /// - `lsn == last + 1` → apply, advance.
403    /// - `lsn == last` && payload hash equal → idempotent skip.
404    /// - `lsn == last` && payload hash differs → `Divergence` (fail closed).
405    /// - `lsn < last` → older replay, skip with debug log.
406    /// - `lsn > last + 1` → `Gap` (fail closed; caller marks unhealthy).
407    pub fn apply(
408        &self,
409        db: &RedDB,
410        record: &ChangeRecord,
411        mode: ApplyMode,
412    ) -> Result<ApplyOutcome, LogicalApplyError> {
413        self.apply_fenced(db, record, mode, None)
414    }
415
416    /// Apply one record, first gating it against the target range's authority
417    /// watermark (issue #991). When `range_fence` is `Some`, a record stamped
418    /// for that range whose term or ownership epoch is behind the watermark is
419    /// rejected before the global term fence and the LSN state machine run —
420    /// fail-closed, so a deposed range owner cannot advance anything. Records
421    /// for a different range, or with no range identity (legacy /
422    /// non-range-replicated), pass the range fence untouched. `apply` is the
423    /// unfenced shorthand for callers that do not yet hold range authority.
424    pub fn apply_fenced(
425        &self,
426        db: &RedDB,
427        record: &ChangeRecord,
428        mode: ApplyMode,
429        range_fence: Option<&RangeAuthority>,
430    ) -> Result<ApplyOutcome, LogicalApplyError> {
431        let last = self.last_applied_lsn.load(Ordering::Acquire);
432        let last_term = self.last_applied_term.load(Ordering::Acquire);
433
434        // Per-range authority fence (issue #991). Runs before the global
435        // term fence so a stale ownership epoch is rejected even when the
436        // record's term is otherwise current. Only records stamped for the
437        // fence's range are gated; the rest fall through.
438        if let Some(fence) = range_fence {
439            if let Err(reason) = fence.admit(record) {
440                self.metrics.record(ApplyErrorKind::Fenced);
441                return Err(LogicalApplyError::RangeFenced {
442                    range_id: fence.range_id,
443                    lsn: record.lsn,
444                    reason,
445                });
446            }
447        }
448
449        // Stale-term fence (issue #835, ADR 0030). Run the shared primitive
450        // before the LSN state machine so a stale ex-primary can neither
451        // apply nor advance the chain/watermark. TermFence also adopts a
452        // higher term before admitting its record.
453        if let FenceVerdict::Fenced(rejection) = self
454            .term_fence
455            .admit_record(record.term)
456            .expect("in-memory term store")
457        {
458            self.metrics.record(ApplyErrorKind::Fenced);
459            return Err(LogicalApplyError::StaleTermFenced {
460                record_term: rejection.incoming_term,
461                current_term: rejection.current_term,
462                lsn: record.lsn,
463            });
464        }
465
466        let payload_hash = record_payload_hash(record);
467        self.received_frontier_lsn
468            .fetch_max(record.lsn, Ordering::AcqRel);
469
470        if last == 0 && record.lsn > 0 {
471            self.do_apply(db, record, mode)?;
472            self.last_applied_term.store(record.term, Ordering::Release);
473            self.last_applied_lsn.store(record.lsn, Ordering::Release);
474            *self.last_payload_hash.lock().expect("payload hash mutex") = Some(payload_hash);
475            self.apply_wait.1.notify_all();
476            return Ok(ApplyOutcome::Applied);
477        }
478
479        if record.lsn == last {
480            let prior = *self.last_payload_hash.lock().expect("payload hash mutex");
481            return match prior {
482                Some(p) if p == payload_hash => Ok(ApplyOutcome::Idempotent),
483                Some(p) => Err(LogicalApplyError::Divergence {
484                    expected_term: last_term,
485                    got_term: record.term,
486                    lsn: record.lsn,
487                    expected: hex_digest(&p),
488                    got: hex_digest(&payload_hash),
489                }),
490                None => Ok(ApplyOutcome::Idempotent),
491            };
492        }
493        if record.lsn < last {
494            return Ok(ApplyOutcome::Skipped);
495        }
496        if record.lsn > last + 1 {
497            return Err(LogicalApplyError::Gap {
498                last,
499                next: record.lsn,
500            });
501        }
502
503        self.do_apply(db, record, mode)?;
504        self.last_applied_term.store(record.term, Ordering::Release);
505        self.last_applied_lsn.store(record.lsn, Ordering::Release);
506        *self.last_payload_hash.lock().expect("payload hash mutex") = Some(payload_hash);
507        self.apply_wait.1.notify_all();
508        Ok(ApplyOutcome::Applied)
509    }
510
511    fn do_apply(
512        &self,
513        db: &RedDB,
514        record: &ChangeRecord,
515        mode: ApplyMode,
516    ) -> Result<(), LogicalApplyError> {
517        Self::apply_record_with_metrics(db, record, mode, &self.metrics).map_err(|err| {
518            LogicalApplyError::Apply {
519                lsn: record.lsn,
520                source: err,
521            }
522        })
523    }
524
525    /// Stateless apply — applies the record without monotonicity
526    /// checks. Kept for callers that don't yet thread the stateful
527    /// applier through. New code should prefer
528    /// `LogicalChangeApplier::new()` + `apply()`. Apply misses (delete
529    /// against a missing target) are recorded into a throwaway metrics
530    /// handle; use [`apply_record_with_metrics`] to surface them.
531    pub fn apply_record(db: &RedDB, record: &ChangeRecord, mode: ApplyMode) -> RedDBResult<()> {
532        Self::apply_record_with_metrics(db, record, mode, &ReplicaApplyMetrics::default())
533    }
534
535    /// Stateless apply that records apply misses (issue #814) into
536    /// `metrics`. A delete against a missing collection or a missing
537    /// entity is a non-fatal divergence signal: it bumps
538    /// `ApplyErrorKind::Miss` and emits a structured warn line, but still
539    /// returns `Ok(())` so the LSN chain advances and idempotent re-pull
540    /// (#813) converges. A genuine (non-missing-target) store error on a
541    /// delete propagates as a real apply error — counted, fail-closed —
542    /// rather than being swallowed by the old `let _ =`.
543    pub fn apply_record_with_metrics(
544        db: &RedDB,
545        record: &ChangeRecord,
546        _mode: ApplyMode,
547        metrics: &ReplicaApplyMetrics,
548    ) -> RedDBResult<()> {
549        // Issue #1242 — compute payload bytes before the apply so we can
550        // increment the throughput counter unconditionally at the end (early
551        // `return Err` paths skip the counter, correctly counting only
552        // successful applies).
553        let payload_bytes: u64 = match record.operation {
554            ChangeOperation::Insert | ChangeOperation::Update => record
555                .entity_bytes
556                .as_ref()
557                .map(|b| b.len() as u64)
558                .unwrap_or(0),
559            ChangeOperation::Refresh => record
560                .refresh_records
561                .as_ref()
562                .map(|recs| recs.iter().map(|r| r.len() as u64).sum())
563                .unwrap_or(0),
564            ChangeOperation::Delete => 0,
565        };
566        let store = db.store();
567        match record.operation {
568            ChangeOperation::Delete => {
569                match store.delete(&record.collection, EntityId::new(record.entity_id)) {
570                    Ok(true) => {}
571                    Ok(false) => {
572                        // Target collection existed but no such entity —
573                        // the delete found nothing to remove.
574                        metrics.record(ApplyErrorKind::Miss);
575                        tracing::warn!(
576                            target: "reddb::replication::apply",
577                            lsn = record.lsn,
578                            collection = %record.collection,
579                            entity_id = record.entity_id,
580                            "replica delete found no matching entity; recorded apply miss (non-fatal divergence signal)"
581                        );
582                    }
583                    Err(crate::storage::StoreError::CollectionNotFound(name)) => {
584                        // The whole collection is absent on the replica —
585                        // a missed delete that can drive count drift.
586                        metrics.record(ApplyErrorKind::Miss);
587                        tracing::warn!(
588                            target: "reddb::replication::apply",
589                            lsn = record.lsn,
590                            collection = %name,
591                            entity_id = record.entity_id,
592                            "replica delete against missing collection; recorded apply miss (non-fatal divergence signal)"
593                        );
594                    }
595                    Err(err) => {
596                        // A real store error is a genuine apply failure:
597                        // surface it instead of discarding it so the
598                        // caller counts it and the replica fails closed.
599                        return Err(RedDBError::Internal(err.to_string()));
600                    }
601                }
602            }
603            ChangeOperation::Refresh => {
604                // Issue #596 slice 9d — replica replay of
605                // `REFRESH MATERIALIZED VIEW v`. The primary
606                // emitted the serialized backing-collection
607                // contents in `refresh_records`; apply the
608                // atomic swap on the replica's local store
609                // (which also persists a `RefreshCollection`
610                // WAL action so the post-swap contents survive
611                // a replica restart).
612                let records = record.refresh_records.clone().ok_or_else(|| {
613                    RedDBError::Internal(
614                        "replication refresh record missing refresh_records payload".to_string(),
615                    )
616                })?;
617                let mut replayed_xid = 0;
618                for bytes in &records {
619                    let (entity, _) =
620                        UnifiedStore::deserialize_entity_record(bytes, store.format_version())
621                            .map_err(|err| RedDBError::Internal(err.to_string()))?;
622                    replayed_xid = replayed_xid.max(entity.xmin).max(entity.xmax);
623                }
624                store
625                    .refresh_collection_from_records(&record.collection, records)
626                    .map_err(|err| RedDBError::Internal(err.to_string()))?;
627                if let Some(manager) = store.snapshot_manager.get() {
628                    manager.observe_committed_xid(replayed_xid);
629                }
630            }
631            ChangeOperation::Insert | ChangeOperation::Update => {
632                let Some(bytes) = &record.entity_bytes else {
633                    return Err(RedDBError::Internal(
634                        "replication record missing entity payload".to_string(),
635                    ));
636                };
637                let entity = UnifiedStore::deserialize_entity(bytes, store.format_version())
638                    .map_err(|err| RedDBError::Internal(err.to_string()))?;
639
640                // Issue #813 — MVCC table-row supersession on the replica.
641                //
642                // A table-row UPDATE on the primary installs a NEW physical
643                // version (fresh `EntityId`) that shares the row's stable
644                // `logical_id`, and marks the prior version superseded
645                // (`xmax != 0`) so snapshot reads skip it. Only the new
646                // version travels on the wire — the prior version's `xmax`
647                // bump is implicit. Without reproducing it here the replica
648                // leaves every prior version LIVE, so each update to a row
649                // accumulates a stale live duplicate and a full re-pull
650                // replays them all (the observed 22× inflation). Before
651                // upserting the incoming version, mark any *other* live
652                // version of the same logical id superseded — mirroring
653                // `install_versioned_table_row_update` on the primary. This
654                // is idempotent under re-pull: re-applying a record updates
655                // its version in place (resetting its `xmax` from the
656                // serialized bytes), and the last writer per logical id in
657                // LSN order wins, converging on the primary's live set.
658                if matches!(entity.kind, EntityKind::TableRow { .. }) {
659                    let logical = entity.logical_id();
660                    let new_id = entity.id;
661                    let superseding_xid = if entity.xmin != 0 { entity.xmin } else { 1 };
662                    let stale: Vec<_> = store
663                        .table_row_versions_by_logical_id(&record.collection, logical)
664                        .into_iter()
665                        .filter(|version| version.id != new_id && version.xmax == 0)
666                        .collect();
667                    if !stale.is_empty() {
668                        let manager = store
669                            .get_collection(&record.collection)
670                            .ok_or_else(|| RedDBError::NotFound(record.collection.clone()))?;
671                        for mut version in stale {
672                            version.set_xmax(superseding_xid);
673                            manager
674                                .update(version)
675                                .map_err(|err| RedDBError::Internal(err.to_string()))?;
676                        }
677                    }
678                }
679
680                let exists = store
681                    .get(&record.collection, EntityId::new(record.entity_id))
682                    .is_some();
683                if exists {
684                    let manager = store
685                        .get_collection(&record.collection)
686                        .ok_or_else(|| RedDBError::NotFound(record.collection.clone()))?;
687                    manager
688                        .update(entity.clone())
689                        .map_err(|err| RedDBError::Internal(err.to_string()))?;
690                } else {
691                    store
692                        .insert_auto(&record.collection, entity.clone())
693                        .map_err(|err| RedDBError::Internal(err.to_string()))?;
694                }
695                if let Some(metadata_json) = &record.metadata {
696                    let metadata_json = wire_json_to_server_json(metadata_json);
697                    let metadata = metadata_from_json(&metadata_json)
698                        .map_err(|err| RedDBError::Internal(err.to_string()))?;
699                    store
700                        .set_metadata(&record.collection, entity.id, metadata)
701                        .map_err(|err| RedDBError::Internal(err.to_string()))?;
702                }
703                store
704                    .context_index()
705                    .index_entity(&record.collection, &entity);
706                // The runtime owns the read snapshot allocator. Logical
707                // replay preserves source xids, so publish their floor only
708                // after the entity and its metadata have been installed.
709                if let Some(manager) = store.snapshot_manager.get() {
710                    manager.observe_committed_xid(entity.xmin.max(entity.xmax));
711                }
712            }
713        }
714        // Issue #1242 — only reached on successful apply (all early `return
715        // Err` paths skip this). Fenced and error records never reach here.
716        metrics
717            .bytes_applied_total
718            .fetch_add(payload_bytes, Ordering::Relaxed);
719        metrics
720            .records_applied_total
721            .fetch_add(1, Ordering::Relaxed);
722        Ok(())
723    }
724}
725
726fn record_payload_hash(record: &ChangeRecord) -> [u8; 32] {
727    let mut hasher = crate::crypto::sha256::Sha256::new();
728    hasher.update(&record.term.to_le_bytes());
729    hasher.update(&record.lsn.to_le_bytes());
730    hasher.update(&[record.operation as u8]);
731    hasher.update(record.collection.as_bytes());
732    hasher.update(&record.entity_id.to_le_bytes());
733    // Issue #991 — range authority participates in the payload hash so two
734    // records at the same LSN that differ only in range identity or ownership
735    // epoch are flagged divergent rather than silently treated as idempotent.
736    // `u64::MAX` stands in for an absent field (a value real epochs/ids never
737    // reach) so `None` and `Some(MAX)` stay distinguishable.
738    hasher.update(&record.range_id.unwrap_or(u64::MAX).to_le_bytes());
739    hasher.update(&record.ownership_epoch.unwrap_or(u64::MAX).to_le_bytes());
740    if let Some(bytes) = &record.entity_bytes {
741        hasher.update(bytes);
742    }
743    // Issue #596 slice 9d — refresh payload participates in the
744    // payload-hash so the same-LSN-idempotent / different-payload-
745    // divergence state machine works for Refresh records too.
746    if let Some(records) = &record.refresh_records {
747        hasher.update(&(records.len() as u64).to_le_bytes());
748        for r in records {
749            hasher.update(&(r.len() as u64).to_le_bytes());
750            hasher.update(r);
751        }
752    }
753    hasher.finalize()
754}
755
756fn hex_digest(bytes: &[u8; 32]) -> String {
757    crate::utils::to_hex(bytes)
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763    use crate::replication::cdc::ChangeOperation;
764    use crate::storage::{EntityData, EntityId, EntityKind, RedDB, RowData, UnifiedEntity};
765    use reddb_types::Value;
766    use std::sync::Arc;
767
768    fn open_db() -> (RedDB, std::path::PathBuf) {
769        let path = std::env::temp_dir().join(format!(
770            "reddb_logical_apply_{}_{}",
771            std::process::id(),
772            std::time::SystemTime::now()
773                .duration_since(std::time::UNIX_EPOCH)
774                .unwrap()
775                .as_nanos()
776        ));
777        let _ = std::fs::remove_file(&path);
778        let db = RedDB::open(&path).unwrap();
779        (db, path)
780    }
781
782    fn record(lsn: u64, payload: &[u8]) -> ChangeRecord {
783        let timestamp = 100 + lsn;
784        let mut entity = UnifiedEntity::new(
785            EntityId::new(lsn),
786            EntityKind::TableRow {
787                table: Arc::from("users"),
788                row_id: lsn,
789            },
790            EntityData::Row(RowData::with_names(
791                vec![Value::UnsignedInteger(lsn), Value::Blob(payload.to_vec())],
792                vec!["id".to_string(), "payload".to_string()],
793            )),
794        );
795        entity.created_at = timestamp;
796        entity.updated_at = timestamp;
797        entity.sequence_id = lsn;
798        change_record_from_entity(
799            lsn,
800            timestamp,
801            ChangeOperation::Insert,
802            "users",
803            "row",
804            &entity,
805            crate::api::REDDB_FORMAT_VERSION,
806            None,
807        )
808    }
809
810    fn delete_record(lsn: u64, collection: &str, entity_id: u64) -> ChangeRecord {
811        ChangeRecord {
812            term: crate::replication::DEFAULT_REPLICATION_TERM,
813            lsn,
814            timestamp: 100 + lsn,
815            operation: ChangeOperation::Delete,
816            collection: collection.to_string(),
817            entity_id,
818            entity_kind: "row".to_string(),
819            entity_bytes: None,
820            metadata: None,
821            refresh_records: None,
822            range_id: None,
823            ownership_epoch: None,
824        }
825    }
826
827    fn table_row_entity(id: u64) -> UnifiedEntity {
828        let mut entity = UnifiedEntity::new(
829            EntityId::new(id),
830            EntityKind::TableRow {
831                table: Arc::from("users"),
832                row_id: id,
833            },
834            EntityData::Row(RowData::with_names(
835                vec![Value::UnsignedInteger(id)],
836                vec!["id".to_string()],
837            )),
838        );
839        entity.created_at = 100 + id;
840        entity.updated_at = 100 + id;
841        entity.sequence_id = id;
842        entity
843    }
844
845    // Issue #814 — a delete against a missing collection must record an
846    // apply miss (not a silent no-op) and still return Ok so the LSN
847    // chain advances (idempotent re-pull, #813).
848    #[test]
849    fn delete_against_missing_collection_records_apply_miss() {
850        let (db, path) = open_db();
851        let metrics = ReplicaApplyMetrics::default();
852        let before = metrics.apply_miss_total.load(Ordering::Relaxed);
853
854        LogicalChangeApplier::apply_record_with_metrics(
855            &db,
856            &delete_record(1, "no_such_collection", 42),
857            ApplyMode::Replica,
858            &metrics,
859        )
860        .expect("missing-target delete is non-fatal");
861
862        assert_eq!(
863            metrics.apply_miss_total.load(Ordering::Relaxed),
864            before + 1,
865            "delete against a missing collection must bump the apply-miss signal"
866        );
867        let _ = std::fs::remove_file(path);
868    }
869
870    // Issue #814 — a delete against an existing collection but absent
871    // entity is likewise a recorded miss, not a swallowed no-op.
872    #[test]
873    fn delete_against_missing_entity_records_apply_miss() {
874        let (db, path) = open_db();
875        let _ = db.store().get_or_create_collection("users");
876        let metrics = ReplicaApplyMetrics::default();
877
878        LogicalChangeApplier::apply_record_with_metrics(
879            &db,
880            &delete_record(1, "users", 9999),
881            ApplyMode::Replica,
882            &metrics,
883        )
884        .expect("missing-entity delete is non-fatal");
885
886        assert_eq!(
887            metrics.apply_miss_total.load(Ordering::Relaxed),
888            1,
889            "delete of an absent entity must bump the apply-miss signal"
890        );
891        let _ = std::fs::remove_file(path);
892    }
893
894    // Issue #814 — the normal path (target present) deletes without
895    // firing the miss signal. No behavioral regression.
896    #[test]
897    fn delete_of_present_target_records_no_apply_miss() {
898        let (db, path) = open_db();
899        let store = db.store();
900        let _ = store.get_or_create_collection("users");
901        let id = store
902            .insert_auto("users", table_row_entity(1))
903            .expect("insert entity");
904        let metrics = ReplicaApplyMetrics::default();
905
906        LogicalChangeApplier::apply_record_with_metrics(
907            &db,
908            &delete_record(1, "users", id.raw()),
909            ApplyMode::Replica,
910            &metrics,
911        )
912        .expect("present-target delete applies");
913
914        assert_eq!(
915            metrics.apply_miss_total.load(Ordering::Relaxed),
916            0,
917            "deleting a present target must not fire the apply-miss signal"
918        );
919        assert!(
920            store.get("users", id).is_none(),
921            "the entity must actually be removed on the normal path"
922        );
923        let _ = std::fs::remove_file(path);
924    }
925
926    // Issue #814 — the shared-metrics handle on the stateful applier
927    // surfaces the miss so `/metrics` (which reads the same Arc) sees it.
928    #[test]
929    fn stateful_apply_surfaces_delete_miss_via_metrics_handle() {
930        let (db, path) = open_db();
931        let applier =
932            LogicalChangeApplier::with_metrics(0, Arc::new(ReplicaApplyMetrics::default()));
933
934        applier
935            .apply(&db, &delete_record(1, "ghost", 7), ApplyMode::Replica)
936            .expect("missing-target delete advances the chain");
937
938        assert_eq!(
939            applier.metrics().apply_miss_total.load(Ordering::Relaxed),
940            1,
941            "the applier's shared metrics handle must record the miss"
942        );
943        assert_eq!(
944            applier.last_applied_lsn(),
945            1,
946            "a non-fatal miss still advances the LSN chain"
947        );
948        let _ = std::fs::remove_file(path);
949    }
950
951    // Issue #1242 — bytes_applied_total and records_applied_total must
952    // reflect observed apply throughput: Applied increments both, Idempotent
953    // and Skipped increment neither, and deletes contribute 0 bytes but 1
954    // record.
955    #[test]
956    fn throughput_counters_reflect_applied_bytes_and_records() {
957        let (db, path) = open_db();
958        let metrics = Arc::new(ReplicaApplyMetrics::default());
959        let applier = LogicalChangeApplier::with_metrics(0, Arc::clone(&metrics));
960
961        // Three insert records with payloads of different sizes.
962        let r1 = record(1, b"hello");
963        let r2 = record(2, b"world-longer-payload");
964        let r3 = record(3, b"x");
965
966        // Compute expected bytes from the serialized entity_bytes (not the raw
967        // payload, which goes through entity serialization before it lands in
968        // entity_bytes).
969        let expected_bytes: u64 = [&r1, &r2, &r3]
970            .iter()
971            .map(|r| r.entity_bytes.as_ref().map(|b| b.len() as u64).unwrap_or(0))
972            .sum();
973        assert!(expected_bytes > 0, "test records must carry entity payload");
974
975        for r in [&r1, &r2, &r3] {
976            assert_eq!(
977                applier.apply(&db, r, ApplyMode::Replica).unwrap(),
978                ApplyOutcome::Applied
979            );
980        }
981
982        assert_eq!(
983            metrics.records_applied_total.load(Ordering::Relaxed),
984            3,
985            "three applied records must increment records counter by 3"
986        );
987        assert_eq!(
988            metrics.bytes_applied_total.load(Ordering::Relaxed),
989            expected_bytes,
990            "bytes counter must match the sum of entity_bytes across applied records"
991        );
992
993        // Idempotent replay must not increment either counter.
994        assert_eq!(
995            applier.apply(&db, &r3, ApplyMode::Replica).unwrap(),
996            ApplyOutcome::Idempotent
997        );
998        assert_eq!(
999            metrics.records_applied_total.load(Ordering::Relaxed),
1000            3,
1001            "idempotent replay must not increment the records counter"
1002        );
1003        assert_eq!(
1004            metrics.bytes_applied_total.load(Ordering::Relaxed),
1005            expected_bytes,
1006            "idempotent replay must not increment the bytes counter"
1007        );
1008
1009        // A delete-miss (non-fatal) increments records by 1 but contributes
1010        // 0 bytes — deletes carry no entity payload.
1011        let del = delete_record(4, "ghost_collection", 999);
1012        applier
1013            .apply(&db, &del, ApplyMode::Replica)
1014            .expect("delete miss is non-fatal and still advances the chain");
1015        assert_eq!(
1016            metrics.records_applied_total.load(Ordering::Relaxed),
1017            4,
1018            "a delete (including a miss) must increment the records counter"
1019        );
1020        assert_eq!(
1021            metrics.bytes_applied_total.load(Ordering::Relaxed),
1022            expected_bytes,
1023            "a delete contributes 0 bytes to the bytes counter"
1024        );
1025
1026        let _ = std::fs::remove_file(path);
1027    }
1028
1029    #[test]
1030    fn apply_advances_on_monotonic_lsn() {
1031        let (db, path) = open_db();
1032        let applier = LogicalChangeApplier::new(0);
1033        assert_eq!(
1034            applier
1035                .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1036                .unwrap(),
1037            ApplyOutcome::Applied
1038        );
1039        assert_eq!(applier.last_applied_lsn(), 1);
1040        assert_eq!(
1041            applier
1042                .apply(&db, &record(2, b"b"), ApplyMode::Replica)
1043                .unwrap(),
1044            ApplyOutcome::Applied
1045        );
1046        assert_eq!(applier.last_applied_lsn(), 2);
1047        let _ = std::fs::remove_file(path);
1048    }
1049
1050    #[test]
1051    fn apply_idempotent_on_duplicate_lsn_same_payload() {
1052        let (db, path) = open_db();
1053        let applier = LogicalChangeApplier::new(0);
1054        let r = record(5, b"same");
1055        applier.apply(&db, &r, ApplyMode::Replica).unwrap();
1056        assert_eq!(
1057            applier.apply(&db, &r, ApplyMode::Replica).unwrap(),
1058            ApplyOutcome::Idempotent
1059        );
1060        assert_eq!(applier.last_applied_lsn(), 5);
1061        let _ = std::fs::remove_file(path);
1062    }
1063
1064    #[test]
1065    fn apply_fails_closed_on_lsn_collision_diff_payload() {
1066        let (db, path) = open_db();
1067        let applier = LogicalChangeApplier::new(0);
1068        applier
1069            .apply(&db, &record(7, b"first"), ApplyMode::Replica)
1070            .unwrap();
1071        let err = applier
1072            .apply(&db, &record(7, b"different"), ApplyMode::Replica)
1073            .unwrap_err();
1074        assert!(
1075            matches!(err, LogicalApplyError::Divergence { lsn: 7, .. }),
1076            "got {err:?}"
1077        );
1078        let _ = std::fs::remove_file(path);
1079    }
1080
1081    #[test]
1082    fn apply_fails_closed_on_same_lsn_different_term() {
1083        let (db, path) = open_db();
1084        let applier = LogicalChangeApplier::new(0);
1085        applier
1086            .apply(&db, &record(7, b"same").with_term(1), ApplyMode::Replica)
1087            .unwrap();
1088        let err = applier
1089            .apply(&db, &record(7, b"same").with_term(2), ApplyMode::Replica)
1090            .unwrap_err();
1091        assert!(
1092            matches!(
1093                err,
1094                LogicalApplyError::Divergence {
1095                    lsn: 7,
1096                    expected_term: 1,
1097                    got_term: 2,
1098                    ..
1099                }
1100            ),
1101            "got {err:?}"
1102        );
1103        assert_eq!(applier.last_applied_term(), 1);
1104        assert_eq!(applier.last_applied_lsn(), 7);
1105        let _ = std::fs::remove_file(path);
1106    }
1107
1108    // Issue #835 — a record from a term behind the replica's adopted term
1109    // is fenced at the apply boundary: rejected, counted, and crucially the
1110    // LSN/term chain does NOT advance, so a returning ex-primary on a stale
1111    // term cannot move any watermark.
1112    #[test]
1113    fn apply_fences_stale_term_record() {
1114        let (db, path) = open_db();
1115        let applier = LogicalChangeApplier::new(0);
1116
1117        // Replica adopts term 5 from the legitimate primary at lsn 1.
1118        applier
1119            .apply(&db, &record(1, b"a").with_term(5), ApplyMode::Replica)
1120            .unwrap();
1121        assert_eq!(applier.last_applied_term(), 5);
1122        assert_eq!(applier.last_applied_lsn(), 1);
1123
1124        // A returning ex-primary streams the next record on the old term 4.
1125        let before = applier.metrics().fenced_total.load(Ordering::Relaxed);
1126        let err = applier
1127            .apply(&db, &record(2, b"b").with_term(4), ApplyMode::Replica)
1128            .unwrap_err();
1129        assert!(
1130            matches!(
1131                err,
1132                LogicalApplyError::StaleTermFenced {
1133                    record_term: 4,
1134                    current_term: 5,
1135                    lsn: 2,
1136                }
1137            ),
1138            "got {err:?}"
1139        );
1140        assert_eq!(err.kind(), ApplyErrorKind::Fenced);
1141        assert_eq!(
1142            applier.metrics().fenced_total.load(Ordering::Relaxed),
1143            before + 1,
1144            "the fence must leave a metrics trail"
1145        );
1146        // The fenced record advanced nothing — no apply, no watermark move.
1147        assert_eq!(applier.last_applied_lsn(), 1, "watermark must not advance");
1148        assert_eq!(applier.last_applied_term(), 5);
1149        assert_eq!(
1150            applier.received_frontier_lsn(),
1151            1,
1152            "a fenced record must not even advance the received frontier"
1153        );
1154        let _ = std::fs::remove_file(path);
1155    }
1156
1157    // The fence only bites a *behind* term. A record on the same term
1158    // applies normally, and a record on a higher term is the new primary's
1159    // timeline — adopted on apply.
1160    #[test]
1161    fn apply_admits_same_term_and_adopts_higher_term() {
1162        let (db, path) = open_db();
1163        let applier = LogicalChangeApplier::new(0);
1164
1165        applier
1166            .apply(&db, &record(1, b"a").with_term(3), ApplyMode::Replica)
1167            .unwrap();
1168        // Same term → admitted.
1169        applier
1170            .apply(&db, &record(2, b"b").with_term(3), ApplyMode::Replica)
1171            .unwrap();
1172        assert_eq!(applier.last_applied_term(), 3);
1173        // Higher term → adopted.
1174        applier
1175            .apply(&db, &record(3, b"c").with_term(7), ApplyMode::Replica)
1176            .unwrap();
1177        assert_eq!(applier.last_applied_term(), 7);
1178        assert_eq!(applier.last_applied_lsn(), 3);
1179
1180        // Now a record back on term 3 is fenced.
1181        let err = applier
1182            .apply(&db, &record(4, b"d").with_term(3), ApplyMode::Replica)
1183            .unwrap_err();
1184        assert!(
1185            matches!(err, LogicalApplyError::StaleTermFenced { .. }),
1186            "got {err:?}"
1187        );
1188        let _ = std::fs::remove_file(path);
1189    }
1190
1191    // Issue #991 — a record stamped for the target range but carrying an
1192    // ownership epoch behind the range's accepted epoch is a write from a
1193    // deposed owner. It is fenced at the apply boundary: rejected, counted on
1194    // the Fenced lane, and the LSN/term chain does not advance.
1195    #[test]
1196    fn apply_fenced_rejects_stale_ownership_epoch() {
1197        let (db, path) = open_db();
1198        let applier = LogicalChangeApplier::new(0);
1199        let fence = RangeAuthority {
1200            range_id: 7,
1201            min_term: 1,
1202            min_ownership_epoch: 5,
1203        };
1204
1205        let stale = record(1, b"a").with_range_authority(7, 4);
1206        let before = applier.metrics().fenced_total.load(Ordering::Relaxed);
1207        let err = applier
1208            .apply_fenced(&db, &stale, ApplyMode::Replica, Some(&fence))
1209            .unwrap_err();
1210        assert!(
1211            matches!(
1212                err,
1213                LogicalApplyError::RangeFenced {
1214                    range_id: 7,
1215                    lsn: 1,
1216                    reason: RangeAdmitError::StaleOwnershipEpoch {
1217                        record_epoch: 4,
1218                        accepted_epoch: 5,
1219                    },
1220                }
1221            ),
1222            "got {err:?}"
1223        );
1224        assert_eq!(err.kind(), ApplyErrorKind::Fenced);
1225        assert_eq!(
1226            applier.metrics().fenced_total.load(Ordering::Relaxed),
1227            before + 1
1228        );
1229        assert_eq!(applier.last_applied_lsn(), 0, "watermark must not advance");
1230        assert_eq!(
1231            applier.received_frontier_lsn(),
1232            0,
1233            "a range-fenced record must not advance the received frontier"
1234        );
1235        let _ = std::fs::remove_file(path);
1236    }
1237
1238    // Issue #991 — the same fence applies on the recovery/restore path: a
1239    // record on a stale term for the target range is rejected there too.
1240    #[test]
1241    fn apply_fenced_rejects_stale_range_term_on_restore() {
1242        let (db, path) = open_db();
1243        let applier = LogicalChangeApplier::new(0);
1244        let fence = RangeAuthority {
1245            range_id: 3,
1246            min_term: 6,
1247            min_ownership_epoch: 1,
1248        };
1249
1250        let stale = record(1, b"a").with_term(4).with_range_authority(3, 9);
1251        let err = applier
1252            .apply_fenced(&db, &stale, ApplyMode::Restore, Some(&fence))
1253            .unwrap_err();
1254        assert!(
1255            matches!(
1256                err,
1257                LogicalApplyError::RangeFenced {
1258                    range_id: 3,
1259                    reason: RangeAdmitError::StaleTerm {
1260                        record_term: 4,
1261                        accepted_term: 6,
1262                    },
1263                    ..
1264                }
1265            ),
1266            "got {err:?}"
1267        );
1268        let _ = std::fs::remove_file(path);
1269    }
1270
1271    // Issue #991 — a record current for the range applies through the fence,
1272    // and a record for a *different* range is not gated by this fence.
1273    #[test]
1274    fn apply_fenced_admits_current_and_ignores_other_ranges() {
1275        let (db, path) = open_db();
1276        let applier = LogicalChangeApplier::new(0);
1277        let fence = RangeAuthority {
1278            range_id: 7,
1279            min_term: 1,
1280            min_ownership_epoch: 5,
1281        };
1282
1283        // Current epoch for the fenced range → applies and advances.
1284        applier
1285            .apply_fenced(
1286                &db,
1287                &record(1, b"a").with_range_authority(7, 5),
1288                ApplyMode::Replica,
1289                Some(&fence),
1290            )
1291            .expect("current record applies");
1292        assert_eq!(applier.last_applied_lsn(), 1);
1293
1294        // A record stamped for a different (stale-looking) range is not this
1295        // fence's concern and still applies.
1296        applier
1297            .apply_fenced(
1298                &db,
1299                &record(2, b"b").with_range_authority(99, 1),
1300                ApplyMode::Replica,
1301                Some(&fence),
1302            )
1303            .expect("other-range record bypasses this fence");
1304        assert_eq!(applier.last_applied_lsn(), 2);
1305        let _ = std::fs::remove_file(path);
1306    }
1307
1308    // Issue #992 — end-to-end range-indexed catch-up over the single physical
1309    // WAL: a follower plans its range's work out of the shared stream, then
1310    // applies exactly that work through the same `apply_fenced` gate. The other
1311    // range's records and a deposed-owner write never reach apply.
1312    #[test]
1313    fn range_catchup_plan_applies_only_its_range_through_the_fence() {
1314        use crate::replication::cdc::{plan_range_catchup, RangeStreamPosition, RangeStreamReject};
1315
1316        let (db, path) = open_db();
1317        let applier = LogicalChangeApplier::new(0);
1318
1319        // One sequential WAL slice: range 7 at LSN 1..3 (epoch 5), range 9 at
1320        // 4..5, and a returning ex-owner of range 7 at LSN 6 on a stale epoch.
1321        let stream = vec![
1322            record(1, b"a").with_range_authority(7, 5),
1323            record(2, b"b").with_range_authority(7, 5),
1324            record(3, b"c").with_range_authority(7, 5),
1325            record(4, b"d").with_range_authority(9, 2),
1326            record(5, b"e").with_range_authority(9, 2),
1327            record(6, b"f").with_range_authority(7, 4),
1328        ];
1329
1330        // Range-7 follower resumes from origin, already knowing owner epoch 5.
1331        let position = RangeStreamPosition::new(7, 0, 1, 5);
1332        let plan = plan_range_catchup(&position, &stream);
1333
1334        // Only range 7's current records were selected; the stale-epoch write
1335        // is rejected, not selected.
1336        assert_eq!(plan.apply, vec![0, 1, 2]);
1337        assert_eq!(
1338            plan.rejected,
1339            vec![RangeStreamReject {
1340                lsn: 6,
1341                error: RangeAdmitError::StaleOwnershipEpoch {
1342                    record_epoch: 4,
1343                    accepted_epoch: 5,
1344                },
1345            }]
1346        );
1347        assert_eq!(plan.resume.applied_lsn, 3);
1348
1349        // Apply exactly the planned records through the per-range fence.
1350        let fence = position.authority();
1351        for index in &plan.apply {
1352            applier
1353                .apply_fenced(&db, &stream[*index], ApplyMode::Replica, Some(&fence))
1354                .expect("planned record applies through the fence");
1355        }
1356        assert_eq!(applier.last_applied_lsn(), 3);
1357
1358        // The deposed-owner record the plan refused is exactly what the apply
1359        // fence would also reject, never advancing the chain.
1360        let stale = &stream[5];
1361        let err = applier
1362            .apply_fenced(&db, stale, ApplyMode::Replica, Some(&fence))
1363            .unwrap_err();
1364        assert!(
1365            matches!(err, LogicalApplyError::RangeFenced { range_id: 7, .. }),
1366            "got {err:?}"
1367        );
1368        assert_eq!(
1369            applier.last_applied_lsn(),
1370            3,
1371            "stale write must not advance"
1372        );
1373        let _ = std::fs::remove_file(path);
1374    }
1375
1376    #[test]
1377    fn apply_skips_older_lsn() {
1378        let (db, path) = open_db();
1379        let applier = LogicalChangeApplier::new(0);
1380        applier
1381            .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1382            .unwrap();
1383        applier
1384            .apply(&db, &record(2, b"b"), ApplyMode::Replica)
1385            .unwrap();
1386        assert_eq!(
1387            applier
1388                .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1389                .unwrap(),
1390            ApplyOutcome::Skipped
1391        );
1392        assert_eq!(applier.last_applied_lsn(), 2);
1393        let _ = std::fs::remove_file(path);
1394    }
1395
1396    #[test]
1397    fn apply_returns_gap_on_future_lsn() {
1398        let (db, path) = open_db();
1399        let applier = LogicalChangeApplier::new(0);
1400        applier
1401            .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1402            .unwrap();
1403        let err = applier
1404            .apply(&db, &record(5, b"e"), ApplyMode::Replica)
1405            .unwrap_err();
1406        assert!(
1407            matches!(err, LogicalApplyError::Gap { last: 1, next: 5 }),
1408            "got {err:?}"
1409        );
1410        assert_eq!(applier.last_applied_lsn(), 1);
1411        let _ = std::fs::remove_file(path);
1412    }
1413}