Skip to main content

max / synckit

4.9 KB · 142 lines History Blame Raw
1 //! Background scheduler support: the observer interface the engine reports
2 //! through, and the small pure decisions the scheduler loop makes (backoff,
3 //! interval-elapsed, subscription-required classification).
4 //!
5 //! The loop itself lives on [`SyncStore`](super::facade::SyncStore) (it needs the
6 //! concrete client's SSE stream and auth state); the pieces here are factored out
7 //! so they can be unit-tested in isolation.
8
9 use std::collections::HashSet;
10 use std::time::Duration;
11
12 use chrono::{DateTime, Utc};
13
14 use crate::error::SyncKitError;
15
16 /// Coarse sync state reported to the UI. (Named to avoid colliding with the
17 /// server-facing [`SyncStatus`](crate::types::SyncStatus).)
18 #[derive(Debug, Clone, PartialEq, Eq)]
19 pub enum SyncState {
20 /// Not currently syncing; last cycle succeeded (or none has run).
21 Idle,
22 /// A sync cycle is in progress.
23 Syncing,
24 /// The last cycle failed; carries a human-readable reason.
25 Error(String),
26 /// Sync is blocked pending an active membership/purchase (server `402`).
27 SubscriptionRequired,
28 /// The session is gone (token expired / cleared); re-auth needed.
29 LoggedOut,
30 }
31
32 /// How the engine reports progress. All methods default to no-ops, so an app
33 /// implements only the signals it renders. Bridges any UI model (Tauri events,
34 /// an `Arc<Mutex<_>>`, a channel).
35 pub trait SyncObserver: Send + Sync {
36 /// The coarse state changed.
37 fn on_status(&self, _state: SyncState) {}
38 /// A pull applied changes touching these tables (for selective invalidation).
39 fn on_changes_applied(&self, _tables: &HashSet<String>) {}
40 /// The server refused sync pending a subscription.
41 fn on_subscription_required(&self) {}
42 }
43
44 /// An observer that discards every signal.
45 #[derive(Debug, Default, Clone, Copy)]
46 pub struct NoopObserver;
47 impl SyncObserver for NoopObserver {}
48
49 /// Exponential backoff after `consecutive_failures` failed cycles: 1, 2, 4, 8,
50 /// then capped at 15 minutes. `0` failures means "no backoff" is not this, the
51 /// first failure (count 1) waits 2 minutes; call with the post-increment count.
52 pub fn backoff_delay(consecutive_failures: u32) -> Duration {
53 let mins = 2u64.pow(consecutive_failures.min(4)).min(15);
54 Duration::from_secs(mins * 60)
55 }
56
57 /// Whether enough time has elapsed since `last_sync` to sync again on the timer
58 /// path. A missing/unparseable `last_sync` is always due.
59 pub fn interval_elapsed(
60 last_sync: Option<DateTime<Utc>>,
61 interval_minutes: i64,
62 now: DateTime<Utc>,
63 ) -> bool {
64 match last_sync {
65 None => true,
66 Some(prev) => now.signed_duration_since(prev).num_minutes() >= interval_minutes,
67 }
68 }
69
70 /// Whether an error means the server is gating sync behind a subscription (`402`).
71 pub fn is_subscription_required(err: &SyncKitError) -> bool {
72 matches!(err, SyncKitError::Server { status: 402, .. })
73 }
74
75 /// Whether an error is a lost/expired session the caller should surface as
76 /// logged-out (`401`/`403`, or the client's own token-expiry signal).
77 pub fn is_auth_lost(err: &SyncKitError) -> bool {
78 matches!(
79 err,
80 SyncKitError::TokenExpired | SyncKitError::NotAuthenticated
81 ) || matches!(
82 err,
83 SyncKitError::Server {
84 status: 401 | 403,
85 ..
86 }
87 )
88 }
89
90 #[cfg(test)]
91 mod tests {
92 use super::*;
93
94 #[test]
95 fn backoff_is_exponential_then_capped() {
96 assert_eq!(backoff_delay(0).as_secs(), 60); // 2^0 = 1 minute
97 assert_eq!(backoff_delay(1).as_secs(), 2 * 60);
98 assert_eq!(backoff_delay(2).as_secs(), 4 * 60);
99 assert_eq!(backoff_delay(3).as_secs(), 8 * 60);
100 assert_eq!(backoff_delay(4).as_secs(), 15 * 60); // 2^4=16 capped to 15
101 assert_eq!(backoff_delay(50).as_secs(), 15 * 60); // no overflow, still capped
102 }
103
104 #[test]
105 fn interval_elapsed_rules() {
106 let now = Utc::now();
107 assert!(
108 interval_elapsed(None, 15, now),
109 "never-synced is always due"
110 );
111 let five_ago = now - chrono::Duration::minutes(5);
112 assert!(!interval_elapsed(Some(five_ago), 15, now), "too soon");
113 let twenty_ago = now - chrono::Duration::minutes(20);
114 assert!(
115 interval_elapsed(Some(twenty_ago), 15, now),
116 "past the interval"
117 );
118 }
119
120 #[test]
121 fn error_classification() {
122 let sub = SyncKitError::Server {
123 status: 402,
124 message: "pay".into(),
125 retry_after_secs: None,
126 };
127 assert!(is_subscription_required(&sub));
128 assert!(!is_auth_lost(&sub));
129
130 assert!(is_auth_lost(&SyncKitError::TokenExpired));
131 assert!(is_auth_lost(&SyncKitError::Server {
132 status: 401,
133 message: String::new(),
134 retry_after_secs: None
135 }));
136 assert!(!is_subscription_required(&SyncKitError::TokenExpired));
137
138 let other = SyncKitError::Internal("boom".into());
139 assert!(!is_subscription_required(&other) && !is_auth_lost(&other));
140 }
141 }
142