1use std::collections::BTreeMap;
34use std::sync::{Arc, Mutex};
35
36use serde::{Deserialize, Serialize};
37
38use crate::checkpoint::Checkpoint;
39use crate::lease::{InMemoryLeaseCoordinator, Lease, LeaseCoordinator, LeaseError};
40use crate::oplog::{Hlc, OpRecord, WallClock};
41use crate::relay::{
42 AckOutcome, Frontier, GcReport, PullResult, PushOutcome, Relay, RelayConfig, RelayError,
43 RosterEntry,
44};
45
46#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum TransportError {
51 Unavailable(String),
54 Unauthorized(String),
56 Protocol(String),
58 FrontierTruncated {
64 device_id: String,
65 dropped_below: u64,
66 },
67}
68
69impl std::fmt::Display for TransportError {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 TransportError::Unavailable(m) => write!(f, "sync transport unavailable: {m}"),
73 TransportError::Unauthorized(m) => write!(f, "sync transport unauthorized: {m}"),
74 TransportError::Protocol(m) => write!(f, "sync transport protocol error: {m}"),
75 TransportError::FrontierTruncated {
76 device_id,
77 dropped_below,
78 } => write!(
79 f,
80 "sync transport frontier truncated: device {device_id} ops below seq \
81 {dropped_below} were GC'd (cold-bootstrap from checkpoint)"
82 ),
83 }
84 }
85}
86impl std::error::Error for TransportError {}
87
88impl From<TransportError> for RelayError {
89 fn from(e: TransportError) -> Self {
90 match e {
91 TransportError::FrontierTruncated {
94 device_id,
95 dropped_below,
96 } => RelayError::FrontierTruncated {
97 device_id,
98 dropped_below,
99 },
100 other => RelayError::Io(std::io::Error::other(other.to_string())),
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
111pub enum LeaseWire {
112 Granted(Lease),
114 Held {
116 holder: String,
117 epoch: u64,
118 expires_at_ms: u64,
119 },
120 Lost {
122 claimed_epoch: u64,
123 current_epoch: u64,
124 },
125 Released,
127 Current(Option<Lease>),
129}
130
131pub trait SyncTransport: Send + Sync {
137 fn enroll(&self, scope: &str, device_id: &str) -> Result<RosterEntry, TransportError>;
138 fn push(
139 &self,
140 scope: &str,
141 device_id: &str,
142 ops: &[OpRecord],
143 ) -> Result<PushOutcome, TransportError>;
144 fn pull(
145 &self,
146 scope: &str,
147 device_id: &str,
148 since: &Frontier,
149 ) -> Result<PullResult, TransportError>;
150 fn ack(
151 &self,
152 scope: &str,
153 device_id: &str,
154 frontier: Hlc,
155 ) -> Result<AckOutcome, TransportError>;
156 fn checkpoint_put(
157 &self,
158 scope: &str,
159 device_id: &str,
160 checkpoint: &Checkpoint,
161 ) -> Result<bool, TransportError>;
162 fn checkpoint_get(&self, scope: &str) -> Result<Option<Checkpoint>, TransportError>;
163 fn roster(&self, scope: &str) -> Result<Vec<RosterEntry>, TransportError>;
164 fn stable_frontier(&self, scope: &str) -> Result<Option<Hlc>, TransportError>;
165 fn gc(&self, scope: &str) -> Result<GcReport, TransportError>;
166
167 fn lease_acquire(
169 &self,
170 scope: &str,
171 agent_id: &str,
172 device_id: &str,
173 ttl_ms: u64,
174 ) -> Result<LeaseWire, TransportError>;
175 fn lease_renew(
176 &self,
177 scope: &str,
178 agent_id: &str,
179 device_id: &str,
180 epoch: u64,
181 ttl_ms: u64,
182 ) -> Result<LeaseWire, TransportError>;
183 fn lease_release(
184 &self,
185 scope: &str,
186 agent_id: &str,
187 device_id: &str,
188 epoch: u64,
189 ) -> Result<LeaseWire, TransportError>;
190 fn lease_current(&self, scope: &str, agent_id: &str) -> Result<LeaseWire, TransportError>;
191}
192
193pub struct NetworkRelay {
196 transport: Arc<dyn SyncTransport>,
197 scope: String,
198}
199
200impl NetworkRelay {
201 pub fn new(transport: Arc<dyn SyncTransport>, scope: impl Into<String>) -> Self {
202 Self {
203 transport,
204 scope: scope.into(),
205 }
206 }
207}
208
209impl Relay for NetworkRelay {
210 fn register(&mut self, device_id: &str) -> Result<RosterEntry, RelayError> {
211 Ok(self.transport.enroll(&self.scope, device_id)?)
212 }
213 fn push(&mut self, device_id: &str, ops: &[OpRecord]) -> Result<PushOutcome, RelayError> {
214 Ok(self.transport.push(&self.scope, device_id, ops)?)
215 }
216 fn pull(&mut self, device_id: &str, since: &Frontier) -> Result<PullResult, RelayError> {
217 Ok(self.transport.pull(&self.scope, device_id, since)?)
218 }
219 fn ack(&mut self, device_id: &str, frontier: Hlc) -> Result<AckOutcome, RelayError> {
220 Ok(self.transport.ack(&self.scope, device_id, frontier)?)
221 }
222 fn checkpoint_put(
223 &mut self,
224 device_id: &str,
225 checkpoint: &Checkpoint,
226 ) -> Result<bool, RelayError> {
227 Ok(self
228 .transport
229 .checkpoint_put(&self.scope, device_id, checkpoint)?)
230 }
231 fn checkpoint_get(&mut self) -> Result<Option<Checkpoint>, RelayError> {
232 Ok(self.transport.checkpoint_get(&self.scope)?)
233 }
234 fn roster(&mut self) -> Result<Vec<RosterEntry>, RelayError> {
235 Ok(self.transport.roster(&self.scope)?)
236 }
237 fn stable_frontier(&mut self) -> Result<Option<Hlc>, RelayError> {
238 Ok(self.transport.stable_frontier(&self.scope)?)
239 }
240 fn gc(&mut self) -> Result<GcReport, RelayError> {
241 Ok(self.transport.gc(&self.scope)?)
242 }
243}
244
245pub struct NetworkLeaseCoordinator {
250 transport: Arc<dyn SyncTransport>,
251 scope: String,
252}
253
254impl NetworkLeaseCoordinator {
255 pub fn new(transport: Arc<dyn SyncTransport>, scope: impl Into<String>) -> Self {
256 Self {
257 transport,
258 scope: scope.into(),
259 }
260 }
261}
262
263fn wire_to_lease(agent_id: &str, wire: LeaseWire, ctx: &'static str) -> Result<Lease, LeaseError> {
264 match wire {
265 LeaseWire::Granted(l) => Ok(l),
266 LeaseWire::Held {
267 holder,
268 epoch,
269 expires_at_ms,
270 } => Err(LeaseError::Held {
271 agent_id: agent_id.to_string(),
272 holder,
273 epoch,
274 expires_at_ms,
275 }),
276 LeaseWire::Lost {
277 claimed_epoch,
278 current_epoch,
279 } => Err(LeaseError::Lost {
280 agent_id: agent_id.to_string(),
281 claimed_epoch,
282 current_epoch,
283 }),
284 other => Err(LeaseError::Backend(format!(
285 "{ctx}: unexpected lease verdict {other:?}"
286 ))),
287 }
288}
289
290impl LeaseCoordinator for NetworkLeaseCoordinator {
291 fn acquire(
292 &mut self,
293 agent_id: &str,
294 device_id: &str,
295 ttl_ms: u64,
296 ) -> Result<Lease, LeaseError> {
297 let wire = self
298 .transport
299 .lease_acquire(&self.scope, agent_id, device_id, ttl_ms)
300 .map_err(|e| LeaseError::Backend(e.to_string()))?;
301 wire_to_lease(agent_id, wire, "acquire")
302 }
303
304 fn renew(
305 &mut self,
306 agent_id: &str,
307 device_id: &str,
308 epoch: u64,
309 ttl_ms: u64,
310 ) -> Result<Lease, LeaseError> {
311 let wire = self
312 .transport
313 .lease_renew(&self.scope, agent_id, device_id, epoch, ttl_ms)
314 .map_err(|e| LeaseError::Backend(e.to_string()))?;
315 wire_to_lease(agent_id, wire, "renew")
316 }
317
318 fn release(&mut self, agent_id: &str, device_id: &str, epoch: u64) -> Result<(), LeaseError> {
319 let wire = self
320 .transport
321 .lease_release(&self.scope, agent_id, device_id, epoch)
322 .map_err(|e| LeaseError::Backend(e.to_string()))?;
323 match wire {
324 LeaseWire::Released => Ok(()),
325 LeaseWire::Lost {
326 claimed_epoch,
327 current_epoch,
328 } => Err(LeaseError::Lost {
329 agent_id: agent_id.to_string(),
330 claimed_epoch,
331 current_epoch,
332 }),
333 other => Err(LeaseError::Backend(format!(
334 "release: unexpected lease verdict {other:?}"
335 ))),
336 }
337 }
338
339 fn current(&mut self, agent_id: &str) -> Result<Option<Lease>, LeaseError> {
340 let wire = self
341 .transport
342 .lease_current(&self.scope, agent_id)
343 .map_err(|e| LeaseError::Backend(e.to_string()))?;
344 match wire {
345 LeaseWire::Current(l) => Ok(l),
346 other => Err(LeaseError::Backend(format!(
347 "current: unexpected lease verdict {other:?}"
348 ))),
349 }
350 }
351}
352
353struct ScopeBackend {
358 relay: crate::relay::FsRelay,
359 lease: InMemoryLeaseCoordinator,
360}
361
362struct LoopbackInner {
363 dir: tempfile::TempDir,
364 wall: WallClock,
365 config: RelayConfig,
366 scopes: BTreeMap<String, ScopeBackend>,
367}
368
369#[derive(Clone)]
373pub struct LoopbackTransport {
374 inner: Arc<Mutex<LoopbackInner>>,
375}
376
377impl LoopbackTransport {
378 pub fn new() -> std::io::Result<Self> {
380 Self::with_config(RelayConfig::default(), crate::oplog::system_clock())
381 }
382
383 pub fn with_config(config: RelayConfig, wall: WallClock) -> std::io::Result<Self> {
384 Ok(Self {
385 inner: Arc::new(Mutex::new(LoopbackInner {
386 dir: tempfile::TempDir::new()?,
387 wall,
388 config,
389 scopes: BTreeMap::new(),
390 })),
391 })
392 }
393
394 fn with_scope<R>(
396 &self,
397 scope: &str,
398 f: impl FnOnce(&mut ScopeBackend) -> Result<R, TransportError>,
399 ) -> Result<R, TransportError> {
400 let mut inner = self
401 .inner
402 .lock()
403 .map_err(|_| TransportError::Unavailable("loopback lock poisoned".into()))?;
404 if !inner.scopes.contains_key(scope) {
405 let dir = inner.dir.path().join(sanitize_scope(scope));
407 std::fs::create_dir_all(&dir)
408 .map_err(|e| TransportError::Unavailable(format!("loopback mkdir: {e}")))?;
409 let relay = crate::relay::FsRelay::open(&dir, inner.config.clone(), inner.wall.clone())
410 .map_err(|e| TransportError::Unavailable(format!("loopback FsRelay: {e}")))?;
411 let lease = InMemoryLeaseCoordinator::new(inner.wall.clone());
412 inner
413 .scopes
414 .insert(scope.to_string(), ScopeBackend { relay, lease });
415 }
416 let backend = inner.scopes.get_mut(scope).expect("just inserted");
417 f(backend)
418 }
419}
420
421fn sanitize_scope(scope: &str) -> String {
423 scope
424 .chars()
425 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
426 .collect()
427}
428
429fn relay_err(e: RelayError) -> TransportError {
430 TransportError::Unavailable(e.to_string())
431}
432
433impl SyncTransport for LoopbackTransport {
434 fn enroll(&self, scope: &str, device_id: &str) -> Result<RosterEntry, TransportError> {
435 self.with_scope(scope, |b| b.relay.register(device_id).map_err(relay_err))
436 }
437 fn push(
438 &self,
439 scope: &str,
440 device_id: &str,
441 ops: &[OpRecord],
442 ) -> Result<PushOutcome, TransportError> {
443 self.with_scope(scope, |b| b.relay.push(device_id, ops).map_err(relay_err))
444 }
445 fn pull(
446 &self,
447 scope: &str,
448 device_id: &str,
449 since: &Frontier,
450 ) -> Result<PullResult, TransportError> {
451 self.with_scope(scope, |b| b.relay.pull(device_id, since).map_err(relay_err))
452 }
453 fn ack(
454 &self,
455 scope: &str,
456 device_id: &str,
457 frontier: Hlc,
458 ) -> Result<AckOutcome, TransportError> {
459 self.with_scope(scope, |b| {
460 b.relay.ack(device_id, frontier).map_err(relay_err)
461 })
462 }
463 fn checkpoint_put(
464 &self,
465 scope: &str,
466 device_id: &str,
467 checkpoint: &Checkpoint,
468 ) -> Result<bool, TransportError> {
469 self.with_scope(scope, |b| {
470 b.relay
471 .checkpoint_put(device_id, checkpoint)
472 .map_err(relay_err)
473 })
474 }
475 fn checkpoint_get(&self, scope: &str) -> Result<Option<Checkpoint>, TransportError> {
476 self.with_scope(scope, |b| b.relay.checkpoint_get().map_err(relay_err))
477 }
478 fn roster(&self, scope: &str) -> Result<Vec<RosterEntry>, TransportError> {
479 self.with_scope(scope, |b| b.relay.roster().map_err(relay_err))
480 }
481 fn stable_frontier(&self, scope: &str) -> Result<Option<Hlc>, TransportError> {
482 self.with_scope(scope, |b| b.relay.stable_frontier().map_err(relay_err))
483 }
484 fn gc(&self, scope: &str) -> Result<GcReport, TransportError> {
485 self.with_scope(scope, |b| b.relay.gc().map_err(relay_err))
486 }
487
488 fn lease_acquire(
489 &self,
490 scope: &str,
491 agent_id: &str,
492 device_id: &str,
493 ttl_ms: u64,
494 ) -> Result<LeaseWire, TransportError> {
495 self.with_scope(scope, |b| {
496 Ok(match b.lease.acquire(agent_id, device_id, ttl_ms) {
497 Ok(l) => LeaseWire::Granted(l),
498 Err(LeaseError::Held {
499 holder,
500 epoch,
501 expires_at_ms,
502 ..
503 }) => LeaseWire::Held {
504 holder,
505 epoch,
506 expires_at_ms,
507 },
508 Err(e) => return Err(TransportError::Unavailable(e.to_string())),
509 })
510 })
511 }
512 fn lease_renew(
513 &self,
514 scope: &str,
515 agent_id: &str,
516 device_id: &str,
517 epoch: u64,
518 ttl_ms: u64,
519 ) -> Result<LeaseWire, TransportError> {
520 self.with_scope(scope, |b| {
521 Ok(match b.lease.renew(agent_id, device_id, epoch, ttl_ms) {
522 Ok(l) => LeaseWire::Granted(l),
523 Err(LeaseError::Lost {
524 claimed_epoch,
525 current_epoch,
526 ..
527 }) => LeaseWire::Lost {
528 claimed_epoch,
529 current_epoch,
530 },
531 Err(e) => return Err(TransportError::Unavailable(e.to_string())),
532 })
533 })
534 }
535 fn lease_release(
536 &self,
537 scope: &str,
538 agent_id: &str,
539 device_id: &str,
540 epoch: u64,
541 ) -> Result<LeaseWire, TransportError> {
542 self.with_scope(scope, |b| {
543 Ok(match b.lease.release(agent_id, device_id, epoch) {
544 Ok(()) => LeaseWire::Released,
545 Err(LeaseError::Lost {
546 claimed_epoch,
547 current_epoch,
548 ..
549 }) => LeaseWire::Lost {
550 claimed_epoch,
551 current_epoch,
552 },
553 Err(e) => return Err(TransportError::Unavailable(e.to_string())),
554 })
555 })
556 }
557 fn lease_current(&self, scope: &str, agent_id: &str) -> Result<LeaseWire, TransportError> {
558 self.with_scope(scope, |b| {
559 b.lease
560 .current(agent_id)
561 .map(LeaseWire::Current)
562 .map_err(|e| TransportError::Unavailable(e.to_string()))
563 })
564 }
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use crate::relay::Relay;
571
572 fn transport() -> Arc<dyn SyncTransport> {
573 let clock: WallClock = Arc::new(|| 1000);
574 Arc::new(LoopbackTransport::with_config(RelayConfig::default(), clock).unwrap())
575 }
576
577 #[test]
578 fn two_devices_converge_through_a_shared_service() {
579 let svc = transport();
583 let scope = "user:matt";
584 let mut mac = NetworkRelay::new(svc.clone(), scope);
585 let mut phone = NetworkRelay::new(svc.clone(), scope);
586
587 mac.register("mac").unwrap();
588 phone.register("phone").unwrap();
589
590 let op = crate::oplog::OpRecord::new(
593 Hlc {
594 wall_ms: 1000,
595 counter: 0,
596 device_id: "mac".into(),
597 },
598 0,
599 None,
600 crate::oplog::Scope::Personal,
601 crate::oplog::Surface::Routing,
602 serde_json::json!({"hello": "phone"}),
603 );
604 let out = mac.push("mac", std::slice::from_ref(&op)).unwrap();
605 assert_eq!(out.accepted, 1);
606
607 let pulled = phone.pull("phone", &Frontier::new()).unwrap();
609 assert_eq!(pulled.ops.len(), 1);
610 assert_eq!(pulled.ops[0].op_id, op.op_id);
611
612 let mut other = NetworkRelay::new(svc.clone(), "user:someone_else");
614 assert!(other.pull("d", &Frontier::new()).unwrap().ops.is_empty());
615 }
616
617 #[test]
618 fn frontier_truncation_maps_to_the_relay_cold_bootstrap_signal() {
619 let te = TransportError::FrontierTruncated {
623 device_id: "mac".into(),
624 dropped_below: 7,
625 };
626 match RelayError::from(te) {
627 RelayError::FrontierTruncated {
628 device_id,
629 dropped_below,
630 } => {
631 assert_eq!(device_id, "mac");
632 assert_eq!(dropped_below, 7);
633 }
634 other => panic!("expected FrontierTruncated, got {other:?}"),
635 }
636 }
637
638 #[test]
639 fn distributed_lease_is_mutually_exclusive_across_devices() {
640 let svc = transport();
645 let scope = "user:matt";
646 let mut mac = NetworkLeaseCoordinator::new(svc.clone(), scope);
647 let mut phone = NetworkLeaseCoordinator::new(svc.clone(), scope);
648
649 let l1 = mac.acquire("milo", "mac", 10_000).unwrap();
650 assert_eq!(l1.epoch, 1);
651 assert_eq!(l1.holder, "mac");
652
653 match phone.acquire("milo", "phone", 10_000) {
655 Err(LeaseError::Held { holder, epoch, .. }) => {
656 assert_eq!(holder, "mac");
657 assert_eq!(epoch, 1);
658 }
659 other => panic!("expected Held, got {other:?}"),
660 }
661
662 assert_eq!(phone.current("milo").unwrap().unwrap().holder, "mac");
664
665 mac.release("milo", "mac", 1).unwrap();
667 let l2 = phone.acquire("milo", "phone", 10_000).unwrap();
668 assert_eq!(l2.epoch, 2);
669 assert_eq!(l2.holder, "phone");
670
671 match mac.renew("milo", "mac", 1, 10_000) {
673 Err(LeaseError::Lost { current_epoch, .. }) => assert_eq!(current_epoch, 2),
674 other => panic!("expected Lost, got {other:?}"),
675 }
676 }
677}