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