1use crate::checkpoint::{Checkpoint, CheckpointError, FrontierEntry};
97use crate::oplog::{Hlc, OpRecord, WallClock};
98use serde::{Deserialize, Serialize};
99use std::collections::BTreeMap;
100use std::fmt;
101use std::fs::{self, File, OpenOptions};
102use std::io::Write;
103use std::path::{Path, PathBuf};
104
105pub type Frontier = BTreeMap<String, u64>;
110
111pub fn frontier_of(ops: &[OpRecord]) -> Frontier {
113 let mut frontier = Frontier::new();
114 for op in ops {
115 let entry = frontier.entry(op.device_id.clone()).or_insert(op.seq);
116 if op.seq > *entry {
117 *entry = op.seq;
118 }
119 }
120 frontier
121}
122
123pub fn checkpoint_frontier(checkpoint: &Checkpoint) -> Frontier {
126 checkpoint
127 .frontier
128 .iter()
129 .map(|(device, entry)| (device.clone(), entry.seq))
130 .collect()
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum DeviceStatus {
138 Active,
139 Evicted,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct RosterEntry {
147 pub device_id: String,
148 pub added_at: Hlc,
150 pub last_seen_ms: u64,
152 pub acked: Option<Hlc>,
155 pub status: DeviceStatus,
156}
157
158#[derive(Debug, Clone, Default)]
160pub struct RelayConfig {
161 pub eviction_horizon_ms: Option<u64>,
165}
166
167#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
170pub struct PushOutcome {
171 pub accepted: usize,
173 pub deduped: usize,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct PullResult {
182 pub ops: Vec<OpRecord>,
185 pub latest_checkpoint: Option<String>,
188}
189
190#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
192pub struct AckOutcome {
193 pub advanced: bool,
195 pub reinstated: bool,
197}
198
199#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
201pub struct GcReport {
202 pub dropped: BTreeMap<String, usize>,
203}
204
205impl GcReport {
206 pub fn total(&self) -> usize {
207 self.dropped.values().sum()
208 }
209}
210
211#[derive(Debug)]
213pub enum RelayError {
214 Chain {
217 device_id: String,
218 detail: String,
219 },
220 Fork {
223 device_id: String,
224 seq: u64,
225 },
226 Gap {
229 device_id: String,
230 expected: u64,
231 found: u64,
232 },
233 ForeignOps {
235 device_id: String,
236 op_device: String,
237 },
238 FrontierTruncated {
243 device_id: String,
244 dropped_below: u64,
245 },
246 Checkpoint(CheckpointError),
248 CheckpointFrontierUnverified {
258 device_id: String,
259 seq: u64,
260 detail: String,
261 },
262 Io(std::io::Error),
263}
264
265impl fmt::Display for RelayError {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 match self {
268 RelayError::Chain { device_id, detail } => {
269 write!(f, "relay push rejected for {device_id}: {detail}")
270 }
271 RelayError::Fork { device_id, seq } => write!(
272 f,
273 "relay push rejected: a different op already holds {device_id} seq {seq} — \
274 device chain fork"
275 ),
276 RelayError::Gap {
277 device_id,
278 expected,
279 found,
280 } => write!(
281 f,
282 "relay push rejected for {device_id}: seq gap (relay expects {expected}, \
283 got {found}) — push contiguously"
284 ),
285 RelayError::ForeignOps {
286 device_id,
287 op_device,
288 } => write!(
289 f,
290 "relay push rejected: device {device_id} pushed an op emitted by {op_device} — \
291 a device pushes only its own chain"
292 ),
293 RelayError::FrontierTruncated {
294 device_id,
295 dropped_below,
296 } => write!(
297 f,
298 "pull frontier reaches into GC'd space (device {device_id}: ops below seq \
299 {dropped_below} were truncated) — cold-bootstrap from the latest checkpoint"
300 ),
301 RelayError::Checkpoint(e) => write!(f, "relay checkpoint rejected: {e}"),
302 RelayError::CheckpointFrontierUnverified {
303 device_id,
304 seq,
305 detail,
306 } => write!(
307 f,
308 "relay checkpoint rejected: frontier for device {device_id} at seq {seq} does \
309 not match the relay-held chain ({detail}) — forged or foreign checkpoint, \
310 refusing to store it (it would become GC coverage for ops it does not cover)"
311 ),
312 RelayError::Io(e) => write!(f, "relay io error: {e}"),
313 }
314 }
315}
316
317impl std::error::Error for RelayError {}
318
319pub trait Relay {
323 fn register(&mut self, device_id: &str) -> Result<RosterEntry, RelayError>;
326 fn push(&mut self, device_id: &str, ops: &[OpRecord]) -> Result<PushOutcome, RelayError>;
330 fn pull(&mut self, device_id: &str, since: &Frontier) -> Result<PullResult, RelayError>;
333 fn ack(&mut self, device_id: &str, frontier: Hlc) -> Result<AckOutcome, RelayError>;
337 fn checkpoint_put(
341 &mut self,
342 device_id: &str,
343 checkpoint: &Checkpoint,
344 ) -> Result<bool, RelayError>;
345 fn checkpoint_get(&mut self) -> Result<Option<Checkpoint>, RelayError>;
347 fn roster(&mut self) -> Result<Vec<RosterEntry>, RelayError>;
349 fn stable_frontier(&mut self) -> Result<Option<Hlc>, RelayError>;
352 fn gc(&mut self) -> Result<GcReport, RelayError>;
355}
356
357#[derive(Debug, Clone, Default, Serialize, Deserialize)]
359struct DeviceChain {
360 ops: BTreeMap<u64, OpRecord>,
362 dropped_below: u64,
364 dropped_head: Option<(u64, String, Hlc)>,
367}
368
369impl DeviceChain {
370 fn head(&self) -> Option<(u64, &str, &Hlc)> {
373 self.ops
374 .iter()
375 .next_back()
376 .map(|(seq, op)| (*seq, op.op_id.as_str(), &op.hlc))
377 .or_else(|| {
378 self.dropped_head
379 .as_ref()
380 .map(|(seq, id, hlc)| (*seq, id.as_str(), hlc))
381 })
382 }
383
384 fn next_seq(&self) -> u64 {
385 self.head().map(|(seq, _, _)| seq + 1).unwrap_or(0)
386 }
387}
388
389#[derive(Debug, Default, Serialize, Deserialize)]
392struct RelayState {
393 roster: BTreeMap<String, RosterEntry>,
394 chains: BTreeMap<String, DeviceChain>,
395 #[serde(skip)]
399 checkpoints: BTreeMap<String, Checkpoint>,
400 latest_checkpoint: Option<String>,
402}
403
404pub(crate) fn frontier_dominates(a: &Checkpoint, b: &Checkpoint) -> bool {
408 b.frontier.iter().all(|(device, entry)| {
409 a.frontier
410 .get(device)
411 .is_some_and(|ae: &FrontierEntry| ae.seq >= entry.seq)
412 })
413}
414
415impl RelayState {
416 fn touch_and_sweep(&mut self, device_id: &str, now_ms: u64, config: &RelayConfig) {
420 let entry = self
421 .roster
422 .entry(device_id.to_string())
423 .or_insert_with(|| RosterEntry {
424 device_id: device_id.to_string(),
425 added_at: Hlc {
426 wall_ms: now_ms,
427 counter: 0,
428 device_id: device_id.to_string(),
429 },
430 last_seen_ms: now_ms,
431 acked: None,
432 status: DeviceStatus::Active,
433 });
434 entry.last_seen_ms = now_ms;
435 if let Some(horizon) = config.eviction_horizon_ms {
436 for entry in self.roster.values_mut() {
437 if entry.status == DeviceStatus::Active
438 && now_ms.saturating_sub(entry.last_seen_ms) > horizon
439 {
440 entry.status = DeviceStatus::Evicted;
441 }
442 }
443 }
444 }
445
446 fn stable_frontier(&self) -> Option<Hlc> {
447 let active: Vec<&RosterEntry> = self
448 .roster
449 .values()
450 .filter(|e| e.status == DeviceStatus::Active)
451 .collect();
452 if active.is_empty() || active.iter().any(|e| e.acked.is_none()) {
453 return None;
454 }
455 active.iter().filter_map(|e| e.acked.clone()).min()
456 }
457
458 fn push(&mut self, device_id: &str, ops: &[OpRecord]) -> Result<PushOutcome, RelayError> {
459 let mut outcome = PushOutcome::default();
460 let mut sorted: Vec<&OpRecord> = ops.iter().collect();
462 sorted.sort_by_key(|op| op.seq);
463 for op in sorted {
464 if op.device_id != device_id {
465 return Err(RelayError::ForeignOps {
466 device_id: device_id.to_string(),
467 op_device: op.device_id.clone(),
468 });
469 }
470 if !op.id_valid() {
471 return Err(RelayError::Chain {
472 device_id: device_id.to_string(),
473 detail: format!("op {}: stored op_id does not match content", op.op_id),
474 });
475 }
476 if op.hlc.device_id != op.device_id {
477 return Err(RelayError::Chain {
478 device_id: device_id.to_string(),
479 detail: format!("op {}: hlc.device_id != device_id", op.op_id),
480 });
481 }
482 let chain = self.chains.entry(device_id.to_string()).or_default();
483 if op.seq < chain.dropped_below {
484 outcome.deduped += 1;
487 continue;
488 }
489 if let Some(existing) = chain.ops.get(&op.seq) {
490 if existing.op_id == op.op_id {
491 outcome.deduped += 1;
492 continue;
493 }
494 return Err(RelayError::Fork {
495 device_id: device_id.to_string(),
496 seq: op.seq,
497 });
498 }
499 let expected = chain.next_seq();
500 if op.seq != expected {
501 return Err(RelayError::Gap {
502 device_id: device_id.to_string(),
503 expected,
504 found: op.seq,
505 });
506 }
507 match chain.head() {
508 Some((_, head_id, head_hlc)) => {
509 if op.prev.as_deref() != Some(head_id) {
510 return Err(RelayError::Chain {
511 device_id: device_id.to_string(),
512 detail: format!(
513 "op {}: prev does not link the relay-held head {head_id}",
514 op.op_id
515 ),
516 });
517 }
518 if op.hlc <= *head_hlc {
519 return Err(RelayError::Chain {
520 device_id: device_id.to_string(),
521 detail: format!(
522 "op {}: hlc does not advance past the relay-held head",
523 op.op_id
524 ),
525 });
526 }
527 }
528 None => {
529 if op.prev.is_some() {
530 return Err(RelayError::Chain {
531 device_id: device_id.to_string(),
532 detail: format!("op {}: seq 0 must have no prev", op.op_id),
533 });
534 }
535 }
536 }
537 chain.ops.insert(op.seq, op.clone());
538 outcome.accepted += 1;
539 }
540 Ok(outcome)
541 }
542
543 fn pull(&self, since: &Frontier) -> Result<PullResult, RelayError> {
544 let mut ops = Vec::new();
545 for (device_id, chain) in &self.chains {
546 let start = since.get(device_id).map(|held| held + 1).unwrap_or(0);
547 if start < chain.dropped_below {
548 return Err(RelayError::FrontierTruncated {
549 device_id: device_id.clone(),
550 dropped_below: chain.dropped_below,
551 });
552 }
553 ops.extend(chain.ops.range(start..).map(|(_, op)| op.clone()));
554 }
555 ops.sort_by(|a, b| (&a.hlc, &a.op_id).cmp(&(&b.hlc, &b.op_id)));
556 Ok(PullResult {
557 ops,
558 latest_checkpoint: self.latest_checkpoint.clone(),
559 })
560 }
561
562 fn ack(&mut self, device_id: &str, frontier: Hlc) -> AckOutcome {
563 let others_frontier = {
566 let others: Vec<&RosterEntry> = self
567 .roster
568 .values()
569 .filter(|e| e.status == DeviceStatus::Active && e.device_id != device_id)
570 .collect();
571 if others.is_empty() || others.iter().any(|e| e.acked.is_none()) {
572 None
573 } else {
574 others.iter().filter_map(|e| e.acked.clone()).min()
575 }
576 };
577 let entry = self.roster.get_mut(device_id).expect("touched before ack");
578 let advanced = match &entry.acked {
579 Some(current) if frontier <= *current => false,
580 _ => {
581 entry.acked = Some(frontier);
582 true
583 }
584 };
585 let mut reinstated = false;
586 if entry.status == DeviceStatus::Evicted {
587 let caught_up = match (&entry.acked, &others_frontier) {
591 (Some(acked), Some(frontier)) => acked >= frontier,
592 (Some(_), None) => true,
593 (None, _) => false,
594 };
595 if caught_up {
596 entry.status = DeviceStatus::Active;
597 reinstated = true;
598 }
599 }
600 AckOutcome {
601 advanced,
602 reinstated,
603 }
604 }
605
606 fn validate_frontier(&self, checkpoint: &Checkpoint) -> Result<(), RelayError> {
614 for (device_id, entry) in &checkpoint.frontier {
615 let unverified = |detail: &str| RelayError::CheckpointFrontierUnverified {
616 device_id: device_id.clone(),
617 seq: entry.seq,
618 detail: detail.to_string(),
619 };
620 let Some(chain) = self.chains.get(device_id) else {
621 return Err(unverified("relay holds no chain for this device"));
622 };
623 if entry.seq >= chain.dropped_below {
624 match chain.ops.get(&entry.seq) {
626 Some(op) if op.op_id == entry.head => {}
627 Some(_) => {
628 return Err(unverified(
629 "frontier head does not match the relay-held op at this seq",
630 ))
631 }
632 None => {
633 return Err(unverified(
634 "relay holds no op at the claimed frontier seq (claims coverage \
635 beyond its chain head)",
636 ))
637 }
638 }
639 } else {
640 match &chain.dropped_head {
645 Some((seq, id, _)) if *seq == entry.seq => {
646 if id != &entry.head {
647 return Err(unverified(
648 "frontier head does not match the relay's GC'd dropped head",
649 ));
650 }
651 }
652 Some((seq, _, _)) if entry.seq < *seq => {}
653 _ => {
654 return Err(unverified(
655 "claimed frontier seq is below the relay's GC floor with no \
656 matching record",
657 ))
658 }
659 }
660 }
661 }
662 Ok(())
663 }
664
665 fn checkpoint_put(&mut self, checkpoint: &Checkpoint) -> Result<bool, RelayError> {
666 checkpoint.verify().map_err(RelayError::Checkpoint)?;
667 self.validate_frontier(checkpoint)?;
673 let stored = if self.checkpoints.contains_key(&checkpoint.checkpoint_hash) {
676 false
677 } else {
678 self.checkpoints
679 .insert(checkpoint.checkpoint_hash.clone(), checkpoint.clone());
680 true
681 };
682 let advance = match self
683 .latest_checkpoint
684 .as_ref()
685 .and_then(|hash| self.checkpoints.get(hash))
686 {
687 Some(current) => {
688 checkpoint.checkpoint_hash != current.checkpoint_hash
689 && frontier_dominates(checkpoint, current)
690 }
691 None => true,
692 };
693 if advance {
694 self.latest_checkpoint = Some(checkpoint.checkpoint_hash.clone());
695 }
696 Ok(stored)
697 }
698
699 fn checkpoint_get(&self) -> Option<Checkpoint> {
700 self.latest_checkpoint
701 .as_ref()
702 .and_then(|hash| self.checkpoints.get(hash))
703 .cloned()
704 }
705
706 fn gc(&mut self) -> GcReport {
707 let mut report = GcReport::default();
708 let Some(frontier) = self.stable_frontier() else {
709 return report;
710 };
711 let mut max_covered: BTreeMap<String, u64> = BTreeMap::new();
715 for ckpt in self.checkpoints.values() {
716 for (device, entry) in &ckpt.frontier {
717 let slot = max_covered.entry(device.clone()).or_insert(entry.seq);
718 if entry.seq > *slot {
719 *slot = entry.seq;
720 }
721 }
722 }
723 for (device_id, chain) in &mut self.chains {
724 let covered_through = max_covered.get(device_id).copied();
727 let mut droppable: Vec<u64> = Vec::new();
728 for (seq, op) in &chain.ops {
729 let below_frontier = op.hlc <= frontier;
730 let covered = covered_through.is_some_and(|through| through >= *seq);
731 if below_frontier && covered {
732 droppable.push(*seq);
733 } else {
734 break;
735 }
736 }
737 for seq in &droppable {
738 let op = chain.ops.remove(seq).expect("collected from the map");
739 chain.dropped_below = seq + 1;
740 chain.dropped_head = Some((*seq, op.op_id, op.hlc));
741 }
742 if !droppable.is_empty() {
743 report.dropped.insert(device_id.clone(), droppable.len());
744 }
745 }
746 report
747 }
748}
749
750pub struct InMemoryRelay {
752 state: RelayState,
753 config: RelayConfig,
754 wall: WallClock,
755}
756
757impl fmt::Debug for InMemoryRelay {
758 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
759 f.debug_struct("InMemoryRelay")
760 .field("state", &self.state)
761 .field("config", &self.config)
762 .finish_non_exhaustive()
763 }
764}
765
766impl InMemoryRelay {
767 pub fn new(config: RelayConfig, wall: WallClock) -> Self {
768 Self {
769 state: RelayState::default(),
770 config,
771 wall,
772 }
773 }
774}
775
776impl Relay for InMemoryRelay {
777 fn register(&mut self, device_id: &str) -> Result<RosterEntry, RelayError> {
778 let now = (self.wall)();
779 self.state.touch_and_sweep(device_id, now, &self.config);
780 Ok(self.state.roster[device_id].clone())
781 }
782
783 fn push(&mut self, device_id: &str, ops: &[OpRecord]) -> Result<PushOutcome, RelayError> {
784 let now = (self.wall)();
785 self.state.touch_and_sweep(device_id, now, &self.config);
786 self.state.push(device_id, ops)
787 }
788
789 fn pull(&mut self, device_id: &str, since: &Frontier) -> Result<PullResult, RelayError> {
790 let now = (self.wall)();
791 self.state.touch_and_sweep(device_id, now, &self.config);
792 self.state.pull(since)
793 }
794
795 fn ack(&mut self, device_id: &str, frontier: Hlc) -> Result<AckOutcome, RelayError> {
796 let now = (self.wall)();
797 self.state.touch_and_sweep(device_id, now, &self.config);
798 Ok(self.state.ack(device_id, frontier))
799 }
800
801 fn checkpoint_put(
802 &mut self,
803 device_id: &str,
804 checkpoint: &Checkpoint,
805 ) -> Result<bool, RelayError> {
806 let now = (self.wall)();
807 self.state.touch_and_sweep(device_id, now, &self.config);
808 self.state.checkpoint_put(checkpoint)
809 }
810
811 fn checkpoint_get(&mut self) -> Result<Option<Checkpoint>, RelayError> {
812 Ok(self.state.checkpoint_get())
813 }
814
815 fn roster(&mut self) -> Result<Vec<RosterEntry>, RelayError> {
816 Ok(self.state.roster.values().cloned().collect())
817 }
818
819 fn stable_frontier(&mut self) -> Result<Option<Hlc>, RelayError> {
820 Ok(self.state.stable_frontier())
821 }
822
823 fn gc(&mut self) -> Result<GcReport, RelayError> {
824 Ok(self.state.gc())
825 }
826}
827
828pub struct FsRelay {
853 dir: PathBuf,
854 config: RelayConfig,
855 wall: WallClock,
856 checkpoint_cache: BTreeMap<String, Checkpoint>,
860}
861
862impl fmt::Debug for FsRelay {
863 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
864 f.debug_struct("FsRelay")
865 .field("dir", &self.dir)
866 .field("config", &self.config)
867 .finish_non_exhaustive()
868 }
869}
870
871impl FsRelay {
872 pub fn open(dir: &Path, config: RelayConfig, wall: WallClock) -> std::io::Result<Self> {
873 fs::create_dir_all(dir.join("checkpoints"))?;
874 Ok(Self {
875 dir: dir.to_path_buf(),
876 config,
877 wall,
878 checkpoint_cache: BTreeMap::new(),
879 })
880 }
881
882 fn state_path(&self) -> PathBuf {
883 self.dir.join("relay-state.json")
884 }
885
886 fn checkpoints_dir(&self) -> PathBuf {
887 self.dir.join("checkpoints")
888 }
889
890 fn with_state<T>(
894 &mut self,
895 f: impl FnOnce(&mut RelayState, u64, &RelayConfig) -> Result<T, RelayError>,
896 ) -> Result<T, RelayError> {
897 let lock = OpenOptions::new()
898 .read(true)
899 .write(true)
900 .create(true)
901 .truncate(false)
902 .open(self.dir.join("relay.lock"))
903 .map_err(RelayError::Io)?;
904 lock.lock().map_err(RelayError::Io)?; let mut state: RelayState = match fs::read_to_string(self.state_path()) {
907 Ok(raw) => {
908 serde_json::from_str(&raw).map_err(|e| RelayError::Io(std::io::Error::other(e)))?
909 }
910 Err(e) if e.kind() == std::io::ErrorKind::NotFound => RelayState::default(),
911 Err(e) => return Err(RelayError::Io(e)),
912 };
913 for entry in fs::read_dir(self.checkpoints_dir()).map_err(RelayError::Io)? {
918 let path = entry.map_err(RelayError::Io)?.path();
919 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
920 continue;
921 };
922 let Some(hash) = name.strip_suffix(".checkpoint.json") else {
923 continue;
924 };
925 let ckpt = match self.checkpoint_cache.get(hash) {
926 Some(cached) => cached.clone(),
927 None => {
928 let ckpt = Checkpoint::load(&path).map_err(RelayError::Checkpoint)?;
929 self.checkpoint_cache
930 .insert(ckpt.checkpoint_hash.clone(), ckpt.clone());
931 ckpt
932 }
933 };
934 state.checkpoints.insert(ckpt.checkpoint_hash.clone(), ckpt);
935 }
936
937 let now = (self.wall)();
938 let result = f(&mut state, now, &self.config);
939
940 for ckpt in state.checkpoints.values() {
948 let path = self.checkpoints_dir().join(ckpt.file_name());
949 if !path.exists() {
950 ckpt.save(&self.checkpoints_dir()).map_err(RelayError::Io)?;
951 }
952 self.checkpoint_cache
953 .entry(ckpt.checkpoint_hash.clone())
954 .or_insert_with(|| ckpt.clone());
955 }
956 let tmp = self.dir.join("relay-state.json.tmp");
957 {
958 let mut file = File::create(&tmp).map_err(RelayError::Io)?;
959 file.write_all(
960 serde_json::to_string(&state)
961 .map_err(|e| RelayError::Io(std::io::Error::other(e)))?
962 .as_bytes(),
963 )
964 .map_err(RelayError::Io)?;
965 file.sync_all().map_err(RelayError::Io)?;
966 }
967 fs::rename(&tmp, self.state_path()).map_err(RelayError::Io)?;
968 result
969 }
970}
971
972impl Relay for FsRelay {
973 fn register(&mut self, device_id: &str) -> Result<RosterEntry, RelayError> {
974 self.with_state(|state, now, config| {
975 state.touch_and_sweep(device_id, now, config);
976 Ok(state.roster[device_id].clone())
977 })
978 }
979
980 fn push(&mut self, device_id: &str, ops: &[OpRecord]) -> Result<PushOutcome, RelayError> {
981 self.with_state(|state, now, config| {
982 state.touch_and_sweep(device_id, now, config);
983 state.push(device_id, ops)
984 })
985 }
986
987 fn pull(&mut self, device_id: &str, since: &Frontier) -> Result<PullResult, RelayError> {
988 self.with_state(|state, now, config| {
989 state.touch_and_sweep(device_id, now, config);
990 state.pull(since)
991 })
992 }
993
994 fn ack(&mut self, device_id: &str, frontier: Hlc) -> Result<AckOutcome, RelayError> {
995 self.with_state(|state, now, config| {
996 state.touch_and_sweep(device_id, now, config);
997 Ok(state.ack(device_id, frontier))
998 })
999 }
1000
1001 fn checkpoint_put(
1002 &mut self,
1003 device_id: &str,
1004 checkpoint: &Checkpoint,
1005 ) -> Result<bool, RelayError> {
1006 self.with_state(|state, now, config| {
1007 state.touch_and_sweep(device_id, now, config);
1008 state.checkpoint_put(checkpoint)
1009 })
1010 }
1011
1012 fn checkpoint_get(&mut self) -> Result<Option<Checkpoint>, RelayError> {
1013 self.with_state(|state, _, _| Ok(state.checkpoint_get()))
1014 }
1015
1016 fn roster(&mut self) -> Result<Vec<RosterEntry>, RelayError> {
1017 self.with_state(|state, _, _| Ok(state.roster.values().cloned().collect()))
1018 }
1019
1020 fn stable_frontier(&mut self) -> Result<Option<Hlc>, RelayError> {
1021 self.with_state(|state, _, _| Ok(state.stable_frontier()))
1022 }
1023
1024 fn gc(&mut self) -> Result<GcReport, RelayError> {
1025 self.with_state(|state, _, _| Ok(state.gc()))
1026 }
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032 use crate::oplog::{DeviceLog, Scope, Surface};
1033 use std::sync::atomic::{AtomicU64, Ordering};
1034 use std::sync::Arc;
1035
1036 fn manual_clock() -> (Arc<AtomicU64>, WallClock) {
1037 let t = Arc::new(AtomicU64::new(0));
1038 let reader = t.clone();
1039 (t, Arc::new(move || reader.load(Ordering::SeqCst)))
1040 }
1041
1042 fn mem_relay() -> InMemoryRelay {
1043 InMemoryRelay::new(RelayConfig::default(), Arc::new(|| 0))
1044 }
1045
1046 fn ops_for(device: &str, n: usize) -> (DeviceLog, Vec<OpRecord>) {
1047 let mut log = DeviceLog::new(device);
1048 let ops = (0..n)
1049 .map(|i| {
1050 log.append(
1051 Scope::Personal,
1052 Surface::Knowledge,
1053 serde_json::json!({"id": format!("{device}-f{i}")}),
1054 )
1055 })
1056 .collect();
1057 (log, ops)
1058 }
1059
1060 #[test]
1061 fn push_validates_the_chain_and_dedups_retransmission() {
1062 let mut relay = mem_relay();
1063 let (mut log, ops) = ops_for("a", 3);
1064
1065 let outcome = relay.push("a", &ops).unwrap();
1066 assert_eq!(
1067 outcome,
1068 PushOutcome {
1069 accepted: 3,
1070 deduped: 0
1071 }
1072 );
1073
1074 let again = relay.push("a", &ops).unwrap();
1076 assert_eq!(
1077 again,
1078 PushOutcome {
1079 accepted: 0,
1080 deduped: 3
1081 }
1082 );
1083
1084 let next = log.append(
1086 Scope::Personal,
1087 Surface::Knowledge,
1088 serde_json::json!({"id": "x"}),
1089 );
1090 assert_eq!(relay.push("a", &[next]).unwrap().accepted, 1);
1091
1092 log.append(
1094 Scope::Personal,
1095 Surface::Knowledge,
1096 serde_json::json!({"id": "skipped"}),
1097 );
1098 let ahead = log.append(
1099 Scope::Personal,
1100 Surface::Knowledge,
1101 serde_json::json!({"id": "y"}),
1102 );
1103 assert!(matches!(
1104 relay.push("a", &[ahead]),
1105 Err(RelayError::Gap {
1106 expected: 4,
1107 found: 5,
1108 ..
1109 })
1110 ));
1111
1112 let mut forked = DeviceLog::new("a");
1114 let f0 = forked.append(
1115 Scope::Personal,
1116 Surface::Knowledge,
1117 serde_json::json!({"id": "evil"}),
1118 );
1119 assert!(matches!(
1120 relay.push("a", &[f0]),
1121 Err(RelayError::Fork { seq: 0, .. })
1122 ));
1123
1124 let (_, b_ops) = ops_for("b", 1);
1126 assert!(matches!(
1127 relay.push("a", &b_ops),
1128 Err(RelayError::ForeignOps { .. })
1129 ));
1130
1131 let mut tampered = ops[0].clone();
1133 tampered.payload = serde_json::json!({"forged": true});
1134 assert!(matches!(
1135 relay.push("b", &[tampered]),
1136 Err(RelayError::ForeignOps { .. })
1137 ));
1138 let mut own_tampered = ops[0].clone();
1139 own_tampered.payload = serde_json::json!({"id": "a-f0", "forged": true});
1140 assert!(matches!(
1141 relay.push("a", &[own_tampered]),
1142 Err(RelayError::Chain { .. })
1143 ));
1144 }
1145
1146 #[test]
1147 fn pull_is_a_seq_cursor_and_serves_canonical_order() {
1148 let mut relay = mem_relay();
1149 let (_, a_ops) = ops_for("a", 3);
1150 let (_, b_ops) = ops_for("b", 2);
1151 relay.push("a", &a_ops).unwrap();
1152 relay.push("b", &b_ops).unwrap();
1153
1154 let all = relay.pull("c", &Frontier::new()).unwrap();
1156 assert_eq!(all.ops.len(), 5);
1157 assert!(all.latest_checkpoint.is_none());
1158
1159 let mut since = Frontier::new();
1161 since.insert("a".into(), 1);
1162 since.insert("b".into(), 1);
1163 let tail = relay.pull("c", &since).unwrap();
1164 assert_eq!(tail.ops.len(), 1);
1165 assert_eq!(tail.ops[0].seq, 2);
1166 assert_eq!(tail.ops[0].device_id, "a");
1167 }
1168
1169 #[test]
1170 fn stable_frontier_requires_every_active_device_acked() {
1171 let mut relay = mem_relay();
1172 let (_, a_ops) = ops_for("a", 2);
1173 relay.push("a", &a_ops).unwrap();
1174 relay.register("b").unwrap();
1175
1176 relay.ack("a", a_ops[1].hlc.clone()).unwrap();
1179 assert_eq!(relay.stable_frontier().unwrap(), None);
1180
1181 relay.ack("b", a_ops[0].hlc.clone()).unwrap();
1182 assert_eq!(
1183 relay.stable_frontier().unwrap(),
1184 Some(a_ops[0].hlc.clone()),
1185 "min(acked)"
1186 );
1187
1188 let outcome = relay.ack("b", a_ops[0].hlc.clone()).unwrap();
1190 assert!(!outcome.advanced);
1191 let outcome = relay.ack("b", a_ops[1].hlc.clone()).unwrap();
1192 assert!(outcome.advanced);
1193 assert_eq!(relay.stable_frontier().unwrap(), Some(a_ops[1].hlc.clone()));
1194 }
1195
1196 #[test]
1197 fn gc_requires_both_frontier_and_covering_checkpoint() {
1198 let mut relay = mem_relay();
1199 let (_, ops) = ops_for("a", 4);
1200 relay.push("a", &ops).unwrap();
1201 relay.ack("a", ops[3].hlc.clone()).unwrap();
1202
1203 assert_eq!(relay.gc().unwrap().total(), 0);
1206
1207 let ckpt = Checkpoint::from_ops(&ops[..2]).unwrap();
1209 assert!(relay.checkpoint_put("a", &ckpt).unwrap());
1210 let report = relay.gc().unwrap();
1211 assert_eq!(report.dropped["a"], 2);
1212
1213 let mut log = DeviceLog::resume("a", &ops).unwrap();
1215 let newer = log.append(
1216 Scope::Personal,
1217 Surface::Knowledge,
1218 serde_json::json!({"id": "n"}),
1219 );
1220 relay.push("a", std::slice::from_ref(&newer)).unwrap();
1221 let full_ckpt = {
1222 let mut all = ops.clone();
1223 all.push(newer);
1224 Checkpoint::from_ops(&all).unwrap()
1225 };
1226 relay.checkpoint_put("a", &full_ckpt).unwrap();
1227 let report = relay.gc().unwrap();
1230 assert_eq!(report.dropped["a"], 2);
1231 let survivors = relay.pull("b", &Frontier::new());
1232 assert!(matches!(
1234 survivors,
1235 Err(RelayError::FrontierTruncated {
1236 dropped_below: 4,
1237 ..
1238 })
1239 ));
1240 let mut since = Frontier::new();
1242 since.insert("a".into(), 3);
1243 assert_eq!(relay.pull("b", &since).unwrap().ops.len(), 1);
1244 }
1245
1246 #[test]
1247 fn gc_preserves_chain_continuity_for_later_pushes() {
1248 let mut relay = mem_relay();
1249 let (mut log, ops) = ops_for("a", 3);
1250 relay.push("a", &ops).unwrap();
1251 relay.ack("a", ops[2].hlc.clone()).unwrap();
1252 let ckpt = Checkpoint::from_ops(&ops).unwrap();
1253 relay.checkpoint_put("a", &ckpt).unwrap();
1254 assert_eq!(relay.gc().unwrap().dropped["a"], 3);
1255
1256 let next = log.append(
1259 Scope::Personal,
1260 Surface::Knowledge,
1261 serde_json::json!({"id": "n"}),
1262 );
1263 assert_eq!(relay.push("a", &[next]).unwrap().accepted, 1);
1264 assert_eq!(
1266 relay.push("a", &ops).unwrap(),
1267 PushOutcome {
1268 accepted: 0,
1269 deduped: 3
1270 }
1271 );
1272 }
1273
1274 #[test]
1275 fn forged_checkpoint_frontier_is_rejected_and_never_becomes_gc_coverage() {
1276 let mut relay = mem_relay();
1282
1283 let mut a = DeviceLog::new("a");
1285 let real: Vec<OpRecord> = (0..3)
1286 .map(|i| {
1287 a.append(
1288 Scope::Personal,
1289 Surface::Knowledge,
1290 serde_json::json!({"id": format!("real-{i}")}),
1291 )
1292 })
1293 .collect();
1294 relay.push("a", &real).unwrap();
1295 relay.ack("a", real[2].hlc.clone()).unwrap();
1296
1297 let mut fake = DeviceLog::new("a");
1300 let other: Vec<OpRecord> = (0..3)
1301 .map(|i| {
1302 fake.append(
1303 Scope::Personal,
1304 Surface::Knowledge,
1305 serde_json::json!({"id": format!("other-{i}")}),
1306 )
1307 })
1308 .collect();
1309 let forged = Checkpoint::from_ops(&other).unwrap();
1310
1311 assert!(matches!(
1314 relay.checkpoint_put("a", &forged),
1315 Err(RelayError::CheckpointFrontierUnverified { device_id, .. }) if device_id == "a"
1316 ));
1317
1318 assert_eq!(
1323 relay.gc().unwrap().total(),
1324 0,
1325 "no forged coverage, no data loss"
1326 );
1327 assert!(relay.checkpoint_get().unwrap().is_none());
1328 let served = relay.pull("a", &Frontier::new()).unwrap();
1330 assert_eq!(served.ops.len(), 3);
1331 assert!(served
1332 .ops
1333 .iter()
1334 .all(|op| op.payload["id"].as_str().unwrap().starts_with("real-")));
1335
1336 let mut a2 = DeviceLog::resume("a", &real).unwrap();
1339 let mut ahead = real.clone();
1340 ahead.push(a2.append(
1341 Scope::Personal,
1342 Surface::Knowledge,
1343 serde_json::json!({"id": "real-3"}),
1344 ));
1345 let claims_beyond = Checkpoint::from_ops(&ahead).unwrap(); assert!(matches!(
1347 relay.checkpoint_put("a", &claims_beyond),
1348 Err(RelayError::CheckpointFrontierUnverified { seq: 3, .. })
1349 ));
1350
1351 let mut c = DeviceLog::new("c");
1354 let c_ops: Vec<OpRecord> = (0..2)
1355 .map(|i| {
1356 c.append(
1357 Scope::Personal,
1358 Surface::Knowledge,
1359 serde_json::json!({"id": format!("c{i}")}),
1360 )
1361 })
1362 .collect();
1363 let unknown_dev = Checkpoint::from_ops(&c_ops).unwrap();
1364 assert!(matches!(
1365 relay.checkpoint_put("a", &unknown_dev),
1366 Err(RelayError::CheckpointFrontierUnverified { device_id, .. }) if device_id == "c"
1367 ));
1368
1369 let honest = Checkpoint::from_ops(&real[..2]).unwrap();
1372 assert!(relay.checkpoint_put("a", &honest).unwrap());
1373 assert_eq!(relay.gc().unwrap().dropped["a"], 2);
1374 }
1375
1376 #[test]
1377 fn checkpoint_dedup_keys_on_whole_record_address_not_state_hash() {
1378 let mut a = DeviceLog::new("a");
1384 let mut b = DeviceLog::new("b");
1385 let fact = serde_json::json!({"id": "f1", "body": "hi"});
1386 let oa = a.append(Scope::Personal, Surface::Knowledge, fact.clone());
1387 let ob = b.append(Scope::Personal, Surface::Knowledge, fact);
1388 let just_a = Checkpoint::from_ops(std::slice::from_ref(&oa)).unwrap();
1389 let both = Checkpoint::from_ops(&[oa.clone(), ob.clone()]).unwrap();
1390 assert_eq!(
1391 just_a.state_hash, both.state_hash,
1392 "the cross-device dedup collision"
1393 );
1394
1395 let mut relay = mem_relay();
1396 relay.push("a", std::slice::from_ref(&oa)).unwrap();
1399 relay.push("b", std::slice::from_ref(&ob)).unwrap();
1400 assert!(relay.checkpoint_put("a", &just_a).unwrap());
1401 assert!(
1402 relay.checkpoint_put("b", &both).unwrap(),
1403 "same state_hash is NOT a dedup"
1404 );
1405 assert!(
1406 !relay.checkpoint_put("a", &just_a).unwrap(),
1407 "same checkpoint_hash IS"
1408 );
1409
1410 assert_eq!(
1413 relay.checkpoint_get().unwrap().unwrap().checkpoint_hash,
1414 both.checkpoint_hash
1415 );
1416 relay.checkpoint_put("a", &just_a).unwrap();
1417 assert_eq!(
1418 relay.checkpoint_get().unwrap().unwrap().checkpoint_hash,
1419 both.checkpoint_hash,
1420 "dominance-monotone pointer"
1421 );
1422
1423 let mut forged = both.clone();
1425 forged.state.logs.clear();
1426 assert!(matches!(
1427 relay.checkpoint_put("b", &forged),
1428 Err(RelayError::Checkpoint(CheckpointError::HashMismatch { .. }))
1429 ));
1430 }
1431
1432 #[test]
1433 fn eviction_unpins_the_frontier_and_ack_reinstates() {
1434 let (t, wall) = manual_clock();
1435 let mut relay = InMemoryRelay::new(
1436 RelayConfig {
1437 eviction_horizon_ms: Some(1_000),
1438 },
1439 wall,
1440 );
1441 let (_, a_ops) = ops_for("a", 2);
1442 relay.push("a", &a_ops).unwrap();
1443
1444 t.store(100, Ordering::SeqCst);
1445 relay.ack("a", a_ops[1].hlc.clone()).unwrap();
1446 relay.ack("c", a_ops[0].hlc.clone()).unwrap(); assert_eq!(relay.stable_frontier().unwrap(), Some(a_ops[0].hlc.clone()));
1448
1449 t.store(2_000, Ordering::SeqCst);
1451 relay.register("a").unwrap();
1452 let roster: BTreeMap<String, RosterEntry> = relay
1453 .roster()
1454 .unwrap()
1455 .into_iter()
1456 .map(|e| (e.device_id.clone(), e))
1457 .collect();
1458 assert_eq!(roster["c"].status, DeviceStatus::Evicted);
1459 assert_eq!(roster["a"].status, DeviceStatus::Active);
1460 assert_eq!(
1461 relay.stable_frontier().unwrap(),
1462 Some(a_ops[1].hlc.clone()),
1463 "the evicted device's ack no longer holds the frontier"
1464 );
1465
1466 let outcome = relay.ack("c", a_ops[0].hlc.clone()).unwrap();
1469 assert!(!outcome.advanced && !outcome.reinstated);
1470 assert_eq!(relay.stable_frontier().unwrap(), Some(a_ops[1].hlc.clone()));
1471
1472 let outcome = relay.ack("c", a_ops[1].hlc.clone()).unwrap();
1474 assert!(outcome.advanced && outcome.reinstated);
1475 let roster: BTreeMap<String, RosterEntry> = relay
1476 .roster()
1477 .unwrap()
1478 .into_iter()
1479 .map(|e| (e.device_id.clone(), e))
1480 .collect();
1481 assert_eq!(roster["c"].status, DeviceStatus::Active);
1482 }
1483
1484 #[test]
1485 fn fs_relay_matches_in_memory_semantics_and_persists() {
1486 let dir = tempfile::tempdir().unwrap();
1487 let (_, ops) = ops_for("a", 3);
1488 {
1489 let mut relay =
1490 FsRelay::open(dir.path(), RelayConfig::default(), Arc::new(|| 7)).unwrap();
1491 assert_eq!(relay.push("a", &ops).unwrap().accepted, 3);
1492 relay.ack("a", ops[2].hlc.clone()).unwrap();
1493 let ckpt = Checkpoint::from_ops(&ops[..2]).unwrap();
1494 assert!(relay.checkpoint_put("a", &ckpt).unwrap());
1495 }
1496 let mut relay = FsRelay::open(dir.path(), RelayConfig::default(), Arc::new(|| 8)).unwrap();
1498 assert_eq!(relay.stable_frontier().unwrap(), Some(ops[2].hlc.clone()));
1499 assert_eq!(relay.pull("b", &Frontier::new()).unwrap().ops, ops);
1502 assert_eq!(relay.stable_frontier().unwrap(), None);
1503 relay.ack("b", ops[2].hlc.clone()).unwrap();
1504 assert_eq!(
1505 relay.push("a", &ops).unwrap(),
1506 PushOutcome {
1507 accepted: 0,
1508 deduped: 3
1509 }
1510 );
1511 let report = relay.gc().unwrap();
1512 assert_eq!(report.dropped["a"], 2);
1513 let roster = relay.roster().unwrap();
1514 assert_eq!(roster.len(), 2);
1515 assert_eq!(
1516 roster[0].added_at.wall_ms, 7,
1517 "roster added_at survives restart"
1518 );
1519
1520 let ckpt = relay.checkpoint_get().unwrap().unwrap();
1523 assert_eq!(ckpt.frontier["a"].seq, 1);
1524
1525 let path = dir
1532 .path()
1533 .join("checkpoints")
1534 .join(format!("{}.checkpoint.json", ckpt.checkpoint_hash));
1535 let raw = fs::read_to_string(&path).unwrap();
1536 fs::write(&path, raw.replace("\"a-f0\"", "\"a-f0-forged\"")).unwrap();
1537 let mut fresh = FsRelay::open(dir.path(), RelayConfig::default(), Arc::new(|| 9)).unwrap();
1538 assert!(matches!(
1539 fresh.checkpoint_get(),
1540 Err(RelayError::Checkpoint(_))
1541 ));
1542 }
1543}