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