1use 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#[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 pub apply_miss_total: std::sync::atomic::AtomicU64,
40 pub fenced_total: std::sync::atomic::AtomicU64,
46 pub bytes_applied_total: std::sync::atomic::AtomicU64,
52 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 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 Miss,
124 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum ApplyOutcome {
161 Applied,
163 Idempotent,
165 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 StaleTermFenced {
191 record_term: u64,
192 current_term: u64,
193 lsn: u64,
194 },
195 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
293pub 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 metrics: std::sync::Arc<ReplicaApplyMetrics>,
310}
311
312impl LogicalChangeApplier {
313 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 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 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 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 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 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 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 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 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 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 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 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 let _topology = topology.as_ref().map(|lock| lock.read_recursive());
605 let _storage_publication = store.vault_publication_lock.lock();
606 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 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 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 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 return Err(RedDBError::Internal(err.to_string()));
719 }
720 }
721 }
722 ChangeOperation::Refresh => {
723 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 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 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 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 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 ¤t {
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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 let r1 = record(1, b"hello");
1441 let r2 = record(2, b"world-longer-payload");
1442 let r3 = record(3, b"x");
1443
1444 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 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 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 #[test]
1613 fn apply_fences_stale_term_record() {
1614 let (db, path) = open_db();
1615 let applier = LogicalChangeApplier::new(0);
1616
1617 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 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 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 #[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 applier
1670 .apply(&db, &record(2, b"b").with_term(3), ApplyMode::Replica)
1671 .unwrap();
1672 assert_eq!(applier.last_applied_term(), 3);
1673 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 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 #[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 #[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 #[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 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 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 #[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 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 let position = RangeStreamPosition::new(7, 0, 1, 5);
1832 let plan = plan_range_catchup(&position, &stream);
1833
1834 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 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 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}