Skip to main content

syncweb_core/sync/
engine.rs

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/// Current aggregate statistics for a synchronization intent.
36#[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/// Coordinates document reconciliation and blob availability for a folder.
74#[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    /// Optional reconciler invoked after live synchronization ingests remote
82    /// entries. The daemon uses it to materialize writable folders.
83    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    /// Set the node database for checkpoint tracking.
105    #[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    /// Attach a reconciliation hook called after remote entries are ingested.
112    #[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    /// Start reconciling a managed folder.
129    ///
130    /// Iroh Docs performs network reconciliation itself. This intent provides
131    /// lifecycle, control, and aggregate transfer events around that process.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if the folder is not managed locally.
136    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    /// Start a synchronization intent with subscription filtering.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if the folder is not managed locally or live sync
145    /// cannot be started.
146    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    /// Subscribe to live changes in a folder.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if the folder is not managed locally or live sync
160    /// cannot be started.
161    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    /// Fetch folder content using a full or filtered reconciliation strategy.
166    ///
167    /// Peer-count filters are applied by callers that have availability
168    /// observations. The document subscription still carries path, size, and
169    /// count limits so the remote reconciliation remains bounded.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the folder is not managed locally or synchronization
174    /// cannot be started.
175    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    /// Start a synchronization intent and filter emitted entries.
182    ///
183    /// The document reconciliation remains content-addressed and is handled
184    /// by iroh-docs; the filter controls which entries are reported to the
185    /// automatic synchronization consumer.
186    ///
187    /// # Errors
188    ///
189    /// Returns an error if the folder is not managed locally or live sync
190    /// cannot be started.
191    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        // Feed peers discovered over the topic tracker straight into the
232        // document sync below. Without this the tracker only logged the peers
233        // it found and the intent synced with nobody: the first local change
234        // after the initial join then never propagated, and a creator could
235        // not push to a joiner it had learned about out of band.
236        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                        // A failed sync *attempt* is a normal, non-fatal event
566                        // in a peer-to-peer swarm: the remote peer may be
567                        // offline, busy, or already syncing. Tearing down the
568                        // continuous intent here would drop the live sync
569                        // connection and restart it with backoff, which both
570                        // stops local changes from being pushed and makes the
571                        // intent replay every entry on each restart. Record it
572                        // and keep syncing.
573                        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
672/// Notify a folder's reconciler that content just arrived locally.
673///
674/// The daemon attaches a reconciler to writable folders so remote entries are
675/// materialized (with conflict resolution) as soon as their blobs are local.
676/// The reconciler is fire-and-forget: materialization is idempotent and skips
677/// blobs that are already on disk.
678fn 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    /// Regression: a `SyncFinished` event carrying an `Err` describes a failed
695    /// sync *attempt* with one peer, which is routine in a swarm. It must not
696    /// fail the intent (the old behaviour terminated the continuous intent and
697    /// restarted it with backoff, dropping the live sync connection).
698    #[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}