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.xmin).max(entity.xmax);
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.xmin.max(entity.xmax);
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        let applier = Arc::new(LogicalChangeApplier::new(0));
1110        let barrier = Arc::new(std::sync::Barrier::new(2));
1111        *applier
1112            .metrics()
1113            .transaction_member_barrier
1114            .lock()
1115            .expect("barrier mutex") = Some(barrier.clone());
1116        let transaction =
1117            ChangeRecord::for_transaction(1, 1, vec![record(1, b"a"), record(2, b"b")]);
1118        let writer = replica.clone();
1119        let worker = std::thread::spawn(move || {
1120            applier.apply(writer.db().as_ref(), &transaction, ApplyMode::Replica)
1121        });
1122        barrier.wait();
1123        let during = replica.execute_query("SELECT id FROM users");
1124        barrier.wait();
1125        worker
1126            .join()
1127            .expect("apply worker")
1128            .expect("apply transaction");
1129        assert!(during.expect("concurrent query").result.records.is_empty());
1130        assert_eq!(
1131            replica
1132                .execute_query("SELECT id FROM users")
1133                .expect("query")
1134                .result
1135                .records
1136                .len(),
1137            2
1138        );
1139    }
1140
1141    #[test]
1142    fn transaction_retry_supersedes_versions_from_aborted_apply() {
1143        let replica = crate::RedDBRuntime::with_options(crate::RedDBOptions::in_memory())
1144            .expect("in-memory replica should open");
1145        replica
1146            .execute_query("CREATE TABLE users (id INT, payload BLOB)")
1147            .expect("users table should be created");
1148        let applier = LogicalChangeApplier::new(0);
1149        applier
1150            .apply(
1151                replica.db().as_ref(),
1152                &record(1, b"old"),
1153                ApplyMode::Replica,
1154            )
1155            .expect("initial record should apply");
1156        let mut update = record(2, b"new");
1157        let mut entity = UnifiedStore::deserialize_entity(
1158            update
1159                .entity_bytes
1160                .as_ref()
1161                .expect("update record should contain an entity"),
1162            crate::api::REDDB_FORMAT_VERSION,
1163        )
1164        .expect("update entity should deserialize");
1165        entity.set_logical_id(EntityId::new(1));
1166        update.entity_bytes = Some(UnifiedStore::serialize_entity(
1167            &entity,
1168            replica.db().store().format_version(),
1169        ));
1170        update.operation = ChangeOperation::Update;
1171        let transaction = ChangeRecord::for_transaction(2, 1, vec![update]);
1172        replica
1173            .db()
1174            .store()
1175            .test_fail_deferred_wal_append
1176            .store(true, Ordering::Release);
1177        assert!(applier
1178            .apply(replica.db().as_ref(), &transaction, ApplyMode::Replica)
1179            .is_err());
1180        assert_eq!(
1181            replica
1182                .execute_query("SELECT * FROM users")
1183                .expect("users query should succeed after failed apply")
1184                .result
1185                .records
1186                .len(),
1187            1
1188        );
1189        applier
1190            .apply(replica.db().as_ref(), &transaction, ApplyMode::Replica)
1191            .expect("transaction retry should apply");
1192        let versions = replica
1193            .db()
1194            .store()
1195            .get_collection("users")
1196            .expect("users collection should exist")
1197            .query_all(|_| true);
1198        assert_eq!(
1199            replica
1200                .execute_query("SELECT * FROM users")
1201                .expect("users query should succeed after retry")
1202                .result
1203                .records
1204                .len(),
1205            1,
1206            "versions: {:?}",
1207            versions
1208                .iter()
1209                .map(|row| (row.id, row.logical_id(), row.xmin, row.xmax))
1210                .collect::<Vec<_>>()
1211        );
1212        assert_eq!(applier.last_applied_lsn(), 2);
1213    }
1214
1215    #[test]
1216    fn failed_replay_cannot_hide_an_already_committed_transaction() {
1217        let replica = crate::RedDBRuntime::with_options(crate::RedDBOptions::in_memory())
1218            .expect("in-memory replica should open");
1219        replica
1220            .execute_query("CREATE TABLE users (id INT, payload BLOB)")
1221            .expect("users table should be created");
1222        let transaction =
1223            ChangeRecord::for_transaction(1, 1, vec![record(1, b"a"), record(2, b"b")]);
1224        LogicalChangeApplier::new(0)
1225            .apply(replica.db().as_ref(), &transaction, ApplyMode::Replica)
1226            .expect("transaction should apply initially");
1227        replica
1228            .db()
1229            .store()
1230            .test_fail_deferred_wal_append
1231            .store(true, Ordering::Release);
1232        assert!(LogicalChangeApplier::new(0)
1233            .apply(replica.db().as_ref(), &transaction, ApplyMode::Replica)
1234            .is_err());
1235        assert_eq!(
1236            replica
1237                .execute_query("SELECT * FROM users")
1238                .expect("users query should succeed after failed replay")
1239                .result
1240                .records
1241                .len(),
1242            2
1243        );
1244    }
1245
1246    fn open_db() -> (RedDB, std::path::PathBuf) {
1247        let path = std::env::temp_dir().join(format!(
1248            "reddb_logical_apply_{}_{}",
1249            std::process::id(),
1250            std::time::SystemTime::now()
1251                .duration_since(std::time::UNIX_EPOCH)
1252                .unwrap()
1253                .as_nanos()
1254        ));
1255        let _ = std::fs::remove_file(&path);
1256        let db = RedDB::open(&path).unwrap();
1257        (db, path)
1258    }
1259
1260    fn record(lsn: u64, payload: &[u8]) -> ChangeRecord {
1261        let timestamp = 100 + lsn;
1262        let mut entity = UnifiedEntity::new(
1263            EntityId::new(lsn),
1264            EntityKind::TableRow {
1265                table: Arc::from("users"),
1266                row_id: lsn,
1267            },
1268            EntityData::Row(RowData::with_names(
1269                vec![Value::UnsignedInteger(lsn), Value::Blob(payload.to_vec())],
1270                vec!["id".to_string(), "payload".to_string()],
1271            )),
1272        );
1273        entity.created_at = timestamp;
1274        entity.updated_at = timestamp;
1275        entity.sequence_id = lsn;
1276        change_record_from_entity(
1277            lsn,
1278            timestamp,
1279            ChangeOperation::Insert,
1280            "users",
1281            "row",
1282            &entity,
1283            crate::api::REDDB_FORMAT_VERSION,
1284            None,
1285        )
1286    }
1287
1288    fn delete_record(lsn: u64, collection: &str, entity_id: u64) -> ChangeRecord {
1289        ChangeRecord {
1290            term: crate::replication::DEFAULT_REPLICATION_TERM,
1291            lsn,
1292            timestamp: 100 + lsn,
1293            operation: ChangeOperation::Delete,
1294            collection: collection.to_string(),
1295            entity_id,
1296            entity_kind: "row".to_string(),
1297            entity_bytes: None,
1298            metadata: None,
1299            refresh_records: None,
1300            range_id: None,
1301            ownership_epoch: None,
1302        }
1303    }
1304
1305    fn table_row_entity(id: u64) -> UnifiedEntity {
1306        let mut entity = UnifiedEntity::new(
1307            EntityId::new(id),
1308            EntityKind::TableRow {
1309                table: Arc::from("users"),
1310                row_id: id,
1311            },
1312            EntityData::Row(RowData::with_names(
1313                vec![Value::UnsignedInteger(id)],
1314                vec!["id".to_string()],
1315            )),
1316        );
1317        entity.created_at = 100 + id;
1318        entity.updated_at = 100 + id;
1319        entity.sequence_id = id;
1320        entity
1321    }
1322
1323    // Issue #814 — a delete against a missing collection must record an
1324    // apply miss (not a silent no-op) and still return Ok so the LSN
1325    // chain advances (idempotent re-pull, #813).
1326    #[test]
1327    fn delete_against_missing_collection_records_apply_miss() {
1328        let (db, path) = open_db();
1329        let metrics = ReplicaApplyMetrics::default();
1330        let before = metrics.apply_miss_total.load(Ordering::Relaxed);
1331
1332        LogicalChangeApplier::apply_record_with_metrics(
1333            &db,
1334            &delete_record(1, "no_such_collection", 42),
1335            ApplyMode::Replica,
1336            &metrics,
1337        )
1338        .expect("missing-target delete is non-fatal");
1339
1340        assert_eq!(
1341            metrics.apply_miss_total.load(Ordering::Relaxed),
1342            before + 1,
1343            "delete against a missing collection must bump the apply-miss signal"
1344        );
1345        let _ = std::fs::remove_file(path);
1346    }
1347
1348    // Issue #814 — a delete against an existing collection but absent
1349    // entity is likewise a recorded miss, not a swallowed no-op.
1350    #[test]
1351    fn delete_against_missing_entity_records_apply_miss() {
1352        let (db, path) = open_db();
1353        let _ = db.store().get_or_create_collection("users");
1354        let metrics = ReplicaApplyMetrics::default();
1355
1356        LogicalChangeApplier::apply_record_with_metrics(
1357            &db,
1358            &delete_record(1, "users", 9999),
1359            ApplyMode::Replica,
1360            &metrics,
1361        )
1362        .expect("missing-entity delete is non-fatal");
1363
1364        assert_eq!(
1365            metrics.apply_miss_total.load(Ordering::Relaxed),
1366            1,
1367            "delete of an absent entity must bump the apply-miss signal"
1368        );
1369        let _ = std::fs::remove_file(path);
1370    }
1371
1372    // Issue #814 — the normal path (target present) deletes without
1373    // firing the miss signal. No behavioral regression.
1374    #[test]
1375    fn delete_of_present_target_records_no_apply_miss() {
1376        let (db, path) = open_db();
1377        let store = db.store();
1378        let _ = store.get_or_create_collection("users");
1379        let id = store
1380            .insert_auto("users", table_row_entity(1))
1381            .expect("insert entity");
1382        let metrics = ReplicaApplyMetrics::default();
1383
1384        LogicalChangeApplier::apply_record_with_metrics(
1385            &db,
1386            &delete_record(1, "users", id.raw()),
1387            ApplyMode::Replica,
1388            &metrics,
1389        )
1390        .expect("present-target delete applies");
1391
1392        assert_eq!(
1393            metrics.apply_miss_total.load(Ordering::Relaxed),
1394            0,
1395            "deleting a present target must not fire the apply-miss signal"
1396        );
1397        assert!(
1398            store.get("users", id).is_none(),
1399            "the entity must actually be removed on the normal path"
1400        );
1401        let _ = std::fs::remove_file(path);
1402    }
1403
1404    // Issue #814 — the shared-metrics handle on the stateful applier
1405    // surfaces the miss so `/metrics` (which reads the same Arc) sees it.
1406    #[test]
1407    fn stateful_apply_surfaces_delete_miss_via_metrics_handle() {
1408        let (db, path) = open_db();
1409        let applier =
1410            LogicalChangeApplier::with_metrics(0, Arc::new(ReplicaApplyMetrics::default()));
1411
1412        applier
1413            .apply(&db, &delete_record(1, "ghost", 7), ApplyMode::Replica)
1414            .expect("missing-target delete advances the chain");
1415
1416        assert_eq!(
1417            applier.metrics().apply_miss_total.load(Ordering::Relaxed),
1418            1,
1419            "the applier's shared metrics handle must record the miss"
1420        );
1421        assert_eq!(
1422            applier.last_applied_lsn(),
1423            1,
1424            "a non-fatal miss still advances the LSN chain"
1425        );
1426        let _ = std::fs::remove_file(path);
1427    }
1428
1429    // Issue #1242 — bytes_applied_total and records_applied_total must
1430    // reflect observed apply throughput: Applied increments both, Idempotent
1431    // and Skipped increment neither, and deletes contribute 0 bytes but 1
1432    // record.
1433    #[test]
1434    fn throughput_counters_reflect_applied_bytes_and_records() {
1435        let (db, path) = open_db();
1436        let metrics = Arc::new(ReplicaApplyMetrics::default());
1437        let applier = LogicalChangeApplier::with_metrics(0, Arc::clone(&metrics));
1438
1439        // Three insert records with payloads of different sizes.
1440        let r1 = record(1, b"hello");
1441        let r2 = record(2, b"world-longer-payload");
1442        let r3 = record(3, b"x");
1443
1444        // Compute expected bytes from the serialized entity_bytes (not the raw
1445        // payload, which goes through entity serialization before it lands in
1446        // entity_bytes).
1447        let expected_bytes: u64 = [&r1, &r2, &r3]
1448            .iter()
1449            .map(|r| r.entity_bytes.as_ref().map(|b| b.len() as u64).unwrap_or(0))
1450            .sum();
1451        assert!(expected_bytes > 0, "test records must carry entity payload");
1452
1453        for r in [&r1, &r2, &r3] {
1454            assert_eq!(
1455                applier.apply(&db, r, ApplyMode::Replica).unwrap(),
1456                ApplyOutcome::Applied
1457            );
1458        }
1459
1460        assert_eq!(
1461            metrics.records_applied_total.load(Ordering::Relaxed),
1462            3,
1463            "three applied records must increment records counter by 3"
1464        );
1465        assert_eq!(
1466            metrics.bytes_applied_total.load(Ordering::Relaxed),
1467            expected_bytes,
1468            "bytes counter must match the sum of entity_bytes across applied records"
1469        );
1470
1471        // Idempotent replay must not increment either counter.
1472        assert_eq!(
1473            applier.apply(&db, &r3, ApplyMode::Replica).unwrap(),
1474            ApplyOutcome::Idempotent
1475        );
1476        assert_eq!(
1477            metrics.records_applied_total.load(Ordering::Relaxed),
1478            3,
1479            "idempotent replay must not increment the records counter"
1480        );
1481        assert_eq!(
1482            metrics.bytes_applied_total.load(Ordering::Relaxed),
1483            expected_bytes,
1484            "idempotent replay must not increment the bytes counter"
1485        );
1486
1487        // A delete-miss (non-fatal) increments records by 1 but contributes
1488        // 0 bytes — deletes carry no entity payload.
1489        let del = delete_record(4, "ghost_collection", 999);
1490        applier
1491            .apply(&db, &del, ApplyMode::Replica)
1492            .expect("delete miss is non-fatal and still advances the chain");
1493        assert_eq!(
1494            metrics.records_applied_total.load(Ordering::Relaxed),
1495            4,
1496            "a delete (including a miss) must increment the records counter"
1497        );
1498        assert_eq!(
1499            metrics.bytes_applied_total.load(Ordering::Relaxed),
1500            expected_bytes,
1501            "a delete contributes 0 bytes to the bytes counter"
1502        );
1503
1504        let _ = std::fs::remove_file(path);
1505    }
1506
1507    #[test]
1508    fn apply_advances_on_monotonic_lsn() {
1509        let (db, path) = open_db();
1510        let applier = LogicalChangeApplier::new(0);
1511        assert_eq!(
1512            applier
1513                .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1514                .unwrap(),
1515            ApplyOutcome::Applied
1516        );
1517        assert_eq!(applier.last_applied_lsn(), 1);
1518        assert_eq!(
1519            applier
1520                .apply(&db, &record(2, b"b"), ApplyMode::Replica)
1521                .unwrap(),
1522            ApplyOutcome::Applied
1523        );
1524        assert_eq!(applier.last_applied_lsn(), 2);
1525        let _ = std::fs::remove_file(path);
1526    }
1527
1528    #[test]
1529    fn bookmark_timeout_does_not_wait_for_inflight_apply_lock() {
1530        let applier = std::sync::Arc::new(LogicalChangeApplier::new(0));
1531        let apply = applier.apply_lock.lock().expect("inflight apply");
1532        let reader = std::sync::Arc::clone(&applier);
1533        let (send, receive) = std::sync::mpsc::channel();
1534        let thread = std::thread::spawn(move || {
1535            send.send(reader.wait_for_bookmark(
1536                &crate::replication::CausalBookmark::new(1, 1),
1537                std::time::Duration::from_millis(20),
1538            ))
1539            .expect("send result");
1540        });
1541        let result = receive.recv_timeout(std::time::Duration::from_secs(2));
1542        drop(apply);
1543        thread.join().expect("waiter");
1544        assert!(matches!(
1545            result.expect("bookmark respects its deadline during apply"),
1546            Err(BookmarkWaitError::Timeout { .. })
1547        ));
1548    }
1549
1550    #[test]
1551    fn apply_idempotent_on_duplicate_lsn_same_payload() {
1552        let (db, path) = open_db();
1553        let applier = LogicalChangeApplier::new(0);
1554        let r = record(5, b"same");
1555        applier.apply(&db, &r, ApplyMode::Replica).unwrap();
1556        assert_eq!(
1557            applier.apply(&db, &r, ApplyMode::Replica).unwrap(),
1558            ApplyOutcome::Idempotent
1559        );
1560        assert_eq!(applier.last_applied_lsn(), 5);
1561        let _ = std::fs::remove_file(path);
1562    }
1563
1564    #[test]
1565    fn apply_fails_closed_on_lsn_collision_diff_payload() {
1566        let (db, path) = open_db();
1567        let applier = LogicalChangeApplier::new(0);
1568        applier
1569            .apply(&db, &record(7, b"first"), ApplyMode::Replica)
1570            .unwrap();
1571        let err = applier
1572            .apply(&db, &record(7, b"different"), ApplyMode::Replica)
1573            .unwrap_err();
1574        assert!(
1575            matches!(err, LogicalApplyError::Divergence { lsn: 7, .. }),
1576            "got {err:?}"
1577        );
1578        let _ = std::fs::remove_file(path);
1579    }
1580
1581    #[test]
1582    fn apply_fails_closed_on_same_lsn_different_term() {
1583        let (db, path) = open_db();
1584        let applier = LogicalChangeApplier::new(0);
1585        applier
1586            .apply(&db, &record(7, b"same").with_term(1), ApplyMode::Replica)
1587            .unwrap();
1588        let err = applier
1589            .apply(&db, &record(7, b"same").with_term(2), ApplyMode::Replica)
1590            .unwrap_err();
1591        assert!(
1592            matches!(
1593                err,
1594                LogicalApplyError::Divergence {
1595                    lsn: 7,
1596                    expected_term: 1,
1597                    got_term: 2,
1598                    ..
1599                }
1600            ),
1601            "got {err:?}"
1602        );
1603        assert_eq!(applier.last_applied_term(), 1);
1604        assert_eq!(applier.last_applied_lsn(), 7);
1605        let _ = std::fs::remove_file(path);
1606    }
1607
1608    // Issue #835 — a record from a term behind the replica's adopted term
1609    // is fenced at the apply boundary: rejected, counted, and crucially the
1610    // LSN/term chain does NOT advance, so a returning ex-primary on a stale
1611    // term cannot move any watermark.
1612    #[test]
1613    fn apply_fences_stale_term_record() {
1614        let (db, path) = open_db();
1615        let applier = LogicalChangeApplier::new(0);
1616
1617        // Replica adopts term 5 from the legitimate primary at lsn 1.
1618        applier
1619            .apply(&db, &record(1, b"a").with_term(5), ApplyMode::Replica)
1620            .unwrap();
1621        assert_eq!(applier.last_applied_term(), 5);
1622        assert_eq!(applier.last_applied_lsn(), 1);
1623
1624        // A returning ex-primary streams the next record on the old term 4.
1625        let before = applier.metrics().fenced_total.load(Ordering::Relaxed);
1626        let err = applier
1627            .apply(&db, &record(2, b"b").with_term(4), ApplyMode::Replica)
1628            .unwrap_err();
1629        assert!(
1630            matches!(
1631                err,
1632                LogicalApplyError::StaleTermFenced {
1633                    record_term: 4,
1634                    current_term: 5,
1635                    lsn: 2,
1636                }
1637            ),
1638            "got {err:?}"
1639        );
1640        assert_eq!(err.kind(), ApplyErrorKind::Fenced);
1641        assert_eq!(
1642            applier.metrics().fenced_total.load(Ordering::Relaxed),
1643            before + 1,
1644            "the fence must leave a metrics trail"
1645        );
1646        // The fenced record advanced nothing — no apply, no watermark move.
1647        assert_eq!(applier.last_applied_lsn(), 1, "watermark must not advance");
1648        assert_eq!(applier.last_applied_term(), 5);
1649        assert_eq!(
1650            applier.received_frontier_lsn(),
1651            1,
1652            "a fenced record must not even advance the received frontier"
1653        );
1654        let _ = std::fs::remove_file(path);
1655    }
1656
1657    // The fence only bites a *behind* term. A record on the same term
1658    // applies normally, and a record on a higher term is the new primary's
1659    // timeline — adopted on apply.
1660    #[test]
1661    fn apply_admits_same_term_and_adopts_higher_term() {
1662        let (db, path) = open_db();
1663        let applier = LogicalChangeApplier::new(0);
1664
1665        applier
1666            .apply(&db, &record(1, b"a").with_term(3), ApplyMode::Replica)
1667            .unwrap();
1668        // Same term → admitted.
1669        applier
1670            .apply(&db, &record(2, b"b").with_term(3), ApplyMode::Replica)
1671            .unwrap();
1672        assert_eq!(applier.last_applied_term(), 3);
1673        // Higher term → adopted.
1674        applier
1675            .apply(&db, &record(3, b"c").with_term(7), ApplyMode::Replica)
1676            .unwrap();
1677        assert_eq!(applier.last_applied_term(), 7);
1678        assert_eq!(applier.last_applied_lsn(), 3);
1679
1680        // Now a record back on term 3 is fenced.
1681        let err = applier
1682            .apply(&db, &record(4, b"d").with_term(3), ApplyMode::Replica)
1683            .unwrap_err();
1684        assert!(
1685            matches!(err, LogicalApplyError::StaleTermFenced { .. }),
1686            "got {err:?}"
1687        );
1688        let _ = std::fs::remove_file(path);
1689    }
1690
1691    // Issue #991 — a record stamped for the target range but carrying an
1692    // ownership epoch behind the range's accepted epoch is a write from a
1693    // deposed owner. It is fenced at the apply boundary: rejected, counted on
1694    // the Fenced lane, and the LSN/term chain does not advance.
1695    #[test]
1696    fn apply_fenced_rejects_stale_ownership_epoch() {
1697        let (db, path) = open_db();
1698        let applier = LogicalChangeApplier::new(0);
1699        let fence = RangeAuthority {
1700            range_id: 7,
1701            min_term: 1,
1702            min_ownership_epoch: 5,
1703        };
1704
1705        let stale = record(1, b"a").with_range_authority(7, 4);
1706        let before = applier.metrics().fenced_total.load(Ordering::Relaxed);
1707        let err = applier
1708            .apply_fenced(&db, &stale, ApplyMode::Replica, Some(&fence))
1709            .unwrap_err();
1710        assert!(
1711            matches!(
1712                err,
1713                LogicalApplyError::RangeFenced {
1714                    range_id: 7,
1715                    lsn: 1,
1716                    reason: RangeAdmitError::StaleOwnershipEpoch {
1717                        record_epoch: 4,
1718                        accepted_epoch: 5,
1719                    },
1720                }
1721            ),
1722            "got {err:?}"
1723        );
1724        assert_eq!(err.kind(), ApplyErrorKind::Fenced);
1725        assert_eq!(
1726            applier.metrics().fenced_total.load(Ordering::Relaxed),
1727            before + 1
1728        );
1729        assert_eq!(applier.last_applied_lsn(), 0, "watermark must not advance");
1730        assert_eq!(
1731            applier.received_frontier_lsn(),
1732            0,
1733            "a range-fenced record must not advance the received frontier"
1734        );
1735        let _ = std::fs::remove_file(path);
1736    }
1737
1738    // Issue #991 — the same fence applies on the recovery/restore path: a
1739    // record on a stale term for the target range is rejected there too.
1740    #[test]
1741    fn apply_fenced_rejects_stale_range_term_on_restore() {
1742        let (db, path) = open_db();
1743        let applier = LogicalChangeApplier::new(0);
1744        let fence = RangeAuthority {
1745            range_id: 3,
1746            min_term: 6,
1747            min_ownership_epoch: 1,
1748        };
1749
1750        let stale = record(1, b"a").with_term(4).with_range_authority(3, 9);
1751        let err = applier
1752            .apply_fenced(&db, &stale, ApplyMode::Restore, Some(&fence))
1753            .unwrap_err();
1754        assert!(
1755            matches!(
1756                err,
1757                LogicalApplyError::RangeFenced {
1758                    range_id: 3,
1759                    reason: RangeAdmitError::StaleTerm {
1760                        record_term: 4,
1761                        accepted_term: 6,
1762                    },
1763                    ..
1764                }
1765            ),
1766            "got {err:?}"
1767        );
1768        let _ = std::fs::remove_file(path);
1769    }
1770
1771    // Issue #991 — a record current for the range applies through the fence,
1772    // and a record for a *different* range is not gated by this fence.
1773    #[test]
1774    fn apply_fenced_admits_current_and_ignores_other_ranges() {
1775        let (db, path) = open_db();
1776        let applier = LogicalChangeApplier::new(0);
1777        let fence = RangeAuthority {
1778            range_id: 7,
1779            min_term: 1,
1780            min_ownership_epoch: 5,
1781        };
1782
1783        // Current epoch for the fenced range → applies and advances.
1784        applier
1785            .apply_fenced(
1786                &db,
1787                &record(1, b"a").with_range_authority(7, 5),
1788                ApplyMode::Replica,
1789                Some(&fence),
1790            )
1791            .expect("current record applies");
1792        assert_eq!(applier.last_applied_lsn(), 1);
1793
1794        // A record stamped for a different (stale-looking) range is not this
1795        // fence's concern and still applies.
1796        applier
1797            .apply_fenced(
1798                &db,
1799                &record(2, b"b").with_range_authority(99, 1),
1800                ApplyMode::Replica,
1801                Some(&fence),
1802            )
1803            .expect("other-range record bypasses this fence");
1804        assert_eq!(applier.last_applied_lsn(), 2);
1805        let _ = std::fs::remove_file(path);
1806    }
1807
1808    // Issue #992 — end-to-end range-indexed catch-up over the single physical
1809    // WAL: a follower plans its range's work out of the shared stream, then
1810    // applies exactly that work through the same `apply_fenced` gate. The other
1811    // range's records and a deposed-owner write never reach apply.
1812    #[test]
1813    fn range_catchup_plan_applies_only_its_range_through_the_fence() {
1814        use crate::replication::cdc::{plan_range_catchup, RangeStreamPosition, RangeStreamReject};
1815
1816        let (db, path) = open_db();
1817        let applier = LogicalChangeApplier::new(0);
1818
1819        // One sequential WAL slice: range 7 at LSN 1..3 (epoch 5), range 9 at
1820        // 4..5, and a returning ex-owner of range 7 at LSN 6 on a stale epoch.
1821        let stream = vec![
1822            record(1, b"a").with_range_authority(7, 5),
1823            record(2, b"b").with_range_authority(7, 5),
1824            record(3, b"c").with_range_authority(7, 5),
1825            record(4, b"d").with_range_authority(9, 2),
1826            record(5, b"e").with_range_authority(9, 2),
1827            record(6, b"f").with_range_authority(7, 4),
1828        ];
1829
1830        // Range-7 follower resumes from origin, already knowing owner epoch 5.
1831        let position = RangeStreamPosition::new(7, 0, 1, 5);
1832        let plan = plan_range_catchup(&position, &stream);
1833
1834        // Only range 7's current records were selected; the stale-epoch write
1835        // is rejected, not selected.
1836        assert_eq!(plan.apply, vec![0, 1, 2]);
1837        assert_eq!(
1838            plan.rejected,
1839            vec![RangeStreamReject {
1840                lsn: 6,
1841                error: RangeAdmitError::StaleOwnershipEpoch {
1842                    record_epoch: 4,
1843                    accepted_epoch: 5,
1844                },
1845            }]
1846        );
1847        assert_eq!(plan.resume.applied_lsn, 3);
1848
1849        // Apply exactly the planned records through the per-range fence.
1850        let fence = position.authority();
1851        for index in &plan.apply {
1852            applier
1853                .apply_fenced(&db, &stream[*index], ApplyMode::Replica, Some(&fence))
1854                .expect("planned record applies through the fence");
1855        }
1856        assert_eq!(applier.last_applied_lsn(), 3);
1857
1858        // The deposed-owner record the plan refused is exactly what the apply
1859        // fence would also reject, never advancing the chain.
1860        let stale = &stream[5];
1861        let err = applier
1862            .apply_fenced(&db, stale, ApplyMode::Replica, Some(&fence))
1863            .unwrap_err();
1864        assert!(
1865            matches!(err, LogicalApplyError::RangeFenced { range_id: 7, .. }),
1866            "got {err:?}"
1867        );
1868        assert_eq!(
1869            applier.last_applied_lsn(),
1870            3,
1871            "stale write must not advance"
1872        );
1873        let _ = std::fs::remove_file(path);
1874    }
1875
1876    #[test]
1877    fn apply_skips_older_lsn() {
1878        let (db, path) = open_db();
1879        let applier = LogicalChangeApplier::new(0);
1880        applier
1881            .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1882            .unwrap();
1883        applier
1884            .apply(&db, &record(2, b"b"), ApplyMode::Replica)
1885            .unwrap();
1886        assert_eq!(
1887            applier
1888                .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1889                .unwrap(),
1890            ApplyOutcome::Skipped
1891        );
1892        assert_eq!(applier.last_applied_lsn(), 2);
1893        let _ = std::fs::remove_file(path);
1894    }
1895
1896    #[test]
1897    fn apply_returns_gap_on_future_lsn() {
1898        let (db, path) = open_db();
1899        let applier = LogicalChangeApplier::new(0);
1900        applier
1901            .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1902            .unwrap();
1903        let err = applier
1904            .apply(&db, &record(5, b"e"), ApplyMode::Replica)
1905            .unwrap_err();
1906        assert!(
1907            matches!(err, LogicalApplyError::Gap { last: 1, next: 5 }),
1908            "got {err:?}"
1909        );
1910        assert_eq!(applier.last_applied_lsn(), 1);
1911        let _ = std::fs::remove_file(path);
1912    }
1913}