Skip to main content

syncweb_core/daemon/
supervisor.rs

1use std::time::{Duration, Instant};
2use std::{
3    collections::HashMap,
4    sync::{
5        Arc, Mutex,
6        atomic::{AtomicU64, Ordering},
7    },
8};
9
10use iroh_docs::NamespaceId;
11use n0_future::StreamExt;
12use tokio::{
13    sync::{broadcast, oneshot},
14    time::sleep,
15};
16
17use crate::{
18    daemon::current_timestamp,
19    error::{Result, SyncwebError},
20    filter::FilterEngine,
21    net::NetworkLogger,
22    sync::{ActiveSession, IntentControl, IntentHandle, SubscribeParams, SyncEngine, SyncEvent},
23};
24
25/// A synchronization intent together with the information needed to restart it.
26#[non_exhaustive]
27pub struct SupervisedIntent {
28    pub namespace: NamespaceId,
29    pub handle: Option<IntentHandle>,
30    pub retry_count: u32,
31    pub last_error: Option<String>,
32    pub last_started_at: Option<u64>,
33    /// Total bytes received by this intent since it was first started.
34    pub bytes_transferred: u64,
35    /// Total files received by this intent since it was first started.
36    pub files_transferred: u64,
37    session: Option<ActiveSession>,
38}
39
40/// Live bandwidth accounting for a supervised intent.
41///
42/// Records the delta between successive [`SyncEvent::Stats`] reports as
43/// download events so `stats network` reflects transfers while the daemon is
44/// still running (a continuous intent only returns on shutdown).
45#[derive(Clone, Debug)]
46pub struct BandwidthAccountant {
47    logger: NetworkLogger,
48    network_id: Option<String>,
49    folder: String,
50    recorded: Arc<AtomicU64>,
51}
52
53impl BandwidthAccountant {
54    #[must_use]
55    pub fn new(logger: NetworkLogger, network_id: Option<String>, folder: impl Into<String>) -> Self {
56        Self {
57            logger,
58            network_id,
59            folder: folder.into(),
60            recorded: Arc::new(AtomicU64::new(0)),
61        }
62    }
63
64    /// Record any newly observed bytes as a download event.
65    pub fn observe(&self, total_bytes: u64, files: u64) {
66        let previous = self.recorded.load(Ordering::Relaxed);
67        if total_bytes > previous
68            && let Err(error) = self.logger.record_bandwidth_download(
69                total_bytes.saturating_sub(previous),
70                files,
71                Some(&self.folder),
72                None,
73                self.network_id.as_deref(),
74            )
75        {
76            tracing::warn!(%error, "failed to record bandwidth event");
77        }
78        self.recorded.store(total_bytes.max(previous), Ordering::Relaxed);
79    }
80
81    /// Total bytes recorded so far.
82    #[must_use]
83    pub fn total(&self) -> u64 {
84        self.recorded.load(Ordering::Relaxed)
85    }
86}
87
88impl std::fmt::Debug for SupervisedIntent {
89    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        formatter
91            .debug_struct("SupervisedIntent")
92            .field("namespace", &self.namespace)
93            .field("handle", &self.handle)
94            .field("retry_count", &self.retry_count)
95            .field("last_error", &self.last_error)
96            .field("last_started_at", &self.last_started_at)
97            .field("bytes_transferred", &self.bytes_transferred)
98            .finish_non_exhaustive()
99    }
100}
101
102/// Default retry window measured from an intent's first attempt.
103///
104/// Three months is the hard default; configure
105/// [`DaemonConfig::retry_window`](crate::daemon::DaemonConfig::retry_window) to
106/// override it.
107pub const DEFAULT_RETRY_WINDOW: Duration = Duration::from_hours(2_160);
108
109/// Starts synchronization intents and restarts failed intents with bounded
110/// exponential backoff until a deadline measured from the first attempt.
111#[derive(Clone, Copy, Debug)]
112pub struct IntentSupervisor {
113    retry_window: Duration,
114    backoff_base: Duration,
115    backoff_max: Duration,
116}
117
118/// Shared controls for intents currently owned by the daemon.
119pub type IntentControls = Arc<Mutex<HashMap<NamespaceId, IntentControl>>>;
120
121/// Internal startup options for a supervised daemon intent.
122pub struct SupervisionOptions {
123    controls: Option<IntentControls>,
124    filter: Option<FilterEngine>,
125    ready: Option<oneshot::Sender<()>>,
126    bandwidth: Option<BandwidthAccountant>,
127}
128
129impl SupervisionOptions {
130    pub const fn with_ready(
131        controls: IntentControls,
132        filter: Option<FilterEngine>,
133        ready: oneshot::Sender<()>,
134    ) -> Self {
135        Self {
136            controls: Some(controls),
137            filter,
138            ready: Some(ready),
139            bandwidth: None,
140        }
141    }
142
143    /// Attach a live bandwidth accountant to the supervised intent.
144    #[must_use]
145    pub fn with_bandwidth(mut self, bandwidth: BandwidthAccountant) -> Self {
146        self.bandwidth = Some(bandwidth);
147        self
148    }
149}
150
151impl IntentSupervisor {
152    #[must_use]
153    pub const fn new(retry_window: Duration, backoff_base: Duration, backoff_max: Duration) -> Self {
154        Self {
155            retry_window,
156            backoff_base,
157            backoff_max,
158        }
159    }
160
161    /// Start one continuous intent and record any startup failure.
162    pub async fn run_intent(
163        &self,
164        sync: &SyncEngine,
165        namespace: NamespaceId,
166        params: SubscribeParams,
167    ) -> SupervisedIntent {
168        self.run_intent_with_filter(sync, namespace, params, None).await
169    }
170
171    /// Start one filtered synchronization intent.
172    pub async fn run_intent_with_filter(
173        &self,
174        sync: &SyncEngine,
175        namespace: NamespaceId,
176        params: SubscribeParams,
177        filter_engine: Option<FilterEngine>,
178    ) -> SupervisedIntent {
179        let result = match filter_engine {
180            Some(engine) => {
181                sync.sync_with_filter(namespace, crate::sync::SessionMode::Continuous, params, engine)
182                    .await
183            }
184            None => sync.subscribe(namespace, params).await,
185        };
186        match result {
187            Ok(handle) => {
188                let session = ActiveSession::register(namespace, handle.cancel_sender());
189                SupervisedIntent {
190                    namespace,
191                    handle: Some(handle),
192                    retry_count: 0,
193                    last_error: None,
194                    last_started_at: Some(current_timestamp()),
195                    bytes_transferred: 0,
196                    files_transferred: 0,
197                    session: Some(session),
198                }
199            }
200            Err(error) => SupervisedIntent {
201                namespace,
202                handle: None,
203                retry_count: 0,
204                last_error: Some(error.to_string()),
205                last_started_at: None,
206                bytes_transferred: 0,
207                files_transferred: 0,
208                session: None,
209            },
210        }
211    }
212
213    /// Supervise one intent until shutdown or until its retry budget is spent.
214    ///
215    /// A normal `Finished` or `Cancelled` event ends supervision. A `Failed`
216    /// event, an unexpectedly closed stream, or a startup error consumes one
217    /// retry and restarts the intent after backoff.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if supervision infrastructure fails while waiting for
222    /// shutdown or retrying an intent.
223    pub async fn supervise(
224        &self,
225        sync: &SyncEngine,
226        namespace: NamespaceId,
227        params: SubscribeParams,
228        shutdown: broadcast::Receiver<()>,
229    ) -> Result<SupervisedIntent> {
230        self.supervise_inner(
231            sync,
232            namespace,
233            params,
234            shutdown,
235            SupervisionOptions {
236                controls: None,
237                filter: None,
238                ready: None,
239                bandwidth: None,
240            },
241        )
242        .await
243    }
244
245    /// Supervise an intent while exposing pause/resume controls to its owner.
246    ///
247    /// # Errors
248    ///
249    /// Returns an error if the shared control registry is poisoned or the
250    /// intent supervision infrastructure cannot wait for shutdown.
251    pub async fn supervise_with_controls(
252        &self,
253        sync: &SyncEngine,
254        namespace: NamespaceId,
255        params: SubscribeParams,
256        shutdown: broadcast::Receiver<()>,
257        controls: IntentControls,
258        filter: Option<FilterEngine>,
259    ) -> Result<SupervisedIntent> {
260        self.supervise_inner(
261            sync,
262            namespace,
263            params,
264            shutdown,
265            SupervisionOptions {
266                controls: Some(controls),
267                filter,
268                ready: None,
269                bandwidth: None,
270            },
271        )
272        .await
273    }
274
275    pub(crate) async fn supervise_with_controls_and_ready(
276        &self,
277        sync: &SyncEngine,
278        namespace: NamespaceId,
279        params: SubscribeParams,
280        shutdown: broadcast::Receiver<()>,
281        options: SupervisionOptions,
282    ) -> Result<SupervisedIntent> {
283        self.supervise_inner(sync, namespace, params, shutdown, options).await
284    }
285
286    async fn supervise_inner(
287        &self,
288        sync: &SyncEngine,
289        namespace: NamespaceId,
290        params: SubscribeParams,
291        mut shutdown: broadcast::Receiver<()>,
292        mut options: SupervisionOptions,
293    ) -> Result<SupervisedIntent> {
294        let mut supervised = self
295            .run_intent_with_filter(sync, namespace, params.clone(), options.filter.clone())
296            .await;
297        let mut ready = options.ready.take();
298        let first_attempt = Instant::now();
299
300        loop {
301            let Some(mut handle) = supervised.handle.take() else {
302                if let Some(sender) = ready.take() {
303                    let _ = sender.send(());
304                }
305                Self::remove_control(options.controls.as_ref(), namespace)?;
306                if self.retry_window_exhausted(first_attempt) {
307                    tracing::warn!(
308                        %namespace,
309                        retry_count = supervised.retry_count,
310                        window_secs = self.retry_window.as_secs(),
311                        "retry window elapsed; giving up on supervised intent"
312                    );
313                    return Ok(supervised);
314                }
315                let retry_number = supervised.retry_count.saturating_add(1);
316                if !self
317                    .wait_for_retry(self.backoff_delay(retry_number), &mut shutdown)
318                    .await
319                {
320                    return Ok(supervised);
321                }
322                supervised = self
323                    .run_intent_with_filter(sync, namespace, params.clone(), options.filter.clone())
324                    .await;
325                supervised.retry_count = retry_number;
326                continue;
327            };
328
329            Self::insert_control(options.controls.as_ref(), namespace, handle.control())?;
330            if let Some(sender) = ready.take() {
331                let _ = sender.send(());
332            }
333            let session = supervised.session.take();
334            let event = loop {
335                tokio::select! {
336                    shutdown_result = shutdown.recv() => {
337                        match shutdown_result {
338                            Ok(()) | Err(broadcast::error::RecvError::Closed) => {
339                                if let Err(error) = handle.cancel() {
340                                    supervised.last_error = Some(format!("failed to cancel intent: {error}"));
341                                }
342                                Self::remove_control(options.controls.as_ref(), namespace)?;
343                                drop(session);
344                                supervised.handle = None;
345                                return Ok(supervised);
346                            }
347                            Err(broadcast::error::RecvError::Lagged(_)) => {}
348                        }
349                    }
350                    next_event = handle.next() => break next_event,
351                }
352            };
353
354            Self::remove_control(options.controls.as_ref(), namespace)?;
355            match event {
356                Some(SyncEvent::Started) => {
357                    supervised.last_error = None;
358                    supervised.last_started_at = Some(current_timestamp());
359                    supervised.handle = Some(handle);
360                    supervised.session = session;
361                }
362                Some(SyncEvent::Cancelled | SyncEvent::Finished) => {
363                    supervised.handle = None;
364                    drop(session);
365                    return Ok(supervised);
366                }
367                Some(SyncEvent::Failed(error)) => {
368                    supervised.last_error = Some(error);
369                    supervised.handle = None;
370                    drop(session);
371                }
372                Some(SyncEvent::Progress { .. } | SyncEvent::Paused | SyncEvent::Resumed) => {
373                    supervised.handle = Some(handle);
374                    supervised.session = session;
375                }
376                Some(SyncEvent::Stats(stats)) => {
377                    supervised.bytes_transferred = stats.bytes_transferred;
378                    if let Some(accountant) = options.bandwidth.as_ref() {
379                        accountant.observe(stats.bytes_transferred, 0);
380                    }
381                    supervised.handle = Some(handle);
382                    supervised.session = session;
383                }
384                None => {
385                    supervised.last_error = Some("synchronization intent ended unexpectedly".to_owned());
386                    supervised.handle = None;
387                    drop(session);
388                }
389            }
390        }
391    }
392
393    fn insert_control(controls: Option<&IntentControls>, namespace: NamespaceId, control: IntentControl) -> Result<()> {
394        let Some(control_registry) = controls else {
395            return Ok(());
396        };
397        control_registry
398            .lock()
399            .map_err(|error| SyncwebError::operation("daemon intent control mutex is poisoned", error))?
400            .insert(namespace, control);
401        Ok(())
402    }
403
404    fn remove_control(controls: Option<&IntentControls>, namespace: NamespaceId) -> Result<()> {
405        let Some(control_registry) = controls else {
406            return Ok(());
407        };
408        control_registry
409            .lock()
410            .map_err(|error| SyncwebError::operation("daemon intent control mutex is poisoned", error))?
411            .remove(&namespace);
412        Ok(())
413    }
414
415    /// Return whether the retry window measured from `first_attempt` has elapsed.
416    #[must_use]
417    pub fn retry_window_exhausted(&self, first_attempt: Instant) -> bool {
418        first_attempt.elapsed() >= self.retry_window
419    }
420
421    /// Return the delay for a one-based retry number.
422    #[must_use]
423    pub fn backoff_delay(&self, retry_number: u32) -> Duration {
424        const MIN_BACKOFF: Duration = Duration::from_millis(100);
425        if retry_number == 0 {
426            return Duration::ZERO;
427        }
428        let mut delay = self.backoff_base.max(MIN_BACKOFF);
429        for _ in 1..retry_number {
430            delay = match delay.checked_mul(2) {
431                Some(value) if value <= self.backoff_max => value,
432                _ => self.backoff_max,
433            };
434            if delay == self.backoff_max {
435                break;
436            }
437        }
438        delay.min(self.backoff_max)
439    }
440
441    async fn wait_for_retry(&self, delay: Duration, shutdown: &mut broadcast::Receiver<()>) -> bool {
442        tokio::select! {
443            () = sleep(delay) => true,
444            shutdown_result = shutdown.recv() => {
445                !matches!(shutdown_result, Ok(()) | Err(broadcast::error::RecvError::Closed))
446            }
447        }
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn backoff_delay_starts_at_base() {
457        let supervisor = IntentSupervisor::new(
458            Duration::from_hours(2_160),
459            Duration::from_secs(1),
460            Duration::from_mins(1),
461        );
462        assert_eq!(supervisor.backoff_delay(1), Duration::from_secs(1));
463    }
464
465    #[test]
466    fn backoff_delay_doubles() {
467        let supervisor = IntentSupervisor::new(
468            Duration::from_hours(2_160),
469            Duration::from_secs(1),
470            Duration::from_mins(1),
471        );
472        assert_eq!(supervisor.backoff_delay(2), Duration::from_secs(2));
473        assert_eq!(supervisor.backoff_delay(3), Duration::from_secs(4));
474    }
475
476    #[test]
477    fn backoff_delay_capped() {
478        let supervisor = IntentSupervisor::new(
479            Duration::from_hours(2_160),
480            Duration::from_secs(1),
481            Duration::from_secs(3),
482        );
483        assert_eq!(supervisor.backoff_delay(2), Duration::from_secs(2));
484        assert_eq!(supervisor.backoff_delay(3), Duration::from_secs(3));
485        assert_eq!(supervisor.backoff_delay(10), Duration::from_secs(3));
486    }
487
488    #[test]
489    fn backoff_delay_zero_retry() {
490        let supervisor = IntentSupervisor::new(
491            Duration::from_hours(2_160),
492            Duration::from_secs(1),
493            Duration::from_mins(1),
494        );
495        assert_eq!(supervisor.backoff_delay(0), Duration::ZERO);
496    }
497
498    #[test]
499    fn backoff_delay_zero_base() {
500        let supervisor = IntentSupervisor::new(Duration::from_hours(2_160), Duration::ZERO, Duration::from_mins(1));
501        assert_eq!(supervisor.backoff_delay(1), Duration::from_millis(100));
502        assert_eq!(supervisor.backoff_delay(5), Duration::from_millis(1600));
503    }
504
505    #[test]
506    fn auto_backoff_delay_respects_max_retries() {
507        let supervisor = IntentSupervisor::new(
508            Duration::from_hours(2_160),
509            Duration::from_secs(2),
510            Duration::from_secs(20),
511        );
512        let delays: Vec<_> = (1..=8).map(|n| supervisor.backoff_delay(n)).collect();
513        let expected = [
514            Duration::from_secs(2),
515            Duration::from_secs(4),
516            Duration::from_secs(8),
517            Duration::from_secs(16),
518            Duration::from_secs(20),
519            Duration::from_secs(20),
520            Duration::from_secs(20),
521            Duration::from_secs(20),
522        ];
523        for (delay, want) in delays.iter().zip(expected.iter()) {
524            assert_eq!(delay, want);
525        }
526    }
527
528    #[test]
529    fn backoff_delay_starting_value_equals_base() {
530        let supervisor = IntentSupervisor::new(
531            Duration::from_hours(2_160),
532            Duration::from_millis(500),
533            Duration::from_secs(5),
534        );
535        assert_eq!(supervisor.backoff_delay(1), Duration::from_millis(500));
536    }
537
538    #[test]
539    fn retry_window_is_measured_from_first_attempt() {
540        let supervisor = IntentSupervisor::new(Duration::from_mins(1), Duration::from_secs(1), Duration::from_mins(1));
541        let now = Instant::now();
542        assert!(!supervisor.retry_window_exhausted(now));
543        let started_ago = now.checked_sub(Duration::from_secs(61)).expect("instant underflow");
544        assert!(supervisor.retry_window_exhausted(started_ago));
545        assert_eq!(DEFAULT_RETRY_WINDOW, Duration::from_hours(2_160));
546    }
547}