1use std::collections::{BTreeSet, HashMap, HashSet};
7
8use fsqlite_error::{FrankenError, Result};
9use tracing::{debug, error, info, warn};
10
11const BEAD_ID: &str = "bd-1hi.19";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum ReplicationRole {
24 Leader,
26 Follower,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
32pub enum ReplicationMode {
33 #[default]
35 LeaderCommitClock,
36 MultiWriter,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
46pub struct ObjectId([u8; 16]);
47
48impl ObjectId {
49 #[must_use]
50 pub const fn from_bytes(b: [u8; 16]) -> Self {
51 Self(b)
52 }
53
54 #[must_use]
55 pub const fn as_bytes(&self) -> &[u8; 16] {
56 &self.0
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum ReplicatedObjectKind {
63 CommitCapsule,
64 CommitMarker,
65 IndexSegment,
66 ReadWitness,
67 WriteWitness,
68 WitnessDelta,
69 WitnessIndexSegment,
70 DependencyEdge,
71 CommitProof,
72 AbortWitness,
73 MergeWitness,
74 CheckpointChunk,
75 SnapshotManifest,
76 DecodeProof,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct CommitMarker {
82 pub commit_seq: u64,
83 pub capsule_id: ObjectId,
84 pub timestamp_ns: u64,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub struct IdempotencyKey([u8; 16]);
90
91impl IdempotencyKey {
92 #[must_use]
94 pub fn from_marker(marker: &CommitMarker) -> Self {
95 let mut hasher = blake3::Hasher::new();
96 hasher.update(b"fsqlite:repl:idempotency:v1");
97 hasher.update(&marker.commit_seq.to_le_bytes());
98 hasher.update(marker.capsule_id.as_bytes());
99 let hash = hasher.finalize();
100 let mut out = [0_u8; 16];
101 out.copy_from_slice(&hash.as_bytes()[..16]);
102 Self(out)
103 }
104}
105
106#[derive(Debug, Default)]
108pub struct CommitDeduplicator {
109 seen: HashSet<IdempotencyKey>,
110}
111
112impl CommitDeduplicator {
113 pub fn should_accept(&mut self, marker: &CommitMarker) -> bool {
115 self.seen.insert(IdempotencyKey::from_marker(marker))
116 }
117
118 #[must_use]
120 pub fn seen_count(&self) -> usize {
121 self.seen.len()
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ReplicaTip {
132 pub root_manifest_id: ObjectId,
134 pub marker_position: u64,
136 pub index_segment_tips: Vec<ObjectId>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct MissingObjects {
143 pub needed: BTreeSet<ObjectId>,
145 pub to_offer: BTreeSet<ObjectId>,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum AntiEntropyPhase {
152 ExchangeTips,
154 ComputeMissing,
156 RequestSymbols,
158 StreamUntilDecode,
160 PersistAndUpdate,
162 Complete,
164}
165
166#[derive(Debug)]
168pub struct AntiEntropySession {
169 phase: AntiEntropyPhase,
170 local_tip: Option<ReplicaTip>,
171 remote_tip: Option<ReplicaTip>,
172 missing: Option<MissingObjects>,
173 decoded_objects: HashSet<ObjectId>,
174}
175
176impl AntiEntropySession {
177 #[must_use]
179 pub fn new() -> Self {
180 debug!(bead_id = BEAD_ID, "starting anti-entropy session");
181 Self {
182 phase: AntiEntropyPhase::ExchangeTips,
183 local_tip: None,
184 remote_tip: None,
185 missing: None,
186 decoded_objects: HashSet::new(),
187 }
188 }
189
190 #[must_use]
192 pub const fn phase(&self) -> AntiEntropyPhase {
193 self.phase
194 }
195
196 pub fn exchange_tips(&mut self, local: ReplicaTip, remote: ReplicaTip) -> Result<()> {
198 if self.phase != AntiEntropyPhase::ExchangeTips {
199 return Err(FrankenError::Internal(format!(
200 "anti-entropy: expected ExchangeTips, got {:?}",
201 self.phase
202 )));
203 }
204 debug!(
205 bead_id = BEAD_ID,
206 local_pos = local.marker_position,
207 remote_pos = remote.marker_position,
208 "exchanged tips"
209 );
210 self.local_tip = Some(local);
211 self.remote_tip = Some(remote);
212 self.phase = AntiEntropyPhase::ComputeMissing;
213 Ok(())
214 }
215
216 pub fn compute_missing(
218 &mut self,
219 local_objects: &BTreeSet<ObjectId>,
220 remote_objects: &BTreeSet<ObjectId>,
221 ) -> Result<&MissingObjects> {
222 if self.phase != AntiEntropyPhase::ComputeMissing {
223 return Err(FrankenError::Internal(format!(
224 "anti-entropy: expected ComputeMissing, got {:?}",
225 self.phase
226 )));
227 }
228
229 let needed: BTreeSet<ObjectId> =
230 remote_objects.difference(local_objects).copied().collect();
231 let to_offer: BTreeSet<ObjectId> =
232 local_objects.difference(remote_objects).copied().collect();
233
234 debug!(
235 bead_id = BEAD_ID,
236 needed_count = needed.len(),
237 to_offer_count = to_offer.len(),
238 "computed missing objects"
239 );
240
241 self.missing = Some(MissingObjects { needed, to_offer });
242 self.phase = AntiEntropyPhase::RequestSymbols;
243 Ok(self.missing.as_ref().expect("just set"))
244 }
245
246 #[must_use]
248 pub fn objects_to_request(&self) -> Option<&BTreeSet<ObjectId>> {
249 self.missing.as_ref().map(|m| &m.needed)
250 }
251
252 pub fn record_decoded(&mut self, object_id: ObjectId) -> Result<()> {
254 if self.phase != AntiEntropyPhase::RequestSymbols
255 && self.phase != AntiEntropyPhase::StreamUntilDecode
256 {
257 return Err(FrankenError::Internal(format!(
258 "anti-entropy: expected RequestSymbols/StreamUntilDecode, got {:?}",
259 self.phase
260 )));
261 }
262 self.phase = AntiEntropyPhase::StreamUntilDecode;
263 self.decoded_objects.insert(object_id);
264
265 if let Some(missing) = &self.missing
267 && missing
268 .needed
269 .iter()
270 .all(|id| self.decoded_objects.contains(id))
271 {
272 debug!(
273 bead_id = BEAD_ID,
274 decoded_count = self.decoded_objects.len(),
275 "all missing objects decoded"
276 );
277 self.phase = AntiEntropyPhase::PersistAndUpdate;
278 }
279 Ok(())
280 }
281
282 pub fn finalize(&mut self) -> Result<()> {
284 if self.phase != AntiEntropyPhase::PersistAndUpdate {
285 return Err(FrankenError::Internal(format!(
286 "anti-entropy: expected PersistAndUpdate, got {:?}",
287 self.phase
288 )));
289 }
290 info!(
291 bead_id = BEAD_ID,
292 decoded_count = self.decoded_objects.len(),
293 "anti-entropy session complete — persisted"
294 );
295 self.phase = AntiEntropyPhase::Complete;
296 Ok(())
297 }
298
299 #[must_use]
301 pub const fn is_converged(&self) -> bool {
302 matches!(self.phase, AntiEntropyPhase::Complete)
303 }
304}
305
306impl Default for AntiEntropySession {
307 fn default() -> Self {
308 Self::new()
309 }
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct QuorumPolicy {
319 pub required: u32,
321 pub total: u32,
323}
324
325impl QuorumPolicy {
326 #[must_use]
328 pub const fn local_only() -> Self {
329 Self {
330 required: 1,
331 total: 1,
332 }
333 }
334
335 #[must_use]
337 pub const fn two_of_three() -> Self {
338 Self {
339 required: 2,
340 total: 3,
341 }
342 }
343
344 pub fn new(required: u32, total: u32) -> Result<Self> {
346 if required == 0 || required > total {
347 return Err(FrankenError::Internal(format!(
348 "invalid quorum: required={required}, total={total}"
349 )));
350 }
351 Ok(Self { required, total })
352 }
353}
354
355#[derive(Debug)]
357pub struct QuorumTracker {
358 policy: QuorumPolicy,
359 accepted: HashSet<u32>,
360}
361
362impl QuorumTracker {
363 #[must_use]
365 pub fn new(policy: QuorumPolicy) -> Self {
366 Self {
367 policy,
368 accepted: HashSet::new(),
369 }
370 }
371
372 pub fn record_acceptance(&mut self, store_id: u32) {
374 self.accepted.insert(store_id);
375 debug!(
376 bead_id = BEAD_ID,
377 store_id,
378 accepted = self.accepted.len(),
379 required = self.policy.required,
380 "store accepted symbols"
381 );
382 }
383
384 #[must_use]
386 #[allow(clippy::cast_possible_truncation)]
387 pub fn is_satisfied(&self) -> bool {
388 self.accepted.len() as u32 >= self.policy.required
389 }
390
391 #[must_use]
393 pub fn accepted_count(&self) -> usize {
394 self.accepted.len()
395 }
396
397 #[must_use]
399 pub const fn policy(&self) -> &QuorumPolicy {
400 &self.policy
401 }
402}
403
404#[derive(Debug, Clone)]
410pub struct ConsistentHashRing {
411 ring: Vec<(u64, u32)>,
413 vnodes: u32,
415}
416
417impl ConsistentHashRing {
418 #[must_use]
420 pub fn new(node_ids: &[u32], vnodes: u32) -> Self {
421 let mut ring = Vec::with_capacity(node_ids.len() * vnodes as usize);
422 for &nid in node_ids {
423 for v in 0..vnodes {
424 let hash = Self::hash_vnode(nid, v);
425 ring.push((hash, nid));
426 }
427 }
428 ring.sort_unstable_by_key(|&(h, _)| h);
429 Self { ring, vnodes }
430 }
431
432 #[must_use]
434 pub fn route(&self, object_id: &ObjectId, esi: u32) -> Option<u32> {
435 if self.ring.is_empty() {
436 return None;
437 }
438 let key = Self::hash_symbol(object_id, esi);
439 let idx = self.ring.partition_point(|&(h, _)| h < key);
441 let idx = if idx >= self.ring.len() { 0 } else { idx };
442 Some(self.ring[idx].1)
443 }
444
445 #[must_use]
447 pub fn add_node(&mut self, node_id: u32) -> Self {
448 let mut node_ids: BTreeSet<u32> = self.ring.iter().map(|&(_, n)| n).collect();
449 node_ids.insert(node_id);
450 let ids: Vec<u32> = node_ids.into_iter().collect();
451 Self::new(&ids, self.vnodes)
452 }
453
454 #[must_use]
456 pub fn node_count(&self) -> usize {
457 let nodes: HashSet<u32> = self.ring.iter().map(|&(_, n)| n).collect();
458 nodes.len()
459 }
460
461 fn hash_vnode(node_id: u32, vnode: u32) -> u64 {
462 let mut buf = [0u8; 8];
463 buf[..4].copy_from_slice(&node_id.to_le_bytes());
464 buf[4..8].copy_from_slice(&vnode.to_le_bytes());
465 xxhash_rust::xxh3::xxh3_64(&buf)
466 }
467
468 fn hash_symbol(object_id: &ObjectId, esi: u32) -> u64 {
469 let mut buf = [0u8; 20];
470 buf[..16].copy_from_slice(object_id.as_bytes());
471 buf[16..20].copy_from_slice(&esi.to_le_bytes());
472 xxhash_rust::xxh3::xxh3_64(&buf)
473 }
474}
475
476#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct AuthenticatedSymbol {
483 pub object_id: ObjectId,
484 pub esi: u32,
485 pub data: Vec<u8>,
486 pub auth_tag: [u8; 16],
488}
489
490impl AuthenticatedSymbol {
491 #[must_use]
493 pub fn compute_auth_tag(object_id: &ObjectId, esi: u32, data: &[u8]) -> [u8; 16] {
494 let mut hasher = blake3::Hasher::new();
495 hasher.update(b"fsqlite:repl:auth:v1");
496 hasher.update(object_id.as_bytes());
497 hasher.update(&esi.to_le_bytes());
498 hasher.update(data);
499 let hash = hasher.finalize();
500 let mut tag = [0u8; 16];
501 tag.copy_from_slice(&hash.as_bytes()[..16]);
502 tag
503 }
504
505 #[must_use]
507 pub fn verify(&self) -> bool {
508 let expected = Self::compute_auth_tag(&self.object_id, self.esi, &self.data);
509 self.auth_tag == expected
510 }
511
512 #[must_use]
514 pub fn new(object_id: ObjectId, esi: u32, data: Vec<u8>) -> Self {
515 let auth_tag = Self::compute_auth_tag(&object_id, esi, &data);
516 Self {
517 object_id,
518 esi,
519 data,
520 auth_tag,
521 }
522 }
523}
524
525#[derive(Debug, Clone)]
531pub struct ReplicationConfig {
532 pub role: ReplicationRole,
533 pub mode: ReplicationMode,
534 pub quorum: QuorumPolicy,
535 pub security_enabled: bool,
536 pub multi_writer_explicit: bool,
537}
538
539impl Default for ReplicationConfig {
540 fn default() -> Self {
541 Self {
542 role: ReplicationRole::Leader,
543 mode: ReplicationMode::LeaderCommitClock,
544 quorum: QuorumPolicy::local_only(),
545 security_enabled: false,
546 multi_writer_explicit: false,
547 }
548 }
549}
550
551pub fn validate_config(config: &ReplicationConfig) -> Result<()> {
553 if config.mode == ReplicationMode::MultiWriter && !config.multi_writer_explicit {
554 error!(
555 bead_id = BEAD_ID,
556 "multi-writer mode requires explicit configuration"
557 );
558 return Err(FrankenError::Internal(
559 "multi-writer replication mode requires explicit opt-in via multi_writer_explicit=true"
560 .into(),
561 ));
562 }
563 info!(
564 bead_id = BEAD_ID,
565 role = ?config.role,
566 mode = ?config.mode,
567 quorum_required = config.quorum.required,
568 quorum_total = config.quorum.total,
569 security = config.security_enabled,
570 "replication config validated"
571 );
572 Ok(())
573}
574
575#[derive(Debug)]
582pub struct CommitPublicationGate {
583 tracker: QuorumTracker,
584 marker: Option<CommitMarker>,
585 published: bool,
586}
587
588impl CommitPublicationGate {
589 #[must_use]
591 pub fn new(marker: CommitMarker, policy: QuorumPolicy) -> Self {
592 Self {
593 tracker: QuorumTracker::new(policy),
594 marker: Some(marker),
595 published: false,
596 }
597 }
598
599 pub fn record_store_acceptance(&mut self, store_id: u32) {
601 self.tracker.record_acceptance(store_id);
602 }
603
604 pub fn try_publish(&mut self) -> Option<&CommitMarker> {
607 if self.published {
608 return self.marker.as_ref();
609 }
610 if self.tracker.is_satisfied() {
611 self.published = true;
612 info!(
613 bead_id = BEAD_ID,
614 accepted = self.tracker.accepted_count(),
615 required = self.tracker.policy().required,
616 "quorum satisfied — marker published"
617 );
618 self.marker.as_ref()
619 } else {
620 warn!(
621 bead_id = BEAD_ID,
622 accepted = self.tracker.accepted_count(),
623 required = self.tracker.policy().required,
624 "quorum not yet satisfied — marker withheld"
625 );
626 None
627 }
628 }
629
630 #[must_use]
632 pub const fn is_published(&self) -> bool {
633 self.published
634 }
635}
636
637pub fn filter_authenticated_symbols(
643 symbols: &[AuthenticatedSymbol],
644) -> (Vec<&AuthenticatedSymbol>, Vec<&AuthenticatedSymbol>) {
645 let mut accepted = Vec::new();
646 let mut rejected = Vec::new();
647
648 for sym in symbols {
649 if sym.verify() {
650 accepted.push(sym);
651 } else {
652 debug!(
653 bead_id = BEAD_ID,
654 esi = sym.esi,
655 "rejected unauthenticated symbol"
656 );
657 rejected.push(sym);
658 }
659 }
660
661 if !rejected.is_empty() {
662 warn!(
663 bead_id = BEAD_ID,
664 rejected_count = rejected.len(),
665 accepted_count = accepted.len(),
666 "filtered out unauthenticated symbols"
667 );
668 }
669
670 (accepted, rejected)
671}
672
673#[derive(Debug, Clone, PartialEq, Eq)]
679pub struct TraceEvent {
680 pub node_id: u32,
681 pub commit_seq: u64,
682 pub object_id: ObjectId,
683 pub event_type: TraceEventType,
684}
685
686#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
688pub enum TraceEventType {
689 Published,
690 Received,
691 Applied,
692}
693
694#[derive(Debug, Clone, PartialEq, Eq)]
696pub struct SheafCheckResult {
697 pub is_consistent: bool,
698 pub anomalies: Vec<SheafAnomaly>,
699}
700
701#[derive(Debug, Clone, PartialEq, Eq)]
703pub struct SheafAnomaly {
704 pub description: String,
705 pub commit_seq: u64,
706 pub involved_nodes: Vec<u32>,
707}
708
709#[must_use]
714pub fn sheaf_consistency_check(events: &[TraceEvent]) -> SheafCheckResult {
715 let mut node_events: HashMap<(u64, u32), HashSet<TraceEventType>> = HashMap::new();
717 for ev in events {
718 node_events
719 .entry((ev.commit_seq, ev.node_id))
720 .or_default()
721 .insert(ev.event_type);
722 }
723
724 let commit_seqs: BTreeSet<u64> = events.iter().map(|e| e.commit_seq).collect();
726 let all_nodes: BTreeSet<u32> = events.iter().map(|e| e.node_id).collect();
727
728 let mut anomalies = Vec::new();
729
730 for &seq in &commit_seqs {
731 let has_end_to_end = all_nodes.iter().any(|&nid| {
733 let key = (seq, nid);
734 if let Some(types) = node_events.get(&key) {
735 types.contains(&TraceEventType::Published)
736 && types.contains(&TraceEventType::Applied)
737 } else {
738 false
739 }
740 });
741
742 if !has_end_to_end {
743 let involved: Vec<u32> = all_nodes
745 .iter()
746 .filter(|&&nid| node_events.contains_key(&(seq, nid)))
747 .copied()
748 .collect();
749
750 if !involved.is_empty() {
751 anomalies.push(SheafAnomaly {
752 description: format!(
753 "phantom commit at seq {seq}: no single node has both Published and Applied"
754 ),
755 commit_seq: seq,
756 involved_nodes: involved,
757 });
758 }
759 }
760 }
761
762 let is_consistent = anomalies.is_empty();
763
764 if is_consistent {
765 debug!(
766 bead_id = BEAD_ID,
767 commit_count = commit_seqs.len(),
768 "sheaf consistency check passed"
769 );
770 } else {
771 warn!(
772 bead_id = BEAD_ID,
773 anomaly_count = anomalies.len(),
774 "sheaf consistency check found anomalies"
775 );
776 }
777
778 SheafCheckResult {
779 is_consistent,
780 anomalies,
781 }
782}
783
784#[must_use]
790pub fn export_tla_trace(events: &[TraceEvent]) -> String {
791 use std::fmt::Write;
792 let mut out = String::new();
793 let _ = writeln!(out, "---- MODULE ReplicationTrace ----");
794 let _ = writeln!(out, "EXTENDS Integers, Sequences, FiniteSets");
795 let _ = writeln!(out);
796 let _ = writeln!(out, "VARIABLES committed, applied");
797 let _ = writeln!(out);
798 let _ = writeln!(out, "Init ==");
799 let _ = writeln!(out, " /\\ committed = {{}}");
800 let _ = writeln!(out, " /\\ applied = {{}}");
801 let _ = writeln!(out);
802
803 for (i, ev) in events.iter().enumerate() {
804 let _ = writeln!(
805 out,
806 "\\* Step {i}: node={}, seq={}",
807 ev.node_id, ev.commit_seq
808 );
809 match ev.event_type {
810 TraceEventType::Published => {
811 let _ = writeln!(
812 out,
813 "Step{i} == committed' = committed \\cup {{{}}}",
814 ev.commit_seq
815 );
816 }
817 TraceEventType::Applied => {
818 let _ = writeln!(
819 out,
820 "Step{i} == applied' = applied \\cup {{{}}}",
821 ev.commit_seq
822 );
823 }
824 TraceEventType::Received => {
825 let _ = writeln!(out, "\\* Received event (no state change in this model)");
826 }
827 }
828 let _ = writeln!(out);
829 }
830
831 let _ = writeln!(out, "====");
832 out
833}
834
835#[cfg(test)]
840#[allow(clippy::too_many_lines)]
841mod tests {
842 use super::*;
843
844 fn make_oid(seed: u8) -> ObjectId {
845 let mut b = [0u8; 16];
846 b[0] = seed;
847 ObjectId::from_bytes(b)
848 }
849
850 #[test]
853 fn test_bd_1hi_19_unit_compliance_gate() {
854 assert_eq!(BEAD_ID, "bd-1hi.19");
855 let _ = ReplicationRole::Leader;
857 let _ = ReplicationRole::Follower;
858 let _ = ReplicationMode::LeaderCommitClock;
859 let _ = ReplicationMode::MultiWriter;
860 let _ = AntiEntropyPhase::ExchangeTips;
861 let _ = QuorumPolicy::local_only();
862 }
863
864 #[test]
865 fn prop_bd_1hi_19_structure_compliance() {
866 let mut session = AntiEntropySession::new();
868 assert_eq!(session.phase(), AntiEntropyPhase::ExchangeTips);
869
870 let local = ReplicaTip {
871 root_manifest_id: make_oid(1),
872 marker_position: 10,
873 index_segment_tips: vec![],
874 };
875 let remote = ReplicaTip {
876 root_manifest_id: make_oid(2),
877 marker_position: 12,
878 index_segment_tips: vec![],
879 };
880 session.exchange_tips(local, remote).unwrap();
881 assert_eq!(session.phase(), AntiEntropyPhase::ComputeMissing);
882 }
883
884 #[test]
885 fn test_e2e_bd_1hi_19_compliance() {
886 let config = ReplicationConfig::default();
888 validate_config(&config).unwrap();
889
890 let mut session = AntiEntropySession::new();
891 let local = ReplicaTip {
892 root_manifest_id: make_oid(1),
893 marker_position: 5,
894 index_segment_tips: vec![],
895 };
896 let remote = ReplicaTip {
897 root_manifest_id: make_oid(2),
898 marker_position: 7,
899 index_segment_tips: vec![],
900 };
901 session.exchange_tips(local, remote).unwrap();
902
903 let local_objects: BTreeSet<ObjectId> = [make_oid(10), make_oid(20)].into();
904 let remote_objects: BTreeSet<ObjectId> = [make_oid(20), make_oid(30)].into();
905 let missing = session
906 .compute_missing(&local_objects, &remote_objects)
907 .unwrap();
908 assert!(missing.needed.contains(&make_oid(30)));
909
910 session.record_decoded(make_oid(30)).unwrap();
911 assert_eq!(session.phase(), AntiEntropyPhase::PersistAndUpdate);
912 session.finalize().unwrap();
913 assert!(session.is_converged());
914 }
915
916 #[test]
919 fn test_leader_follower_replication() {
920 let config = ReplicationConfig {
921 role: ReplicationRole::Leader,
922 mode: ReplicationMode::LeaderCommitClock,
923 ..Default::default()
924 };
925 validate_config(&config).unwrap();
926
927 let follower_config = ReplicationConfig {
928 role: ReplicationRole::Follower,
929 mode: ReplicationMode::LeaderCommitClock,
930 ..Default::default()
931 };
932 validate_config(&follower_config).unwrap();
933 }
934
935 #[test]
938 fn test_anti_entropy_exchange_tips() {
939 let mut session = AntiEntropySession::new();
940 let local = ReplicaTip {
941 root_manifest_id: make_oid(1),
942 marker_position: 10,
943 index_segment_tips: vec![make_oid(100)],
944 };
945 let remote = ReplicaTip {
946 root_manifest_id: make_oid(2),
947 marker_position: 15,
948 index_segment_tips: vec![make_oid(200)],
949 };
950 session.exchange_tips(local, remote).unwrap();
951 assert_eq!(session.phase(), AntiEntropyPhase::ComputeMissing);
952 }
953
954 #[test]
955 fn test_anti_entropy_compute_missing() {
956 let mut session = AntiEntropySession::new();
957 session
958 .exchange_tips(
959 ReplicaTip {
960 root_manifest_id: make_oid(1),
961 marker_position: 0,
962 index_segment_tips: vec![],
963 },
964 ReplicaTip {
965 root_manifest_id: make_oid(2),
966 marker_position: 0,
967 index_segment_tips: vec![],
968 },
969 )
970 .unwrap();
971
972 let local: BTreeSet<ObjectId> = [make_oid(1), make_oid(2), make_oid(3)].into();
973 let remote: BTreeSet<ObjectId> = [make_oid(2), make_oid(3), make_oid(4)].into();
974
975 let missing = session.compute_missing(&local, &remote).unwrap();
976 assert_eq!(missing.needed, [make_oid(4)].into());
977 assert_eq!(missing.to_offer, [make_oid(1)].into());
978 }
979
980 #[test]
981 fn test_anti_entropy_stream_until_decode() {
982 let mut session = AntiEntropySession::new();
983 session
984 .exchange_tips(
985 ReplicaTip {
986 root_manifest_id: make_oid(1),
987 marker_position: 0,
988 index_segment_tips: vec![],
989 },
990 ReplicaTip {
991 root_manifest_id: make_oid(2),
992 marker_position: 0,
993 index_segment_tips: vec![],
994 },
995 )
996 .unwrap();
997
998 let local: BTreeSet<ObjectId> = [make_oid(1)].into();
999 let remote: BTreeSet<ObjectId> = [make_oid(1), make_oid(2), make_oid(3)].into();
1000 session.compute_missing(&local, &remote).unwrap();
1001
1002 session.record_decoded(make_oid(2)).unwrap();
1004 assert_eq!(session.phase(), AntiEntropyPhase::StreamUntilDecode);
1005 session.record_decoded(make_oid(3)).unwrap();
1006 assert_eq!(session.phase(), AntiEntropyPhase::PersistAndUpdate);
1007 }
1008
1009 #[test]
1010 fn test_anti_entropy_convergence() {
1011 let mut session = AntiEntropySession::new();
1012 session
1013 .exchange_tips(
1014 ReplicaTip {
1015 root_manifest_id: make_oid(1),
1016 marker_position: 0,
1017 index_segment_tips: vec![],
1018 },
1019 ReplicaTip {
1020 root_manifest_id: make_oid(2),
1021 marker_position: 0,
1022 index_segment_tips: vec![],
1023 },
1024 )
1025 .unwrap();
1026
1027 let local: BTreeSet<ObjectId> = [make_oid(1), make_oid(2)].into();
1028 let remote: BTreeSet<ObjectId> = [make_oid(2), make_oid(3)].into();
1029 session.compute_missing(&local, &remote).unwrap();
1030 session.record_decoded(make_oid(3)).unwrap();
1031 session.finalize().unwrap();
1032 assert!(session.is_converged());
1033 }
1034
1035 #[test]
1038 fn test_quorum_local_only() {
1039 let policy = QuorumPolicy::local_only();
1040 let mut tracker = QuorumTracker::new(policy);
1041 assert!(!tracker.is_satisfied());
1042 tracker.record_acceptance(0);
1043 assert!(tracker.is_satisfied());
1044 }
1045
1046 #[test]
1047 fn test_quorum_2_of_3() {
1048 let policy = QuorumPolicy::two_of_three();
1049 let mut tracker = QuorumTracker::new(policy);
1050 assert!(!tracker.is_satisfied());
1051 tracker.record_acceptance(0);
1052 assert!(!tracker.is_satisfied()); tracker.record_acceptance(1);
1054 assert!(tracker.is_satisfied()); tracker.record_acceptance(2);
1056 assert!(tracker.is_satisfied()); }
1058
1059 #[test]
1060 fn test_quorum_blocks_marker_publication() {
1061 let marker = CommitMarker {
1062 commit_seq: 42,
1063 capsule_id: make_oid(10),
1064 timestamp_ns: 1_000_000,
1065 };
1066 let policy = QuorumPolicy::two_of_three();
1067 let mut gate = CommitPublicationGate::new(marker, policy);
1068
1069 assert!(!gate.is_published());
1071 assert!(gate.try_publish().is_none());
1072
1073 gate.record_store_acceptance(0);
1074 assert!(gate.try_publish().is_none()); gate.record_store_acceptance(1);
1077 let published = gate.try_publish();
1078 assert!(published.is_some());
1079 assert_eq!(published.unwrap().commit_seq, 42);
1080 assert!(gate.is_published());
1081 }
1082
1083 #[test]
1086 fn test_symbol_routing_consistent_hash() {
1087 let ring = ConsistentHashRing::new(&[1, 2, 3], 100);
1088 assert_eq!(ring.node_count(), 3);
1089
1090 let oid = make_oid(42);
1091 let node = ring.route(&oid, 0).unwrap();
1092 assert!([1, 2, 3].contains(&node));
1093
1094 let node2 = ring.route(&oid, 0).unwrap();
1096 assert_eq!(node, node2);
1097 }
1098
1099 #[test]
1100 fn test_symbol_routing_add_node_minimal_reroute() {
1101 let mut ring3 = ConsistentHashRing::new(&[1, 2, 3], 100);
1102 let ring4 = ring3.add_node(4);
1103 assert_eq!(ring4.node_count(), 4);
1104
1105 let oid = make_oid(1);
1107 let mut rerouted = 0_u32;
1108 for esi in 0..1000 {
1109 let n3 = ring3.route(&oid, esi).unwrap();
1110 let n4 = ring4.route(&oid, esi).unwrap();
1111 if n3 != n4 {
1112 rerouted += 1;
1113 }
1114 }
1115 assert!(rerouted < 500, "too many reroutes: {rerouted}/1000");
1118 }
1119
1120 #[test]
1123 fn test_authenticated_symbols_verified() {
1124 let sym = AuthenticatedSymbol::new(make_oid(1), 0, vec![1, 2, 3]);
1125 assert!(sym.verify());
1126
1127 let mut bad = sym.clone();
1129 bad.data[0] = 99;
1130 assert!(!bad.verify());
1131
1132 let mut bad2 = sym;
1134 bad2.auth_tag[0] ^= 0xFF;
1135 assert!(!bad2.verify());
1136 }
1137
1138 #[test]
1139 fn test_unauthenticated_fallback() {
1140 let good1 = AuthenticatedSymbol::new(make_oid(1), 0, vec![10, 20]);
1141 let good2 = AuthenticatedSymbol::new(make_oid(1), 1, vec![30, 40]);
1142 let mut bad = AuthenticatedSymbol::new(make_oid(1), 2, vec![50, 60]);
1143 bad.auth_tag[0] ^= 0xFF; let all = [good1, good2, bad];
1146 let (accepted, rejected) = filter_authenticated_symbols(&all);
1147 assert_eq!(accepted.len(), 2);
1148 assert_eq!(rejected.len(), 1);
1149 assert_eq!(rejected[0].esi, 2);
1150 }
1151
1152 #[test]
1155 fn test_sheaf_consistency_check_clean() {
1156 let events = vec![
1157 TraceEvent {
1158 node_id: 1,
1159 commit_seq: 1,
1160 object_id: make_oid(10),
1161 event_type: TraceEventType::Published,
1162 },
1163 TraceEvent {
1164 node_id: 1,
1165 commit_seq: 1,
1166 object_id: make_oid(10),
1167 event_type: TraceEventType::Applied,
1168 },
1169 ];
1170 let result = sheaf_consistency_check(&events);
1171 assert!(result.is_consistent);
1172 assert!(result.anomalies.is_empty());
1173 }
1174
1175 #[test]
1176 fn test_sheaf_consistency_check_phantom() {
1177 let events = vec![
1179 TraceEvent {
1180 node_id: 1,
1181 commit_seq: 1,
1182 object_id: make_oid(10),
1183 event_type: TraceEventType::Published,
1184 },
1185 TraceEvent {
1186 node_id: 2,
1187 commit_seq: 1,
1188 object_id: make_oid(10),
1189 event_type: TraceEventType::Applied,
1190 },
1191 ];
1192 let result = sheaf_consistency_check(&events);
1193 assert!(!result.is_consistent);
1194 assert_eq!(result.anomalies.len(), 1);
1195 assert_eq!(result.anomalies[0].commit_seq, 1);
1196 }
1197
1198 #[test]
1201 fn test_tla_export() {
1202 let events = vec![
1203 TraceEvent {
1204 node_id: 1,
1205 commit_seq: 1,
1206 object_id: make_oid(10),
1207 event_type: TraceEventType::Published,
1208 },
1209 TraceEvent {
1210 node_id: 2,
1211 commit_seq: 1,
1212 object_id: make_oid(10),
1213 event_type: TraceEventType::Applied,
1214 },
1215 ];
1216 let tla = export_tla_trace(&events);
1217 assert!(tla.contains("MODULE ReplicationTrace"));
1218 assert!(tla.contains("committed"));
1219 assert!(tla.contains("applied"));
1220 assert!(tla.contains("===="));
1221 }
1222
1223 #[test]
1226 fn test_multiwriter_not_default() {
1227 let config = ReplicationConfig {
1228 mode: ReplicationMode::MultiWriter,
1229 multi_writer_explicit: false,
1230 ..Default::default()
1231 };
1232 let result = validate_config(&config);
1233 assert!(result.is_err());
1234 }
1235
1236 #[test]
1237 fn test_multiwriter_explicit_ok() {
1238 let config = ReplicationConfig {
1239 mode: ReplicationMode::MultiWriter,
1240 multi_writer_explicit: true,
1241 ..Default::default()
1242 };
1243 validate_config(&config).unwrap();
1244 }
1245
1246 #[test]
1249 fn prop_anti_entropy_convergence() {
1250 for seed in 0..20_u8 {
1252 let local: BTreeSet<ObjectId> = (0..seed).map(|i| make_oid(i * 2)).collect();
1253 let remote: BTreeSet<ObjectId> = (0..seed).map(|i| make_oid(i * 2 + 1)).collect();
1254
1255 let mut session = AntiEntropySession::new();
1256 session
1257 .exchange_tips(
1258 ReplicaTip {
1259 root_manifest_id: make_oid(100),
1260 marker_position: 0,
1261 index_segment_tips: vec![],
1262 },
1263 ReplicaTip {
1264 root_manifest_id: make_oid(200),
1265 marker_position: 0,
1266 index_segment_tips: vec![],
1267 },
1268 )
1269 .unwrap();
1270 let missing = session.compute_missing(&local, &remote).unwrap();
1271 for &oid in &missing.needed.clone() {
1272 session.record_decoded(oid).unwrap();
1273 }
1274 if session.phase() == AntiEntropyPhase::PersistAndUpdate {
1275 session.finalize().unwrap();
1276 }
1277 assert!(
1279 session.is_converged() || session.phase() == AntiEntropyPhase::RequestSymbols,
1280 "failed to converge for seed={seed}"
1281 );
1282 }
1283 }
1284
1285 #[test]
1286 fn prop_quorum_safety() {
1287 for m in 1..=5_u32 {
1289 for n in m..=5 {
1290 let policy = QuorumPolicy::new(m, n).unwrap();
1291 let mut tracker = QuorumTracker::new(policy);
1292 for i in 0..m - 1 {
1293 tracker.record_acceptance(i);
1294 assert!(
1295 !tracker.is_satisfied(),
1296 "should not be satisfied with {} of {} (need {})",
1297 i + 1,
1298 n,
1299 m
1300 );
1301 }
1302 tracker.record_acceptance(m - 1);
1303 assert!(
1304 tracker.is_satisfied(),
1305 "should be satisfied with {m} of {n}"
1306 );
1307 }
1308 }
1309 }
1310
1311 #[test]
1314 fn test_ecs_replication_ordering() {
1315 let markers = [
1317 CommitMarker {
1318 commit_seq: 1,
1319 capsule_id: make_oid(1),
1320 timestamp_ns: 100,
1321 },
1322 CommitMarker {
1323 commit_seq: 2,
1324 capsule_id: make_oid(2),
1325 timestamp_ns: 200,
1326 },
1327 CommitMarker {
1328 commit_seq: 3,
1329 capsule_id: make_oid(3),
1330 timestamp_ns: 300,
1331 },
1332 ];
1333
1334 for w in markers.windows(2) {
1336 assert!(w[0].commit_seq < w[1].commit_seq);
1337 }
1338 }
1339
1340 #[test]
1341 fn test_ecs_replication_commit_capsules() {
1342 let local_capsules: BTreeSet<ObjectId> = [make_oid(1), make_oid(2)].into();
1344 let remote_capsules: BTreeSet<ObjectId> = [make_oid(1), make_oid(2), make_oid(3)].into();
1345
1346 let mut session = AntiEntropySession::new();
1347 session
1348 .exchange_tips(
1349 ReplicaTip {
1350 root_manifest_id: make_oid(10),
1351 marker_position: 1,
1352 index_segment_tips: vec![],
1353 },
1354 ReplicaTip {
1355 root_manifest_id: make_oid(11),
1356 marker_position: 2,
1357 index_segment_tips: vec![],
1358 },
1359 )
1360 .unwrap();
1361
1362 let missing = session
1363 .compute_missing(&local_capsules, &remote_capsules)
1364 .unwrap();
1365 assert_eq!(missing.needed, [make_oid(3)].into());
1366 }
1367
1368 #[test]
1369 fn test_ecs_replication_dedup() {
1370 let marker = CommitMarker {
1372 commit_seq: 77,
1373 capsule_id: make_oid(9),
1374 timestamp_ns: 1_234,
1375 };
1376 let mut dedup = CommitDeduplicator::default();
1377
1378 assert!(dedup.should_accept(&marker));
1379 assert!(!dedup.should_accept(&marker));
1380 assert_eq!(dedup.seen_count(), 1);
1381 }
1382
1383 #[test]
1386 fn test_e2e_3_node_replication() {
1387 let mut leader_objects: BTreeSet<ObjectId> = BTreeSet::new();
1389
1390 for i in 0..10_u8 {
1392 leader_objects.insert(make_oid(i));
1393 }
1394
1395 let follower1_objects: BTreeSet<ObjectId> = BTreeSet::new();
1397
1398 let mut session = AntiEntropySession::new();
1400 session
1401 .exchange_tips(
1402 ReplicaTip {
1403 root_manifest_id: make_oid(0),
1404 marker_position: 0,
1405 index_segment_tips: vec![],
1406 },
1407 ReplicaTip {
1408 root_manifest_id: make_oid(9),
1409 marker_position: 10,
1410 index_segment_tips: vec![],
1411 },
1412 )
1413 .unwrap();
1414 let missing = session
1415 .compute_missing(&follower1_objects, &leader_objects)
1416 .unwrap();
1417 assert_eq!(missing.needed.len(), 10);
1418
1419 for &oid in &missing.needed.clone() {
1420 session.record_decoded(oid).unwrap();
1421 }
1422 session.finalize().unwrap();
1423 assert!(session.is_converged());
1424 }
1425
1426 #[test]
1427 fn test_e2e_node_failure_recovery() {
1428 let policy = QuorumPolicy::two_of_three();
1430 let marker = CommitMarker {
1431 commit_seq: 1,
1432 capsule_id: make_oid(1),
1433 timestamp_ns: 1000,
1434 };
1435 let mut gate = CommitPublicationGate::new(marker, policy);
1436
1437 gate.record_store_acceptance(0);
1439 gate.record_store_acceptance(2);
1442
1443 assert!(gate.try_publish().is_some());
1445 }
1446
1447 #[test]
1448 fn test_e2e_lossy_replication_convergence() {
1449 fn delivered_with_loss(oid: &ObjectId, round: u32, loss_per_mille: u64) -> bool {
1451 let mut material = [0_u8; 20];
1452 material[..16].copy_from_slice(oid.as_bytes());
1453 material[16..].copy_from_slice(&round.to_le_bytes());
1454 xxhash_rust::xxh3::xxh3_64(&material) % 1000 >= loss_per_mille
1455 }
1456
1457 let leader_objects: BTreeSet<ObjectId> = (0_u8..100).map(make_oid).collect();
1458 let mut follower_objects: BTreeSet<ObjectId> = BTreeSet::new();
1459
1460 for round in 0_u32..32 {
1461 if follower_objects == leader_objects {
1462 break;
1463 }
1464
1465 let mut session = AntiEntropySession::new();
1466 session
1467 .exchange_tips(
1468 ReplicaTip {
1469 root_manifest_id: make_oid(1),
1470 marker_position: follower_objects.len() as u64,
1471 index_segment_tips: vec![],
1472 },
1473 ReplicaTip {
1474 root_manifest_id: make_oid(2),
1475 marker_position: leader_objects.len() as u64,
1476 index_segment_tips: vec![],
1477 },
1478 )
1479 .unwrap();
1480
1481 let missing = session
1482 .compute_missing(&follower_objects, &leader_objects)
1483 .unwrap()
1484 .needed
1485 .clone();
1486
1487 for oid in missing {
1488 if delivered_with_loss(&oid, round, 100) {
1489 session.record_decoded(oid).unwrap();
1490 follower_objects.insert(oid);
1491 }
1492 }
1493
1494 if session.phase() == AntiEntropyPhase::PersistAndUpdate {
1495 session.finalize().unwrap();
1496 }
1497 }
1498
1499 assert_eq!(follower_objects, leader_objects);
1500 }
1501}