Skip to main content

origin_sync/
engine.rs

1use crate::source::{SyncContext, SyncResult, SyncSource, SyncThrottle};
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, ThrottleReason,
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        let policy_due = last_attempt + delay;
146
147        // A service-imposed floor (quota reset, `X-Poll-Interval`) stretches the
148        // cadence but never shortens it.
149        match state.not_before {
150            Some(not_before) => policy_due.max(not_before),
151            None => policy_due,
152        }
153    }
154
155    /// Sync every target that is due at `now`.
156    ///
157    /// Separate from the background loop so scheduling can be tested by moving a fake
158    /// clock instead of by sleeping.
159    pub async fn run_due(&self, now: OffsetDateTime) -> Vec<(SyncTarget, Result<SyncOutcome>)> {
160        let candidates: Vec<(SyncTarget, SyncPolicy)> = self
161            .read()
162            .iter()
163            .map(|(target, registration)| (target.clone(), registration.policy))
164            .collect();
165
166        let mut results = Vec::new();
167        let mut runs = Vec::new();
168        for (target, policy) in candidates {
169            let state = match self.state.load(&target).await {
170                Ok(state) => state,
171                Err(error) => {
172                    results.push((target, Err(error)));
173                    continue;
174                }
175            };
176
177            if now < self.due_at_for(&state, &policy) {
178                continue;
179            }
180
181            let engine = self.clone();
182            let run_target = target.clone();
183            runs.push((
184                target,
185                tokio::spawn(async move { engine.sync_if_still_due(&run_target, now).await }),
186            ));
187        }
188
189        for (target, run) in runs {
190            let outcome = match run.await {
191                Ok(Some(outcome)) => outcome,
192                // The target was still due when this task started but no longer was
193                // once it got the lock — another run (a manual refresh, or this same
194                // scheduler tick racing itself) already covered it.
195                Ok(None) => continue,
196                Err(error) => Err(AppError::internal(format!(
197                    "sync task for {target} failed: {error}"
198                ))),
199            };
200            results.push((target, outcome));
201        }
202
203        results
204    }
205
206    /// Like [`SyncEngine::sync_now`], but re-checks the schedule after acquiring the
207    /// target lock rather than before.
208    ///
209    /// Only the scheduler calls this. Between `run_due` deciding a target is due and
210    /// this task acquiring the single-flight lock, a manual [`SyncEngine::sync_now`] or
211    /// [`SyncEngine::sync_if_due`] may already have covered it — without the recheck,
212    /// this task would run a second, immediately-redundant sync the moment the lock
213    /// frees up. [`SyncEngine::sync_now`] itself must stay unconditional: a caller
214    /// invoking it directly asked for a sync *now*, not for the scheduler's due check.
215    async fn sync_if_still_due(
216        &self,
217        target: &SyncTarget,
218        now: OffsetDateTime,
219    ) -> Option<Result<SyncOutcome>> {
220        let (policy, source, running, cancel) = {
221            let targets = self.read();
222            let registration = targets.get(target)?;
223            (
224                registration.policy,
225                registration.source.clone(),
226                registration.running.clone(),
227                registration.cancel.clone(),
228            )
229        };
230
231        let _guard = running.lock().await;
232        let state = match self.state.load(target).await {
233            Ok(state) => state,
234            Err(error) => return Some(Err(error)),
235        };
236
237        if now < self.due_at_for(&state, &policy) {
238            tracing::debug!(%target, "sync skipped: no longer due once the lock was free");
239            return None;
240        }
241
242        Some(self.sync_with(target, policy, source, cancel, state).await)
243    }
244
245    /// Sync unless the target ran very recently.
246    ///
247    /// This is the entry point for triggers that fire on their own — window focus,
248    /// network coming back, a view being opened. Without the throttle, alt-tabbing
249    /// twenty times means twenty syncs.
250    ///
251    /// Returns `Ok(None)` when the run was skipped. A user pressing *Refresh* should
252    /// go through [`SyncEngine::sync_now`] instead: they asked explicitly.
253    pub async fn sync_if_due(&self, target: &SyncTarget) -> Result<Option<SyncOutcome>> {
254        let (policy, source, running, cancel) = {
255            let targets = self.read();
256            let registration = targets
257                .get(target)
258                .ok_or_else(|| AppError::validation(format!("unknown sync target {target}")))?;
259            (
260                registration.policy,
261                registration.source.clone(),
262                registration.running.clone(),
263                registration.cancel.clone(),
264            )
265        };
266
267        let _guard = running.lock().await;
268        let state = self.state.load(target).await?;
269        if let Some(last_attempt) = state.last_attempt
270            && self.clock.now() < last_attempt + policy.min_interval
271        {
272            tracing::debug!(%target, "sync skipped: ran too recently");
273            return Ok(None);
274        }
275
276        self.sync_with(target, policy, source, cancel, state)
277            .await
278            .map(Some)
279    }
280
281    /// Sync one target immediately, whatever the throttle says.
282    ///
283    /// Single-flight: a second caller waits for the run in flight instead of starting
284    /// a parallel one. Two concurrent syncs of the same target would race on the
285    /// validators and could store an older result over a newer one.
286    pub async fn sync_now(&self, target: &SyncTarget) -> Result<SyncOutcome> {
287        let (policy, source, running, cancel) = {
288            let targets = self.read();
289            let registration = targets
290                .get(target)
291                .ok_or_else(|| AppError::validation(format!("unknown sync target {target}")))?;
292            (
293                registration.policy,
294                registration.source.clone(),
295                registration.running.clone(),
296                registration.cancel.clone(),
297            )
298        };
299
300        let _guard = running.lock().await;
301        let state = self.state.load(target).await?;
302        self.sync_with(target, policy, source, cancel, state).await
303    }
304
305    async fn sync_with(
306        &self,
307        target: &SyncTarget,
308        policy: SyncPolicy,
309        source: Arc<dyn SyncSource>,
310        cancel: CancellationToken,
311        state: SyncState,
312    ) -> Result<SyncOutcome> {
313        let sync_id = SyncId::generate();
314        let context = SyncContext::new(sync_id.clone(), target.clone(), state.clone(), cancel);
315
316        let span = tracing::info_span!(
317            "sync",
318            sync_id = sync_id.as_str(),
319            connector = target.connector.as_str(),
320            account_id = target.account.as_str(),
321            target = target.name.as_str(),
322        );
323        let _entered = span.enter();
324
325        let result = source.sync(&context).await;
326        let now = self.clock.now();
327        let mut state = state;
328
329        match result {
330            Ok(SyncResult::Updated(report)) => {
331                state.record(now, SyncOutcome::Updated);
332                // Validators are only replaced when the service sent new ones; a
333                // response without an ETag must not clear the one we still hold.
334                if report.etag.is_some() {
335                    state.etag = report.etag.clone();
336                }
337                if report.last_modified.is_some() {
338                    state.last_modified = report.last_modified.clone();
339                }
340                self.apply_throttle(&mut state, report.throttle, &policy, target, now);
341                self.state.save(target, &state).await?;
342
343                tracing::debug!(changed = report.changed, "sync updated");
344                self.publish_completed(target, &sync_id, report.changed, now);
345                Ok(SyncOutcome::Updated)
346            }
347
348            Ok(SyncResult::NotModified) => {
349                state.record(now, SyncOutcome::NotModified);
350                // A validator hit carries no throttle of its own; any floor from the
351                // previous run has already passed by now, so clear it rather than
352                // leave stale bookkeeping behind.
353                state.clear_throttle();
354                self.state.save(target, &state).await?;
355
356                tracing::debug!("sync reported no change");
357                self.publish_completed(target, &sync_id, 0, now);
358                Ok(SyncOutcome::NotModified)
359            }
360
361            Err(error) => {
362                let outcome = SyncOutcome::Failed {
363                    kind: error.kind(),
364                    message: error.to_string(),
365                };
366                state.record(now, outcome.clone());
367
368                // A rate-limited response names its own retry delay. Honour it as a
369                // floor instead of letting the failure streak's exponential backoff
370                // decide alone — the service knows when its window reopens.
371                if let AppError::RateLimited {
372                    retry_after_seconds: Some(seconds),
373                    ..
374                } = &error
375                {
376                    let delay = Duration::seconds(*seconds as i64).min(policy.max_throttle);
377                    state.throttle_until(now + delay, ThrottleReason::RateLimited);
378                }
379
380                self.state.save(target, &state).await?;
381
382                let retry_at = Some(self.due_at_for(&state, &policy));
383                tracing::warn!(kind = ?error.kind(), %error, ?retry_at, "sync failed");
384
385                let _ = self.events.publish(PlatformEvent::SyncFailed(SyncFailed {
386                    sync: sync_id,
387                    connector: target.connector.clone(),
388                    account: target.account.clone(),
389                    kind: error.kind(),
390                    message: error.to_string(),
391                    retry_at,
392                }));
393
394                Err(error)
395            }
396        }
397    }
398
399    /// Apply a service-reported throttle, or clear a stale one.
400    ///
401    /// The delay is clamped to the policy's `max_throttle` so a buggy or hostile
402    /// response cannot freeze a target indefinitely.
403    fn apply_throttle(
404        &self,
405        state: &mut SyncState,
406        throttle: Option<SyncThrottle>,
407        policy: &SyncPolicy,
408        target: &SyncTarget,
409        now: OffsetDateTime,
410    ) {
411        let Some(throttle) = throttle else {
412            state.clear_throttle();
413            return;
414        };
415
416        let delay = if throttle.delay > policy.max_throttle {
417            tracing::warn!(
418                %target,
419                requested = ?throttle.delay,
420                max = ?policy.max_throttle,
421                reason = ?throttle.reason,
422                "server-imposed throttle clamped to the policy maximum"
423            );
424            policy.max_throttle
425        } else {
426            throttle.delay
427        };
428
429        tracing::debug!(%target, ?delay, reason = ?throttle.reason, "sync throttled by the service");
430        state.throttle_until(now + delay, throttle.reason);
431    }
432
433    /// Health across all registered targets — the worst state wins.
434    pub async fn health(&self) -> Health {
435        let now = self.clock.now();
436        let targets: Vec<(SyncTarget, SyncPolicy)> = self
437            .read()
438            .iter()
439            .map(|(target, registration)| (target.clone(), registration.policy))
440            .collect();
441
442        let mut states = Vec::new();
443        for (target, policy) in targets {
444            let state = self.state.load(&target).await.unwrap_or_default();
445            states.push(health_of(&state, &policy, now));
446        }
447
448        Health::aggregate(states)
449    }
450
451    /// Health of everything belonging to one account.
452    pub async fn health_of_account(&self, connector: &ConnectorId, account: &AccountId) -> Health {
453        let now = self.clock.now();
454        let targets: Vec<(SyncTarget, SyncPolicy)> = self
455            .read()
456            .iter()
457            .filter(|(target, _)| &target.connector == connector && &target.account == account)
458            .map(|(target, registration)| (target.clone(), registration.policy))
459            .collect();
460
461        let mut states = Vec::new();
462        for (target, policy) in targets {
463            let state = self.state.load(&target).await.unwrap_or_default();
464            states.push(health_of(&state, &policy, now));
465        }
466
467        Health::aggregate(states)
468    }
469
470    /// Run the scheduler until `stop` is cancelled.
471    ///
472    /// Returns a future rather than spawning a task: which executor runs it, and on
473    /// which thread, is the host's decision. A platform crate that called
474    /// `tokio::spawn` itself would panic wherever no runtime is entered — which is
475    /// exactly what a Tauri `setup` hook is.
476    ///
477    /// A thin wrapper around [`SyncEngine::run_due`]; all the logic worth testing is
478    /// in there, not in this loop.
479    pub async fn run(&self, stop: CancellationToken) {
480        tracing::debug!("sync scheduler started");
481        let mut ticker = tokio::time::interval(TICK);
482
483        loop {
484            tokio::select! {
485                _ = stop.cancelled() => break,
486                _ = ticker.tick() => {
487                    self.run_due(self.clock.now()).await;
488                }
489            }
490        }
491
492        tracing::debug!("sync scheduler stopped");
493    }
494
495    fn publish_completed(
496        &self,
497        target: &SyncTarget,
498        sync_id: &SyncId,
499        changed: u64,
500        at: OffsetDateTime,
501    ) {
502        let _ = self
503            .events
504            .publish(PlatformEvent::SyncCompleted(SyncCompleted {
505                sync: sync_id.clone(),
506                connector: target.connector.clone(),
507                account: target.account.clone(),
508                changed,
509                at,
510            }));
511    }
512
513    /// A value in `0.0..1.0` for jitter.
514    fn next_random(&self) -> f64 {
515        let previous = self
516            .seed
517            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |seed| {
518                Some(
519                    seed.wrapping_mul(6_364_136_223_846_793_005)
520                        .wrapping_add(1_442_695_040_888_963_407),
521                )
522            });
523
524        let value = previous
525            .unwrap_or(0)
526            .wrapping_mul(6_364_136_223_846_793_005);
527        f64::from((value >> 40) as u32) / f64::from(1u32 << 24)
528    }
529}
530
531/// Convenience for the common "every interval" registration.
532impl SyncEngine {
533    pub fn register_every(
534        &self,
535        target: SyncTarget,
536        interval: Duration,
537        source: Arc<dyn SyncSource>,
538    ) {
539        self.register(target, SyncPolicy::every(interval), source);
540    }
541}