Skip to main content

storekit/
backup.rs

1use crate::Db;
2use rusqlite::Connection;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
6
7pub const KEEP_DAYS: i64 = 14;
8
9const POLL_SECS: u64 = 60;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Kept {
13    pub path: PathBuf,
14    pub bytes: u64,
15}
16
17#[derive(Debug, Clone, Default)]
18pub struct Status {
19    pub queue_depth: usize,
20    pub last_error: Option<String>,
21    pub last_run_unix: Option<i64>,
22    pub next_run_unix: Option<i64>,
23    pub running: bool,
24    pub progress: f32,
25    pub last_elapsed: Option<Duration>,
26    pub last_changed: Option<usize>,
27    pub last_bytes: Option<u64>,
28    pub unconfigured: bool,
29    pub detail: Option<String>,
30    pub rate_per_s: Option<f32>,
31}
32
33#[derive(Clone)]
34pub struct Handle {
35    status: Arc<Mutex<Status>>,
36    nudge: std::sync::mpsc::Sender<()>,
37}
38
39impl Handle {
40    pub fn status(&self) -> Status {
41        self.status
42            .lock()
43            .unwrap_or_else(|poison| poison.into_inner())
44            .clone()
45    }
46
47    pub fn backup_now(&self) {
48        let _ = self.nudge.send(());
49    }
50}
51
52pub fn keep(connection: &Connection, dir: &Path, stem: &str) -> Result<Kept, String> {
53    std::fs::create_dir_all(dir).map_err(|error| error.to_string())?;
54    let stamp = format_stamp(now_unix());
55    let final_name = format!("{stem}-{stamp}.db.gz");
56    let plain_path = dir.join(format!("{final_name}.plain"));
57    let part_path = dir.join(format!("{final_name}.part"));
58    let final_path = dir.join(&final_name);
59
60    backup_into(connection, &plain_path).map_err(|error| error.to_string())?;
61    crate::archive::gzip_file(&plain_path, &part_path).map_err(|error| error.to_string())?;
62    let _ = std::fs::remove_file(&plain_path);
63    std::fs::rename(&part_path, &final_path).map_err(|error| error.to_string())?;
64
65    if let Ok(existing) = list_backup_names(dir, stem) {
66        for name in backups_to_delete(&existing, stem, now_unix()) {
67            let _ = std::fs::remove_file(dir.join(name));
68        }
69    }
70    let bytes = std::fs::metadata(&final_path)
71        .map(|metadata| metadata.len())
72        .unwrap_or(0);
73    Ok(Kept {
74        path: final_path,
75        bytes,
76    })
77}
78
79pub fn due(dir: &Path, stem: &str, now: i64) -> bool {
80    match latest(dir, stem) {
81        Some(at) => at.div_euclid(86_400) != now.div_euclid(86_400),
82        None => true,
83    }
84}
85
86pub fn next_due_unix(after: i64) -> i64 {
87    (after.div_euclid(86_400) + 1) * 86_400
88}
89
90pub fn latest(dir: &Path, stem: &str) -> Option<i64> {
91    list_backup_names(dir, stem)
92        .ok()?
93        .last()
94        .and_then(|name| stamp_to_unix(name, stem))
95}
96
97pub fn backup_into(connection: &Connection, destination: &Path) -> rusqlite::Result<()> {
98    let destination = destination
99        .to_str()
100        .expect("backup destination must be valid utf-8");
101    connection.execute("VACUUM INTO ?1", [destination])?;
102    Ok(())
103}
104
105pub fn spawn(db: Db, dir_of: fn(&Connection) -> Option<PathBuf>, stem: &'static str) -> Handle {
106    let status = Arc::new(Mutex::new(Status::default()));
107    let (nudge, nudged) = std::sync::mpsc::channel();
108    let handle = Handle {
109        status: Arc::clone(&status),
110        nudge,
111    };
112    std::thread::Builder::new()
113        .name("backup".into())
114        .spawn(move || run_scheduled(db, status, nudged, dir_of, stem))
115        .expect("the backup thread must start");
116    handle
117}
118
119fn run_scheduled(
120    db: Db,
121    status: Arc<Mutex<Status>>,
122    nudged: std::sync::mpsc::Receiver<()>,
123    dir_of: fn(&Connection) -> Option<PathBuf>,
124    stem: &'static str,
125) {
126    let mut forced = false;
127    loop {
128        run_once(&db, &status, forced, dir_of, stem);
129        forced = match nudged.recv_timeout(Duration::from_secs(POLL_SECS)) {
130            Ok(()) => true,
131            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => false,
132            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
133                std::thread::sleep(Duration::from_secs(POLL_SECS));
134                false
135            }
136        };
137    }
138}
139
140fn run_once(
141    db: &Db,
142    status: &Mutex<Status>,
143    forced: bool,
144    dir_of: fn(&Connection) -> Option<PathBuf>,
145    stem: &str,
146) {
147    let dir = {
148        let connection = db.lock().unwrap_or_else(|poison| poison.into_inner());
149        dir_of(&connection)
150    };
151    let Some(dir) = dir else {
152        let mut status = status.lock().unwrap_or_else(|poison| poison.into_inner());
153        status.unconfigured = true;
154        status.next_run_unix = None;
155        return;
156    };
157
158    let now = now_unix();
159    if forced || due(&dir, stem, now) {
160        {
161            let mut status = status.lock().unwrap_or_else(|poison| poison.into_inner());
162            status.unconfigured = false;
163            status.running = true;
164            status.progress = 0.0;
165            status.queue_depth = 0;
166        }
167        let started = Instant::now();
168        let outcome = {
169            let connection = db.lock().unwrap_or_else(|poison| poison.into_inner());
170            keep(&connection, &dir, stem)
171        };
172        let ran_at = now_unix();
173        let mut status = status.lock().unwrap_or_else(|poison| poison.into_inner());
174        status.running = false;
175        status.progress = 0.0;
176        status.queue_depth = 0;
177        status.last_run_unix = Some(ran_at);
178        status.next_run_unix = Some(next_due_unix(ran_at));
179        status.last_elapsed = Some(started.elapsed());
180        match outcome {
181            Ok(kept) => {
182                status.last_bytes = Some(kept.bytes);
183                status.last_error = None;
184            }
185            Err(error) => status.last_error = Some(error),
186        }
187    } else {
188        let mut status = status.lock().unwrap_or_else(|poison| poison.into_inner());
189        status.unconfigured = false;
190        status.next_run_unix = Some(latest(&dir, stem).map(next_due_unix).unwrap_or(now));
191    }
192}
193
194fn list_backup_names(dir: &Path, stem: &str) -> std::io::Result<Vec<String>> {
195    let prefix = format!("{stem}-");
196    let mut names: Vec<_> = std::fs::read_dir(dir)?
197        .filter_map(Result::ok)
198        .filter_map(|entry| entry.file_name().to_str().map(str::to_owned))
199        .filter(|name| {
200            name.starts_with(&prefix) && (name.ends_with(".db") || name.ends_with(".db.gz"))
201        })
202        .collect();
203    names.sort();
204    Ok(names)
205}
206
207fn backups_to_delete(names: &[String], stem: &str, now: i64) -> Vec<String> {
208    let cutoff = now - KEEP_DAYS * 86_400;
209    names
210        .iter()
211        .filter(|name| stamp_to_unix(name, stem).is_some_and(|at| at < cutoff))
212        .cloned()
213        .collect()
214}
215
216fn stamp_to_unix(name: &str, stem: &str) -> Option<i64> {
217    let core = name.strip_prefix(&format!("{stem}-"))?;
218    let core = core
219        .strip_suffix(".gz")
220        .unwrap_or(core)
221        .strip_suffix(".db")?;
222    let (date, time) = core.split_once('-')?;
223    if date.len() != 8 || time.len() != 4 {
224        return None;
225    }
226    let year = date[0..4].parse().ok()?;
227    let month = date[4..6].parse().ok()?;
228    let day = date[6..8].parse().ok()?;
229    let hour: i64 = time[0..2].parse().ok()?;
230    let minute: i64 = time[2..4].parse().ok()?;
231    Some(days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60)
232}
233
234fn now_unix() -> i64 {
235    SystemTime::now()
236        .duration_since(UNIX_EPOCH)
237        .map(|duration| duration.as_secs() as i64)
238        .unwrap_or(0)
239}
240
241fn format_stamp(unix: i64) -> String {
242    let days = unix.div_euclid(86_400);
243    let seconds = unix.rem_euclid(86_400);
244    let (year, month, day) = civil_from_days(days);
245    let hour = seconds / 3_600;
246    let minute = seconds % 3_600 / 60;
247    format!("{year:04}{month:02}{day:02}-{hour:02}{minute:02}")
248}
249
250fn civil_from_days(days: i64) -> (i64, u32, u32) {
251    let shifted = days + 719_468;
252    let era = if shifted >= 0 {
253        shifted
254    } else {
255        shifted - 146_096
256    } / 146_097;
257    let day_of_era = (shifted - era * 146_097) as u64;
258    let year_of_era =
259        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
260    let year = year_of_era as i64 + era * 400;
261    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
262    let month_prime = (5 * day_of_year + 2) / 153;
263    let day = (day_of_year - (153 * month_prime + 2) / 5 + 1) as u32;
264    let month = if month_prime < 10 {
265        month_prime + 3
266    } else {
267        month_prime - 9
268    } as u32;
269    (if month <= 2 { year + 1 } else { year }, month, day)
270}
271
272fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
273    let year = if month <= 2 { year - 1 } else { year };
274    let era = if year >= 0 { year } else { year - 399 } / 400;
275    let year_of_era = year - era * 400;
276    let month_prime = if month > 2 { month - 3 } else { month + 9 };
277    let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
278    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
279    era * 146_097 + day_of_era - 719_468
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::settings::{self, SETTINGS_TABLE_SQL};
286
287    fn scratch(tag: &str) -> tempfile::TempDir {
288        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tmp/testscratch");
289        std::fs::create_dir_all(&root).unwrap();
290        tempfile::Builder::new()
291            .prefix(&format!("backup-{tag}-"))
292            .tempdir_in(root)
293            .unwrap()
294    }
295
296    fn backup_dir(connection: &Connection) -> Option<PathBuf> {
297        settings::get(connection, "backup_dir")
298            .ok()
299            .flatten()
300            .map(PathBuf::from)
301    }
302
303    fn db_with_backup_dir(dir: Option<&Path>) -> Db {
304        let connection = Connection::open_in_memory().unwrap();
305        connection.execute_batch(SETTINGS_TABLE_SQL).unwrap();
306        if let Some(dir) = dir {
307            settings::set(&connection, "backup_dir", &dir.to_string_lossy()).unwrap();
308        }
309        Arc::new(Mutex::new(connection))
310    }
311
312    #[test]
313    fn keep_days_keeps_the_fourteen_day_ruling() {
314        assert_eq!(KEEP_DAYS, 14);
315    }
316
317    #[test]
318    fn keeps_everything_inside_fourteen_days() {
319        let now = stamp_to_unix("desk-20260818-1200.db.gz", "desk").unwrap();
320        let names = vec![
321            "desk-20260805-1200.db.gz".to_owned(),
322            "desk-20260812-0400.db.gz".to_owned(),
323            "desk-20260818-1100.db.gz".to_owned(),
324        ];
325        assert!(backups_to_delete(&names, "desk", now).is_empty());
326    }
327
328    #[test]
329    fn deletes_only_what_is_older_than_fourteen_days() {
330        let now = stamp_to_unix("desk-20260818-1200.db.gz", "desk").unwrap();
331        let names = vec![
332            "desk-20260720-1200.db".to_owned(),
333            "desk-20260804-1100.db.gz".to_owned(),
334            "desk-20260810-0400.db.gz".to_owned(),
335        ];
336        assert_eq!(
337            backups_to_delete(&names, "desk", now),
338            vec![
339                "desk-20260720-1200.db".to_owned(),
340                "desk-20260804-1100.db.gz".to_owned()
341            ]
342        );
343    }
344
345    #[test]
346    fn a_name_without_a_readable_stamp_is_never_deleted() {
347        let now = stamp_to_unix("desk-20260818-1200.db.gz", "desk").unwrap();
348        assert!(backups_to_delete(&["desk-garbled.db".to_owned()], "desk", now).is_empty());
349    }
350
351    #[test]
352    fn backup_names_round_trip_through_their_stamp() {
353        let stamp = format_stamp(1_700_000_000);
354        assert_eq!(
355            stamp_to_unix(&format!("desk-{stamp}.db"), "desk"),
356            Some(1_700_000_000 - 20)
357        );
358    }
359
360    #[test]
361    fn a_gz_stamp_reads_the_same_as_a_bare_one() {
362        assert_eq!(
363            stamp_to_unix("desk-20260812-0400.db.gz", "desk"),
364            stamp_to_unix("desk-20260812-0400.db", "desk")
365        );
366        assert!(stamp_to_unix("desk-assets.tar", "desk").is_none());
367    }
368
369    #[test]
370    fn a_folder_with_no_copies_is_always_due() {
371        let holder = scratch("due-empty");
372        assert!(due(holder.path(), "desk", 1_786_000_000));
373    }
374
375    #[test]
376    fn a_missing_folder_is_due_rather_than_an_error() {
377        let holder = scratch("due-missing");
378        assert!(due(&holder.path().join("not-there"), "desk", 1_786_000_000));
379    }
380
381    #[test]
382    fn one_copy_a_day_holds_every_later_boot_off() {
383        let holder = scratch("due-fresh");
384        std::fs::write(holder.path().join("desk-20260812-0400.db.gz"), b"x").unwrap();
385        let at = latest(holder.path(), "desk").unwrap();
386        assert!(!due(holder.path(), "desk", at + 60));
387        assert!(!due(holder.path(), "desk", at + 12 * 3_600));
388        assert!(due(holder.path(), "desk", at + 86_400));
389    }
390
391    #[test]
392    fn a_late_night_copy_is_stale_the_next_morning() {
393        let holder = scratch("due-midnight");
394        std::fs::write(holder.path().join("desk-20260812-2350.db.gz"), b"x").unwrap();
395        let at = latest(holder.path(), "desk").unwrap();
396        assert!(due(holder.path(), "desk", at + 20 * 60));
397    }
398
399    #[test]
400    fn next_due_is_the_coming_midnight() {
401        let at = stamp_to_unix("desk-20260812-0400.db.gz", "desk").unwrap();
402        assert_eq!(
403            next_due_unix(at),
404            stamp_to_unix("desk-20260813-0000.db.gz", "desk").unwrap()
405        );
406    }
407
408    #[test]
409    fn strangers_in_the_folder_are_ignored() {
410        let holder = scratch("strangers");
411        for name in ["notes.txt", "desk-nonsense.db", "backup.db"] {
412            std::fs::write(holder.path().join(name), b"x").unwrap();
413        }
414        assert_eq!(latest(holder.path(), "desk"), None);
415        assert!(due(holder.path(), "desk", 1_786_000_000));
416    }
417
418    #[test]
419    fn a_malformed_stamp_reads_as_nothing() {
420        assert_eq!(stamp_to_unix("desk-2026081-0400.db", "desk"), None);
421        assert_eq!(stamp_to_unix("desk-20260812-040.db", "desk"), None);
422        assert_eq!(stamp_to_unix("desk-abcdefgh-0400.db", "desk"), None);
423        assert_eq!(stamp_to_unix("other-20260812-0400.db", "desk"), None);
424        assert_eq!(stamp_to_unix("desk-20260812-0400.sqlite", "desk"), None);
425    }
426
427    #[test]
428    fn stamps_advance_with_the_clock() {
429        let earlier = stamp_to_unix("desk-20260812-0400.db", "desk").unwrap();
430        let later = stamp_to_unix("desk-20260812-0500.db", "desk").unwrap();
431        assert_eq!(later - earlier, 3_600);
432        let tomorrow = stamp_to_unix("desk-20260813-0400.db", "desk").unwrap();
433        assert_eq!(tomorrow - earlier, 86_400);
434    }
435
436    #[test]
437    fn run_once_backs_up_when_due_and_reports_a_fresh_next_due() {
438        let holder = scratch("run-once-due");
439        let db = db_with_backup_dir(Some(holder.path()));
440        let status = Mutex::new(Status::default());
441
442        run_once(&db, &status, false, backup_dir, "desk");
443
444        let status = status.into_inner().unwrap();
445        assert!(!status.running);
446        assert_eq!(status.progress, 0.0);
447        assert_eq!(status.queue_depth, 0);
448        let last = status.last_run_unix.unwrap();
449        assert_eq!(status.next_run_unix, Some(next_due_unix(last)));
450        assert!(status.last_error.is_none());
451        assert!(status.last_elapsed.is_some());
452        assert_eq!(list_backup_names(holder.path(), "desk").unwrap().len(), 1);
453    }
454
455    #[test]
456    fn run_once_leaves_a_fresh_backup_alone_but_still_reports_next_due() {
457        let holder = scratch("not-due");
458        let stamp = format_stamp(now_unix());
459        std::fs::write(holder.path().join(format!("desk-{stamp}.db.gz")), b"x").unwrap();
460        let db = db_with_backup_dir(Some(holder.path()));
461        let status = Mutex::new(Status::default());
462
463        run_once(&db, &status, false, backup_dir, "desk");
464
465        let status = status.into_inner().unwrap();
466        assert_eq!(status.last_run_unix, None);
467        assert_eq!(list_backup_names(holder.path(), "desk").unwrap().len(), 1);
468        assert!(status.next_run_unix.is_some());
469    }
470
471    #[test]
472    fn run_once_on_an_unset_location_does_nothing_and_never_errors() {
473        let db = db_with_backup_dir(None);
474        let status = Mutex::new(Status::default());
475        run_once(&db, &status, false, backup_dir, "desk");
476        let status = status.into_inner().unwrap();
477        assert!(status.unconfigured);
478        assert!(!status.running);
479        assert_eq!(status.last_error, None);
480        assert_eq!(status.last_run_unix, None);
481        assert_eq!(status.next_run_unix, None);
482    }
483
484    #[test]
485    fn backup_dir_reads_back_exactly_what_settings_wrote_with_no_fallback() {
486        let connection = Connection::open_in_memory().unwrap();
487        connection.execute_batch(SETTINGS_TABLE_SQL).unwrap();
488        assert_eq!(backup_dir(&connection), None);
489        settings::set(&connection, "backup_dir", "/picked/location").unwrap();
490        assert_eq!(
491            backup_dir(&connection),
492            Some(PathBuf::from("/picked/location"))
493        );
494    }
495
496    #[test]
497    fn latest_reads_the_newest_stamp() {
498        let holder = scratch("latest");
499        for name in [
500            "desk-20260810-0100.db.gz",
501            "desk-20260812-2300.db.gz",
502            "desk-20260811-1200.db.gz",
503        ] {
504            std::fs::write(holder.path().join(name), b"x").unwrap();
505        }
506        assert_eq!(
507            latest(holder.path(), "desk"),
508            stamp_to_unix("desk-20260812-2300.db.gz", "desk")
509        );
510    }
511}