Skip to main content

client_core/sync/
backlog_flusher.rs

1//! Local-clip backlog: enqueue offline-captured clips, flush them on
2//! (re)connect in chronological order. See
3//! docs/superpowers/specs/2026-05-20-clipboard-backlog-flush-design.md.
4
5use chrono::{SecondsFormat, TimeZone, Utc};
6
7/// Convert a Unix millis timestamp to the RFC3339 string the relay's parser
8/// expects for `client_created_at`.
9pub fn format_rfc3339_millis(unix_millis: i64) -> String {
10    Utc.timestamp_millis_opt(unix_millis)
11        .single()
12        .expect("valid millis")
13        .to_rfc3339_opts(SecondsFormat::Millis, true)
14}
15
16use crate::store::models::StoredClip;
17use crate::store::{queries, Store, StoreError};
18use ulid::Ulid;
19
20/// Cap on number of `synced=false` rows allowed in the local store. Older
21/// rows are dropped when this limit is exceeded.
22pub const MAX_UNSYNCED: usize = 1000;
23
24/// Persist a clip to the local store with a `local-<ULID>` id and
25/// `synced=false`, then enforce the offline cap. Returns the temporary id —
26/// also used as the relay `idempotency_key` on flush so the relay can dedup
27/// retries.
28pub fn enqueue_local(
29    store: &Store,
30    source: &str,
31    _label: &str,
32    content_type: &str,
33    raw: Vec<u8>,
34    byte_size: i64,
35) -> Result<String, StoreError> {
36    let clip_id = format!("local-{}", Ulid::new());
37    let stored = StoredClip {
38        id: clip_id.clone(),
39        source: source.to_string(),
40        source_key: None,
41        content_type: content_type.to_string(),
42        content: Some(raw),
43        media_path: None,
44        byte_size,
45        created_at: chrono::Utc::now().timestamp_millis(),
46        pinned: false,
47        pinned_at: None,
48        synced: false,
49    };
50    queries::insert_clip(store, &stored)?;
51    queries::enforce_offline_cap(store, MAX_UNSYNCED)?;
52    Ok(clip_id)
53}
54
55use crate::crypto;
56use crate::http::{HttpError, RestClient};
57use crate::rest::PushRequest;
58
59/// Push every `synced=false` clip in `created_at ASC` order. Stops on the
60/// first transient (5xx / Network) error; drops rows on permanent (4xx /
61/// Decode) errors; drops rows whose plaintext content is missing (media-only).
62///
63/// Idempotent via `idempotency_key = clip.id` — relay-side dedup absorbs
64/// retries from interrupted flushes.
65pub async fn flush_once(
66    store: &crate::store::Store,
67    client: &RestClient,
68    enc_key: [u8; 32],
69) -> Result<FlushReport, FlushError> {
70    let pending = queries::list_unsynced_clips(store)?;
71    let total = pending.len();
72    let mut report = FlushReport::default();
73
74    for clip in pending {
75        let plaintext = match clip.content.clone() {
76            Some(b) => b,
77            None => {
78                queries::delete_clip(store, &clip.id)?;
79                report.dropped += 1;
80                continue;
81            }
82        };
83
84        let ciphertext = crypto::encrypt(&enc_key, &plaintext).map_err(FlushError::Crypto)?;
85        let req = PushRequest {
86            content: ciphertext,
87            content_type: clip.content_type.clone(),
88            label: String::new(),
89            source: clip.source.clone(),
90            media_path: None,
91            byte_size: clip.byte_size,
92            encrypted: true,
93            target_device_id: None,
94            client_created_at: Some(format_rfc3339_millis(clip.created_at)),
95            idempotency_key: Some(clip.id.clone()),
96        };
97
98        match client.push_clip_json(&req).await {
99            Ok(resp) => {
100                queries::replace_id_and_mark_synced(store, &clip.id, &resp.clip_id)?;
101                report.flushed += 1;
102            }
103            Err(e) if is_transient(&e) => {
104                report.remaining = total - report.flushed - report.dropped;
105                return Ok(report);
106            }
107            Err(e) => {
108                log::warn!(
109                    "backlog: dropping clip {} on permanent error: {}",
110                    clip.id,
111                    e
112                );
113                queries::delete_clip(store, &clip.id)?;
114                report.dropped += 1;
115            }
116        }
117    }
118
119    Ok(report)
120}
121
122fn is_transient(e: &HttpError) -> bool {
123    match e {
124        HttpError::Network(_) => true,
125        HttpError::Relay { status, .. } => (500..600).contains(status),
126        HttpError::Unauthorized | HttpError::Decode(_) | HttpError::Build(_) => false,
127    }
128}
129
130use std::sync::atomic::{AtomicBool, Ordering};
131
132#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
133pub struct FlushReport {
134    pub flushed: usize,
135    pub dropped: usize,
136    pub remaining: usize,
137}
138
139#[derive(Debug, thiserror::Error)]
140pub enum FlushError {
141    #[error("encryption failed: {0}")]
142    Crypto(String),
143    #[error("store error: {0}")]
144    Store(#[from] crate::store::StoreError),
145}
146
147/// Reentrancy gate for `flush_once`. Process-local — call `try_enter` before
148/// dispatching a flush; if it returns None, another flush is in progress and
149/// the caller should skip silently.
150pub struct FlushGate(AtomicBool);
151
152impl FlushGate {
153    pub const fn new() -> Self {
154        Self(AtomicBool::new(false))
155    }
156    pub fn try_enter(&self) -> Option<FlushGuard<'_>> {
157        if self.0.swap(true, Ordering::AcqRel) {
158            None
159        } else {
160            Some(FlushGuard(&self.0))
161        }
162    }
163}
164
165impl Default for FlushGate {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171pub struct FlushGuard<'a>(&'a AtomicBool);
172
173impl Drop for FlushGuard<'_> {
174    fn drop(&mut self) {
175        self.0.store(false, Ordering::Release);
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::store::queries;
183    use crate::store::Store;
184
185    fn fresh_store() -> Store {
186        Store::open(std::path::Path::new(":memory:")).unwrap()
187    }
188
189    #[test]
190    fn format_rfc3339_millis_emits_z_suffix_with_millis() {
191        assert_eq!(
192            format_rfc3339_millis(1_700_000_000_123),
193            "2023-11-14T22:13:20.123Z"
194        );
195    }
196
197    #[test]
198    fn enqueue_local_persists_unsynced_with_local_prefix() {
199        let store = fresh_store();
200        let id = enqueue_local(&store, "remote:host", "label", "text", b"hello".to_vec(), 5)
201            .expect("enqueue");
202        assert!(
203            id.starts_with("local-"),
204            "id must start with 'local-' marker, got {id}"
205        );
206        let rows = queries::list_unsynced_clips(&store).unwrap();
207        assert_eq!(rows.len(), 1);
208        let row = &rows[0];
209        assert_eq!(row.id, id);
210        assert!(!row.synced);
211        assert_eq!(row.source, "remote:host");
212        assert_eq!(row.content.as_deref(), Some(&b"hello"[..]));
213        assert_eq!(row.content_type, "text");
214        assert_eq!(row.byte_size, 5);
215    }
216
217    #[test]
218    fn enqueue_local_three_under_cap_all_survive() {
219        let store = fresh_store();
220        for _ in 0..3 {
221            enqueue_local(&store, "s", "", "text", b"x".to_vec(), 1).unwrap();
222        }
223        let rows = queries::list_unsynced_clips(&store).unwrap();
224        assert_eq!(
225            rows.len(),
226            3,
227            "default cap is 1000; 3 enqueued must all survive"
228        );
229    }
230
231    #[test]
232    fn flush_gate_blocks_second_entrant_until_first_drops() {
233        let gate = FlushGate::new();
234        let g1 = gate.try_enter().unwrap();
235        assert!(
236            gate.try_enter().is_none(),
237            "second try_enter must be blocked"
238        );
239        drop(g1);
240        assert!(
241            gate.try_enter().is_some(),
242            "after guard drop, new entrant ok"
243        );
244    }
245
246    fn seed_three_unsynced(store: &Store) {
247        use crate::store::models::StoredClip;
248        for (id, ts, content) in [
249            ("local-a", 10i64, b"first" as &[u8]),
250            ("local-b", 20, b"second"),
251            ("local-c", 30, b"third"),
252        ] {
253            let c = StoredClip {
254                id: id.into(),
255                source: "s".into(),
256                source_key: None,
257                content_type: "text".into(),
258                content: Some(content.to_vec()),
259                media_path: None,
260                byte_size: content.len() as i64,
261                created_at: ts,
262                pinned: false,
263                pinned_at: None,
264                synced: false,
265            };
266            queries::insert_clip(store, &c).unwrap();
267        }
268    }
269
270    #[tokio::test]
271    async fn flush_once_pushes_in_chronological_order_and_swaps_ids() {
272        let store = std::sync::Arc::new(fresh_store());
273        seed_three_unsynced(&store);
274        let key = [9u8; 32];
275        let client = crate::http::RestClient::for_test_recording();
276        let report = flush_once(&store, &client, key).await.expect("flush_once");
277        assert_eq!(report.flushed, 3);
278        assert_eq!(report.dropped, 0);
279        assert_eq!(report.remaining, 0);
280
281        // No more unsynced rows.
282        let pending = queries::list_unsynced_clips(&store).unwrap();
283        assert!(pending.is_empty(), "all rows should be synced");
284
285        // The mock recorded calls in chronological order with idempotency keys.
286        let calls = client.recorded_pushes();
287        assert_eq!(calls.len(), 3);
288        let keys: Vec<_> = calls.iter().map(|r| r.idempotency_key.clone()).collect();
289        assert_eq!(
290            keys,
291            vec![
292                Some("local-a".into()),
293                Some("local-b".into()),
294                Some("local-c".into())
295            ]
296        );
297
298        // Each call carried client_created_at and was encrypted.
299        for call in &calls {
300            assert!(call.client_created_at.is_some());
301            assert!(call.encrypted);
302        }
303    }
304
305    #[tokio::test]
306    async fn flush_once_stops_on_transient_and_reports_remaining() {
307        let store = std::sync::Arc::new(fresh_store());
308        seed_three_unsynced(&store);
309        let key = [9u8; 32];
310        let client = crate::http::RestClient::for_test_with_failures(vec![
311            crate::http::FakePush::Ok,
312            crate::http::FakePush::Network,
313        ]);
314        let report = flush_once(&store, &client, key).await.unwrap();
315        assert_eq!(report.flushed, 1);
316        assert_eq!(report.dropped, 0);
317        assert_eq!(report.remaining, 2);
318        assert_eq!(queries::list_unsynced_clips(&store).unwrap().len(), 2);
319    }
320
321    #[tokio::test]
322    async fn flush_once_drops_on_permanent_and_continues() {
323        let store = std::sync::Arc::new(fresh_store());
324        seed_three_unsynced(&store);
325        let key = [9u8; 32];
326        let client = crate::http::RestClient::for_test_with_failures(vec![
327            crate::http::FakePush::Ok,
328            crate::http::FakePush::Relay {
329                status: 413,
330                msg: "payload too large".into(),
331            },
332            crate::http::FakePush::Ok,
333        ]);
334        let report = flush_once(&store, &client, key).await.unwrap();
335        assert_eq!(report.flushed, 2);
336        assert_eq!(report.dropped, 1);
337        assert_eq!(queries::list_unsynced_clips(&store).unwrap().len(), 0);
338    }
339
340    #[tokio::test]
341    async fn flush_once_drops_rows_with_null_content() {
342        use crate::store::models::StoredClip;
343        let store = std::sync::Arc::new(fresh_store());
344        let c = StoredClip {
345            id: "local-nocontent".into(),
346            source: "s".into(),
347            source_key: None,
348            content_type: "text".into(),
349            content: None,
350            media_path: Some("/tmp/clip.png".into()),
351            byte_size: 0,
352            created_at: 10,
353            pinned: false,
354            pinned_at: None,
355            synced: false,
356        };
357        queries::insert_clip(&store, &c).unwrap();
358        let key = [9u8; 32];
359        let client = crate::http::RestClient::for_test_recording();
360        let report = flush_once(&store, &client, key).await.unwrap();
361        assert_eq!(report.flushed, 0);
362        assert_eq!(report.dropped, 1);
363        assert!(queries::list_unsynced_clips(&store).unwrap().is_empty());
364        assert!(
365            client.recorded_pushes().is_empty(),
366            "no relay call for null-content row"
367        );
368    }
369}