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 pub gap_total: std::sync::atomic::AtomicU64,
29 pub divergence_total: std::sync::atomic::AtomicU64,
30 pub apply_error_total: std::sync::atomic::AtomicU64,
31 pub decode_error_total: std::sync::atomic::AtomicU64,
32 pub apply_miss_total: std::sync::atomic::AtomicU64,
38 pub fenced_total: std::sync::atomic::AtomicU64,
44 pub bytes_applied_total: std::sync::atomic::AtomicU64,
50 pub records_applied_total: std::sync::atomic::AtomicU64,
55}
56
57impl ReplicaApplyMetrics {
58 pub fn record(&self, kind: ApplyErrorKind) {
59 use std::sync::atomic::Ordering::Relaxed;
60 match kind {
61 ApplyErrorKind::Gap => {
62 self.gap_total.fetch_add(1, Relaxed);
63 }
64 ApplyErrorKind::Divergence => {
65 self.divergence_total.fetch_add(1, Relaxed);
66 }
67 ApplyErrorKind::Apply => {
68 self.apply_error_total.fetch_add(1, Relaxed);
69 }
70 ApplyErrorKind::Decode => {
71 self.decode_error_total.fetch_add(1, Relaxed);
72 }
73 ApplyErrorKind::Miss => {
74 self.apply_miss_total.fetch_add(1, Relaxed);
75 }
76 ApplyErrorKind::Fenced => {
77 self.fenced_total.fetch_add(1, Relaxed);
78 }
79 }
80 }
81
82 pub fn snapshot(&self) -> [(ApplyErrorKind, u64); 6] {
83 use std::sync::atomic::Ordering::Relaxed;
84 [
85 (ApplyErrorKind::Gap, self.gap_total.load(Relaxed)),
86 (
87 ApplyErrorKind::Divergence,
88 self.divergence_total.load(Relaxed),
89 ),
90 (ApplyErrorKind::Apply, self.apply_error_total.load(Relaxed)),
91 (
92 ApplyErrorKind::Decode,
93 self.decode_error_total.load(Relaxed),
94 ),
95 (ApplyErrorKind::Miss, self.apply_miss_total.load(Relaxed)),
96 (ApplyErrorKind::Fenced, self.fenced_total.load(Relaxed)),
97 ]
98 }
99
100 pub fn snapshot_throughput(&self) -> (u64, u64) {
105 use std::sync::atomic::Ordering::Relaxed;
106 (
107 self.bytes_applied_total.load(Relaxed),
108 self.records_applied_total.load(Relaxed),
109 )
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum ApplyErrorKind {
115 Gap,
116 Divergence,
117 Apply,
118 Decode,
119 Miss,
122 Fenced,
125}
126
127impl ApplyErrorKind {
128 pub fn label(self) -> &'static str {
129 match self {
130 Self::Gap => "gap",
131 Self::Divergence => "divergence",
132 Self::Apply => "apply",
133 Self::Decode => "decode",
134 Self::Miss => "apply_miss",
135 Self::Fenced => "fenced",
136 }
137 }
138}
139
140impl LogicalApplyError {
141 pub fn kind(&self) -> ApplyErrorKind {
142 match self {
143 Self::Gap { .. } => ApplyErrorKind::Gap,
144 Self::Divergence { .. } => ApplyErrorKind::Divergence,
145 Self::Apply { .. } => ApplyErrorKind::Apply,
146 Self::StaleTermFenced { .. } => ApplyErrorKind::Fenced,
147 Self::RangeFenced { .. } => ApplyErrorKind::Fenced,
148 }
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum ApplyOutcome {
159 Applied,
161 Idempotent,
163 Skipped,
165}
166
167#[derive(Debug)]
168pub enum LogicalApplyError {
169 Gap {
170 last: u64,
171 next: u64,
172 },
173 Divergence {
174 expected_term: u64,
175 got_term: u64,
176 lsn: u64,
177 expected: String,
178 got: String,
179 },
180 Apply {
181 lsn: u64,
182 source: RedDBError,
183 },
184 StaleTermFenced {
189 record_term: u64,
190 current_term: u64,
191 lsn: u64,
192 },
193 RangeFenced {
201 range_id: u64,
202 lsn: u64,
203 reason: RangeAdmitError,
204 },
205}
206
207impl std::fmt::Display for LogicalApplyError {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 match self {
210 Self::Gap { last, next } => write!(f, "LSN gap on apply: last={last} next={next}"),
211 Self::StaleTermFenced {
212 record_term,
213 current_term,
214 lsn,
215 } => write!(
216 f,
217 "stale-term record fenced at lsn={lsn}: record term {record_term} is behind current term {current_term}"
218 ),
219 Self::RangeFenced {
220 range_id,
221 lsn,
222 reason,
223 } => match reason {
224 RangeAdmitError::StaleTerm {
225 record_term,
226 accepted_term,
227 } => write!(
228 f,
229 "range-stale record fenced at lsn={lsn} for range {range_id}: record term {record_term} is behind accepted term {accepted_term}"
230 ),
231 RangeAdmitError::StaleOwnershipEpoch {
232 record_epoch,
233 accepted_epoch,
234 } => write!(
235 f,
236 "range-stale record fenced at lsn={lsn} for range {range_id}: ownership epoch {record_epoch} is behind accepted epoch {accepted_epoch}"
237 ),
238 },
239 Self::Divergence {
240 expected_term,
241 got_term,
242 lsn,
243 expected,
244 got,
245 } => write!(
246 f,
247 "LSN divergence on apply at term/lsn=({got_term},{lsn}): expected term {expected_term} payload hash {expected}, got {got}"
248 ),
249 Self::Apply { lsn, source } => write!(f, "apply error at lsn={lsn}: {source}"),
250 }
251 }
252}
253
254impl std::error::Error for LogicalApplyError {}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum BookmarkWaitError {
258 Timeout { target_lsn: u64, applied_lsn: u64 },
259 TermMismatch { target_term: u64, applied_term: u64 },
260}
261
262impl BookmarkWaitError {
263 pub fn is_timeout(&self) -> bool {
264 matches!(self, Self::Timeout { .. })
265 }
266}
267
268impl std::fmt::Display for BookmarkWaitError {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 match self {
271 Self::Timeout {
272 target_lsn,
273 applied_lsn,
274 } => write!(
275 f,
276 "timed out waiting for causal bookmark lsn {target_lsn}; applied={applied_lsn}"
277 ),
278 Self::TermMismatch {
279 target_term,
280 applied_term,
281 } => write!(
282 f,
283 "causal bookmark term mismatch: target={target_term} applied={applied_term}"
284 ),
285 }
286 }
287}
288
289impl std::error::Error for BookmarkWaitError {}
290
291pub struct LogicalChangeApplier {
296 last_applied_term: AtomicU64,
297 term_fence: TermFence<MemoryTermStore>,
298 last_applied_lsn: AtomicU64,
299 received_frontier_lsn: AtomicU64,
300 last_payload_hash: Mutex<Option<[u8; 32]>>,
301 apply_wait: (Mutex<()>, Condvar),
302 metrics: std::sync::Arc<ReplicaApplyMetrics>,
307}
308
309impl LogicalChangeApplier {
310 pub fn new(starting_lsn: u64) -> Self {
315 Self::with_metrics(
316 starting_lsn,
317 std::sync::Arc::new(ReplicaApplyMetrics::default()),
318 )
319 }
320
321 pub fn with_metrics(starting_lsn: u64, metrics: std::sync::Arc<ReplicaApplyMetrics>) -> Self {
326 Self {
327 last_applied_term: AtomicU64::new(crate::replication::DEFAULT_REPLICATION_TERM),
328 term_fence: TermFence::new(MemoryTermStore::new()),
329 last_applied_lsn: AtomicU64::new(starting_lsn),
330 received_frontier_lsn: AtomicU64::new(starting_lsn),
331 last_payload_hash: Mutex::new(None),
332 apply_wait: (Mutex::new(()), Condvar::new()),
333 metrics,
334 }
335 }
336
337 pub fn metrics(&self) -> &std::sync::Arc<ReplicaApplyMetrics> {
339 &self.metrics
340 }
341
342 pub fn last_applied_lsn(&self) -> u64 {
343 self.last_applied_lsn.load(Ordering::Acquire)
344 }
345
346 pub fn received_frontier_lsn(&self) -> u64 {
347 self.received_frontier_lsn.load(Ordering::Acquire)
348 }
349
350 pub fn last_applied_term(&self) -> u64 {
351 self.last_applied_term.load(Ordering::Acquire)
352 }
353
354 pub fn wait_for_bookmark(
355 &self,
356 bookmark: &crate::replication::CausalBookmark,
357 timeout: std::time::Duration,
358 ) -> Result<(), BookmarkWaitError> {
359 let deadline = std::time::Instant::now() + timeout;
360 let target_lsn = bookmark.commit_lsn();
361 let target_term = bookmark.term();
362
363 let mut guard = self.apply_wait.0.lock().expect("apply wait mutex");
364 loop {
365 let applied_lsn = self.last_applied_lsn();
366 let applied_term = self.last_applied_term();
367 if applied_lsn >= target_lsn {
368 if applied_term == target_term {
369 return Ok(());
370 }
371 return Err(BookmarkWaitError::TermMismatch {
372 target_term,
373 applied_term,
374 });
375 }
376
377 let now = std::time::Instant::now();
378 if now >= deadline {
379 return Err(BookmarkWaitError::Timeout {
380 target_lsn,
381 applied_lsn,
382 });
383 }
384 let remaining = deadline.saturating_duration_since(now);
385 let (next_guard, wait_result) = self
386 .apply_wait
387 .1
388 .wait_timeout(guard, remaining)
389 .expect("apply wait condvar");
390 guard = next_guard;
391 if wait_result.timed_out() {
392 return Err(BookmarkWaitError::Timeout {
393 target_lsn,
394 applied_lsn: self.last_applied_lsn(),
395 });
396 }
397 }
398 }
399
400 pub fn apply(
408 &self,
409 db: &RedDB,
410 record: &ChangeRecord,
411 mode: ApplyMode,
412 ) -> Result<ApplyOutcome, LogicalApplyError> {
413 self.apply_fenced(db, record, mode, None)
414 }
415
416 pub fn apply_fenced(
425 &self,
426 db: &RedDB,
427 record: &ChangeRecord,
428 mode: ApplyMode,
429 range_fence: Option<&RangeAuthority>,
430 ) -> Result<ApplyOutcome, LogicalApplyError> {
431 let last = self.last_applied_lsn.load(Ordering::Acquire);
432 let last_term = self.last_applied_term.load(Ordering::Acquire);
433
434 if let Some(fence) = range_fence {
439 if let Err(reason) = fence.admit(record) {
440 self.metrics.record(ApplyErrorKind::Fenced);
441 return Err(LogicalApplyError::RangeFenced {
442 range_id: fence.range_id,
443 lsn: record.lsn,
444 reason,
445 });
446 }
447 }
448
449 if let FenceVerdict::Fenced(rejection) = self
454 .term_fence
455 .admit_record(record.term)
456 .expect("in-memory term store")
457 {
458 self.metrics.record(ApplyErrorKind::Fenced);
459 return Err(LogicalApplyError::StaleTermFenced {
460 record_term: rejection.incoming_term,
461 current_term: rejection.current_term,
462 lsn: record.lsn,
463 });
464 }
465
466 let payload_hash = record_payload_hash(record);
467 self.received_frontier_lsn
468 .fetch_max(record.lsn, Ordering::AcqRel);
469
470 if last == 0 && record.lsn > 0 {
471 self.do_apply(db, record, mode)?;
472 self.last_applied_term.store(record.term, Ordering::Release);
473 self.last_applied_lsn.store(record.lsn, Ordering::Release);
474 *self.last_payload_hash.lock().expect("payload hash mutex") = Some(payload_hash);
475 self.apply_wait.1.notify_all();
476 return Ok(ApplyOutcome::Applied);
477 }
478
479 if record.lsn == last {
480 let prior = *self.last_payload_hash.lock().expect("payload hash mutex");
481 return match prior {
482 Some(p) if p == payload_hash => Ok(ApplyOutcome::Idempotent),
483 Some(p) => Err(LogicalApplyError::Divergence {
484 expected_term: last_term,
485 got_term: record.term,
486 lsn: record.lsn,
487 expected: hex_digest(&p),
488 got: hex_digest(&payload_hash),
489 }),
490 None => Ok(ApplyOutcome::Idempotent),
491 };
492 }
493 if record.lsn < last {
494 return Ok(ApplyOutcome::Skipped);
495 }
496 if record.lsn > last + 1 {
497 return Err(LogicalApplyError::Gap {
498 last,
499 next: record.lsn,
500 });
501 }
502
503 self.do_apply(db, record, mode)?;
504 self.last_applied_term.store(record.term, Ordering::Release);
505 self.last_applied_lsn.store(record.lsn, Ordering::Release);
506 *self.last_payload_hash.lock().expect("payload hash mutex") = Some(payload_hash);
507 self.apply_wait.1.notify_all();
508 Ok(ApplyOutcome::Applied)
509 }
510
511 fn do_apply(
512 &self,
513 db: &RedDB,
514 record: &ChangeRecord,
515 mode: ApplyMode,
516 ) -> Result<(), LogicalApplyError> {
517 Self::apply_record_with_metrics(db, record, mode, &self.metrics).map_err(|err| {
518 LogicalApplyError::Apply {
519 lsn: record.lsn,
520 source: err,
521 }
522 })
523 }
524
525 pub fn apply_record(db: &RedDB, record: &ChangeRecord, mode: ApplyMode) -> RedDBResult<()> {
532 Self::apply_record_with_metrics(db, record, mode, &ReplicaApplyMetrics::default())
533 }
534
535 pub fn apply_record_with_metrics(
544 db: &RedDB,
545 record: &ChangeRecord,
546 _mode: ApplyMode,
547 metrics: &ReplicaApplyMetrics,
548 ) -> RedDBResult<()> {
549 let payload_bytes: u64 = match record.operation {
554 ChangeOperation::Insert | ChangeOperation::Update => record
555 .entity_bytes
556 .as_ref()
557 .map(|b| b.len() as u64)
558 .unwrap_or(0),
559 ChangeOperation::Refresh => record
560 .refresh_records
561 .as_ref()
562 .map(|recs| recs.iter().map(|r| r.len() as u64).sum())
563 .unwrap_or(0),
564 ChangeOperation::Delete => 0,
565 };
566 let store = db.store();
567 match record.operation {
568 ChangeOperation::Delete => {
569 match store.delete(&record.collection, EntityId::new(record.entity_id)) {
570 Ok(true) => {}
571 Ok(false) => {
572 metrics.record(ApplyErrorKind::Miss);
575 tracing::warn!(
576 target: "reddb::replication::apply",
577 lsn = record.lsn,
578 collection = %record.collection,
579 entity_id = record.entity_id,
580 "replica delete found no matching entity; recorded apply miss (non-fatal divergence signal)"
581 );
582 }
583 Err(crate::storage::StoreError::CollectionNotFound(name)) => {
584 metrics.record(ApplyErrorKind::Miss);
587 tracing::warn!(
588 target: "reddb::replication::apply",
589 lsn = record.lsn,
590 collection = %name,
591 entity_id = record.entity_id,
592 "replica delete against missing collection; recorded apply miss (non-fatal divergence signal)"
593 );
594 }
595 Err(err) => {
596 return Err(RedDBError::Internal(err.to_string()));
600 }
601 }
602 }
603 ChangeOperation::Refresh => {
604 let records = record.refresh_records.clone().ok_or_else(|| {
613 RedDBError::Internal(
614 "replication refresh record missing refresh_records payload".to_string(),
615 )
616 })?;
617 let mut replayed_xid = 0;
618 for bytes in &records {
619 let (entity, _) =
620 UnifiedStore::deserialize_entity_record(bytes, store.format_version())
621 .map_err(|err| RedDBError::Internal(err.to_string()))?;
622 replayed_xid = replayed_xid.max(entity.xmin).max(entity.xmax);
623 }
624 store
625 .refresh_collection_from_records(&record.collection, records)
626 .map_err(|err| RedDBError::Internal(err.to_string()))?;
627 if let Some(manager) = store.snapshot_manager.get() {
628 manager.observe_committed_xid(replayed_xid);
629 }
630 }
631 ChangeOperation::Insert | ChangeOperation::Update => {
632 let Some(bytes) = &record.entity_bytes else {
633 return Err(RedDBError::Internal(
634 "replication record missing entity payload".to_string(),
635 ));
636 };
637 let entity = UnifiedStore::deserialize_entity(bytes, store.format_version())
638 .map_err(|err| RedDBError::Internal(err.to_string()))?;
639
640 if matches!(entity.kind, EntityKind::TableRow { .. }) {
659 let logical = entity.logical_id();
660 let new_id = entity.id;
661 let superseding_xid = if entity.xmin != 0 { entity.xmin } else { 1 };
662 let stale: Vec<_> = store
663 .table_row_versions_by_logical_id(&record.collection, logical)
664 .into_iter()
665 .filter(|version| version.id != new_id && version.xmax == 0)
666 .collect();
667 if !stale.is_empty() {
668 let manager = store
669 .get_collection(&record.collection)
670 .ok_or_else(|| RedDBError::NotFound(record.collection.clone()))?;
671 for mut version in stale {
672 version.set_xmax(superseding_xid);
673 manager
674 .update(version)
675 .map_err(|err| RedDBError::Internal(err.to_string()))?;
676 }
677 }
678 }
679
680 let exists = store
681 .get(&record.collection, EntityId::new(record.entity_id))
682 .is_some();
683 if exists {
684 let manager = store
685 .get_collection(&record.collection)
686 .ok_or_else(|| RedDBError::NotFound(record.collection.clone()))?;
687 manager
688 .update(entity.clone())
689 .map_err(|err| RedDBError::Internal(err.to_string()))?;
690 } else {
691 store
692 .insert_auto(&record.collection, entity.clone())
693 .map_err(|err| RedDBError::Internal(err.to_string()))?;
694 }
695 if let Some(metadata_json) = &record.metadata {
696 let metadata_json = wire_json_to_server_json(metadata_json);
697 let metadata = metadata_from_json(&metadata_json)
698 .map_err(|err| RedDBError::Internal(err.to_string()))?;
699 store
700 .set_metadata(&record.collection, entity.id, metadata)
701 .map_err(|err| RedDBError::Internal(err.to_string()))?;
702 }
703 store
704 .context_index()
705 .index_entity(&record.collection, &entity);
706 if let Some(manager) = store.snapshot_manager.get() {
710 manager.observe_committed_xid(entity.xmin.max(entity.xmax));
711 }
712 }
713 }
714 metrics
717 .bytes_applied_total
718 .fetch_add(payload_bytes, Ordering::Relaxed);
719 metrics
720 .records_applied_total
721 .fetch_add(1, Ordering::Relaxed);
722 Ok(())
723 }
724}
725
726fn record_payload_hash(record: &ChangeRecord) -> [u8; 32] {
727 let mut hasher = crate::crypto::sha256::Sha256::new();
728 hasher.update(&record.term.to_le_bytes());
729 hasher.update(&record.lsn.to_le_bytes());
730 hasher.update(&[record.operation as u8]);
731 hasher.update(record.collection.as_bytes());
732 hasher.update(&record.entity_id.to_le_bytes());
733 hasher.update(&record.range_id.unwrap_or(u64::MAX).to_le_bytes());
739 hasher.update(&record.ownership_epoch.unwrap_or(u64::MAX).to_le_bytes());
740 if let Some(bytes) = &record.entity_bytes {
741 hasher.update(bytes);
742 }
743 if let Some(records) = &record.refresh_records {
747 hasher.update(&(records.len() as u64).to_le_bytes());
748 for r in records {
749 hasher.update(&(r.len() as u64).to_le_bytes());
750 hasher.update(r);
751 }
752 }
753 hasher.finalize()
754}
755
756fn hex_digest(bytes: &[u8; 32]) -> String {
757 crate::utils::to_hex(bytes)
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763 use crate::replication::cdc::ChangeOperation;
764 use crate::storage::{EntityData, EntityId, EntityKind, RedDB, RowData, UnifiedEntity};
765 use reddb_types::Value;
766 use std::sync::Arc;
767
768 fn open_db() -> (RedDB, std::path::PathBuf) {
769 let path = std::env::temp_dir().join(format!(
770 "reddb_logical_apply_{}_{}",
771 std::process::id(),
772 std::time::SystemTime::now()
773 .duration_since(std::time::UNIX_EPOCH)
774 .unwrap()
775 .as_nanos()
776 ));
777 let _ = std::fs::remove_file(&path);
778 let db = RedDB::open(&path).unwrap();
779 (db, path)
780 }
781
782 fn record(lsn: u64, payload: &[u8]) -> ChangeRecord {
783 let timestamp = 100 + lsn;
784 let mut entity = UnifiedEntity::new(
785 EntityId::new(lsn),
786 EntityKind::TableRow {
787 table: Arc::from("users"),
788 row_id: lsn,
789 },
790 EntityData::Row(RowData::with_names(
791 vec![Value::UnsignedInteger(lsn), Value::Blob(payload.to_vec())],
792 vec!["id".to_string(), "payload".to_string()],
793 )),
794 );
795 entity.created_at = timestamp;
796 entity.updated_at = timestamp;
797 entity.sequence_id = lsn;
798 change_record_from_entity(
799 lsn,
800 timestamp,
801 ChangeOperation::Insert,
802 "users",
803 "row",
804 &entity,
805 crate::api::REDDB_FORMAT_VERSION,
806 None,
807 )
808 }
809
810 fn delete_record(lsn: u64, collection: &str, entity_id: u64) -> ChangeRecord {
811 ChangeRecord {
812 term: crate::replication::DEFAULT_REPLICATION_TERM,
813 lsn,
814 timestamp: 100 + lsn,
815 operation: ChangeOperation::Delete,
816 collection: collection.to_string(),
817 entity_id,
818 entity_kind: "row".to_string(),
819 entity_bytes: None,
820 metadata: None,
821 refresh_records: None,
822 range_id: None,
823 ownership_epoch: None,
824 }
825 }
826
827 fn table_row_entity(id: u64) -> UnifiedEntity {
828 let mut entity = UnifiedEntity::new(
829 EntityId::new(id),
830 EntityKind::TableRow {
831 table: Arc::from("users"),
832 row_id: id,
833 },
834 EntityData::Row(RowData::with_names(
835 vec![Value::UnsignedInteger(id)],
836 vec!["id".to_string()],
837 )),
838 );
839 entity.created_at = 100 + id;
840 entity.updated_at = 100 + id;
841 entity.sequence_id = id;
842 entity
843 }
844
845 #[test]
849 fn delete_against_missing_collection_records_apply_miss() {
850 let (db, path) = open_db();
851 let metrics = ReplicaApplyMetrics::default();
852 let before = metrics.apply_miss_total.load(Ordering::Relaxed);
853
854 LogicalChangeApplier::apply_record_with_metrics(
855 &db,
856 &delete_record(1, "no_such_collection", 42),
857 ApplyMode::Replica,
858 &metrics,
859 )
860 .expect("missing-target delete is non-fatal");
861
862 assert_eq!(
863 metrics.apply_miss_total.load(Ordering::Relaxed),
864 before + 1,
865 "delete against a missing collection must bump the apply-miss signal"
866 );
867 let _ = std::fs::remove_file(path);
868 }
869
870 #[test]
873 fn delete_against_missing_entity_records_apply_miss() {
874 let (db, path) = open_db();
875 let _ = db.store().get_or_create_collection("users");
876 let metrics = ReplicaApplyMetrics::default();
877
878 LogicalChangeApplier::apply_record_with_metrics(
879 &db,
880 &delete_record(1, "users", 9999),
881 ApplyMode::Replica,
882 &metrics,
883 )
884 .expect("missing-entity delete is non-fatal");
885
886 assert_eq!(
887 metrics.apply_miss_total.load(Ordering::Relaxed),
888 1,
889 "delete of an absent entity must bump the apply-miss signal"
890 );
891 let _ = std::fs::remove_file(path);
892 }
893
894 #[test]
897 fn delete_of_present_target_records_no_apply_miss() {
898 let (db, path) = open_db();
899 let store = db.store();
900 let _ = store.get_or_create_collection("users");
901 let id = store
902 .insert_auto("users", table_row_entity(1))
903 .expect("insert entity");
904 let metrics = ReplicaApplyMetrics::default();
905
906 LogicalChangeApplier::apply_record_with_metrics(
907 &db,
908 &delete_record(1, "users", id.raw()),
909 ApplyMode::Replica,
910 &metrics,
911 )
912 .expect("present-target delete applies");
913
914 assert_eq!(
915 metrics.apply_miss_total.load(Ordering::Relaxed),
916 0,
917 "deleting a present target must not fire the apply-miss signal"
918 );
919 assert!(
920 store.get("users", id).is_none(),
921 "the entity must actually be removed on the normal path"
922 );
923 let _ = std::fs::remove_file(path);
924 }
925
926 #[test]
929 fn stateful_apply_surfaces_delete_miss_via_metrics_handle() {
930 let (db, path) = open_db();
931 let applier =
932 LogicalChangeApplier::with_metrics(0, Arc::new(ReplicaApplyMetrics::default()));
933
934 applier
935 .apply(&db, &delete_record(1, "ghost", 7), ApplyMode::Replica)
936 .expect("missing-target delete advances the chain");
937
938 assert_eq!(
939 applier.metrics().apply_miss_total.load(Ordering::Relaxed),
940 1,
941 "the applier's shared metrics handle must record the miss"
942 );
943 assert_eq!(
944 applier.last_applied_lsn(),
945 1,
946 "a non-fatal miss still advances the LSN chain"
947 );
948 let _ = std::fs::remove_file(path);
949 }
950
951 #[test]
956 fn throughput_counters_reflect_applied_bytes_and_records() {
957 let (db, path) = open_db();
958 let metrics = Arc::new(ReplicaApplyMetrics::default());
959 let applier = LogicalChangeApplier::with_metrics(0, Arc::clone(&metrics));
960
961 let r1 = record(1, b"hello");
963 let r2 = record(2, b"world-longer-payload");
964 let r3 = record(3, b"x");
965
966 let expected_bytes: u64 = [&r1, &r2, &r3]
970 .iter()
971 .map(|r| r.entity_bytes.as_ref().map(|b| b.len() as u64).unwrap_or(0))
972 .sum();
973 assert!(expected_bytes > 0, "test records must carry entity payload");
974
975 for r in [&r1, &r2, &r3] {
976 assert_eq!(
977 applier.apply(&db, r, ApplyMode::Replica).unwrap(),
978 ApplyOutcome::Applied
979 );
980 }
981
982 assert_eq!(
983 metrics.records_applied_total.load(Ordering::Relaxed),
984 3,
985 "three applied records must increment records counter by 3"
986 );
987 assert_eq!(
988 metrics.bytes_applied_total.load(Ordering::Relaxed),
989 expected_bytes,
990 "bytes counter must match the sum of entity_bytes across applied records"
991 );
992
993 assert_eq!(
995 applier.apply(&db, &r3, ApplyMode::Replica).unwrap(),
996 ApplyOutcome::Idempotent
997 );
998 assert_eq!(
999 metrics.records_applied_total.load(Ordering::Relaxed),
1000 3,
1001 "idempotent replay must not increment the records counter"
1002 );
1003 assert_eq!(
1004 metrics.bytes_applied_total.load(Ordering::Relaxed),
1005 expected_bytes,
1006 "idempotent replay must not increment the bytes counter"
1007 );
1008
1009 let del = delete_record(4, "ghost_collection", 999);
1012 applier
1013 .apply(&db, &del, ApplyMode::Replica)
1014 .expect("delete miss is non-fatal and still advances the chain");
1015 assert_eq!(
1016 metrics.records_applied_total.load(Ordering::Relaxed),
1017 4,
1018 "a delete (including a miss) must increment the records counter"
1019 );
1020 assert_eq!(
1021 metrics.bytes_applied_total.load(Ordering::Relaxed),
1022 expected_bytes,
1023 "a delete contributes 0 bytes to the bytes counter"
1024 );
1025
1026 let _ = std::fs::remove_file(path);
1027 }
1028
1029 #[test]
1030 fn apply_advances_on_monotonic_lsn() {
1031 let (db, path) = open_db();
1032 let applier = LogicalChangeApplier::new(0);
1033 assert_eq!(
1034 applier
1035 .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1036 .unwrap(),
1037 ApplyOutcome::Applied
1038 );
1039 assert_eq!(applier.last_applied_lsn(), 1);
1040 assert_eq!(
1041 applier
1042 .apply(&db, &record(2, b"b"), ApplyMode::Replica)
1043 .unwrap(),
1044 ApplyOutcome::Applied
1045 );
1046 assert_eq!(applier.last_applied_lsn(), 2);
1047 let _ = std::fs::remove_file(path);
1048 }
1049
1050 #[test]
1051 fn apply_idempotent_on_duplicate_lsn_same_payload() {
1052 let (db, path) = open_db();
1053 let applier = LogicalChangeApplier::new(0);
1054 let r = record(5, b"same");
1055 applier.apply(&db, &r, ApplyMode::Replica).unwrap();
1056 assert_eq!(
1057 applier.apply(&db, &r, ApplyMode::Replica).unwrap(),
1058 ApplyOutcome::Idempotent
1059 );
1060 assert_eq!(applier.last_applied_lsn(), 5);
1061 let _ = std::fs::remove_file(path);
1062 }
1063
1064 #[test]
1065 fn apply_fails_closed_on_lsn_collision_diff_payload() {
1066 let (db, path) = open_db();
1067 let applier = LogicalChangeApplier::new(0);
1068 applier
1069 .apply(&db, &record(7, b"first"), ApplyMode::Replica)
1070 .unwrap();
1071 let err = applier
1072 .apply(&db, &record(7, b"different"), ApplyMode::Replica)
1073 .unwrap_err();
1074 assert!(
1075 matches!(err, LogicalApplyError::Divergence { lsn: 7, .. }),
1076 "got {err:?}"
1077 );
1078 let _ = std::fs::remove_file(path);
1079 }
1080
1081 #[test]
1082 fn apply_fails_closed_on_same_lsn_different_term() {
1083 let (db, path) = open_db();
1084 let applier = LogicalChangeApplier::new(0);
1085 applier
1086 .apply(&db, &record(7, b"same").with_term(1), ApplyMode::Replica)
1087 .unwrap();
1088 let err = applier
1089 .apply(&db, &record(7, b"same").with_term(2), ApplyMode::Replica)
1090 .unwrap_err();
1091 assert!(
1092 matches!(
1093 err,
1094 LogicalApplyError::Divergence {
1095 lsn: 7,
1096 expected_term: 1,
1097 got_term: 2,
1098 ..
1099 }
1100 ),
1101 "got {err:?}"
1102 );
1103 assert_eq!(applier.last_applied_term(), 1);
1104 assert_eq!(applier.last_applied_lsn(), 7);
1105 let _ = std::fs::remove_file(path);
1106 }
1107
1108 #[test]
1113 fn apply_fences_stale_term_record() {
1114 let (db, path) = open_db();
1115 let applier = LogicalChangeApplier::new(0);
1116
1117 applier
1119 .apply(&db, &record(1, b"a").with_term(5), ApplyMode::Replica)
1120 .unwrap();
1121 assert_eq!(applier.last_applied_term(), 5);
1122 assert_eq!(applier.last_applied_lsn(), 1);
1123
1124 let before = applier.metrics().fenced_total.load(Ordering::Relaxed);
1126 let err = applier
1127 .apply(&db, &record(2, b"b").with_term(4), ApplyMode::Replica)
1128 .unwrap_err();
1129 assert!(
1130 matches!(
1131 err,
1132 LogicalApplyError::StaleTermFenced {
1133 record_term: 4,
1134 current_term: 5,
1135 lsn: 2,
1136 }
1137 ),
1138 "got {err:?}"
1139 );
1140 assert_eq!(err.kind(), ApplyErrorKind::Fenced);
1141 assert_eq!(
1142 applier.metrics().fenced_total.load(Ordering::Relaxed),
1143 before + 1,
1144 "the fence must leave a metrics trail"
1145 );
1146 assert_eq!(applier.last_applied_lsn(), 1, "watermark must not advance");
1148 assert_eq!(applier.last_applied_term(), 5);
1149 assert_eq!(
1150 applier.received_frontier_lsn(),
1151 1,
1152 "a fenced record must not even advance the received frontier"
1153 );
1154 let _ = std::fs::remove_file(path);
1155 }
1156
1157 #[test]
1161 fn apply_admits_same_term_and_adopts_higher_term() {
1162 let (db, path) = open_db();
1163 let applier = LogicalChangeApplier::new(0);
1164
1165 applier
1166 .apply(&db, &record(1, b"a").with_term(3), ApplyMode::Replica)
1167 .unwrap();
1168 applier
1170 .apply(&db, &record(2, b"b").with_term(3), ApplyMode::Replica)
1171 .unwrap();
1172 assert_eq!(applier.last_applied_term(), 3);
1173 applier
1175 .apply(&db, &record(3, b"c").with_term(7), ApplyMode::Replica)
1176 .unwrap();
1177 assert_eq!(applier.last_applied_term(), 7);
1178 assert_eq!(applier.last_applied_lsn(), 3);
1179
1180 let err = applier
1182 .apply(&db, &record(4, b"d").with_term(3), ApplyMode::Replica)
1183 .unwrap_err();
1184 assert!(
1185 matches!(err, LogicalApplyError::StaleTermFenced { .. }),
1186 "got {err:?}"
1187 );
1188 let _ = std::fs::remove_file(path);
1189 }
1190
1191 #[test]
1196 fn apply_fenced_rejects_stale_ownership_epoch() {
1197 let (db, path) = open_db();
1198 let applier = LogicalChangeApplier::new(0);
1199 let fence = RangeAuthority {
1200 range_id: 7,
1201 min_term: 1,
1202 min_ownership_epoch: 5,
1203 };
1204
1205 let stale = record(1, b"a").with_range_authority(7, 4);
1206 let before = applier.metrics().fenced_total.load(Ordering::Relaxed);
1207 let err = applier
1208 .apply_fenced(&db, &stale, ApplyMode::Replica, Some(&fence))
1209 .unwrap_err();
1210 assert!(
1211 matches!(
1212 err,
1213 LogicalApplyError::RangeFenced {
1214 range_id: 7,
1215 lsn: 1,
1216 reason: RangeAdmitError::StaleOwnershipEpoch {
1217 record_epoch: 4,
1218 accepted_epoch: 5,
1219 },
1220 }
1221 ),
1222 "got {err:?}"
1223 );
1224 assert_eq!(err.kind(), ApplyErrorKind::Fenced);
1225 assert_eq!(
1226 applier.metrics().fenced_total.load(Ordering::Relaxed),
1227 before + 1
1228 );
1229 assert_eq!(applier.last_applied_lsn(), 0, "watermark must not advance");
1230 assert_eq!(
1231 applier.received_frontier_lsn(),
1232 0,
1233 "a range-fenced record must not advance the received frontier"
1234 );
1235 let _ = std::fs::remove_file(path);
1236 }
1237
1238 #[test]
1241 fn apply_fenced_rejects_stale_range_term_on_restore() {
1242 let (db, path) = open_db();
1243 let applier = LogicalChangeApplier::new(0);
1244 let fence = RangeAuthority {
1245 range_id: 3,
1246 min_term: 6,
1247 min_ownership_epoch: 1,
1248 };
1249
1250 let stale = record(1, b"a").with_term(4).with_range_authority(3, 9);
1251 let err = applier
1252 .apply_fenced(&db, &stale, ApplyMode::Restore, Some(&fence))
1253 .unwrap_err();
1254 assert!(
1255 matches!(
1256 err,
1257 LogicalApplyError::RangeFenced {
1258 range_id: 3,
1259 reason: RangeAdmitError::StaleTerm {
1260 record_term: 4,
1261 accepted_term: 6,
1262 },
1263 ..
1264 }
1265 ),
1266 "got {err:?}"
1267 );
1268 let _ = std::fs::remove_file(path);
1269 }
1270
1271 #[test]
1274 fn apply_fenced_admits_current_and_ignores_other_ranges() {
1275 let (db, path) = open_db();
1276 let applier = LogicalChangeApplier::new(0);
1277 let fence = RangeAuthority {
1278 range_id: 7,
1279 min_term: 1,
1280 min_ownership_epoch: 5,
1281 };
1282
1283 applier
1285 .apply_fenced(
1286 &db,
1287 &record(1, b"a").with_range_authority(7, 5),
1288 ApplyMode::Replica,
1289 Some(&fence),
1290 )
1291 .expect("current record applies");
1292 assert_eq!(applier.last_applied_lsn(), 1);
1293
1294 applier
1297 .apply_fenced(
1298 &db,
1299 &record(2, b"b").with_range_authority(99, 1),
1300 ApplyMode::Replica,
1301 Some(&fence),
1302 )
1303 .expect("other-range record bypasses this fence");
1304 assert_eq!(applier.last_applied_lsn(), 2);
1305 let _ = std::fs::remove_file(path);
1306 }
1307
1308 #[test]
1313 fn range_catchup_plan_applies_only_its_range_through_the_fence() {
1314 use crate::replication::cdc::{plan_range_catchup, RangeStreamPosition, RangeStreamReject};
1315
1316 let (db, path) = open_db();
1317 let applier = LogicalChangeApplier::new(0);
1318
1319 let stream = vec![
1322 record(1, b"a").with_range_authority(7, 5),
1323 record(2, b"b").with_range_authority(7, 5),
1324 record(3, b"c").with_range_authority(7, 5),
1325 record(4, b"d").with_range_authority(9, 2),
1326 record(5, b"e").with_range_authority(9, 2),
1327 record(6, b"f").with_range_authority(7, 4),
1328 ];
1329
1330 let position = RangeStreamPosition::new(7, 0, 1, 5);
1332 let plan = plan_range_catchup(&position, &stream);
1333
1334 assert_eq!(plan.apply, vec![0, 1, 2]);
1337 assert_eq!(
1338 plan.rejected,
1339 vec![RangeStreamReject {
1340 lsn: 6,
1341 error: RangeAdmitError::StaleOwnershipEpoch {
1342 record_epoch: 4,
1343 accepted_epoch: 5,
1344 },
1345 }]
1346 );
1347 assert_eq!(plan.resume.applied_lsn, 3);
1348
1349 let fence = position.authority();
1351 for index in &plan.apply {
1352 applier
1353 .apply_fenced(&db, &stream[*index], ApplyMode::Replica, Some(&fence))
1354 .expect("planned record applies through the fence");
1355 }
1356 assert_eq!(applier.last_applied_lsn(), 3);
1357
1358 let stale = &stream[5];
1361 let err = applier
1362 .apply_fenced(&db, stale, ApplyMode::Replica, Some(&fence))
1363 .unwrap_err();
1364 assert!(
1365 matches!(err, LogicalApplyError::RangeFenced { range_id: 7, .. }),
1366 "got {err:?}"
1367 );
1368 assert_eq!(
1369 applier.last_applied_lsn(),
1370 3,
1371 "stale write must not advance"
1372 );
1373 let _ = std::fs::remove_file(path);
1374 }
1375
1376 #[test]
1377 fn apply_skips_older_lsn() {
1378 let (db, path) = open_db();
1379 let applier = LogicalChangeApplier::new(0);
1380 applier
1381 .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1382 .unwrap();
1383 applier
1384 .apply(&db, &record(2, b"b"), ApplyMode::Replica)
1385 .unwrap();
1386 assert_eq!(
1387 applier
1388 .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1389 .unwrap(),
1390 ApplyOutcome::Skipped
1391 );
1392 assert_eq!(applier.last_applied_lsn(), 2);
1393 let _ = std::fs::remove_file(path);
1394 }
1395
1396 #[test]
1397 fn apply_returns_gap_on_future_lsn() {
1398 let (db, path) = open_db();
1399 let applier = LogicalChangeApplier::new(0);
1400 applier
1401 .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1402 .unwrap();
1403 let err = applier
1404 .apply(&db, &record(5, b"e"), ApplyMode::Replica)
1405 .unwrap_err();
1406 assert!(
1407 matches!(err, LogicalApplyError::Gap { last: 1, next: 5 }),
1408 "got {err:?}"
1409 );
1410 assert_eq!(applier.last_applied_lsn(), 1);
1411 let _ = std::fs::remove_file(path);
1412 }
1413}