Skip to main content

origin_sync/
engine.rs

1use crate::source::{SyncContext, SyncResult, SyncSource};
2use crate::state_store::SyncStateStore;
3use crate::{SyncPolicy, SyncTarget, health_of};
4use origin_domain::{
5    AccountId, AppError, Clock, ConnectorId, ErrorKind, Health, Result, SyncId, SyncOutcome,
6    SyncState,
7};
8use origin_events::{EventBus, PlatformEvent, SyncCompleted, SyncFailed};
9use origin_storage::Storage;
10use std::collections::BTreeMap;
11use std::sync::Arc;
12use std::sync::RwLock;
13use std::sync::atomic::{AtomicU64, Ordering};
14use time::{Duration, OffsetDateTime};
15use tokio::sync::Mutex;
16use tokio_util::sync::CancellationToken;
17
18/// How often the background loop looks for due targets.
19///
20/// Independent of any policy interval: it only has to be fine-grained enough that a
21/// target does not drift noticeably past its due time.
22const TICK: std::time::Duration = std::time::Duration::from_secs(5);
23
24#[derive(Debug)]
25struct Registration {
26    policy: SyncPolicy,
27    source: Arc<dyn SyncSource>,
28    /// Held for the duration of a run, so one target never syncs twice at once.
29    running: Arc<Mutex<()>>,
30    cancel: CancellationToken,
31}
32
33/// Decides when each registered target runs.
34#[derive(Debug, Clone)]
35pub struct SyncEngine {
36    targets: Arc<RwLock<BTreeMap<SyncTarget, Registration>>>,
37    state: SyncStateStore,
38    clock: Arc<dyn Clock>,
39    events: EventBus,
40    /// Seed for jitter. Deterministic on purpose: with a fake clock, tests reproduce.
41    seed: Arc<AtomicU64>,
42}
43
44impl SyncEngine {
45    pub fn new(storage: Arc<dyn Storage>, clock: Arc<dyn Clock>, events: EventBus) -> Self {
46        let seed = clock.now().unix_timestamp_nanos() as u64 | 1;
47
48        Self {
49            targets: Arc::new(RwLock::new(BTreeMap::new())),
50            state: SyncStateStore::new(storage, clock.clone()),
51            clock,
52            events,
53            seed: Arc::new(AtomicU64::new(seed)),
54        }
55    }
56
57    /// Register a target. Registering the same target again replaces it.
58    ///
59    /// Synchronous so that a module can register from `ApplicationModule::register`,
60    /// which runs during startup and has no runtime to await on.
61    pub fn register(&self, target: SyncTarget, policy: SyncPolicy, source: Arc<dyn SyncSource>) {
62        tracing::debug!(%target, interval = ?policy.interval, "sync target registered");
63
64        self.write().insert(
65            target,
66            Registration {
67                policy,
68                source,
69                running: Arc::new(Mutex::new(())),
70                cancel: CancellationToken::new(),
71            },
72        );
73    }
74
75    /// Stop tracking a target. Any run in flight is cancelled.
76    pub fn unregister(&self, target: &SyncTarget) {
77        if let Some(registration) = self.write().remove(target) {
78            registration.cancel.cancel();
79        }
80    }
81
82    pub fn targets(&self) -> Vec<SyncTarget> {
83        self.read().keys().cloned().collect()
84    }
85
86    /// The policy a target was registered with.
87    pub fn policy(&self, target: &SyncTarget) -> Option<SyncPolicy> {
88        self.read()
89            .get(target)
90            .map(|registration| registration.policy)
91    }
92
93    fn read(&self) -> std::sync::RwLockReadGuard<'_, BTreeMap<SyncTarget, Registration>> {
94        self.targets
95            .read()
96            .unwrap_or_else(|poisoned| poisoned.into_inner())
97    }
98
99    fn write(&self) -> std::sync::RwLockWriteGuard<'_, BTreeMap<SyncTarget, Registration>> {
100        self.targets
101            .write()
102            .unwrap_or_else(|poisoned| poisoned.into_inner())
103    }
104
105    pub async fn state(&self, target: &SyncTarget) -> Result<SyncState> {
106        self.state.load(target).await
107    }
108
109    /// When this target may next run.
110    ///
111    /// Healthy targets follow their interval; failing ones follow the backoff; an
112    /// offline machine gets the flat offline retry instead of an exponential one.
113    pub async fn due_at(&self, target: &SyncTarget) -> Result<OffsetDateTime> {
114        let policy = self
115            .read()
116            .get(target)
117            .map(|registration| registration.policy)
118            .ok_or_else(|| AppError::validation(format!("unknown sync target {target}")))?;
119
120        let state = self.state.load(target).await?;
121        Ok(self.due_at_for(&state, &policy))
122    }
123
124    fn due_at_for(&self, state: &SyncState, policy: &SyncPolicy) -> OffsetDateTime {
125        let Some(last_attempt) = state.last_attempt else {
126            // Never ran: due immediately.
127            return OffsetDateTime::UNIX_EPOCH;
128        };
129
130        let delay = match (state.failure_streak, &state.last_outcome) {
131            (0, _) => policy.interval,
132            (
133                _,
134                Some(SyncOutcome::Failed {
135                    kind: ErrorKind::Offline,
136                    ..
137                }),
138            ) => policy.offline_retry,
139            (failures, _) => policy.backoff.delay_for(failures, self.next_random()),
140        };
141
142        // No `min_interval` floor here: that is a throttle for *triggered* syncs
143        // (see `sync_if_due`). Applying it to the scheduler would silently override an
144        // explicitly configured `offline_retry`.
145        last_attempt + delay
146    }
147
148    /// Sync every target that is due at `now`.
149    ///
150    /// Separate from the background loop so scheduling can be tested by moving a fake
151    /// clock instead of by sleeping.
152    pub async fn run_due(&self, now: OffsetDateTime) -> Vec<(SyncTarget, Result<SyncOutcome>)> {
153        let candidates: Vec<(SyncTarget, SyncPolicy)> = self
154            .read()
155            .iter()
156            .map(|(target, registration)| (target.clone(), registration.policy))
157            .collect();
158
159        let mut results = Vec::new();
160        let mut runs = Vec::new();
161        for (target, policy) in candidates {
162            let state = match self.state.load(&target).await {
163                Ok(state) => state,
164                Err(error) => {
165                    results.push((target, Err(error)));
166                    continue;
167                }
168            };
169
170            if now < self.due_at_for(&state, &policy) {
171                continue;
172            }
173
174            let engine = self.clone();
175            let run_target = target.clone();
176            runs.push((
177                target,
178                tokio::spawn(async move { engine.sync_if_still_due(&run_target, now).await }),
179            ));
180        }
181
182        for (target, run) in runs {
183            let outcome = match run.await {
184                Ok(Some(outcome)) => outcome,
185                // The target was still due when this task started but no longer was
186                // once it got the lock — another run (a manual refresh, or this same
187                // scheduler tick racing itself) already covered it.
188                Ok(None) => continue,
189                Err(error) => Err(AppError::internal(format!(
190                    "sync task for {target} failed: {error}"
191                ))),
192            };
193            results.push((target, outcome));
194        }
195
196        results
197    }
198
199    /// Like [`SyncEngine::sync_now`], but re-checks the schedule after acquiring the
200    /// target lock rather than before.
201    ///
202    /// Only the scheduler calls this. Between `run_due` deciding a target is due and
203    /// this task acquiring the single-flight lock, a manual [`SyncEngine::sync_now`] or
204    /// [`SyncEngine::sync_if_due`] may already have covered it — without the recheck,
205    /// this task would run a second, immediately-redundant sync the moment the lock
206    /// frees up. [`SyncEngine::sync_now`] itself must stay unconditional: a caller
207    /// invoking it directly asked for a sync *now*, not for the scheduler's due check.
208    async fn sync_if_still_due(
209        &self,
210        target: &SyncTarget,
211        now: OffsetDateTime,
212    ) -> Option<Result<SyncOutcome>> {
213        let (policy, source, running, cancel) = {
214            let targets = self.read();
215            let registration = targets.get(target)?;
216            (
217                registration.policy,
218                registration.source.clone(),
219                registration.running.clone(),
220                registration.cancel.clone(),
221            )
222        };
223
224        let _guard = running.lock().await;
225        let state = match self.state.load(target).await {
226            Ok(state) => state,
227            Err(error) => return Some(Err(error)),
228        };
229
230        if now < self.due_at_for(&state, &policy) {
231            tracing::debug!(%target, "sync skipped: no longer due once the lock was free");
232            return None;
233        }
234
235        Some(self.sync_with(target, policy, source, cancel, state).await)
236    }
237
238    /// Sync unless the target ran very recently.
239    ///
240    /// This is the entry point for triggers that fire on their own — window focus,
241    /// network coming back, a view being opened. Without the throttle, alt-tabbing
242    /// twenty times means twenty syncs.
243    ///
244    /// Returns `Ok(None)` when the run was skipped. A user pressing *Refresh* should
245    /// go through [`SyncEngine::sync_now`] instead: they asked explicitly.
246    pub async fn sync_if_due(&self, target: &SyncTarget) -> Result<Option<SyncOutcome>> {
247        let (policy, source, running, cancel) = {
248            let targets = self.read();
249            let registration = targets
250                .get(target)
251                .ok_or_else(|| AppError::validation(format!("unknown sync target {target}")))?;
252            (
253                registration.policy,
254                registration.source.clone(),
255                registration.running.clone(),
256                registration.cancel.clone(),
257            )
258        };
259
260        let _guard = running.lock().await;
261        let state = self.state.load(target).await?;
262        if let Some(last_attempt) = state.last_attempt
263            && self.clock.now() < last_attempt + policy.min_interval
264        {
265            tracing::debug!(%target, "sync skipped: ran too recently");
266            return Ok(None);
267        }
268
269        self.sync_with(target, policy, source, cancel, state)
270            .await
271            .map(Some)
272    }
273
274    /// Sync one target immediately, whatever the throttle says.
275    ///
276    /// Single-flight: a second caller waits for the run in flight instead of starting
277    /// a parallel one. Two concurrent syncs of the same target would race on the
278    /// validators and could store an older result over a newer one.
279    pub async fn sync_now(&self, target: &SyncTarget) -> Result<SyncOutcome> {
280        let (policy, source, running, cancel) = {
281            let targets = self.read();
282            let registration = targets
283                .get(target)
284                .ok_or_else(|| AppError::validation(format!("unknown sync target {target}")))?;
285            (
286                registration.policy,
287                registration.source.clone(),
288                registration.running.clone(),
289                registration.cancel.clone(),
290            )
291        };
292
293        let _guard = running.lock().await;
294        let state = self.state.load(target).await?;
295        self.sync_with(target, policy, source, cancel, state).await
296    }
297
298    async fn sync_with(
299        &self,
300        target: &SyncTarget,
301        policy: SyncPolicy,
302        source: Arc<dyn SyncSource>,
303        cancel: CancellationToken,
304        state: SyncState,
305    ) -> Result<SyncOutcome> {
306        let sync_id = SyncId::generate();
307        let context = SyncContext::new(sync_id.clone(), target.clone(), state.clone(), cancel);
308
309        let span = tracing::info_span!(
310            "sync",
311            sync_id = sync_id.as_str(),
312            connector = target.connector.as_str(),
313            account_id = target.account.as_str(),
314            target = target.name.as_str(),
315        );
316        let _entered = span.enter();
317
318        let result = source.sync(&context).await;
319        let now = self.clock.now();
320        let mut state = state;
321
322        match result {
323            Ok(SyncResult::Updated(report)) => {
324                state.record(now, SyncOutcome::Updated);
325                // Validators are only replaced when the service sent new ones; a
326                // response without an ETag must not clear the one we still hold.
327                if report.etag.is_some() {
328                    state.etag = report.etag.clone();
329                }
330                if report.last_modified.is_some() {
331                    state.last_modified = report.last_modified.clone();
332                }
333                self.state.save(target, &state).await?;
334
335                tracing::debug!(changed = report.changed, "sync updated");
336                self.publish_completed(target, &sync_id, report.changed, now);
337                Ok(SyncOutcome::Updated)
338            }
339
340            Ok(SyncResult::NotModified) => {
341                state.record(now, SyncOutcome::NotModified);
342                self.state.save(target, &state).await?;
343
344                tracing::debug!("sync reported no change");
345                self.publish_completed(target, &sync_id, 0, now);
346                Ok(SyncOutcome::NotModified)
347            }
348
349            Err(error) => {
350                let outcome = SyncOutcome::Failed {
351                    kind: error.kind(),
352                    message: error.to_string(),
353                };
354                state.record(now, outcome.clone());
355                self.state.save(target, &state).await?;
356
357                let retry_at = Some(self.due_at_for(&state, &policy));
358                tracing::warn!(kind = ?error.kind(), %error, ?retry_at, "sync failed");
359
360                let _ = self.events.publish(PlatformEvent::SyncFailed(SyncFailed {
361                    sync: sync_id,
362                    connector: target.connector.clone(),
363                    account: target.account.clone(),
364                    kind: error.kind(),
365                    message: error.to_string(),
366                    retry_at,
367                }));
368
369                Err(error)
370            }
371        }
372    }
373
374    /// Health across all registered targets — the worst state wins.
375    pub async fn health(&self) -> Health {
376        let now = self.clock.now();
377        let targets: Vec<(SyncTarget, SyncPolicy)> = self
378            .read()
379            .iter()
380            .map(|(target, registration)| (target.clone(), registration.policy))
381            .collect();
382
383        let mut states = Vec::new();
384        for (target, policy) in targets {
385            let state = self.state.load(&target).await.unwrap_or_default();
386            states.push(health_of(&state, &policy, now));
387        }
388
389        Health::aggregate(states)
390    }
391
392    /// Health of everything belonging to one account.
393    pub async fn health_of_account(&self, connector: &ConnectorId, account: &AccountId) -> Health {
394        let now = self.clock.now();
395        let targets: Vec<(SyncTarget, SyncPolicy)> = self
396            .read()
397            .iter()
398            .filter(|(target, _)| &target.connector == connector && &target.account == account)
399            .map(|(target, registration)| (target.clone(), registration.policy))
400            .collect();
401
402        let mut states = Vec::new();
403        for (target, policy) in targets {
404            let state = self.state.load(&target).await.unwrap_or_default();
405            states.push(health_of(&state, &policy, now));
406        }
407
408        Health::aggregate(states)
409    }
410
411    /// Run the scheduler until `stop` is cancelled.
412    ///
413    /// Returns a future rather than spawning a task: which executor runs it, and on
414    /// which thread, is the host's decision. A platform crate that called
415    /// `tokio::spawn` itself would panic wherever no runtime is entered — which is
416    /// exactly what a Tauri `setup` hook is.
417    ///
418    /// A thin wrapper around [`SyncEngine::run_due`]; all the logic worth testing is
419    /// in there, not in this loop.
420    pub async fn run(&self, stop: CancellationToken) {
421        tracing::debug!("sync scheduler started");
422        let mut ticker = tokio::time::interval(TICK);
423
424        loop {
425            tokio::select! {
426                _ = stop.cancelled() => break,
427                _ = ticker.tick() => {
428                    self.run_due(self.clock.now()).await;
429                }
430            }
431        }
432
433        tracing::debug!("sync scheduler stopped");
434    }
435
436    fn publish_completed(
437        &self,
438        target: &SyncTarget,
439        sync_id: &SyncId,
440        changed: u64,
441        at: OffsetDateTime,
442    ) {
443        let _ = self
444            .events
445            .publish(PlatformEvent::SyncCompleted(SyncCompleted {
446                sync: sync_id.clone(),
447                connector: target.connector.clone(),
448                account: target.account.clone(),
449                changed,
450                at,
451            }));
452    }
453
454    /// A value in `0.0..1.0` for jitter.
455    fn next_random(&self) -> f64 {
456        let previous = self
457            .seed
458            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |seed| {
459                Some(
460                    seed.wrapping_mul(6_364_136_223_846_793_005)
461                        .wrapping_add(1_442_695_040_888_963_407),
462                )
463            });
464
465        let value = previous
466            .unwrap_or(0)
467            .wrapping_mul(6_364_136_223_846_793_005);
468        f64::from((value >> 40) as u32) / f64::from(1u32 << 24)
469    }
470}
471
472/// Convenience for the common "every interval" registration.
473impl SyncEngine {
474    pub fn register_every(
475        &self,
476        target: SyncTarget,
477        interval: Duration,
478        source: Arc<dyn SyncSource>,
479    ) {
480        self.register(target, SyncPolicy::every(interval), source);
481    }
482}