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