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 apply_lock: Mutex<()>,
303 metrics: std::sync::Arc<ReplicaApplyMetrics>,
308}
309
310impl LogicalChangeApplier {
311 pub fn new(starting_lsn: u64) -> Self {
316 Self::with_metrics(
317 starting_lsn,
318 std::sync::Arc::new(ReplicaApplyMetrics::default()),
319 )
320 }
321
322 pub fn with_metrics(starting_lsn: u64, metrics: std::sync::Arc<ReplicaApplyMetrics>) -> Self {
327 Self {
328 last_applied_term: AtomicU64::new(crate::replication::DEFAULT_REPLICATION_TERM),
329 term_fence: TermFence::new(MemoryTermStore::new()),
330 last_applied_lsn: AtomicU64::new(starting_lsn),
331 received_frontier_lsn: AtomicU64::new(starting_lsn),
332 last_payload_hash: Mutex::new(None),
333 apply_wait: (Mutex::new(()), Condvar::new()),
334 apply_lock: Mutex::new(()),
335 metrics,
336 }
337 }
338
339 pub fn metrics(&self) -> &std::sync::Arc<ReplicaApplyMetrics> {
341 &self.metrics
342 }
343
344 pub fn last_applied_lsn(&self) -> u64 {
345 self.last_applied_lsn.load(Ordering::Acquire)
346 }
347
348 pub fn received_frontier_lsn(&self) -> u64 {
349 self.received_frontier_lsn.load(Ordering::Acquire)
350 }
351
352 pub fn last_applied_term(&self) -> u64 {
353 self.last_applied_term.load(Ordering::Acquire)
354 }
355
356 pub fn wait_for_bookmark(
357 &self,
358 bookmark: &crate::replication::CausalBookmark,
359 timeout: std::time::Duration,
360 ) -> Result<(), BookmarkWaitError> {
361 let deadline = std::time::Instant::now() + timeout;
362 let target_lsn = bookmark.commit_lsn();
363 let target_term = bookmark.term();
364
365 let mut guard = self.apply_wait.0.lock().expect("apply wait mutex");
366 loop {
367 let applied_lsn = self.last_applied_lsn();
368 let applied_term = self.last_applied_term();
369 if applied_lsn >= target_lsn {
370 if applied_term == target_term {
371 return Ok(());
372 }
373 return Err(BookmarkWaitError::TermMismatch {
374 target_term,
375 applied_term,
376 });
377 }
378
379 let now = std::time::Instant::now();
380 if now >= deadline {
381 return Err(BookmarkWaitError::Timeout {
382 target_lsn,
383 applied_lsn,
384 });
385 }
386 let remaining = deadline.saturating_duration_since(now);
387 let (next_guard, wait_result) = self
388 .apply_wait
389 .1
390 .wait_timeout(guard, remaining)
391 .expect("apply wait condvar");
392 guard = next_guard;
393 if wait_result.timed_out() {
394 return Err(BookmarkWaitError::Timeout {
395 target_lsn,
396 applied_lsn: self.last_applied_lsn(),
397 });
398 }
399 }
400 }
401
402 pub fn apply(
410 &self,
411 db: &RedDB,
412 record: &ChangeRecord,
413 mode: ApplyMode,
414 ) -> Result<ApplyOutcome, LogicalApplyError> {
415 self.apply_fenced(db, record, mode, None)
416 }
417
418 pub fn apply_fenced(
427 &self,
428 db: &RedDB,
429 record: &ChangeRecord,
430 mode: ApplyMode,
431 range_fence: Option<&RangeAuthority>,
432 ) -> Result<ApplyOutcome, LogicalApplyError> {
433 let _apply = self.apply_lock.lock().expect("apply mutex");
436 let last = self.last_applied_lsn.load(Ordering::Acquire);
437 let last_term = self.last_applied_term.load(Ordering::Acquire);
438
439 if let Some(fence) = range_fence {
444 if let Err(reason) = fence.admit(record) {
445 self.metrics.record(ApplyErrorKind::Fenced);
446 return Err(LogicalApplyError::RangeFenced {
447 range_id: fence.range_id,
448 lsn: record.lsn,
449 reason,
450 });
451 }
452 }
453
454 if let FenceVerdict::Fenced(rejection) = self
459 .term_fence
460 .admit_record(record.term)
461 .expect("in-memory term store")
462 {
463 self.metrics.record(ApplyErrorKind::Fenced);
464 return Err(LogicalApplyError::StaleTermFenced {
465 record_term: rejection.incoming_term,
466 current_term: rejection.current_term,
467 lsn: record.lsn,
468 });
469 }
470
471 let payload_hash = record_payload_hash(record);
472 self.received_frontier_lsn
473 .fetch_max(record.lsn, Ordering::AcqRel);
474
475 if last == 0 && record.lsn > 0 {
476 self.do_apply(db, record, mode)?;
477 self.publish_applied(record, payload_hash);
478 return Ok(ApplyOutcome::Applied);
479 }
480
481 if record.lsn == last {
482 let prior = *self.last_payload_hash.lock().expect("payload hash mutex");
483 return match prior {
484 Some(p) if p == payload_hash => Ok(ApplyOutcome::Idempotent),
485 Some(p) => Err(LogicalApplyError::Divergence {
486 expected_term: last_term,
487 got_term: record.term,
488 lsn: record.lsn,
489 expected: hex_digest(&p),
490 got: hex_digest(&payload_hash),
491 }),
492 None => Ok(ApplyOutcome::Idempotent),
493 };
494 }
495 if record.lsn < last {
496 return Ok(ApplyOutcome::Skipped);
497 }
498 if record.lsn > last + 1 {
499 return Err(LogicalApplyError::Gap {
500 last,
501 next: record.lsn,
502 });
503 }
504
505 self.do_apply(db, record, mode)?;
506 self.publish_applied(record, payload_hash);
507 Ok(ApplyOutcome::Applied)
508 }
509
510 fn publish_applied(&self, record: &ChangeRecord, payload_hash: [u8; 32]) {
511 let _wait = self.apply_wait.0.lock().expect("apply wait mutex");
513 self.last_applied_term.store(record.term, Ordering::Release);
514 self.last_applied_lsn.store(record.lsn, Ordering::Release);
515 *self.last_payload_hash.lock().expect("payload hash mutex") = Some(payload_hash);
516 self.apply_wait.1.notify_all();
517 }
518
519 fn do_apply(
520 &self,
521 db: &RedDB,
522 record: &ChangeRecord,
523 mode: ApplyMode,
524 ) -> Result<(), LogicalApplyError> {
525 Self::apply_record_with_metrics(db, record, mode, &self.metrics).map_err(|err| {
526 LogicalApplyError::Apply {
527 lsn: record.lsn,
528 source: err,
529 }
530 })
531 }
532
533 pub fn apply_record(db: &RedDB, record: &ChangeRecord, mode: ApplyMode) -> RedDBResult<()> {
540 Self::apply_record_with_metrics(db, record, mode, &ReplicaApplyMetrics::default())
541 }
542
543 pub fn apply_record_with_metrics(
552 db: &RedDB,
553 record: &ChangeRecord,
554 _mode: ApplyMode,
555 metrics: &ReplicaApplyMetrics,
556 ) -> RedDBResult<()> {
557 if db.options().read_only {
558 return Err(RedDBError::InvalidOperation(
559 "cannot apply logical changes to a read-only database".into(),
560 ));
561 }
562 let payload_bytes: u64 = match record.operation {
567 ChangeOperation::Insert | ChangeOperation::Update => record
568 .entity_bytes
569 .as_ref()
570 .map(|b| b.len() as u64)
571 .unwrap_or(0),
572 ChangeOperation::Refresh => record
573 .refresh_records
574 .as_ref()
575 .map(|recs| recs.iter().map(|r| r.len() as u64).sum())
576 .unwrap_or(0),
577 ChangeOperation::Delete => 0,
578 };
579 let store = db.store();
580 let runtime = db
581 .runtime
582 .get()
583 .and_then(crate::runtime::WeakRuntime::upgrade);
584 let indexes = runtime.as_ref().map(|runtime| runtime.index_store_ref());
585 let _publication = indexes.map(|indexes| indexes.begin_indexed_mutation());
586 let topology = indexes.map(|indexes| indexes.collection_topology_lock(&record.collection));
587 let _topology = topology.as_ref().map(|lock| lock.read());
588 let _storage_publication = store.vault_publication_lock.lock();
589 let incoming = record
592 .entity_bytes
593 .as_ref()
594 .map(|bytes| {
595 UnifiedStore::deserialize_entity(bytes, store.format_version())
596 .map_err(|err| RedDBError::Internal(err.to_string()))
597 })
598 .transpose()?;
599 if incoming
600 .as_ref()
601 .is_some_and(|entity| entity.id.raw() != record.entity_id)
602 {
603 return Err(RedDBError::Internal(
604 "replication entity identity does not match its payload".into(),
605 ));
606 }
607 let metadata = record
608 .metadata
609 .as_ref()
610 .map(|json| metadata_from_json(&wire_json_to_server_json(json)))
611 .transpose()?;
612 let mut previous = match record.operation {
613 ChangeOperation::Refresh => store
614 .get_collection(&record.collection)
615 .map(|manager| manager.query_all(|_| true))
616 .unwrap_or_default(),
617 _ => store
618 .get(&record.collection, EntityId::new(record.entity_id))
619 .into_iter()
620 .collect(),
621 };
622 if let Some(entity) = &incoming {
623 if matches!(entity.kind, EntityKind::TableRow { .. }) {
624 previous.extend(
625 store
626 .table_row_versions_by_logical_id(&record.collection, entity.logical_id())
627 .into_iter()
628 .filter(|version| version.id != entity.id && version.xmax == 0),
629 );
630 }
631 }
632 let capture = (!UnifiedStore::deferred_store_wal_capture_active())
633 .then(|| crate::storage::unified::store::StoreWalCaptureGuard::begin(None));
634 let mut replayed_xid = 0;
635 match record.operation {
636 ChangeOperation::Delete => {
637 let deletion = if let (Some(runtime), Some(entity)) = (
640 runtime.as_ref(),
641 previous
642 .first()
643 .filter(|entity| matches!(entity.kind, EntityKind::TableRow { .. })),
644 ) {
645 let mut tombstone = entity.clone();
646 if tombstone.xmax == 0 {
647 tombstone.set_xmax(runtime.snapshot_manager().allocate_committed_xid());
648 let manager = store
649 .get_collection(&record.collection)
650 .expect("existing row collection");
651 manager
652 .update(tombstone.clone())
653 .map_err(|error| RedDBError::Internal(error.to_string()))?;
654 store
655 .persist_entities_to_pager(&record.collection, &[tombstone])
656 .map_err(|error| RedDBError::Internal(error.to_string()))?;
657 }
658 Ok(true)
659 } else {
660 store.delete(&record.collection, EntityId::new(record.entity_id))
661 };
662 match deletion {
663 Ok(true) => {}
664 Ok(false) => {
665 metrics.record(ApplyErrorKind::Miss);
668 tracing::warn!(
669 target: "reddb::replication::apply",
670 lsn = record.lsn,
671 collection = %record.collection,
672 entity_id = record.entity_id,
673 "replica delete found no matching entity; recorded apply miss (non-fatal divergence signal)"
674 );
675 }
676 Err(crate::storage::StoreError::CollectionNotFound(name)) => {
677 metrics.record(ApplyErrorKind::Miss);
680 tracing::warn!(
681 target: "reddb::replication::apply",
682 lsn = record.lsn,
683 collection = %name,
684 entity_id = record.entity_id,
685 "replica delete against missing collection; recorded apply miss (non-fatal divergence signal)"
686 );
687 }
688 Err(err) => {
689 return Err(RedDBError::Internal(err.to_string()));
693 }
694 }
695 }
696 ChangeOperation::Refresh => {
697 let records = record.refresh_records.clone().ok_or_else(|| {
706 RedDBError::Internal(
707 "replication refresh record missing refresh_records payload".to_string(),
708 )
709 })?;
710 for bytes in &records {
711 let (entity, _) =
712 UnifiedStore::deserialize_entity_record(bytes, store.format_version())
713 .map_err(|err| RedDBError::Internal(err.to_string()))?;
714 replayed_xid = replayed_xid.max(entity.xmin).max(entity.xmax);
715 }
716 store
717 .refresh_collection_from_records(&record.collection, records)
718 .map_err(|err| RedDBError::Internal(err.to_string()))?;
719 }
720 ChangeOperation::Insert | ChangeOperation::Update => {
721 let mut entity = incoming.clone().ok_or_else(|| {
722 RedDBError::Internal("replication record missing entity payload".to_string())
723 })?;
724 if matches!(entity.kind, EntityKind::TableRow { .. }) && entity.xmax == 0 {
725 if let Some(runtime) = &runtime {
726 if let Some(existing) = previous.iter().find(|old| old.id == entity.id) {
730 entity.set_xmin(existing.xmin);
731 } else {
732 entity.set_xmin(
733 entity
734 .xmin
735 .max(runtime.snapshot_manager().allocate_committed_xid()),
736 );
737 }
738 }
739 }
740
741 if matches!(entity.kind, EntityKind::TableRow { .. }) {
760 let logical = entity.logical_id();
761 let new_id = entity.id;
762 let superseding_xid = if entity.xmin != 0 { entity.xmin } else { 1 };
763 let stale: Vec<_> = store
764 .table_row_versions_by_logical_id(&record.collection, logical)
765 .into_iter()
766 .filter(|version| version.id != new_id && version.xmax == 0)
767 .collect();
768 if !stale.is_empty() {
769 let manager = store
770 .get_collection(&record.collection)
771 .ok_or_else(|| RedDBError::NotFound(record.collection.clone()))?;
772 let mut changed = Vec::with_capacity(stale.len());
773 for mut version in stale {
774 version.set_xmax(superseding_xid);
775 manager
776 .update(version.clone())
777 .map_err(|err| RedDBError::Internal(err.to_string()))?;
778 changed.push(version);
779 }
780 store
781 .persist_entities_to_pager(&record.collection, &changed)
782 .map_err(|err| RedDBError::Internal(err.to_string()))?;
783 }
784 }
785
786 let exists = store
787 .get(&record.collection, EntityId::new(record.entity_id))
788 .is_some();
789 if exists {
790 let manager = store
791 .get_collection(&record.collection)
792 .ok_or_else(|| RedDBError::NotFound(record.collection.clone()))?;
793 manager
794 .update(entity.clone())
795 .map_err(|err| RedDBError::Internal(err.to_string()))?;
796 store
797 .persist_entities_to_pager(
798 &record.collection,
799 std::slice::from_ref(&entity),
800 )
801 .map_err(|err| RedDBError::Internal(err.to_string()))?;
802 } else {
803 store
804 .insert_auto(&record.collection, entity.clone())
805 .map_err(|err| RedDBError::Internal(err.to_string()))?;
806 }
807 if let Some(metadata) = metadata {
808 store
809 .set_metadata(&record.collection, entity.id, metadata)
810 .map_err(|err| RedDBError::Internal(err.to_string()))?;
811 }
812 store
813 .context_index()
814 .index_entity(&record.collection, &entity);
815 replayed_xid = entity.xmin.max(entity.xmax);
818 }
819 }
820 if let Some(capture) = capture {
821 store
822 .append_deferred_store_wal_actions(capture.finish())
823 .map_err(|error| RedDBError::Internal(error.to_string()))?;
824 }
825 if let Some(manager) = store.snapshot_manager.get() {
826 manager.observe_committed_xid(replayed_xid);
827 }
828 if let Some(indexes) = indexes {
829 let publish = (|| -> Result<(), String> {
830 for entity in &previous {
831 indexes.index_entity_delete(
832 &record.collection,
833 entity.id,
834 &crate::application::ports::entity_row_fields_snapshot(entity),
835 )?;
836 }
837 let current = match record.operation {
838 ChangeOperation::Refresh => store
839 .get_collection(&record.collection)
840 .map(|manager| manager.query_all(|entity| entity.xmax == 0))
841 .unwrap_or_default(),
842 ChangeOperation::Insert | ChangeOperation::Update => incoming
843 .into_iter()
844 .filter(|entity| entity.xmax == 0)
845 .collect(),
846 ChangeOperation::Delete => Vec::new(),
847 };
848 for entity in ¤t {
849 indexes.index_entity_insert(
850 &record.collection,
851 entity.id,
852 &crate::application::ports::entity_row_fields_snapshot(entity),
853 )?;
854 }
855 Ok(())
856 })();
857 if let Some(runtime) = &runtime {
858 runtime.invalidate_result_cache_for_table(&record.collection);
859 }
860 if let Err(error) = publish {
861 for index in indexes.list_indices(&record.collection) {
864 indexes.drop_index(&index.name, &record.collection);
865 }
866 return Err(RedDBError::Internal(error));
867 }
868 }
869 metrics
872 .bytes_applied_total
873 .fetch_add(payload_bytes, Ordering::Relaxed);
874 metrics
875 .records_applied_total
876 .fetch_add(1, Ordering::Relaxed);
877 Ok(())
878 }
879}
880
881fn record_payload_hash(record: &ChangeRecord) -> [u8; 32] {
882 let mut hasher = crate::crypto::sha256::Sha256::new();
883 hasher.update(&record.term.to_le_bytes());
884 hasher.update(&record.lsn.to_le_bytes());
885 hasher.update(&[record.operation as u8]);
886 hasher.update(record.collection.as_bytes());
887 hasher.update(&record.entity_id.to_le_bytes());
888 hasher.update(&record.range_id.unwrap_or(u64::MAX).to_le_bytes());
894 hasher.update(&record.ownership_epoch.unwrap_or(u64::MAX).to_le_bytes());
895 if let Some(bytes) = &record.entity_bytes {
896 hasher.update(bytes);
897 }
898 if let Some(records) = &record.refresh_records {
902 hasher.update(&(records.len() as u64).to_le_bytes());
903 for r in records {
904 hasher.update(&(r.len() as u64).to_le_bytes());
905 hasher.update(r);
906 }
907 }
908 hasher.finalize()
909}
910
911fn hex_digest(bytes: &[u8; 32]) -> String {
912 crate::utils::to_hex(bytes)
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918 use crate::replication::cdc::ChangeOperation;
919 use crate::storage::{EntityData, EntityId, EntityKind, RedDB, RowData, UnifiedEntity};
920 use reddb_types::Value;
921 use std::sync::Arc;
922
923 fn open_db() -> (RedDB, std::path::PathBuf) {
924 let path = std::env::temp_dir().join(format!(
925 "reddb_logical_apply_{}_{}",
926 std::process::id(),
927 std::time::SystemTime::now()
928 .duration_since(std::time::UNIX_EPOCH)
929 .unwrap()
930 .as_nanos()
931 ));
932 let _ = std::fs::remove_file(&path);
933 let db = RedDB::open(&path).unwrap();
934 (db, path)
935 }
936
937 fn record(lsn: u64, payload: &[u8]) -> ChangeRecord {
938 let timestamp = 100 + lsn;
939 let mut entity = UnifiedEntity::new(
940 EntityId::new(lsn),
941 EntityKind::TableRow {
942 table: Arc::from("users"),
943 row_id: lsn,
944 },
945 EntityData::Row(RowData::with_names(
946 vec![Value::UnsignedInteger(lsn), Value::Blob(payload.to_vec())],
947 vec!["id".to_string(), "payload".to_string()],
948 )),
949 );
950 entity.created_at = timestamp;
951 entity.updated_at = timestamp;
952 entity.sequence_id = lsn;
953 change_record_from_entity(
954 lsn,
955 timestamp,
956 ChangeOperation::Insert,
957 "users",
958 "row",
959 &entity,
960 crate::api::REDDB_FORMAT_VERSION,
961 None,
962 )
963 }
964
965 fn delete_record(lsn: u64, collection: &str, entity_id: u64) -> ChangeRecord {
966 ChangeRecord {
967 term: crate::replication::DEFAULT_REPLICATION_TERM,
968 lsn,
969 timestamp: 100 + lsn,
970 operation: ChangeOperation::Delete,
971 collection: collection.to_string(),
972 entity_id,
973 entity_kind: "row".to_string(),
974 entity_bytes: None,
975 metadata: None,
976 refresh_records: None,
977 range_id: None,
978 ownership_epoch: None,
979 }
980 }
981
982 fn table_row_entity(id: u64) -> UnifiedEntity {
983 let mut entity = UnifiedEntity::new(
984 EntityId::new(id),
985 EntityKind::TableRow {
986 table: Arc::from("users"),
987 row_id: id,
988 },
989 EntityData::Row(RowData::with_names(
990 vec![Value::UnsignedInteger(id)],
991 vec!["id".to_string()],
992 )),
993 );
994 entity.created_at = 100 + id;
995 entity.updated_at = 100 + id;
996 entity.sequence_id = id;
997 entity
998 }
999
1000 #[test]
1004 fn delete_against_missing_collection_records_apply_miss() {
1005 let (db, path) = open_db();
1006 let metrics = ReplicaApplyMetrics::default();
1007 let before = metrics.apply_miss_total.load(Ordering::Relaxed);
1008
1009 LogicalChangeApplier::apply_record_with_metrics(
1010 &db,
1011 &delete_record(1, "no_such_collection", 42),
1012 ApplyMode::Replica,
1013 &metrics,
1014 )
1015 .expect("missing-target delete is non-fatal");
1016
1017 assert_eq!(
1018 metrics.apply_miss_total.load(Ordering::Relaxed),
1019 before + 1,
1020 "delete against a missing collection must bump the apply-miss signal"
1021 );
1022 let _ = std::fs::remove_file(path);
1023 }
1024
1025 #[test]
1028 fn delete_against_missing_entity_records_apply_miss() {
1029 let (db, path) = open_db();
1030 let _ = db.store().get_or_create_collection("users");
1031 let metrics = ReplicaApplyMetrics::default();
1032
1033 LogicalChangeApplier::apply_record_with_metrics(
1034 &db,
1035 &delete_record(1, "users", 9999),
1036 ApplyMode::Replica,
1037 &metrics,
1038 )
1039 .expect("missing-entity delete is non-fatal");
1040
1041 assert_eq!(
1042 metrics.apply_miss_total.load(Ordering::Relaxed),
1043 1,
1044 "delete of an absent entity must bump the apply-miss signal"
1045 );
1046 let _ = std::fs::remove_file(path);
1047 }
1048
1049 #[test]
1052 fn delete_of_present_target_records_no_apply_miss() {
1053 let (db, path) = open_db();
1054 let store = db.store();
1055 let _ = store.get_or_create_collection("users");
1056 let id = store
1057 .insert_auto("users", table_row_entity(1))
1058 .expect("insert entity");
1059 let metrics = ReplicaApplyMetrics::default();
1060
1061 LogicalChangeApplier::apply_record_with_metrics(
1062 &db,
1063 &delete_record(1, "users", id.raw()),
1064 ApplyMode::Replica,
1065 &metrics,
1066 )
1067 .expect("present-target delete applies");
1068
1069 assert_eq!(
1070 metrics.apply_miss_total.load(Ordering::Relaxed),
1071 0,
1072 "deleting a present target must not fire the apply-miss signal"
1073 );
1074 assert!(
1075 store.get("users", id).is_none(),
1076 "the entity must actually be removed on the normal path"
1077 );
1078 let _ = std::fs::remove_file(path);
1079 }
1080
1081 #[test]
1084 fn stateful_apply_surfaces_delete_miss_via_metrics_handle() {
1085 let (db, path) = open_db();
1086 let applier =
1087 LogicalChangeApplier::with_metrics(0, Arc::new(ReplicaApplyMetrics::default()));
1088
1089 applier
1090 .apply(&db, &delete_record(1, "ghost", 7), ApplyMode::Replica)
1091 .expect("missing-target delete advances the chain");
1092
1093 assert_eq!(
1094 applier.metrics().apply_miss_total.load(Ordering::Relaxed),
1095 1,
1096 "the applier's shared metrics handle must record the miss"
1097 );
1098 assert_eq!(
1099 applier.last_applied_lsn(),
1100 1,
1101 "a non-fatal miss still advances the LSN chain"
1102 );
1103 let _ = std::fs::remove_file(path);
1104 }
1105
1106 #[test]
1111 fn throughput_counters_reflect_applied_bytes_and_records() {
1112 let (db, path) = open_db();
1113 let metrics = Arc::new(ReplicaApplyMetrics::default());
1114 let applier = LogicalChangeApplier::with_metrics(0, Arc::clone(&metrics));
1115
1116 let r1 = record(1, b"hello");
1118 let r2 = record(2, b"world-longer-payload");
1119 let r3 = record(3, b"x");
1120
1121 let expected_bytes: u64 = [&r1, &r2, &r3]
1125 .iter()
1126 .map(|r| r.entity_bytes.as_ref().map(|b| b.len() as u64).unwrap_or(0))
1127 .sum();
1128 assert!(expected_bytes > 0, "test records must carry entity payload");
1129
1130 for r in [&r1, &r2, &r3] {
1131 assert_eq!(
1132 applier.apply(&db, r, ApplyMode::Replica).unwrap(),
1133 ApplyOutcome::Applied
1134 );
1135 }
1136
1137 assert_eq!(
1138 metrics.records_applied_total.load(Ordering::Relaxed),
1139 3,
1140 "three applied records must increment records counter by 3"
1141 );
1142 assert_eq!(
1143 metrics.bytes_applied_total.load(Ordering::Relaxed),
1144 expected_bytes,
1145 "bytes counter must match the sum of entity_bytes across applied records"
1146 );
1147
1148 assert_eq!(
1150 applier.apply(&db, &r3, ApplyMode::Replica).unwrap(),
1151 ApplyOutcome::Idempotent
1152 );
1153 assert_eq!(
1154 metrics.records_applied_total.load(Ordering::Relaxed),
1155 3,
1156 "idempotent replay must not increment the records counter"
1157 );
1158 assert_eq!(
1159 metrics.bytes_applied_total.load(Ordering::Relaxed),
1160 expected_bytes,
1161 "idempotent replay must not increment the bytes counter"
1162 );
1163
1164 let del = delete_record(4, "ghost_collection", 999);
1167 applier
1168 .apply(&db, &del, ApplyMode::Replica)
1169 .expect("delete miss is non-fatal and still advances the chain");
1170 assert_eq!(
1171 metrics.records_applied_total.load(Ordering::Relaxed),
1172 4,
1173 "a delete (including a miss) must increment the records counter"
1174 );
1175 assert_eq!(
1176 metrics.bytes_applied_total.load(Ordering::Relaxed),
1177 expected_bytes,
1178 "a delete contributes 0 bytes to the bytes counter"
1179 );
1180
1181 let _ = std::fs::remove_file(path);
1182 }
1183
1184 #[test]
1185 fn apply_advances_on_monotonic_lsn() {
1186 let (db, path) = open_db();
1187 let applier = LogicalChangeApplier::new(0);
1188 assert_eq!(
1189 applier
1190 .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1191 .unwrap(),
1192 ApplyOutcome::Applied
1193 );
1194 assert_eq!(applier.last_applied_lsn(), 1);
1195 assert_eq!(
1196 applier
1197 .apply(&db, &record(2, b"b"), ApplyMode::Replica)
1198 .unwrap(),
1199 ApplyOutcome::Applied
1200 );
1201 assert_eq!(applier.last_applied_lsn(), 2);
1202 let _ = std::fs::remove_file(path);
1203 }
1204
1205 #[test]
1206 fn bookmark_timeout_does_not_wait_for_inflight_apply_lock() {
1207 let applier = std::sync::Arc::new(LogicalChangeApplier::new(0));
1208 let apply = applier.apply_lock.lock().expect("inflight apply");
1209 let reader = std::sync::Arc::clone(&applier);
1210 let (send, receive) = std::sync::mpsc::channel();
1211 let thread = std::thread::spawn(move || {
1212 send.send(reader.wait_for_bookmark(
1213 &crate::replication::CausalBookmark::new(1, 1),
1214 std::time::Duration::from_millis(20),
1215 ))
1216 .expect("send result");
1217 });
1218 let result = receive.recv_timeout(std::time::Duration::from_secs(2));
1219 drop(apply);
1220 thread.join().expect("waiter");
1221 assert!(matches!(
1222 result.expect("bookmark respects its deadline during apply"),
1223 Err(BookmarkWaitError::Timeout { .. })
1224 ));
1225 }
1226
1227 #[test]
1228 fn apply_idempotent_on_duplicate_lsn_same_payload() {
1229 let (db, path) = open_db();
1230 let applier = LogicalChangeApplier::new(0);
1231 let r = record(5, b"same");
1232 applier.apply(&db, &r, ApplyMode::Replica).unwrap();
1233 assert_eq!(
1234 applier.apply(&db, &r, ApplyMode::Replica).unwrap(),
1235 ApplyOutcome::Idempotent
1236 );
1237 assert_eq!(applier.last_applied_lsn(), 5);
1238 let _ = std::fs::remove_file(path);
1239 }
1240
1241 #[test]
1242 fn apply_fails_closed_on_lsn_collision_diff_payload() {
1243 let (db, path) = open_db();
1244 let applier = LogicalChangeApplier::new(0);
1245 applier
1246 .apply(&db, &record(7, b"first"), ApplyMode::Replica)
1247 .unwrap();
1248 let err = applier
1249 .apply(&db, &record(7, b"different"), ApplyMode::Replica)
1250 .unwrap_err();
1251 assert!(
1252 matches!(err, LogicalApplyError::Divergence { lsn: 7, .. }),
1253 "got {err:?}"
1254 );
1255 let _ = std::fs::remove_file(path);
1256 }
1257
1258 #[test]
1259 fn apply_fails_closed_on_same_lsn_different_term() {
1260 let (db, path) = open_db();
1261 let applier = LogicalChangeApplier::new(0);
1262 applier
1263 .apply(&db, &record(7, b"same").with_term(1), ApplyMode::Replica)
1264 .unwrap();
1265 let err = applier
1266 .apply(&db, &record(7, b"same").with_term(2), ApplyMode::Replica)
1267 .unwrap_err();
1268 assert!(
1269 matches!(
1270 err,
1271 LogicalApplyError::Divergence {
1272 lsn: 7,
1273 expected_term: 1,
1274 got_term: 2,
1275 ..
1276 }
1277 ),
1278 "got {err:?}"
1279 );
1280 assert_eq!(applier.last_applied_term(), 1);
1281 assert_eq!(applier.last_applied_lsn(), 7);
1282 let _ = std::fs::remove_file(path);
1283 }
1284
1285 #[test]
1290 fn apply_fences_stale_term_record() {
1291 let (db, path) = open_db();
1292 let applier = LogicalChangeApplier::new(0);
1293
1294 applier
1296 .apply(&db, &record(1, b"a").with_term(5), ApplyMode::Replica)
1297 .unwrap();
1298 assert_eq!(applier.last_applied_term(), 5);
1299 assert_eq!(applier.last_applied_lsn(), 1);
1300
1301 let before = applier.metrics().fenced_total.load(Ordering::Relaxed);
1303 let err = applier
1304 .apply(&db, &record(2, b"b").with_term(4), ApplyMode::Replica)
1305 .unwrap_err();
1306 assert!(
1307 matches!(
1308 err,
1309 LogicalApplyError::StaleTermFenced {
1310 record_term: 4,
1311 current_term: 5,
1312 lsn: 2,
1313 }
1314 ),
1315 "got {err:?}"
1316 );
1317 assert_eq!(err.kind(), ApplyErrorKind::Fenced);
1318 assert_eq!(
1319 applier.metrics().fenced_total.load(Ordering::Relaxed),
1320 before + 1,
1321 "the fence must leave a metrics trail"
1322 );
1323 assert_eq!(applier.last_applied_lsn(), 1, "watermark must not advance");
1325 assert_eq!(applier.last_applied_term(), 5);
1326 assert_eq!(
1327 applier.received_frontier_lsn(),
1328 1,
1329 "a fenced record must not even advance the received frontier"
1330 );
1331 let _ = std::fs::remove_file(path);
1332 }
1333
1334 #[test]
1338 fn apply_admits_same_term_and_adopts_higher_term() {
1339 let (db, path) = open_db();
1340 let applier = LogicalChangeApplier::new(0);
1341
1342 applier
1343 .apply(&db, &record(1, b"a").with_term(3), ApplyMode::Replica)
1344 .unwrap();
1345 applier
1347 .apply(&db, &record(2, b"b").with_term(3), ApplyMode::Replica)
1348 .unwrap();
1349 assert_eq!(applier.last_applied_term(), 3);
1350 applier
1352 .apply(&db, &record(3, b"c").with_term(7), ApplyMode::Replica)
1353 .unwrap();
1354 assert_eq!(applier.last_applied_term(), 7);
1355 assert_eq!(applier.last_applied_lsn(), 3);
1356
1357 let err = applier
1359 .apply(&db, &record(4, b"d").with_term(3), ApplyMode::Replica)
1360 .unwrap_err();
1361 assert!(
1362 matches!(err, LogicalApplyError::StaleTermFenced { .. }),
1363 "got {err:?}"
1364 );
1365 let _ = std::fs::remove_file(path);
1366 }
1367
1368 #[test]
1373 fn apply_fenced_rejects_stale_ownership_epoch() {
1374 let (db, path) = open_db();
1375 let applier = LogicalChangeApplier::new(0);
1376 let fence = RangeAuthority {
1377 range_id: 7,
1378 min_term: 1,
1379 min_ownership_epoch: 5,
1380 };
1381
1382 let stale = record(1, b"a").with_range_authority(7, 4);
1383 let before = applier.metrics().fenced_total.load(Ordering::Relaxed);
1384 let err = applier
1385 .apply_fenced(&db, &stale, ApplyMode::Replica, Some(&fence))
1386 .unwrap_err();
1387 assert!(
1388 matches!(
1389 err,
1390 LogicalApplyError::RangeFenced {
1391 range_id: 7,
1392 lsn: 1,
1393 reason: RangeAdmitError::StaleOwnershipEpoch {
1394 record_epoch: 4,
1395 accepted_epoch: 5,
1396 },
1397 }
1398 ),
1399 "got {err:?}"
1400 );
1401 assert_eq!(err.kind(), ApplyErrorKind::Fenced);
1402 assert_eq!(
1403 applier.metrics().fenced_total.load(Ordering::Relaxed),
1404 before + 1
1405 );
1406 assert_eq!(applier.last_applied_lsn(), 0, "watermark must not advance");
1407 assert_eq!(
1408 applier.received_frontier_lsn(),
1409 0,
1410 "a range-fenced record must not advance the received frontier"
1411 );
1412 let _ = std::fs::remove_file(path);
1413 }
1414
1415 #[test]
1418 fn apply_fenced_rejects_stale_range_term_on_restore() {
1419 let (db, path) = open_db();
1420 let applier = LogicalChangeApplier::new(0);
1421 let fence = RangeAuthority {
1422 range_id: 3,
1423 min_term: 6,
1424 min_ownership_epoch: 1,
1425 };
1426
1427 let stale = record(1, b"a").with_term(4).with_range_authority(3, 9);
1428 let err = applier
1429 .apply_fenced(&db, &stale, ApplyMode::Restore, Some(&fence))
1430 .unwrap_err();
1431 assert!(
1432 matches!(
1433 err,
1434 LogicalApplyError::RangeFenced {
1435 range_id: 3,
1436 reason: RangeAdmitError::StaleTerm {
1437 record_term: 4,
1438 accepted_term: 6,
1439 },
1440 ..
1441 }
1442 ),
1443 "got {err:?}"
1444 );
1445 let _ = std::fs::remove_file(path);
1446 }
1447
1448 #[test]
1451 fn apply_fenced_admits_current_and_ignores_other_ranges() {
1452 let (db, path) = open_db();
1453 let applier = LogicalChangeApplier::new(0);
1454 let fence = RangeAuthority {
1455 range_id: 7,
1456 min_term: 1,
1457 min_ownership_epoch: 5,
1458 };
1459
1460 applier
1462 .apply_fenced(
1463 &db,
1464 &record(1, b"a").with_range_authority(7, 5),
1465 ApplyMode::Replica,
1466 Some(&fence),
1467 )
1468 .expect("current record applies");
1469 assert_eq!(applier.last_applied_lsn(), 1);
1470
1471 applier
1474 .apply_fenced(
1475 &db,
1476 &record(2, b"b").with_range_authority(99, 1),
1477 ApplyMode::Replica,
1478 Some(&fence),
1479 )
1480 .expect("other-range record bypasses this fence");
1481 assert_eq!(applier.last_applied_lsn(), 2);
1482 let _ = std::fs::remove_file(path);
1483 }
1484
1485 #[test]
1490 fn range_catchup_plan_applies_only_its_range_through_the_fence() {
1491 use crate::replication::cdc::{plan_range_catchup, RangeStreamPosition, RangeStreamReject};
1492
1493 let (db, path) = open_db();
1494 let applier = LogicalChangeApplier::new(0);
1495
1496 let stream = vec![
1499 record(1, b"a").with_range_authority(7, 5),
1500 record(2, b"b").with_range_authority(7, 5),
1501 record(3, b"c").with_range_authority(7, 5),
1502 record(4, b"d").with_range_authority(9, 2),
1503 record(5, b"e").with_range_authority(9, 2),
1504 record(6, b"f").with_range_authority(7, 4),
1505 ];
1506
1507 let position = RangeStreamPosition::new(7, 0, 1, 5);
1509 let plan = plan_range_catchup(&position, &stream);
1510
1511 assert_eq!(plan.apply, vec![0, 1, 2]);
1514 assert_eq!(
1515 plan.rejected,
1516 vec![RangeStreamReject {
1517 lsn: 6,
1518 error: RangeAdmitError::StaleOwnershipEpoch {
1519 record_epoch: 4,
1520 accepted_epoch: 5,
1521 },
1522 }]
1523 );
1524 assert_eq!(plan.resume.applied_lsn, 3);
1525
1526 let fence = position.authority();
1528 for index in &plan.apply {
1529 applier
1530 .apply_fenced(&db, &stream[*index], ApplyMode::Replica, Some(&fence))
1531 .expect("planned record applies through the fence");
1532 }
1533 assert_eq!(applier.last_applied_lsn(), 3);
1534
1535 let stale = &stream[5];
1538 let err = applier
1539 .apply_fenced(&db, stale, ApplyMode::Replica, Some(&fence))
1540 .unwrap_err();
1541 assert!(
1542 matches!(err, LogicalApplyError::RangeFenced { range_id: 7, .. }),
1543 "got {err:?}"
1544 );
1545 assert_eq!(
1546 applier.last_applied_lsn(),
1547 3,
1548 "stale write must not advance"
1549 );
1550 let _ = std::fs::remove_file(path);
1551 }
1552
1553 #[test]
1554 fn apply_skips_older_lsn() {
1555 let (db, path) = open_db();
1556 let applier = LogicalChangeApplier::new(0);
1557 applier
1558 .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1559 .unwrap();
1560 applier
1561 .apply(&db, &record(2, b"b"), ApplyMode::Replica)
1562 .unwrap();
1563 assert_eq!(
1564 applier
1565 .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1566 .unwrap(),
1567 ApplyOutcome::Skipped
1568 );
1569 assert_eq!(applier.last_applied_lsn(), 2);
1570 let _ = std::fs::remove_file(path);
1571 }
1572
1573 #[test]
1574 fn apply_returns_gap_on_future_lsn() {
1575 let (db, path) = open_db();
1576 let applier = LogicalChangeApplier::new(0);
1577 applier
1578 .apply(&db, &record(1, b"a"), ApplyMode::Replica)
1579 .unwrap();
1580 let err = applier
1581 .apply(&db, &record(5, b"e"), ApplyMode::Replica)
1582 .unwrap_err();
1583 assert!(
1584 matches!(err, LogicalApplyError::Gap { last: 1, next: 5 }),
1585 "got {err:?}"
1586 );
1587 assert_eq!(applier.last_applied_lsn(), 1);
1588 let _ = std::fs::remove_file(path);
1589 }
1590}