1use crate::checkpoint::{resume_anchored, AnchorError, Checkpoint, CheckpointError};
52use crate::fold::{fold_onto, state_hash, FoldedRecord, SyncState};
53use crate::journal::OplogJournal;
54use crate::lease::{Intent, IntentStatus, LeaseCoordinator, LeaseError};
55use crate::oplog::{verify_log, ChainError, DeviceLog, Hlc, OpRecord, Scope, Surface, WallClock};
56use crate::relay::{checkpoint_frontier, frontier_of, Frontier, Relay, RelayError};
57use serde_json::Value;
58use std::fmt;
59use std::path::{Path, PathBuf};
60
61#[derive(Debug)]
63pub enum SessionError {
64 Io(std::io::Error),
65 Chain(ChainError),
66 Anchor(AnchorError),
67 Relay(RelayError),
68 Checkpoint(CheckpointError),
69 MissingCheckpoint { checkpoint_hash: String },
73 CheckpointRegression { held: String, offered: String },
76 Lease(LeaseError),
79}
80
81impl fmt::Display for SessionError {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 match self {
84 SessionError::Io(e) => write!(f, "sync session io error: {e}"),
85 SessionError::Chain(e) => write!(f, "sync session chain error: {e}"),
86 SessionError::Anchor(e) => write!(f, "sync session anchor error: {e}"),
87 SessionError::Relay(e) => write!(f, "sync session relay error: {e}"),
88 SessionError::Checkpoint(e) => write!(f, "sync session checkpoint error: {e}"),
89 SessionError::MissingCheckpoint { checkpoint_hash } => write!(
90 f,
91 "journal is truncated below checkpoint {checkpoint_hash}, which is not in the \
92 session checkpoint directory — fetch it (relay checkpoint_get) and retry"
93 ),
94 SessionError::CheckpointRegression { held, offered } => write!(
95 f,
96 "relay's latest checkpoint {offered} does not cover the session's base {held} — \
97 refusing to rebase onto it (state would be lost)"
98 ),
99 SessionError::Lease(e) => write!(f, "sync session lease error: {e}"),
100 }
101 }
102}
103
104impl std::error::Error for SessionError {}
105
106impl From<std::io::Error> for SessionError {
107 fn from(e: std::io::Error) -> Self {
108 SessionError::Io(e)
109 }
110}
111impl From<ChainError> for SessionError {
112 fn from(e: ChainError) -> Self {
113 SessionError::Chain(e)
114 }
115}
116impl From<AnchorError> for SessionError {
117 fn from(e: AnchorError) -> Self {
118 SessionError::Anchor(e)
119 }
120}
121impl From<RelayError> for SessionError {
122 fn from(e: RelayError) -> Self {
123 SessionError::Relay(e)
124 }
125}
126impl From<CheckpointError> for SessionError {
127 fn from(e: CheckpointError) -> Self {
128 SessionError::Checkpoint(e)
129 }
130}
131impl From<LeaseError> for SessionError {
132 fn from(e: LeaseError) -> Self {
133 SessionError::Lease(e)
134 }
135}
136
137#[derive(Debug, Clone, Default, PartialEq)]
139pub struct PumpReport {
140 pub pushed: usize,
142 pub push_deduped: usize,
144 pub folded: usize,
146 pub acked: Option<Hlc>,
148}
149
150pub struct SyncSession {
153 device_id: String,
154 device: DeviceLog,
155 journal: OplogJournal,
156 checkpoint_dir: PathBuf,
157 wall: WallClock,
158 ops: Vec<OpRecord>,
160 base: Option<Checkpoint>,
162 pushed_through: Option<u64>,
165}
166
167impl fmt::Debug for SyncSession {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 f.debug_struct("SyncSession")
170 .field("device_id", &self.device_id)
171 .field("ops", &self.ops.len())
172 .field("base", &self.base.as_ref().map(|c| &c.checkpoint_hash))
173 .field("pushed_through", &self.pushed_through)
174 .finish_non_exhaustive()
175 }
176}
177
178impl SyncSession {
179 pub fn open(
184 device_id: impl Into<String>,
185 journal_path: &Path,
186 checkpoint_dir: &Path,
187 wall: WallClock,
188 ) -> Result<Self, SessionError> {
189 let device_id = device_id.into();
190 let (marker, ops) = OplogJournal::load_with_marker(journal_path)?;
191 let journal = OplogJournal::open(journal_path)?;
192 let (device, base) = match marker {
193 Some(marker) => {
194 let path = checkpoint_dir
195 .join(format!("{}.checkpoint.json", marker.checkpoint_hash));
196 if !path.exists() {
197 return Err(SessionError::MissingCheckpoint {
198 checkpoint_hash: marker.checkpoint_hash,
199 });
200 }
201 let checkpoint = Checkpoint::load(&path)?;
202 let device = resume_anchored(device_id.clone(), &checkpoint, &ops)?;
203 (device, Some(checkpoint))
204 }
205 None => (DeviceLog::resume(device_id.clone(), &ops)?, None),
206 };
207 let mut session = Self {
208 device_id,
209 device,
210 journal,
211 checkpoint_dir: checkpoint_dir.to_path_buf(),
212 wall,
213 ops,
214 base,
215 pushed_through: None,
216 };
217 session.device.set_wall_clock(session.wall.clone());
218 Ok(session)
219 }
220
221 pub fn bootstrap(
227 device_id: impl Into<String>,
228 journal_path: &Path,
229 checkpoint_dir: &Path,
230 relay: &mut dyn Relay,
231 wall: WallClock,
232 ) -> Result<Self, SessionError> {
233 let mut session = Self::open(device_id, journal_path, checkpoint_dir, wall)?;
234 session.rebase(relay)?;
235 Ok(session)
236 }
237
238 pub fn append(
244 &mut self,
245 scope: Scope,
246 surface: Surface,
247 payload: Value,
248 ) -> Result<OpRecord, SessionError> {
249 let op = self.device.append(scope, surface, payload);
250 if let Err(e) = self.journal.append(&op) {
251 self.rebuild_device()?;
252 return Err(SessionError::Io(e));
253 }
254 self.ops.push(op.clone());
255 Ok(op)
256 }
257
258 pub fn committed_run(&self, agent_id: &str, run_id: &str) -> Option<FoldedRecord> {
266 self.state().committed_run(agent_id, run_id).cloned()
267 }
268
269 pub fn record_intent(
286 &mut self,
287 scope: Scope,
288 intent: &Intent,
289 ) -> Result<Option<OpRecord>, SessionError> {
290 if intent.status != IntentStatus::Committed
291 && self.committed_run(&intent.agent_id, &intent.run_id).is_some()
292 {
293 return Ok(None); }
295 Ok(Some(self.append(scope, Surface::Intent, intent.payload())?))
296 }
297
298 pub fn record_intent_if_current(
311 &mut self,
312 scope: Scope,
313 intent: &Intent,
314 coordinator: &mut dyn LeaseCoordinator,
315 ) -> Result<Option<OpRecord>, SessionError> {
316 let current = coordinator.current(&intent.agent_id)?;
317 let still_ours = current
318 .as_ref()
319 .is_some_and(|l| l.holder == self.device_id && l.epoch == intent.epoch);
320 if !still_ours {
321 return Ok(None); }
323 self.record_intent(scope, intent) }
325
326 fn rebuild_device(&mut self) -> Result<(), SessionError> {
329 let mut device = match &self.base {
330 Some(checkpoint) => resume_anchored(self.device_id.clone(), checkpoint, &self.ops)?,
331 None => DeviceLog::resume(self.device_id.clone(), &self.ops)?,
332 };
333 device.set_wall_clock(self.wall.clone());
334 self.device = device;
335 Ok(())
336 }
337
338 fn held_frontier(&self) -> Frontier {
341 let mut frontier = self
342 .base
343 .as_ref()
344 .map(checkpoint_frontier)
345 .unwrap_or_default();
346 for (device, seq) in frontier_of(&self.ops) {
347 let entry = frontier.entry(device).or_insert(seq);
348 if seq > *entry {
349 *entry = seq;
350 }
351 }
352 frontier
353 }
354
355 fn ack_frontier(&self) -> Option<Hlc> {
359 let from_ops = self.ops.iter().map(|op| &op.hlc).max();
360 let from_base = self
361 .base
362 .as_ref()
363 .and_then(|c| c.frontier.values().map(|e| &e.hlc).max());
364 [from_ops, from_base].into_iter().flatten().max().cloned()
365 }
366
367 pub fn pump(&mut self, relay: &mut dyn Relay) -> Result<PumpReport, SessionError> {
375 let mut report = PumpReport::default();
376
377 let mut own: Vec<OpRecord> = self
379 .ops
380 .iter()
381 .filter(|op| {
382 op.device_id == self.device_id
383 && self.pushed_through.is_none_or(|through| op.seq > through)
384 })
385 .cloned()
386 .collect();
387 own.sort_by_key(|op| op.seq);
388 if !own.is_empty() {
389 self.journal.sync()?;
396 let outcome = relay.push(&self.device_id, &own)?;
397 report.pushed = outcome.accepted;
398 report.push_deduped = outcome.deduped;
399 self.pushed_through = own.last().map(|op| op.seq);
400 }
401
402 let pulled = relay.pull(&self.device_id, &self.held_frontier())?;
404
405 let held: std::collections::BTreeSet<&str> =
407 self.ops.iter().map(|op| op.op_id.as_str()).collect();
408 let new_ops: Vec<OpRecord> = pulled
409 .ops
410 .into_iter()
411 .filter(|op| !held.contains(op.op_id.as_str()))
412 .collect();
413 if !new_ops.is_empty() {
414 let mut candidate = self.ops.clone();
415 candidate.extend(new_ops.iter().cloned());
416 match &self.base {
417 Some(checkpoint) => crate::checkpoint::verify_anchored(checkpoint, &candidate)?,
418 None => verify_log(&candidate)?,
419 }
420
421 drop(candidate);
430 for op in &new_ops {
431 self.journal.append(op)?;
432 self.ops.push(op.clone());
433 self.device.observe(&op.hlc);
434 report.folded += 1;
435 }
436 self.journal.sync()?;
439 }
440
441 if let Some(frontier) = self.ack_frontier() {
444 relay.ack(&self.device_id, frontier.clone())?;
445 report.acked = Some(frontier);
446 }
447 Ok(report)
448 }
449
450 pub fn rebase(&mut self, relay: &mut dyn Relay) -> Result<bool, SessionError> {
463 let Some(checkpoint) = relay.checkpoint_get()? else {
464 return Ok(false); };
466 if let Some(base) = &self.base {
467 if base.checkpoint_hash == checkpoint.checkpoint_hash {
468 return Ok(false); }
470 if !crate::relay::frontier_dominates(&checkpoint, base) {
471 return Err(SessionError::CheckpointRegression {
472 held: base.checkpoint_hash.clone(),
473 offered: checkpoint.checkpoint_hash.clone(),
474 });
475 }
476 }
477
478 let pulled = relay.pull(&self.device_id, &checkpoint_frontier(&checkpoint))?;
479
480 let mut tail: Vec<OpRecord> = pulled.ops;
484 let mut seen: std::collections::BTreeSet<String> =
485 tail.iter().map(|op| op.op_id.clone()).collect();
486 for op in &self.ops {
487 let covered = checkpoint
488 .frontier
489 .get(&op.device_id)
490 .is_some_and(|entry| op.seq <= entry.seq);
491 if !covered && seen.insert(op.op_id.clone()) {
492 tail.push(op.clone());
493 }
494 }
495 tail.sort_by(|a, b| (&a.hlc, &a.op_id).cmp(&(&b.hlc, &b.op_id)));
496
497 crate::checkpoint::verify_anchored(&checkpoint, &tail)?;
500 checkpoint.save(&self.checkpoint_dir)?;
501 self.journal.truncate_to(&tail, &checkpoint.checkpoint_hash)?;
502
503 let mut device = resume_anchored(self.device_id.clone(), &checkpoint, &tail)?;
504 device.set_wall_clock(self.wall.clone());
505 self.device = device;
506 self.ops = tail;
507 self.base = Some(checkpoint);
508 self.pushed_through = None;
509 Ok(true)
510 }
511
512 pub fn publish_checkpoint(
521 &mut self,
522 relay: &mut dyn Relay,
523 ) -> Result<Option<Checkpoint>, SessionError> {
524 if self.base.is_some() {
525 return Ok(None);
526 }
527 let Some(frontier) = relay.stable_frontier()? else {
528 return Ok(None);
529 };
530 let below: Vec<OpRecord> = self
531 .ops
532 .iter()
533 .filter(|op| op.hlc <= frontier)
534 .cloned()
535 .collect();
536 if below.is_empty() {
537 return Ok(None);
538 }
539 let checkpoint = Checkpoint::from_ops(&below)?;
540 relay.checkpoint_put(&self.device_id, &checkpoint)?;
541 Ok(Some(checkpoint))
542 }
543
544 pub fn state(&self) -> SyncState {
546 let base = self
547 .base
548 .as_ref()
549 .map(|c| c.state.clone())
550 .unwrap_or_default();
551 fold_onto(&base, &self.ops)
552 }
553
554 pub fn state_hash(&self) -> String {
557 state_hash(&self.state())
558 }
559
560 pub fn device_id(&self) -> &str {
561 &self.device_id
562 }
563
564 pub fn ops(&self) -> &[OpRecord] {
566 &self.ops
567 }
568
569 pub fn base(&self) -> Option<&Checkpoint> {
571 self.base.as_ref()
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use crate::relay::{AckOutcome, GcReport, InMemoryRelay, PullResult, PushOutcome, RelayConfig, RosterEntry};
579 use serde_json::json;
580 use std::sync::atomic::{AtomicBool, Ordering};
581 use std::sync::Arc;
582
583 fn zero_wall() -> WallClock {
584 Arc::new(|| 0)
585 }
586
587 fn mem_relay() -> InMemoryRelay {
588 InMemoryRelay::new(RelayConfig::default(), zero_wall())
589 }
590
591 struct Dirs {
592 _tmp: tempfile::TempDir,
593 journal: std::path::PathBuf,
594 ckpts: std::path::PathBuf,
595 }
596
597 fn dirs() -> Dirs {
598 let tmp = tempfile::tempdir().unwrap();
599 let journal = tmp.path().join("oplog.jsonl");
600 let ckpts = tmp.path().join("checkpoints");
601 Dirs { _tmp: tmp, journal, ckpts }
602 }
603
604 fn open(device: &str, d: &Dirs) -> SyncSession {
605 SyncSession::open(device, &d.journal, &d.ckpts, zero_wall()).unwrap()
606 }
607
608 #[test]
609 fn two_devices_converge_through_the_relay_across_all_tiers() {
610 let mut relay = mem_relay();
611 let (da, db) = (dirs(), dirs());
612 let mut a = open("mac-a", &da);
613 let mut b = open("mac-b", &db);
614
615 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1", "v": 1})).unwrap();
618 a.append(Scope::Personal, Surface::Declagent, json!({"id": "milo", "owner": "a"})).unwrap();
619 a.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})).unwrap();
620 b.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2", "v": 2})).unwrap();
621 b.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})).unwrap();
622
623 a.pump(&mut relay).unwrap();
624 let rb = b.pump(&mut relay).unwrap();
625 assert_eq!(rb.folded, 3, "b folded a's three ops");
626 b.append(Scope::Personal, Surface::Declagent, json!({"id": "milo", "owner": "b"})).unwrap();
628 b.pump(&mut relay).unwrap();
629 let ra = a.pump(&mut relay).unwrap();
630 assert_eq!(ra.folded, 3);
631
632 assert_eq!(a.state_hash(), b.state_hash(), "divergence invariant: same hash");
633 let state = a.state();
634 assert_eq!(
635 state.registries[&Surface::Declagent.tag()]["id:milo"].payload["owner"],
636 json!("b"),
637 "LWW resolved by the hybrid clock's causal order"
638 );
639 assert_eq!(
640 state.log_entries(&Surface::Routing.tag()).len(),
641 2,
642 "the observation multiset survived transport"
643 );
644
645 let ra = a.pump(&mut relay).unwrap();
647 let rb = b.pump(&mut relay).unwrap();
648 assert_eq!((ra.pushed, ra.folded), (0, 0));
649 assert_eq!((rb.pushed, rb.folded), (0, 0));
650 assert_eq!(a.state_hash(), b.state_hash());
651 }
652
653 #[test]
654 fn op_is_journal_durable_before_it_is_ever_transmitted() {
655 let mut relay = mem_relay();
658 let d = dirs();
659 {
660 let mut a = open("mac-a", &d);
661 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})).unwrap();
662 }
664 let mut a = open("mac-a", &d);
665 let next = a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})).unwrap();
667 assert_eq!(next.seq, 1);
668 a.pump(&mut relay).unwrap();
669 assert_eq!(relay.pull("x", &Frontier::new()).unwrap().ops.len(), 2);
670 verify_log(a.ops()).unwrap();
671 }
672
673 #[test]
674 fn ack_is_derived_from_journal_held_state_only() {
675 let mut relay = mem_relay();
679 let (da, db) = (dirs(), dirs());
680 let mut a = open("mac-a", &da);
681 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})).unwrap();
682 a.pump(&mut relay).unwrap();
683
684 let mut b = open("mac-b", &db);
685 let report = b.pump(&mut relay).unwrap();
686 let acked = report.acked.unwrap();
687
688 drop(b);
690 let on_disk = OplogJournal::load(&db.journal).unwrap();
691 assert_eq!(acked, on_disk.iter().map(|op| op.hlc.clone()).max().unwrap());
692 let roster: std::collections::BTreeMap<String, RosterEntry> = relay
693 .roster()
694 .unwrap()
695 .into_iter()
696 .map(|e| (e.device_id.clone(), e))
697 .collect();
698 assert_eq!(roster["mac-b"].acked.as_ref(), Some(&acked));
699 }
700
701 struct FlakyAckRelay<'a> {
705 inner: &'a mut dyn Relay,
706 fail_ack: Arc<AtomicBool>,
707 }
708
709 impl Relay for FlakyAckRelay<'_> {
710 fn register(&mut self, d: &str) -> Result<RosterEntry, RelayError> {
711 self.inner.register(d)
712 }
713 fn push(&mut self, d: &str, ops: &[OpRecord]) -> Result<PushOutcome, RelayError> {
714 self.inner.push(d, ops)
715 }
716 fn pull(&mut self, d: &str, since: &Frontier) -> Result<PullResult, RelayError> {
717 self.inner.pull(d, since)
718 }
719 fn ack(&mut self, d: &str, frontier: Hlc) -> Result<AckOutcome, RelayError> {
720 if self.fail_ack.load(Ordering::SeqCst) {
721 return Err(RelayError::Io(std::io::Error::other("network down")));
722 }
723 self.inner.ack(d, frontier)
724 }
725 fn checkpoint_put(&mut self, d: &str, c: &Checkpoint) -> Result<bool, RelayError> {
726 self.inner.checkpoint_put(d, c)
727 }
728 fn checkpoint_get(&mut self) -> Result<Option<Checkpoint>, RelayError> {
729 self.inner.checkpoint_get()
730 }
731 fn roster(&mut self) -> Result<Vec<RosterEntry>, RelayError> {
732 self.inner.roster()
733 }
734 fn stable_frontier(&mut self) -> Result<Option<Hlc>, RelayError> {
735 self.inner.stable_frontier()
736 }
737 fn gc(&mut self) -> Result<GcReport, RelayError> {
738 self.inner.gc()
739 }
740 }
741
742 #[test]
743 fn crash_mid_pump_is_idempotent_at_every_step() {
744 let mut relay = mem_relay();
745 let (da, db) = (dirs(), dirs());
746 let mut a = open("mac-a", &da);
747 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})).unwrap();
748 a.pump(&mut relay).unwrap();
749
750 let _ = relay.pull("mac-b", &Frontier::new()).unwrap();
755 let roster: std::collections::BTreeMap<String, RosterEntry> = relay
756 .roster()
757 .unwrap()
758 .into_iter()
759 .map(|e| (e.device_id.clone(), e))
760 .collect();
761 assert_eq!(roster["mac-b"].acked, None, "merely-received is never acked");
762
763 let fail = Arc::new(AtomicBool::new(true));
766 let mut b = open("mac-b", &db);
767 b.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})).unwrap();
768 {
769 let mut flaky = FlakyAckRelay { inner: &mut relay, fail_ack: fail.clone() };
770 let err = b.pump(&mut flaky).unwrap_err();
771 assert!(matches!(err, SessionError::Relay(RelayError::Io(_))));
772 }
773 let on_disk = OplogJournal::load(&db.journal).unwrap();
775 assert_eq!(on_disk.len(), 2);
776 let roster: std::collections::BTreeMap<String, RosterEntry> = relay
779 .roster()
780 .unwrap()
781 .into_iter()
782 .map(|e| (e.device_id.clone(), e))
783 .collect();
784 assert_eq!(roster["mac-b"].acked, None);
785
786 drop(b);
790 fail.store(false, Ordering::SeqCst);
791 let mut b = open("mac-b", &db);
792 let report = b.pump(&mut relay).unwrap();
793 assert_eq!(report.folded, 0, "re-pull re-fold is a no-op by op_id dedup");
794 assert_eq!(report.pushed, 0, "re-push deduped relay-side");
795 assert_eq!(report.push_deduped, 1);
796 assert!(report.acked.is_some());
797
798 a.pump(&mut relay).unwrap();
799 assert_eq!(a.state_hash(), b.state_hash(), "convergence after every crash point");
800 }
801
802 #[test]
803 fn cold_bootstrap_goes_through_resume_anchored_and_stays_fenced() {
804 let mut relay = mem_relay();
808 let da = dirs();
809 let mut a = open("mac-a", &da);
810 for i in 0..4 {
811 a.append(Scope::Personal, Surface::Knowledge, json!({"id": format!("f{i}"), "timestamp": i})).unwrap();
812 }
813 a.pump(&mut relay).unwrap();
814 let ckpt = a.publish_checkpoint(&mut relay).unwrap().unwrap();
815 relay.gc().unwrap();
816
817 let db = dirs();
820 let mut b = SyncSession::bootstrap("mac-b", &db.journal, &db.ckpts, &mut relay, zero_wall())
821 .unwrap();
822 assert_eq!(b.base().unwrap().checkpoint_hash, ckpt.checkpoint_hash);
823 b.pump(&mut relay).unwrap();
824 assert_eq!(b.state_hash(), a.state_hash());
825
826 let err = OplogJournal::load(&db.journal).unwrap_err();
828 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
829 let (marker, tail) = OplogJournal::load_with_marker(&db.journal).unwrap();
830 assert_eq!(marker.unwrap().checkpoint_hash, ckpt.checkpoint_hash);
831 drop(b);
833 let mut b = open("mac-b", &db);
834 let op = b.append(Scope::Personal, Surface::Knowledge, json!({"id": "from-b"})).unwrap();
835 assert!(op.hlc > tail.iter().map(|o| o.hlc.clone()).max().unwrap_or(Hlc {
836 wall_ms: 0,
837 counter: 0,
838 device_id: String::new()
839 }));
840 b.pump(&mut relay).unwrap();
841 a.pump(&mut relay).unwrap();
842 assert_eq!(a.state_hash(), b.state_hash());
843 }
844
845 #[test]
846 fn partial_fold_failure_is_retry_safe_and_never_bricks_the_journal() {
847 let mut relay = mem_relay();
854 let (da, db) = (dirs(), dirs());
855 let mut a = open("mac-a", &da);
856 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})).unwrap();
857 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})).unwrap();
858 a.pump(&mut relay).unwrap();
859
860 let mut b = open("mac-b", &db);
861 b.journal.fail_append_after = Some(1);
863 let err = b.pump(&mut relay).unwrap_err();
864 assert!(matches!(err, SessionError::Io(_)), "got {err:?}");
865
866 assert_eq!(b.ops().len(), 1, "only the successfully-journaled fold is in self.ops");
869 let on_disk = OplogJournal::load(&db.journal).unwrap();
870 assert_eq!(on_disk.len(), 1);
871 assert_eq!(on_disk[0].op_id, b.ops()[0].op_id);
872
873 let report = b.pump(&mut relay).unwrap();
877 assert_eq!(report.folded, 1, "only the un-journaled op is folded on retry");
878 let on_disk = OplogJournal::load(&db.journal).unwrap();
879 assert_eq!(on_disk.len(), 2, "no duplicate line — the journal is not bricked");
880 verify_log(&on_disk).expect("no DuplicateSeq: the journal opens cleanly");
881
882 drop(b);
884 let b = open("mac-b", &db);
885 assert_eq!(a.state_hash(), b.state_hash());
886 }
887
888 #[test]
889 fn append_failure_rolls_the_chain_back() {
890 let d = dirs();
895 let mut a = open("mac-a", &d);
896 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})).unwrap();
897 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})).unwrap();
898 let before = a.ops().to_vec();
899 a.rebuild_device().unwrap();
900 let next = a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f3"})).unwrap();
901 assert_eq!(next.seq, 2);
902 assert_eq!(next.prev.as_deref(), Some(before[1].op_id.as_str()));
903 let mut all = before;
904 all.push(next);
905 verify_log(&all).unwrap();
906 }
907
908 #[test]
913 fn partition_both_write_intents_converge_to_the_higher_epoch_winner() {
914 use crate::lease::{InMemoryLeaseCoordinator, IntentStatus};
923 use std::sync::atomic::AtomicU64;
924
925 let coord_t = Arc::new(AtomicU64::new(0));
926 let reader = coord_t.clone();
927 let coord_wall: WallClock = Arc::new(move || reader.load(Ordering::SeqCst));
928 let mut coord = InMemoryLeaseCoordinator::new(coord_wall);
929
930 let mut relay = mem_relay();
931 let (da, db) = (dirs(), dirs());
932 let mut a = open("mac-a", &da);
933 let mut b = open("mac-b", &db);
934 let run = car_proto::deterministic_run_id("milo", "3am digest", "occurrence-1");
935
936 let lease_a = coord.acquire("milo", "mac-a", 100).unwrap();
939 assert_eq!(lease_a.epoch, 1);
940 let recorded = a
941 .record_intent_if_current(
942 Scope::Personal,
943 &Intent::new("milo", &run, 1, IntentStatus::Pending),
944 &mut coord,
945 )
946 .unwrap();
947 assert!(recorded.is_some(), "mac-a holds the lease → intent recorded");
948 a.pump(&mut relay).unwrap();
949
950 b.pump(&mut relay).unwrap();
953 coord_t.store(200, Ordering::SeqCst); let lease_b = coord.acquire("milo", "mac-b", 100).unwrap();
955 assert_eq!((lease_b.epoch, lease_b.holder.as_str()), (2, "mac-b"));
956 b.record_intent_if_current(
957 Scope::Personal,
958 &Intent::new("milo", &run, 2, IntentStatus::Pending),
959 &mut coord,
960 )
961 .unwrap()
962 .expect("mac-b holds epoch 2");
963 let b_commit = b
964 .record_intent_if_current(
965 Scope::Personal,
966 &Intent::new("milo", &run, 2, IntentStatus::Committed),
967 &mut coord,
968 )
969 .unwrap()
970 .expect("mac-b holds epoch 2");
971 b.pump(&mut relay).unwrap();
972
973 a.pump(&mut relay).unwrap();
981 let a_zombie = a
982 .record_intent(
983 Scope::Personal,
984 &Intent::new("milo", &run, 1, IntentStatus::Committed),
985 )
986 .unwrap()
987 .expect("a committed write is recorded (not terminal-guarded)");
988 assert!(a_zombie.hlc > b_commit.hlc, "the zombie's write is later in HLC");
989 a.pump(&mut relay).unwrap();
990 b.pump(&mut relay).unwrap();
991
992 assert_eq!(a.state_hash(), b.state_hash(), "divergence invariant holds");
996 let state = a.state();
997 assert_eq!(state.intents["milo"].runs.len(), 1, "one who-holds record");
998 assert_eq!(state.fencing_epoch("milo"), Some(2));
999 let winner = state.intent("milo", &run).expect("run present");
1000 assert_eq!(winner.op_id, b_commit.op_id, "higher-epoch commit wins despite lower HLC");
1001 let decoded = Intent::from_payload(&winner.payload).unwrap();
1002 assert_eq!((decoded.epoch, decoded.status), (2, IntentStatus::Committed));
1003 assert_eq!(a.committed_run("milo", &run).unwrap().op_id, b_commit.op_id);
1006 assert_eq!(b.committed_run("milo", &run).unwrap().op_id, b_commit.op_id);
1007 }
1008
1009 #[test]
1010 fn record_intent_if_current_gates_out_a_lost_holder() {
1011 use crate::lease::{InMemoryLeaseCoordinator, IntentStatus};
1016 let mut coord = InMemoryLeaseCoordinator::new(zero_wall());
1017 let d = dirs();
1018 let mut a = open("mac-a", &d);
1019 let run = "run-x";
1020
1021 coord.acquire("milo", "mac-a", 100).unwrap();
1022 let recorded = a
1023 .record_intent_if_current(
1024 Scope::Personal,
1025 &Intent::new("milo", run, 1, IntentStatus::Pending),
1026 &mut coord,
1027 )
1028 .unwrap();
1029 assert!(recorded.is_some());
1030 assert_eq!(a.ops().len(), 1);
1031
1032 coord.release("milo", "mac-a", 1).unwrap();
1035 coord.acquire("milo", "mac-b", 100).unwrap();
1036 let gated = a
1037 .record_intent_if_current(
1038 Scope::Personal,
1039 &Intent::new("milo", run, 1, IntentStatus::Committed),
1040 &mut coord,
1041 )
1042 .unwrap();
1043 assert!(gated.is_none(), "coordinator says mac-a lost → not recorded");
1044 assert_eq!(a.ops().len(), 1, "nothing new journaled");
1045 }
1046
1047 #[test]
1048 fn c3_record_intent_no_ops_a_pending_for_an_already_committed_run() {
1049 let mut relay = mem_relay();
1055 let (da, db) = (dirs(), dirs());
1056 let mut a = open("mac-a", &da);
1057 let mut b = open("mac-b", &db);
1058 let run = "run-nightly";
1059
1060 a.record_intent(Scope::Personal, &Intent::new("milo", run, 1, IntentStatus::Committed))
1062 .unwrap()
1063 .expect("first commit recorded");
1064 a.pump(&mut relay).unwrap();
1065 b.pump(&mut relay).unwrap();
1066 assert!(b.committed_run("milo", run).is_some(), "mac-b folded the commit");
1067
1068 let before = b.ops().len();
1071 let attempt = b
1072 .record_intent(Scope::Personal, &Intent::new("milo", run, 2, IntentStatus::Pending))
1073 .unwrap();
1074 assert!(attempt.is_none(), "pending for an already-committed run is a no-op");
1075 assert_eq!(b.ops().len(), before, "nothing journaled");
1076
1077 b.pump(&mut relay).unwrap();
1079 a.pump(&mut relay).unwrap();
1080 assert!(a.committed_run("milo", run).is_some());
1081 let decoded = Intent::from_payload(&b.state().intent("milo", run).unwrap().payload).unwrap();
1082 assert_eq!(decoded.status, IntentStatus::Committed, "not reverted to pending");
1083 }
1084}