1use std::{
2 collections::{HashMap, HashSet},
3 path::Path,
4 pin::Pin,
5 sync::Arc,
6 time::{Duration, Instant},
7};
8
9use iroh_blobs::Hash;
10use iroh_docs::{NamespaceId, engine::LiveEvent};
11use n0_future::StreamExt;
12use tokio::sync::mpsc;
13
14use crate::{
15 error::Result,
16 filter::{FilterAction, FilterEngine, FilterEntry},
17 folder::FolderManager,
18 node::discovery::TopicTracker,
19 node::iroh_node::IrohNode,
20 node::{blob_store::BlobStore, docs_engine::DocsEngine},
21 storage::node_db::NodeDatabase,
22};
23
24use super::checkpoint::SyncCheckpoint;
25use super::{FetchStrategy, IntentHandle, SessionMode, SubscribeParams, SyncCommand, SyncEvent};
26
27type IntentSetup = (
28 IntentState,
29 Pin<Box<tokio::time::Sleep>>,
30 Pin<Box<tokio::time::Sleep>>,
31 Option<SyncCheckpoint>,
32 HashSet<Vec<u8>>,
33);
34
35#[derive(Clone, Debug, Default, Eq, PartialEq)]
37#[non_exhaustive]
38pub struct TransferStats {
39 pub bytes_transferred: u64,
40 pub bytes_total: Option<u64>,
41 pub bytes_per_second: u64,
42 pub peer_count: usize,
43 pub eta: Option<Duration>,
44}
45
46impl TransferStats {
47 #[must_use]
48 pub fn from_progress(
49 bytes_transferred: u64,
50 bytes_total: Option<u64>,
51 elapsed: Duration,
52 peer_count: usize,
53 ) -> Self {
54 let elapsed_millis = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
55 let bytes_per_second = bytes_transferred
56 .saturating_mul(1_000)
57 .checked_div(elapsed_millis)
58 .unwrap_or(0);
59 let eta = bytes_total.and_then(|total| {
60 let remaining = total.saturating_sub(bytes_transferred);
61 remaining.checked_div(bytes_per_second).map(Duration::from_secs)
62 });
63 Self {
64 bytes_transferred,
65 bytes_total,
66 bytes_per_second,
67 peer_count,
68 eta,
69 }
70 }
71}
72
73#[derive(Clone)]
75pub struct SyncEngine {
76 folder_manager: FolderManager,
77 blob_store: BlobStore,
78 docs_engine: DocsEngine,
79 node_db: Option<NodeDatabase>,
80 topic_tracker: Option<TopicTracker>,
81 reconciler: Option<Arc<dyn Fn(NamespaceId) + Send + Sync>>,
84}
85
86impl SyncEngine {
87 #[must_use]
88 pub const fn new(
89 folder_manager: FolderManager,
90 blob_store: BlobStore,
91 docs_engine: DocsEngine,
92 topic_tracker: Option<TopicTracker>,
93 ) -> Self {
94 Self {
95 folder_manager,
96 blob_store,
97 docs_engine,
98 node_db: None,
99 topic_tracker,
100 reconciler: None,
101 }
102 }
103
104 #[must_use]
106 pub fn with_node_db(mut self, node_db: NodeDatabase) -> Self {
107 self.node_db = Some(node_db);
108 self
109 }
110
111 #[must_use]
113 pub fn with_reconciler(mut self, reconciler: Arc<dyn Fn(NamespaceId) + Send + Sync>) -> Self {
114 self.reconciler = Some(reconciler);
115 self
116 }
117
118 #[must_use]
119 pub fn from_node(node: &IrohNode, folder_manager: FolderManager) -> Self {
120 Self::new(
121 folder_manager,
122 node.blob_store().clone(),
123 node.docs_engine().clone(),
124 Some(node.topic_tracker().clone()),
125 )
126 }
127
128 pub async fn sync(&self, folder_id: NamespaceId, mode: SessionMode) -> Result<IntentHandle> {
137 self.sync_with_params(folder_id, mode, SubscribeParams::default()).await
138 }
139
140 pub async fn sync_with_params(
147 &self,
148 folder_id: NamespaceId,
149 mode: SessionMode,
150 params: SubscribeParams,
151 ) -> Result<IntentHandle> {
152 self.start(folder_id, mode, params, None).await
153 }
154
155 pub async fn subscribe(&self, folder_id: NamespaceId, params: SubscribeParams) -> Result<IntentHandle> {
162 self.sync_with_params(folder_id, SessionMode::Continuous, params).await
163 }
164
165 pub async fn fetch(&self, folder_id: NamespaceId, strategy: FetchStrategy) -> Result<IntentHandle> {
176 let params = Self::fetch_params(strategy);
177 self.sync_with_params(folder_id, SessionMode::ReconcileOnce, params)
178 .await
179 }
180
181 pub async fn sync_with_filter(
192 &self,
193 folder_id: NamespaceId,
194 mode: SessionMode,
195 params: SubscribeParams,
196 filter: FilterEngine,
197 ) -> Result<IntentHandle> {
198 self.start(folder_id, mode, params, Some(filter)).await
199 }
200
201 fn fetch_params(strategy: FetchStrategy) -> SubscribeParams {
202 let FetchStrategy::Filter(filter) = strategy else {
203 return SubscribeParams::default();
204 };
205 let mut params = SubscribeParams::default();
206 if let Some(paths) = filter.paths
207 && let Some(path) = paths.first()
208 {
209 params = params.with_area(super::AreaFilter::Prefix(path.clone()));
210 }
211 if filter.max_size.is_some() || filter.max_count.is_some() {
212 let area = params.area_filter.clone().unwrap_or(super::AreaFilter::All);
213 let limits = super::AreaOfInterest::with_limits(
214 area,
215 filter.max_size.unwrap_or(0),
216 u64::try_from(filter.max_count.unwrap_or(0)).unwrap_or(u64::MAX),
217 );
218 params = params.with_limits(limits);
219 }
220 params
221 }
222
223 async fn start(
224 &self,
225 folder_id: NamespaceId,
226 mode: SessionMode,
227 params: SubscribeParams,
228 filter: Option<FilterEngine>,
229 ) -> Result<IntentHandle> {
230 let folder = self.folder_manager.get(folder_id).await?;
231 let mut discovered = Vec::new();
237 if let Some(topic_tracker) = &self.topic_tracker {
238 match topic_tracker.find_peers(folder_id).await {
239 Ok(peers) if !peers.is_empty() => {
240 tracing::debug!(%folder_id, peer_count = peers.len(), "discovered peers via topic tracker");
241 discovered = peers;
242 }
243 Ok(_) => {}
244 Err(error) => {
245 tracing::debug!(%folder_id, %error, "failed to discover peers via topic tracker");
246 }
247 }
248 }
249 let live_events = self.docs_engine.watch(folder.doc()).await?;
250 self.docs_engine.start_sync(folder.doc(), discovered).await?;
251 let (events, commands, handle) = IntentHandle::channel();
252 let node_db = self.node_db.clone();
253 let reconciler = self.reconciler.clone();
254 tokio::spawn(run_intent(
255 folder,
256 IntentConfig {
257 mode,
258 params,
259 filter,
260 reconciler,
261 },
262 self.blob_store.clone(),
263 live_events,
264 events,
265 commands,
266 node_db,
267 ));
268 Ok(handle)
269 }
270}
271
272struct IntentConfig {
273 mode: SessionMode,
274 params: SubscribeParams,
275 filter: Option<FilterEngine>,
276 reconciler: Option<Arc<dyn Fn(NamespaceId) + Send + Sync>>,
277}
278
279async fn run_intent(
280 folder: crate::folder::SyncwebFolder,
281 config: IntentConfig,
282 blob_store: BlobStore,
283 mut live_events: impl n0_future::Stream<Item = Result<LiveEvent>> + Send + Unpin + 'static,
284 events: mpsc::UnboundedSender<SyncEvent>,
285 mut commands: mpsc::UnboundedReceiver<SyncCommand>,
286 node_db: Option<NodeDatabase>,
287) {
288 const SETTLE: Duration = Duration::from_secs(2);
289 const FORCED_QUIESCE: Duration = Duration::from_secs(10);
290
291 let started = Instant::now();
292 if !send_initial_events(&events, started) {
293 return;
294 }
295
296 let (mut state, mut quiesce, mut forced_quiesce, checkpoint, completed_keys) =
297 setup_intent_checkpoint(&folder, node_db.as_ref());
298
299 loop {
300 tokio::select! {
301 received_command = commands.recv() => {
302 let Some(command) = received_command else {
303 let _result = folder.doc().leave().await;
304 finalize_checkpoint(checkpoint.as_ref(), &state, &events);
305 return;
306 };
307 if !handle_command(&folder, command, &mut state.paused, &events).await {
308 finalize_checkpoint(checkpoint.as_ref(), &state, &events);
309 return;
310 }
311 }
312 () = &mut quiesce, if !config.mode.is_continuous() && state.sync_finished && state.sizes.is_empty() => {
313 finalize_checkpoint(checkpoint.as_ref(), &state, &events);
314 return;
315 }
316 () = &mut forced_quiesce, if !config.mode.is_continuous() && state.sync_finished => {
317 let pending = state.sizes.len();
318 if pending > 0 {
319 tracing::warn!(%pending, "forced quiesce: content for some blobs never arrived");
320 }
321 finalize_checkpoint(checkpoint.as_ref(), &state, &events);
322 return;
323 }
324 next_event = live_events.next(), if !state.paused => {
325 let processor = IntentEventProcessor {
326 folder: &folder,
327 config: &config,
328 blob_store: &blob_store,
329 events: &events,
330 checkpoint: checkpoint.as_ref(),
331 state: &mut state,
332 completed_keys: &completed_keys,
333 started,
334 };
335 match processor.process(next_event).await {
336 IntentEventOutcome::Finished => return,
337 IntentEventOutcome::Skipped => continue,
338 IntentEventOutcome::Applied => {}
339 }
340 if !config.mode.is_continuous() && state.sync_finished {
341 let now = tokio::time::Instant::now();
342 quiesce.as_mut().reset(now.checked_add(SETTLE).unwrap_or(now));
343 forced_quiesce.as_mut().reset(now.checked_add(FORCED_QUIESCE).unwrap_or(now));
344 }
345 }
346 }
347 }
348}
349
350enum IntentEventOutcome {
351 Finished,
352 Skipped,
353 Applied,
354}
355
356struct IntentEventProcessor<'a> {
357 folder: &'a crate::folder::SyncwebFolder,
358 config: &'a IntentConfig,
359 blob_store: &'a BlobStore,
360 events: &'a mpsc::UnboundedSender<SyncEvent>,
361 checkpoint: Option<&'a SyncCheckpoint>,
362 state: &'a mut IntentState,
363 completed_keys: &'a HashSet<Vec<u8>>,
364 started: Instant,
365}
366
367impl IntentEventProcessor<'_> {
368 async fn process(self, next_event: Option<Result<LiveEvent>>) -> IntentEventOutcome {
369 let Some(event_result) = next_event else {
370 let _result = self.events.send(SyncEvent::Finished);
371 finalize_checkpoint(self.checkpoint, self.state, self.events);
372 return IntentEventOutcome::Finished;
373 };
374 let live_event = match event_result {
375 Ok(event) => event,
376 Err(error) => {
377 let _result = self.events.send(SyncEvent::Failed(error.to_string()));
378 finalize_checkpoint(self.checkpoint, self.state, self.events);
379 return IntentEventOutcome::Finished;
380 }
381 };
382 let event_name = match &live_event {
383 LiveEvent::InsertLocal { .. } => "InsertLocal",
384 LiveEvent::InsertRemote { .. } => "InsertRemote",
385 LiveEvent::ContentReady { .. } => "ContentReady",
386 LiveEvent::PendingContentReady => "PendingContentReady",
387 LiveEvent::NeighborUp(_) => "NeighborUp",
388 LiveEvent::NeighborDown(_) => "NeighborDown",
389 LiveEvent::SyncFinished(_) => "SyncFinished",
390 };
391 tracing::info!(
392 namespace_id = %self.folder.namespace_id(),
393 event = event_name,
394 included = include_event(&self.config.params, &live_event),
395 "engine live event"
396 );
397 if !include_event(&self.config.params, &live_event) {
398 return IntentEventOutcome::Skipped;
399 }
400 if event_key(&live_event).is_some_and(|key| self.completed_keys.contains(&key)) {
401 return IntentEventOutcome::Skipped;
402 }
403 if let LiveEvent::ContentReady { hash } = &live_event {
404 tracing::info!(%hash, "engine saw ContentReady");
405 match self.blob_store.has(*hash).await {
406 Ok(true) => {}
407 Ok(false) => {
408 let message = format!("content-ready blob {hash} is missing locally");
409 let _result = self.events.send(SyncEvent::Failed(message));
410 finalize_checkpoint(self.checkpoint, self.state, self.events);
411 return IntentEventOutcome::Finished;
412 }
413 Err(error) => {
414 let _result = self.events.send(SyncEvent::Failed(error.to_string()));
415 finalize_checkpoint(self.checkpoint, self.state, self.events);
416 return IntentEventOutcome::Finished;
417 }
418 }
419 if let Some(cp) = self.checkpoint {
420 let _ = cp.mark_completed(hash.as_bytes(), *hash, self.state.sizes.get(hash).copied().unwrap_or(0));
421 }
422 reconcile_after_sync(self.folder.namespace_id(), self.config);
423 }
424 if matches!(live_event, LiveEvent::InsertRemote { .. } | LiveEvent::SyncFinished(_)) {
425 reconcile_after_sync(self.folder.namespace_id(), self.config);
426 }
427 self.state
428 .apply(live_event, &self.config.params, self.config.filter.as_ref());
429 if !send_progress(self.events, self.state, self.started.elapsed()) {
430 finalize_checkpoint(self.checkpoint, self.state, self.events);
431 return IntentEventOutcome::Finished;
432 }
433 IntentEventOutcome::Applied
434 }
435}
436
437fn event_key(event: &LiveEvent) -> Option<Vec<u8>> {
438 match event {
439 LiveEvent::InsertLocal { entry } | LiveEvent::InsertRemote { entry, .. } => Some(entry.key().to_vec()),
440 LiveEvent::ContentReady { .. }
441 | LiveEvent::PendingContentReady
442 | LiveEvent::NeighborUp(_)
443 | LiveEvent::NeighborDown(_)
444 | LiveEvent::SyncFinished(_) => None,
445 }
446}
447
448fn setup_intent_checkpoint(folder: &crate::folder::SyncwebFolder, node_db: Option<&NodeDatabase>) -> IntentSetup {
449 let namespace_id = folder.namespace_id();
450 let checkpoint = node_db.and_then(|db| match SyncCheckpoint::resume(db, namespace_id) {
451 Ok(Some(cp)) => {
452 tracing::info!(%namespace_id, session = %cp.session_id(), "resuming sync checkpoint");
453 Some(cp)
454 }
455 Ok(None) => SyncCheckpoint::create_session(db, namespace_id)
456 .map_err(|e| tracing::warn!(%namespace_id, %e, "failed to create checkpoint"))
457 .ok(),
458 Err(err) => {
459 tracing::warn!(%namespace_id, %err, "failed to resume checkpoint");
460 SyncCheckpoint::create_session(db, namespace_id)
461 .map_err(|e2| tracing::warn!(%namespace_id, %e2, "failed to create checkpoint"))
462 .ok()
463 }
464 });
465 let completed_keys: HashSet<Vec<u8>> = checkpoint
466 .as_ref()
467 .and_then(|cp| cp.completed_keys().ok())
468 .unwrap_or_default();
469
470 let state = IntentState {
471 completed: u64::try_from(completed_keys.len()).unwrap_or(u64::MAX),
472 ..IntentState::default()
473 };
474 let quiesce = Box::pin(tokio::time::sleep(Duration::MAX));
475 let forced_quiesce = Box::pin(tokio::time::sleep(Duration::MAX));
476 (state, quiesce, forced_quiesce, checkpoint, completed_keys)
477}
478
479fn finalize_checkpoint(
480 checkpoint: Option<&SyncCheckpoint>,
481 state: &IntentState,
482 events: &mpsc::UnboundedSender<SyncEvent>,
483) {
484 if let Some(cp) = checkpoint {
485 if state.completed > 0 {
486 let _ = cp.complete();
487 } else {
488 let _ = cp.leave_unfinished();
489 }
490 }
491 let _ = events.send(SyncEvent::Finished);
492}
493
494fn send_initial_events(events: &mpsc::UnboundedSender<SyncEvent>, started: Instant) -> bool {
495 events.send(SyncEvent::Started).is_ok()
496 && events
497 .send(SyncEvent::Progress {
498 completed: 0,
499 total: Some(0),
500 })
501 .is_ok()
502 && events
503 .send(SyncEvent::Stats(TransferStats::from_progress(
504 0,
505 Some(0),
506 started.elapsed(),
507 0,
508 )))
509 .is_ok()
510}
511
512fn send_progress(events: &mpsc::UnboundedSender<SyncEvent>, state: &IntentState, elapsed: Duration) -> bool {
513 events
514 .send(SyncEvent::Progress {
515 completed: state.completed,
516 total: None,
517 })
518 .is_ok()
519 && events
520 .send(SyncEvent::Stats(TransferStats::from_progress(
521 state.transferred,
522 None,
523 elapsed,
524 state.peer_count,
525 )))
526 .is_ok()
527}
528
529#[derive(Default)]
530struct IntentState {
531 paused: bool,
532 completed: u64,
533 transferred: u64,
534 peer_count: usize,
535 sizes: HashMap<Hash, u64>,
536 area_count: u64,
537 area_size: u64,
538 sync_finished: bool,
539}
540
541impl IntentState {
542 fn apply(&mut self, event: LiveEvent, params: &SubscribeParams, filter: Option<&FilterEngine>) {
543 match event {
544 LiveEvent::NeighborUp(_) => self.peer_count = self.peer_count.saturating_add(1),
545 LiveEvent::NeighborDown(_) => self.peer_count = self.peer_count.saturating_sub(1),
546 LiveEvent::InsertLocal { entry } | LiveEvent::InsertRemote { entry, .. } => {
547 self.apply_insert(&entry, params, filter);
548 }
549 LiveEvent::ContentReady { hash } => {
550 self.transferred = self.transferred.saturating_add(self.sizes.remove(&hash).unwrap_or(0));
551 }
552 LiveEvent::PendingContentReady => {}
553 LiveEvent::SyncFinished(sync_event) => {
554 self.sync_finished = true;
555 match sync_event.result {
556 Ok(details)
557 if params.area_filter.is_none() && params.area_of_interest.is_none() && filter.is_none() =>
558 {
559 self.completed = self
560 .completed
561 .saturating_add(u64::try_from(details.entries_received).unwrap_or(u64::MAX));
562 }
563 Ok(_details) => {}
564 Err(error) => {
565 tracing::debug!(error, "sync attempt failed; keeping the live intent alive");
574 }
575 }
576 }
577 }
578 }
579
580 fn apply_insert(&mut self, entry: &iroh_docs::Entry, params: &SubscribeParams, filter: Option<&FilterEngine>) {
581 let path = entry_path(entry);
582 let hash = entry.content_hash();
583 let accepted = params.accepts(&path, &hash)
584 && filter.is_none_or(|engine| {
585 engine.evaluate(&FilterEntry::new(path.clone(), entry.content_len())) != FilterAction::Reject
586 });
587 if !accepted {
588 return;
589 }
590 let size = entry.content_len();
591 if params
592 .area_of_interest
593 .as_ref()
594 .is_some_and(|area| !area.permits(self.area_count, self.area_size, size))
595 {
596 return;
597 }
598 if params.area_of_interest.is_some() {
599 self.area_count = self.area_count.saturating_add(1);
600 self.area_size = self.area_size.saturating_add(size);
601 }
602 self.completed = self.completed.saturating_add(1);
603 self.sizes.insert(hash, size);
604 }
605}
606
607async fn handle_command(
608 folder: &crate::folder::SyncwebFolder,
609 command: SyncCommand,
610 paused: &mut bool,
611 events: &mpsc::UnboundedSender<SyncEvent>,
612) -> bool {
613 match command {
614 SyncCommand::Pause => {
615 if !*paused {
616 if let Err(error) = folder.doc().leave().await {
617 let _result = events.send(SyncEvent::Failed(error.to_string()));
618 return false;
619 }
620 *paused = true;
621 }
622 events.send(SyncEvent::Paused).is_ok()
623 }
624 SyncCommand::Resume => {
625 if *paused {
626 if let Err(error) = folder.doc().start_sync(Vec::new()).await {
627 let _result = events.send(SyncEvent::Failed(error.to_string()));
628 return false;
629 }
630 *paused = false;
631 }
632 events.send(SyncEvent::Resumed).is_ok()
633 }
634 SyncCommand::Cancel => {
635 if let Err(error) = folder.doc().leave().await {
636 let _result = events.send(SyncEvent::Failed(error.to_string()));
637 return false;
638 }
639 let _result = events.send(SyncEvent::Cancelled);
640 false
641 }
642 }
643}
644
645fn include_event(params: &SubscribeParams, event: &LiveEvent) -> bool {
646 if params.ingest_only && matches!(event, LiveEvent::InsertLocal { .. }) {
647 return false;
648 }
649 if params.ignore_session.is_some() && matches!(event, LiveEvent::InsertLocal { .. }) {
650 return false;
651 }
652 match event {
653 LiveEvent::InsertLocal { entry } | LiveEvent::InsertRemote { entry, .. } => {
654 let path = entry_path(entry);
655 params.accepts(&path, &entry.content_hash())
656 }
657 LiveEvent::ContentReady { hash } => params
658 .area_of_interest
659 .as_ref()
660 .is_none_or(|area| area.area.matches_hash(hash)),
661 LiveEvent::PendingContentReady
662 | LiveEvent::NeighborUp(_)
663 | LiveEvent::NeighborDown(_)
664 | LiveEvent::SyncFinished(_) => true,
665 }
666}
667
668fn entry_path(entry: &iroh_docs::Entry) -> std::path::PathBuf {
669 Path::new(String::from_utf8_lossy(entry.key()).as_ref()).to_path_buf()
670}
671
672fn reconcile_after_sync(namespace: NamespaceId, config: &IntentConfig) {
679 if let Some(reconciler) = &config.reconciler {
680 tracing::info!(%namespace, "sync engine invoking folder reconciler");
681 reconciler(namespace);
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use std::time::SystemTime;
688
689 use iroh::SecretKey;
690 use iroh_docs::engine::{LiveEvent, Origin, SyncEvent};
691
692 use super::{IntentState, SubscribeParams};
693
694 #[test]
699 fn failed_sync_attempt_does_not_fail_the_intent() {
700 let event = LiveEvent::SyncFinished(SyncEvent {
701 peer: SecretKey::generate().public(),
702 origin: Origin::Accept,
703 started: SystemTime::now(),
704 finished: SystemTime::now(),
705 result: Err("peer disconnected".to_owned()),
706 });
707 let mut state = IntentState::default();
708 state.apply(event, &SubscribeParams::default(), None);
709 assert!(
710 state.sync_finished,
711 "the intent should still record that a sync round finished"
712 );
713 }
714}