Skip to main content

rightkit_db/
backup.rs

1//! Crash-safe online backups.
2//!
3//! SQLite's Online Backup API copies through the source connection's own pager, so committed
4//! transactions still sitting in the `-wal` are included (a raw file copy would miss them).
5//! The copy lands in a sibling temp file, is checked with `PRAGMA quick_check` (re-opened
6//! with the same key for SQLCipher), narrowed to owner-only, `fsync`ed, and only then renamed
7//! onto the target: a crash leaves the previous target untouched and at worst an orphan temp
8//! file that `rightkit_fs::sweep_stale_temps` reclaims. Donor: CodeRight `private_fs::backup`.
9
10use crate::{apply_key_first, DbError, DbOptions, Result};
11use rusqlite::backup::{Backup, StepResult};
12use rusqlite::Connection;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{Duration, Instant};
16
17static COUNTER: AtomicU64 = AtomicU64::new(0);
18
19pub trait BackupProgress {
20    /// Called after every step with the pages remaining; return `false` to cancel.
21    fn step(&mut self, remaining: i32) -> bool;
22}
23
24/// Back `source` up to `target`. `options` supplies the SQLCipher key (if any) so the backup is
25/// encrypted with the same key; its `path` is ignored.
26///
27/// The key is validated before any staging exists, and the staging file is created
28/// owner-only with `create_private_new` before SQLite opens it, so plaintext is never
29/// exposed under a permissive parent. The target is untouched until the staged copy has
30/// passed `quick_check`, been synced, and is published by rename. Busy/locked steps retry
31/// only until a finite monotonic operation deadline (`options.busy_timeout`,
32/// checked before every step and backoff, with or without a progress callback); every
33/// sleep is capped to the remaining budget, and cancellation or expiry removes only this
34/// call's still-owned staging.
35pub fn online_backup_to(
36    options: &DbOptions,
37    source: &Connection,
38    target: &Path,
39    mut progress: Option<&mut dyn BackupProgress>,
40) -> Result<()> {
41    let started = Instant::now();
42    #[cfg(test)]
43    deadline_tests::start(started);
44    let deadline = started.checked_add(options.busy_timeout).unwrap_or(started);
45    let check_deadline = || -> Result<()> {
46        #[cfg(test)]
47        let now = deadline_tests::now();
48        #[cfg(not(test))]
49        let now = Instant::now();
50        if now >= deadline {
51            Err(DbError::Backup("backup operation timeout expired".into()))
52        } else {
53            Ok(())
54        }
55    };
56    // Key validation first: an invalid/unavailable key creates no staging at all.
57    let key = options.key_pragma_value()?;
58    check_deadline()?;
59    let (temp, created) = create_private_staging(target)?;
60    let result = (|| -> Result<()> {
61        // The staged file must still be the one this call exclusively created.
62        if let Some(want) = created {
63            if rightkit_fs::FileKey::of_path(&temp).ok() != Some(want) {
64                return Err(DbError::Backup(
65                    "backup staging was replaced before it was opened".into(),
66                ));
67            }
68        }
69        let mut dst = Connection::open(&temp)?;
70        // Never let SQLite spend a second independent timeout inside a backup step.
71        dst.busy_timeout(Duration::ZERO)?;
72        apply_key_first(&dst, key.as_ref().map(|k| k.as_str()))?;
73        {
74            let backup = Backup::new(source, &mut dst)?;
75            loop {
76                check_deadline()?;
77                #[cfg(test)]
78                let step = deadline_tests::step(&backup)?;
79                #[cfg(not(test))]
80                let step = backup.step(512)?;
81                check_deadline()?;
82                if let Some(sink) = progress.as_deref_mut() {
83                    if !sink.step(backup.progress().remaining) {
84                        return Err(DbError::Backup("cancelled".into()));
85                    }
86                }
87                check_deadline()?;
88                match step {
89                    StepResult::Done => break,
90                    StepResult::More => {}
91                    // Source busy/locked: back off within the same operation deadline.
92                    _ => {
93                        let now = Instant::now();
94                        let remaining = deadline.saturating_duration_since(now);
95                        if remaining.is_zero() {
96                            return Err(DbError::Backup("backup busy timeout expired".into()));
97                        }
98                        std::thread::sleep(remaining.min(Duration::from_millis(25)));
99                    }
100                }
101            }
102        }
103        check_deadline()?;
104        // Leave one self-contained file: fold any WAL into the main file and drop WAL mode.
105        let _ = dst.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()));
106        dst.query_row("PRAGMA journal_mode=DELETE", [], |row| {
107            row.get::<_, String>(0)
108        })?;
109        let report: String = dst.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
110        if !report.trim().eq_ignore_ascii_case("ok") {
111            return Err(DbError::Backup(format!(
112                "quick_check on the backup said: {report}"
113            )));
114        }
115        drop(dst);
116        rightkit_fs::restrict_owner_only(&temp)?;
117        // Windows FlushFileBuffers needs a writable handle; a read-only open fails "Access is denied".
118        std::fs::OpenOptions::new()
119            .write(true)
120            .open(&temp)
121            .and_then(|f| f.sync_all())
122            .map_err(|e| DbError::Backup(format!("fsync backup: {e}")))?;
123        check_deadline()?;
124        std::fs::rename(&temp, target)
125            .map_err(|e| DbError::Backup(format!("publish backup: {e}")))?;
126        if let Some(parent) = target.parent().filter(|p| !p.as_os_str().is_empty()) {
127            rightkit_fs::atomic::sync_dir(parent);
128        }
129        Ok(())
130    })();
131    if result.is_err() {
132        remove_owned_staging(&temp, created);
133    }
134    result
135}
136
137/// Exclusively creates a unique owner-only staging file beside `target`, retrying on a
138/// name collision, and returns its path with the identity captured at creation. The file
139/// is private from its first observation, before SQLite opens it.
140fn create_private_staging(target: &Path) -> Result<(PathBuf, Option<rightkit_fs::FileKey>)> {
141    for _ in 0..32 {
142        let temp = temp_path(target)?;
143        match rightkit_fs::create_private_new(&temp) {
144            Ok(file) => {
145                let key = rightkit_fs::FileKey::of_file(&file, &temp).ok();
146                drop(file);
147                return Ok((temp, key));
148            }
149            Err(error) if error.kind() == Some(std::io::ErrorKind::AlreadyExists) => {}
150            Err(error) => return Err(error.into()),
151        }
152    }
153    Err(DbError::Backup(
154        "could not create a unique backup staging file".into(),
155    ))
156}
157
158/// Removes this call's staging family only while the main staged file still has the
159/// identity captured at its exclusive creation (or is already gone). A replaced staging
160/// file is another owner's and is retained whole.
161fn remove_owned_staging(temp: &Path, created: Option<rightkit_fs::FileKey>) {
162    if let Some(want) = created {
163        match rightkit_fs::FileKey::of_path(temp) {
164            Ok(now) if now != want => return,
165            _ => {}
166        }
167    }
168    remove_with_sidecars(temp);
169}
170
171/// Validate `backup_path` before copying it into an already-open `destination`.
172/// Uses the SQLite backup API, including committed WAL contents. `options` supplies
173/// the backup's key and timeout; its path is ignored. Invalid backups never touch
174/// `destination`. The destination must be writable and outside a transaction.
175pub fn restore_from(
176    options: &DbOptions,
177    destination: &mut Connection,
178    backup_path: &Path,
179) -> Result<()> {
180    let source = validated_backup(options, backup_path)?;
181    restore_validated(options, &source, destination)
182}
183
184impl DbOptions {
185    /// Validate the backup first, then open the existing live database without
186    /// bootstrap and restore it. Neither missing backup nor missing live DB is created.
187    /// The backup must use the same SQLCipher key as these options.
188    pub fn restore_from(&self, backup_path: impl AsRef<Path>) -> Result<()> {
189        let source = validated_backup(self, backup_path.as_ref())?;
190        let mut destination = self.open_existing_writer()?;
191        restore_validated(self, &source, &mut destination)
192    }
193}
194
195pub(crate) fn validated_backup(options: &DbOptions, path: &Path) -> Result<Connection> {
196    let source = options.open_existing_at(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
197    // Keep the checked snapshot stable until Backup has finished reading it.
198    source.execute_batch("BEGIN;")?;
199    let messages = {
200        let mut statement = source.prepare("PRAGMA integrity_check")?;
201        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
202        rows.collect::<rusqlite::Result<Vec<_>>>()?
203    };
204    if messages.is_empty()
205        || messages
206            .iter()
207            .any(|message| !message.trim().eq_ignore_ascii_case("ok"))
208    {
209        return Err(DbError::Integrity(messages.join("; ")));
210    }
211    Ok(source)
212}
213
214fn restore_validated(
215    options: &DbOptions,
216    source: &Connection,
217    destination: &mut Connection,
218) -> Result<()> {
219    let backup = Backup::new(source, destination)?;
220    let started = Instant::now();
221    let deadline = started.checked_add(options.busy_timeout);
222    loop {
223        match backup.step(512)? {
224            StepResult::Done => return Ok(()),
225            StepResult::More => {}
226            StepResult::Busy | StepResult::Locked => {
227                let Some(deadline) = deadline else {
228                    std::thread::sleep(Duration::from_millis(25));
229                    continue;
230                };
231                let remaining = deadline.saturating_duration_since(Instant::now());
232                if remaining.is_zero() {
233                    return Err(DbError::Backup("restore busy timeout expired".into()));
234                }
235                std::thread::sleep(remaining.min(Duration::from_millis(25)));
236            }
237            _ => return Err(DbError::Backup("unexpected restore step result".into())),
238        }
239    }
240}
241
242fn temp_path(target: &Path) -> Result<PathBuf> {
243    let name = target
244        .file_name()
245        .ok_or(DbError::InvalidDatabasePath)?
246        .to_string_lossy();
247    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
248    Ok(target.with_file_name(format!(
249        ".{name}{}{}-{n}",
250        rightkit_fs::atomic::TEMP_MARKER,
251        std::process::id()
252    )))
253}
254
255fn remove_with_sidecars(path: &Path) {
256    let _ = remove_database_files(path);
257}
258
259/// Delete a database file and its `-wal`, `-shm` and `-journal` sidecars.
260///
261/// A file that does not exist is not an error. Every file is attempted even if an earlier one
262/// fails; the first real failure is returned. On Windows a file still open by a connection
263/// cannot be deleted, so drop the writer connection and close the read pool first
264/// (`ReadPool::close_and_wait` releases every pooled reader).
265pub fn remove_database_files(path: &Path) -> std::io::Result<()> {
266    let mut first_error = None;
267    let mut remove = |p: &Path| {
268        if let Err(e) = std::fs::remove_file(p) {
269            if e.kind() != std::io::ErrorKind::NotFound && first_error.is_none() {
270                first_error = Some(e);
271            }
272        }
273    };
274    remove(path);
275    for suffix in ["-wal", "-shm", "-journal"] {
276        let mut name = path.as_os_str().to_os_string();
277        name.push(suffix);
278        remove(&PathBuf::from(name));
279    }
280    first_error.map_or(Ok(()), Err)
281}
282
283/// The lock file that guards the whole store behind the database at `db_path`:
284/// `<db_path>.store-lock`.
285pub fn store_lock_path(db_path: &Path) -> PathBuf {
286    let mut name = db_path.as_os_str().to_os_string();
287    name.push(".store-lock");
288    PathBuf::from(name)
289}
290
291/// A held store-wide lock (see [`store_lock_path`]). Released on drop.
292///
293/// Processes that use the database (daemon, shell) hold a [`StoreLock::shared`] guard for as long
294/// as they keep connections open; [`replace_database`] takes the [`StoreLock::exclusive`] guard,
295/// so a replace runs only when no cooperating process is using the store. The lock is advisory
296/// and cooperative: a process that never takes the shared guard is not excluded.
297#[derive(Debug)]
298pub struct StoreLock {
299    lock: rightkit_fs::FileLock,
300}
301
302impl StoreLock {
303    /// Shared guard: any number of holders, excluded only by an exclusive holder. Waits per
304    /// `options.timeout` and returns `DbError::Fs` (locked or timed out) when busy.
305    pub fn shared(db_path: &Path, options: &rightkit_fs::LockOptions) -> Result<Self> {
306        let lock = rightkit_fs::FileLock::acquire_shared(&store_lock_path(db_path), options)?;
307        Ok(Self { lock })
308    }
309
310    /// Exclusive guard: excludes every shared and exclusive holder. Waits per `options.timeout`.
311    pub fn exclusive(db_path: &Path, options: &rightkit_fs::LockOptions) -> Result<Self> {
312        let lock = rightkit_fs::FileLock::acquire(&store_lock_path(db_path), options)?;
313        Ok(Self { lock })
314    }
315
316    /// Path of the lock file.
317    pub fn path(&self) -> &Path {
318        self.lock.path()
319    }
320}
321
322/// Atomically replace the database at `options.path` with the database at `replacement`.
323///
324/// Under the store-wide exclusive lock ([`StoreLock::exclusive`], waiting per `lock`):
325/// 1. `replacement` is opened read-only with `options`' key and every `PRAGMA integrity_check`
326///    row must be `ok` (`DbError::Integrity` otherwise; the live database is untouched).
327/// 2. Its committed contents (WAL included) are copied through SQLite's backup API into a
328///    staging file beside the live database, checked, narrowed to owner-only and synced.
329/// 3. The live database's `-wal`, `-shm` and `-journal` sidecars are removed, so a stale log
330///    can never be replayed onto the new file, and the staging file is renamed onto the path.
331///
332/// A failure in step 1 or 2 leaves the live database untouched and removes the staging file. The
333/// only crash window is between removing the sidecars and the rename in step 3: the old main
334/// file is then still in place without its uncheckpointed log, and the staging file remains
335/// (reclaimed by `rightkit_fs::sweep_stale_temps`). The replacement keeps the options' key, so
336/// it must be encrypted with the same key.
337///
338/// Every connection to the live database, in this process and in any other, must be closed
339/// before the call: other processes by holding only [`StoreLock::shared`] guards (the exclusive
340/// guard cannot be taken while one is held), this process by dropping its writer and calling
341/// `ReadPool::close_and_wait`. On Windows an open connection also makes the rename fail.
342pub fn replace_database(
343    options: &DbOptions,
344    replacement: &Path,
345    lock: &rightkit_fs::LockOptions,
346) -> Result<()> {
347    let _guard = StoreLock::exclusive(&options.path, lock)?;
348    replace_database_locked(options, replacement)
349}
350
351/// [`replace_database`] for a caller that already holds the exclusive [`StoreLock`] for
352/// `options.path` (for example to do more work under the same lock).
353pub fn replace_database_locked(options: &DbOptions, replacement: &Path) -> Result<()> {
354    let target = options.path.as_path();
355    let source = validated_backup(options, replacement)?;
356    let staging = temp_path(target)?;
357    let staged = (|| -> Result<()> {
358        online_backup_to(options, &source, &staging, None)?;
359        drop(source);
360        remove_database_files_except_main(target)
361            .map_err(|e| DbError::Backup(format!("clear live sidecars: {e}")))?;
362        std::fs::rename(&staging, target)
363            .map_err(|e| DbError::Backup(format!("publish replacement: {e}")))?;
364        if let Some(parent) = target.parent().filter(|p| !p.as_os_str().is_empty()) {
365            rightkit_fs::atomic::sync_dir(parent);
366        }
367        Ok(())
368    })();
369    if staged.is_err() {
370        let _ = std::fs::remove_file(&staging);
371    }
372    staged
373}
374
375fn remove_database_files_except_main(path: &Path) -> std::io::Result<()> {
376    let mut first_error = None;
377    for suffix in ["-wal", "-shm", "-journal"] {
378        let mut name = path.as_os_str().to_os_string();
379        name.push(suffix);
380        if let Err(e) = std::fs::remove_file(PathBuf::from(name)) {
381            if e.kind() != std::io::ErrorKind::NotFound && first_error.is_none() {
382                first_error = Some(e);
383            }
384        }
385    }
386    first_error.map_or(Ok(()), Err)
387}
388
389// Only unit tests can substitute step outcomes/time. Native integration tests always use SQLite
390// & Instant directly; release builds contain neither this state nor the hooks above.
391#[cfg(test)]
392mod deadline_tests {
393    use super::*;
394    use std::cell::RefCell;
395    use std::collections::VecDeque;
396
397    struct Script {
398        started: Instant,
399        elapsed: Duration,
400        steps: VecDeque<(Duration, rusqlite::Result<StepResult>)>,
401        calls: usize,
402    }
403
404    thread_local! {
405        static SCRIPT: RefCell<Option<Script>> = const { RefCell::new(None) };
406    }
407
408    struct Reset;
409    impl Drop for Reset {
410        fn drop(&mut self) {
411            SCRIPT.with(|script| *script.borrow_mut() = None);
412        }
413    }
414
415    pub(super) fn start(started: Instant) {
416        SCRIPT.with(|script| {
417            if let Some(script) = script.borrow_mut().as_mut() {
418                script.started = started;
419            }
420        });
421    }
422
423    pub(super) fn now() -> Instant {
424        SCRIPT.with(|script| {
425            script
426                .borrow()
427                .as_ref()
428                .map_or_else(Instant::now, |script| script.started + script.elapsed)
429        })
430    }
431
432    pub(super) fn step(backup: &Backup<'_, '_>) -> rusqlite::Result<StepResult> {
433        let scripted = SCRIPT.with(|script| {
434            script.borrow_mut().as_mut().map(|script| {
435                let (elapsed, result) = script.steps.pop_front().expect("unexpected backup step");
436                script.elapsed += elapsed;
437                script.calls += 1;
438                result
439            })
440        });
441        scripted.unwrap_or_else(|| backup.step(512))
442    }
443
444    struct Progress {
445        calls: usize,
446        elapsed: Duration,
447    }
448
449    impl BackupProgress for Progress {
450        fn step(&mut self, _remaining: i32) -> bool {
451            self.calls += 1;
452            SCRIPT.with(|script| {
453                script.borrow_mut().as_mut().unwrap().elapsed += self.elapsed;
454            });
455            true
456        }
457    }
458
459    fn run(
460        steps: Vec<(Duration, rusqlite::Result<StepResult>)>,
461        callback_time: Option<Duration>,
462    ) -> (Result<()>, usize, usize) {
463        run_from(Duration::ZERO, steps, callback_time)
464    }
465
466    fn run_from(
467        initial_elapsed: Duration,
468        steps: Vec<(Duration, rusqlite::Result<StepResult>)>,
469        callback_time: Option<Duration>,
470    ) -> (Result<()>, usize, usize) {
471        let dir = tempfile::tempdir().unwrap();
472        let target = dir.path().join("backup.sqlite");
473        std::fs::write(&target, b"previous backup").unwrap();
474        let source = Connection::open_in_memory().unwrap();
475        source.execute_batch("CREATE TABLE t(v);").unwrap();
476        let mut options = DbOptions::new("unused.sqlite");
477        options.busy_timeout = Duration::from_millis(50);
478        SCRIPT.with(|script| {
479            assert!(script.borrow().is_none());
480            *script.borrow_mut() = Some(Script {
481                started: Instant::now(),
482                elapsed: initial_elapsed,
483                steps: steps.into(),
484                calls: 0,
485            });
486        });
487        let _reset = Reset;
488        let mut progress = Progress {
489            calls: 0,
490            elapsed: callback_time.unwrap_or_default(),
491        };
492        let callback = callback_time.map(|_| &mut progress as &mut dyn BackupProgress);
493        let result = online_backup_to(&options, &source, &target, callback);
494        let calls = SCRIPT.with(|script| script.borrow().as_ref().unwrap().calls);
495        assert_eq!(std::fs::read(&target).unwrap(), b"previous backup");
496        assert_eq!(
497            std::fs::read_dir(dir.path()).unwrap().count(),
498            1,
499            "owned staging must be removed"
500        );
501        (result, calls, progress.calls)
502    }
503
504    fn assert_timeout(result: &Result<()>) {
505        assert!(
506            matches!(result, Err(DbError::Backup(message)) if message.contains("timeout")),
507            "expected total operation deadline: {result:?}",
508        );
509    }
510
511    #[test]
512    fn expired_operation_never_starts_first_step() {
513        let (result, steps, callbacks) =
514            run_from(Duration::from_millis(50), Vec::new(), Some(Duration::ZERO));
515        assert_timeout(&result);
516        assert_eq!((steps, callbacks), (0, 0));
517    }
518
519    #[test]
520    fn recurring_progress_does_not_reset_total_deadline() {
521        // Two callbacks must occur because this test controls step cost. A third completed
522        // chunk crosses the original 50 ms deadline; progress never grants another budget.
523        let steps = (0..3)
524            .map(|_| (Duration::from_millis(20), Ok(StepResult::More)))
525            .collect();
526        let (result, steps, callbacks) = run(steps, Some(Duration::ZERO));
527        assert_timeout(&result);
528        assert_eq!((steps, callbacks), (3, 2));
529    }
530
531    #[test]
532    fn completed_step_cannot_publish_after_callback_expires_deadline() {
533        let (result, steps, callbacks) = run(
534            vec![(Duration::ZERO, Ok(StepResult::Done))],
535            Some(Duration::from_millis(50)),
536        );
537        assert_timeout(&result);
538        assert_eq!((steps, callbacks), (1, 1));
539    }
540
541    #[test]
542    fn expired_step_stops_before_callback() {
543        let (result, steps, callbacks) = run(
544            vec![(Duration::from_millis(50), Ok(StepResult::Done))],
545            Some(Duration::ZERO),
546        );
547        assert_timeout(&result);
548        assert_eq!((steps, callbacks), (1, 0));
549    }
550
551    #[test]
552    fn progressing_steps_without_callback_share_same_deadline() {
553        let steps = (0..3)
554            .map(|_| (Duration::from_millis(20), Ok(StepResult::More)))
555            .collect();
556        let (result, steps, callbacks) = run(steps, None);
557        assert_timeout(&result);
558        assert_eq!((steps, callbacks), (3, 0));
559    }
560
561    #[test]
562    fn step_error_cleans_staging_without_callback() {
563        let (result, steps, callbacks) = run(
564            vec![(Duration::ZERO, Err(rusqlite::Error::InvalidQuery))],
565            Some(Duration::ZERO),
566        );
567        assert!(matches!(
568            result,
569            Err(DbError::Sqlite(rusqlite::Error::InvalidQuery))
570        ));
571        assert_eq!((steps, callbacks), (1, 0));
572    }
573}