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