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