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
19/// Floor of the time one verified copy of a database may take.
20const COPY_BUDGET_FLOOR: Duration = Duration::from_secs(120);
21/// Pessimistic sustained rate (bytes per second) that grows the copy budget with the database.
22const COPY_BYTES_PER_SECOND: u64 = 4 * 1024 * 1024;
23/// The copy budget never exceeds this, so a stalled copy always ends.
24const COPY_BUDGET_CEILING: Duration = Duration::from_secs(6 * 60 * 60);
25
26/// The finite budget for one verified copy of the database behind `source`: a floor plus time
27/// proportional to its size (database and `-wal`), capped. An in-memory database, or one whose
28/// size cannot be read, counts as zero bytes, so the floor still applies.
29///
30/// This is the whole-copy deadline for every copy this crate makes for its own callers (restore,
31/// the encrypted store's `backup_to`, the pre-migration backup, relocation). It is not derived
32/// from `DbOptions::busy_timeout`: that is SQLite's lock wait (five seconds by default), and a
33/// copy that creates a staging file, derives the SQLCipher key, writes, syncs and verifies can
34/// take longer than that on a slow or scanned disk without anything being wrong.
35pub(crate) fn copy_budget(source: &Connection) -> Duration {
36    let bytes = source
37        .path()
38        .filter(|path| !path.is_empty())
39        .map_or(0, |path| sized_copy_bytes(Path::new(path)));
40    budget_for_bytes(bytes)
41}
42
43/// Bytes of the database at `main` and of its `-wal`; an unreadable file counts as zero.
44fn sized_copy_bytes(main: &Path) -> u64 {
45    ["", "-wal"].iter().fold(0u64, |total, suffix| {
46        let mut name = main.as_os_str().to_os_string();
47        name.push(suffix);
48        let len = std::fs::metadata(PathBuf::from(name)).map_or(0, |meta| meta.len());
49        total.saturating_add(len)
50    })
51}
52
53fn budget_for_bytes(bytes: u64) -> Duration {
54    COPY_BUDGET_FLOOR
55        .saturating_add(Duration::from_secs(bytes / COPY_BYTES_PER_SECOND))
56        .min(COPY_BUDGET_CEILING)
57}
58
59/// One uninterrupted spell of busy or locked steps. `busy_since` is when the spell began (`None`
60/// while the copy is making progress); every step that makes progress ends the spell, so a copy
61/// that is merely slow never spends a wait that belongs to a source that is stuck. Returns how
62/// much of `limit` is left at `now`, or, once `limit` is spent, how long the spell has lasted.
63fn busy_wait_left(
64    busy_since: &mut Option<Instant>,
65    now: Instant,
66    limit: Duration,
67) -> std::result::Result<Duration, Duration> {
68    let waited = now.saturating_duration_since(*busy_since.get_or_insert(now));
69    if waited >= limit {
70        Err(waited)
71    } else {
72        Ok(limit.saturating_sub(waited))
73    }
74}
75
76pub trait BackupProgress {
77    /// Called after every step with the pages remaining; return `false` to cancel.
78    fn step(&mut self, remaining: i32) -> bool;
79}
80
81/// Back `source` up to `target`. `options` supplies the SQLCipher key (if any) so the backup is
82/// encrypted with the same key; its `path` is ignored.
83///
84/// The key is validated before any staging exists, and the staging file is created
85/// owner-only with `create_private_new` before SQLite opens it, so plaintext is never
86/// exposed under a permissive parent. The target is untouched until the staged copy has
87/// passed `quick_check`, been synced, and is published by rename. Busy/locked steps retry
88/// only until a finite monotonic operation deadline (`options.busy_timeout`,
89/// checked before every step and backoff, with or without a progress callback); every
90/// sleep is capped to the remaining budget, and cancellation or expiry removes only this
91/// call's still-owned staging.
92///
93/// That makes `options.busy_timeout` the time allowed for the **whole** copy, five seconds by
94/// default. A caller that copies a database of any size, or onto a slow or scanned disk, should
95/// not rely on that: restore, the encrypted store's `backup_to`, the pre-migration backup and
96/// relocation bound the copy by a budget derived from the size of the source instead, and use
97/// `busy_timeout` only for how long a busy or locked source is waited for.
98pub fn online_backup_to(
99    options: &DbOptions,
100    source: &Connection,
101    target: &Path,
102    progress: Option<&mut dyn BackupProgress>,
103) -> Result<()> {
104    backup_within(options, source, target, options.busy_timeout, progress)
105}
106
107/// [`online_backup_to`] as this crate's own operations make it. The whole copy is bounded by
108/// [`copy_budget`] (never less than `options.busy_timeout`, so a caller can still allow longer),
109/// and `options.busy_timeout` bounds only an uninterrupted spell of busy or locked steps, which
110/// restarts after every step that makes progress. A copy that is merely slow is not a busy
111/// source; a source that stays busy still fails after `busy_timeout`.
112pub(crate) fn online_backup_sized(
113    options: &DbOptions,
114    source: &Connection,
115    target: &Path,
116    progress: Option<&mut dyn BackupProgress>,
117) -> Result<()> {
118    let budget = copy_budget(source).max(options.busy_timeout);
119    backup_within(options, source, target, budget, progress)
120}
121
122/// The copy itself. `budget` is the monotonic deadline for the whole operation, checked before
123/// every step and backoff; `options.busy_timeout` bounds one uninterrupted spell of busy or
124/// locked steps inside it, and nothing here lets either bound grow.
125fn backup_within(
126    options: &DbOptions,
127    source: &Connection,
128    target: &Path,
129    budget: Duration,
130    mut progress: Option<&mut dyn BackupProgress>,
131) -> Result<()> {
132    let started = Instant::now();
133    let deadline = started.checked_add(budget).unwrap_or(started);
134    let clock = || -> Instant {
135        {
136            Instant::now()
137        }
138    };
139    let check_deadline = || -> Result<()> {
140        let now = clock();
141        if now >= deadline {
142            Err(DbError::Backup(format!(
143                "backup operation timeout expired after {:?} (budget {:?})",
144                now.saturating_duration_since(started),
145                budget
146            )))
147        } else {
148            Ok(())
149        }
150    };
151    // Start of the current uninterrupted spell of busy or locked steps, if one is running.
152    let mut busy_since: Option<Instant> = None;
153    // Key validation first: an invalid/unavailable key creates no staging at all.
154    let key = options.key_pragma_value()?;
155    check_deadline()?;
156    let (temp, created) = create_private_staging(target)?;
157    let result = (|| -> Result<()> {
158        // The staged file must still be the one this call exclusively created.
159        // A scanner that is looking at the new file for a moment can make the identity open fail
160        // with a transient Windows refusal; that is retried, and what still cannot be read after
161        // the bound is reported as unreadable, not as a replaced file.
162        if let Some(want) = created {
163            match rightkit_fs::transient::retry_transient_fs(|| {
164                rightkit_fs::FileKey::of_path(&temp)
165            }) {
166                Ok(now) if now == want => {}
167                Ok(_) => {
168                    return Err(DbError::Backup(
169                        "backup staging was replaced before it was opened".into(),
170                    ))
171                }
172                Err(error) => {
173                    return Err(DbError::Backup(format!(
174                        "backup staging could not be identified before it was opened: {error}"
175                    )))
176                }
177            }
178        }
179        let mut dst = Connection::open(&temp)?;
180        // Never let SQLite spend a second independent timeout inside a backup step.
181        dst.busy_timeout(Duration::ZERO)?;
182        apply_key_first(&dst, key.as_ref().map(|k| k.as_str()))?;
183        {
184            let backup = Backup::new(source, &mut dst)?;
185            loop {
186                check_deadline()?;
187                let step = backup.step(512)?;
188                check_deadline()?;
189                if let Some(sink) = progress.as_deref_mut() {
190                    if !sink.step(backup.progress().remaining) {
191                        return Err(DbError::Backup("cancelled".into()));
192                    }
193                }
194                check_deadline()?;
195                match step {
196                    StepResult::Done => break,
197                    StepResult::More => busy_since = None,
198                    // Source busy/locked: back off, but only for `busy_timeout` without progress
199                    // and never past the operation deadline.
200                    _ => {
201                        let now = clock();
202                        let left = busy_wait_left(&mut busy_since, now, options.busy_timeout)
203                            .map_err(|waited| {
204                                DbError::Backup(format!(
205                                    "backup busy timeout expired after {waited:?} (busy wait {:?})",
206                                    options.busy_timeout
207                                ))
208                            })?;
209                        let remaining = left.min(deadline.saturating_duration_since(now));
210                        if remaining.is_zero() {
211                            // Only the operation deadline can leave nothing to wait for. Report it
212                            // as the error; this arm must never end the closure with `Ok`, which
213                            // would go on to publish a copy that was not finished.
214                            check_deadline()?;
215                            return Err(DbError::Backup(format!(
216                                "backup stopped waiting for a busy source at the operation \
217                                 deadline (budget {budget:?})"
218                            )));
219                        }
220                        let pause = remaining.min(Duration::from_millis(25));
221                        std::thread::sleep(pause);
222                    }
223                }
224            }
225        }
226        check_deadline()?;
227        // Leave one self-contained file: fold any WAL into the main file and drop WAL mode.
228        let _ = dst.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()));
229        dst.query_row("PRAGMA journal_mode=DELETE", [], |row| {
230            row.get::<_, String>(0)
231        })?;
232        let report: String = dst.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
233        if !report.trim().eq_ignore_ascii_case("ok") {
234            return Err(DbError::Backup(format!(
235                "quick_check on the backup said: {report}"
236            )));
237        }
238        drop(dst);
239        rightkit_fs::restrict_owner_only(&temp)?;
240        // The staged copy is finished and verified. An indexer or scanner that still holds it on
241        // Windows makes the next open or rename fail with a sharing or access error that clears
242        // by itself, so both steps retry that class a bounded number of times, and only while the
243        // staging is still the file this call created.
244        let mut synced_before = false;
245        // Windows FlushFileBuffers needs a writable handle; a read-only open fails "Access is denied".
246        rightkit_fs::transient::retry_transient(|| {
247            if std::mem::replace(&mut synced_before, true) {
248                staging_still_ours(&temp, created)?;
249            }
250            std::fs::OpenOptions::new()
251                .write(true)
252                .open(&temp)
253                .and_then(|f| f.sync_all())
254        })
255        .map_err(|e| DbError::Backup(format!("fsync backup: {e}")))?;
256        check_deadline()?;
257        let mut published_before = false;
258        rightkit_fs::transient::retry_transient(|| {
259            if std::mem::replace(&mut published_before, true) {
260                staging_still_ours(&temp, created)?;
261            }
262            std::fs::rename(&temp, target)
263        })
264        .map_err(|e| DbError::Backup(format!("publish backup: {e}")))?;
265        if let Some(parent) = target.parent().filter(|p| !p.as_os_str().is_empty()) {
266            rightkit_fs::atomic::sync_dir(parent);
267        }
268        Ok(())
269    })();
270    if result.is_err() {
271        remove_owned_staging(&temp, created);
272    }
273    result
274}
275
276/// Exclusively creates a unique owner-only staging file beside `target`, retrying on a
277/// name collision, and returns its path with the identity captured at creation. The file
278/// is private from its first observation, before SQLite opens it.
279fn create_private_staging(target: &Path) -> Result<(PathBuf, Option<rightkit_fs::FileKey>)> {
280    for _ in 0..32 {
281        let temp = temp_path(target)?;
282        match rightkit_fs::create_private_new(&temp) {
283            Ok(file) => {
284                let key = rightkit_fs::FileKey::of_file(&file, &temp).ok();
285                drop(file);
286                return Ok((temp, key));
287            }
288            Err(error) if error.kind() == Some(std::io::ErrorKind::AlreadyExists) => {}
289            Err(error) => return Err(error.into()),
290        }
291    }
292    Err(DbError::Backup(
293        "could not create a unique backup staging file".into(),
294    ))
295}
296
297/// A retry of a publish step may only touch the staging this call created: a staging file whose
298/// identity changed during the pause belongs to someone else and is left alone.
299fn staging_still_ours(temp: &Path, created: Option<rightkit_fs::FileKey>) -> std::io::Result<()> {
300    if let (Some(want), Ok(now)) = (created, rightkit_fs::FileKey::of_path(temp)) {
301        if now != want {
302            return Err(std::io::Error::other(
303                "backup staging was replaced while a publish step was retried",
304            ));
305        }
306    }
307    Ok(())
308}
309
310/// Removes this call's staging family only while the main staged file still has the
311/// identity captured at its exclusive creation (or is already gone). A replaced staging
312/// file is another owner's and is retained whole.
313fn remove_owned_staging(temp: &Path, created: Option<rightkit_fs::FileKey>) {
314    if let Some(want) = created {
315        match rightkit_fs::FileKey::of_path(temp) {
316            Ok(now) if now != want => return,
317            _ => {}
318        }
319    }
320    remove_with_sidecars(temp);
321}
322
323/// Validate `backup_path` before copying it into an already-open `destination`.
324/// Uses the SQLite backup API, including committed WAL contents. `options` supplies
325/// the backup's key and timeout; its path is ignored. Invalid backups never touch
326/// `destination`. The destination must be writable and outside a transaction.
327pub fn restore_from(
328    options: &DbOptions,
329    destination: &mut Connection,
330    backup_path: &Path,
331) -> Result<()> {
332    let source = validated_backup(options, backup_path)?;
333    restore_validated(options, &source, destination)
334}
335
336impl DbOptions {
337    /// Validate the backup first, then open the existing live database without
338    /// bootstrap and restore it. Neither missing backup nor missing live DB is created.
339    /// The backup must use the same SQLCipher key as these options.
340    pub fn restore_from(&self, backup_path: impl AsRef<Path>) -> Result<()> {
341        let source = validated_backup(self, backup_path.as_ref())?;
342        let mut destination = self.open_existing_writer()?;
343        restore_validated(self, &source, &mut destination)
344    }
345}
346
347pub(crate) fn validated_backup(options: &DbOptions, path: &Path) -> Result<Connection> {
348    let source = options.open_existing_at(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
349    // Keep the checked snapshot stable until Backup has finished reading it.
350    source.execute_batch("BEGIN;")?;
351    let messages = {
352        let mut statement = source.prepare("PRAGMA integrity_check")?;
353        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
354        rows.collect::<rusqlite::Result<Vec<_>>>()?
355    };
356    if messages.is_empty()
357        || messages
358            .iter()
359            .any(|message| !message.trim().eq_ignore_ascii_case("ok"))
360    {
361        return Err(DbError::Integrity(messages.join("; ")));
362    }
363    Ok(source)
364}
365
366fn restore_validated(
367    options: &DbOptions,
368    source: &Connection,
369    destination: &mut Connection,
370) -> Result<()> {
371    let backup = Backup::new(source, destination)?;
372    // `busy_timeout` bounds one uninterrupted spell of busy or locked steps, and restarts after
373    // every step that makes progress. It used to run from the start of the whole copy, so a busy
374    // spell that began long into a slow restore failed at once.
375    let mut busy_since: Option<Instant> = None;
376    loop {
377        match backup.step(512)? {
378            StepResult::Done => return Ok(()),
379            StepResult::More => busy_since = None,
380            StepResult::Busy | StepResult::Locked => {
381                let left = busy_wait_left(&mut busy_since, Instant::now(), options.busy_timeout)
382                    .map_err(|waited| {
383                        DbError::Backup(format!(
384                            "restore busy timeout expired after {waited:?} (busy wait {:?})",
385                            options.busy_timeout
386                        ))
387                    })?;
388                std::thread::sleep(left.min(Duration::from_millis(25)));
389            }
390            _ => return Err(DbError::Backup("unexpected restore step result".into())),
391        }
392    }
393}
394
395fn temp_path(target: &Path) -> Result<PathBuf> {
396    let name = target
397        .file_name()
398        .ok_or(DbError::InvalidDatabasePath)?
399        .to_string_lossy();
400    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
401    Ok(target.with_file_name(format!(
402        ".{name}{}{}-{n}",
403        rightkit_fs::atomic::TEMP_MARKER,
404        std::process::id()
405    )))
406}
407
408fn remove_with_sidecars(path: &Path) {
409    let _ = remove_database_files(path);
410}
411
412/// Delete a database file and its `-wal`, `-shm` and `-journal` sidecars.
413///
414/// A file that does not exist is not an error. Every file is attempted even if an earlier one
415/// fails; the first real failure is returned. On Windows a file still open by a connection
416/// cannot be deleted, so drop the writer connection and close the read pool first
417/// (`ReadPool::close_and_wait` releases every pooled reader).
418pub fn remove_database_files(path: &Path) -> std::io::Result<()> {
419    let mut first_error = None;
420    let mut remove = |p: &Path| {
421        if let Err(e) = std::fs::remove_file(p) {
422            if e.kind() != std::io::ErrorKind::NotFound && first_error.is_none() {
423                first_error = Some(e);
424            }
425        }
426    };
427    remove(path);
428    for suffix in ["-wal", "-shm", "-journal"] {
429        let mut name = path.as_os_str().to_os_string();
430        name.push(suffix);
431        remove(&PathBuf::from(name));
432    }
433    first_error.map_or(Ok(()), Err)
434}
435
436/// The lock file that guards the whole store behind the database at `db_path`:
437/// `<db_path>.store-lock`.
438pub fn store_lock_path(db_path: &Path) -> PathBuf {
439    let mut name = db_path.as_os_str().to_os_string();
440    name.push(".store-lock");
441    PathBuf::from(name)
442}
443
444/// A held store-wide lock (see [`store_lock_path`]). Released on drop.
445///
446/// Processes that use the database (daemon, shell) hold a [`StoreLock::shared`] guard for as long
447/// as they keep connections open; [`replace_database`] takes the [`StoreLock::exclusive`] guard,
448/// so a replace runs only when no cooperating process is using the store. The lock is advisory
449/// and cooperative: a process that never takes the shared guard is not excluded.
450#[derive(Debug)]
451pub struct StoreLock {
452    lock: rightkit_fs::FileLock,
453}
454
455impl StoreLock {
456    /// Shared guard: any number of holders, excluded only by an exclusive holder. Waits per
457    /// `options.timeout` and returns `DbError::Fs` (locked or timed out) when busy.
458    pub fn shared(db_path: &Path, options: &rightkit_fs::LockOptions) -> Result<Self> {
459        let lock = rightkit_fs::FileLock::acquire_shared(&store_lock_path(db_path), options)?;
460        Ok(Self { lock })
461    }
462
463    /// Exclusive guard: excludes every shared and exclusive holder. Waits per `options.timeout`.
464    pub fn exclusive(db_path: &Path, options: &rightkit_fs::LockOptions) -> Result<Self> {
465        let lock = rightkit_fs::FileLock::acquire(&store_lock_path(db_path), options)?;
466        Ok(Self { lock })
467    }
468
469    /// Path of the lock file.
470    pub fn path(&self) -> &Path {
471        self.lock.path()
472    }
473}
474
475/// Atomically replace the database at `options.path` with the database at `replacement`.
476///
477/// Under the store-wide exclusive lock ([`StoreLock::exclusive`], waiting per `lock`):
478/// 1. `replacement` is opened read-only with `options`' key and every `PRAGMA integrity_check`
479///    row must be `ok` (`DbError::Integrity` otherwise; the live database is untouched).
480/// 2. Its committed contents (WAL included) are copied through SQLite's backup API into a
481///    staging file beside the live database, checked, narrowed to owner-only and synced.
482/// 3. The live database's `-wal`, `-shm` and `-journal` sidecars are removed, so a stale log
483///    can never be replayed onto the new file, and the staging file is renamed onto the path.
484///
485/// A failure in step 1 or 2 leaves the live database untouched and removes the staging file. The
486/// only crash window is between removing the sidecars and the rename in step 3: the old main
487/// file is then still in place without its uncheckpointed log, and the staging file remains
488/// (reclaimed by `rightkit_fs::sweep_stale_temps`). The replacement keeps the options' key, so
489/// it must be encrypted with the same key.
490///
491/// Every connection to the live database, in this process and in any other, must be closed
492/// before the call: other processes by holding only [`StoreLock::shared`] guards (the exclusive
493/// guard cannot be taken while one is held), this process by dropping its writer and calling
494/// `ReadPool::close_and_wait`. On Windows an open connection also makes the rename fail.
495pub fn replace_database(
496    options: &DbOptions,
497    replacement: &Path,
498    lock: &rightkit_fs::LockOptions,
499) -> Result<()> {
500    let _guard = StoreLock::exclusive(&options.path, lock)?;
501    replace_database_locked(options, replacement)
502}
503
504/// [`replace_database`] for a caller that already holds the exclusive [`StoreLock`] for
505/// `options.path` (for example to do more work under the same lock).
506pub fn replace_database_locked(options: &DbOptions, replacement: &Path) -> Result<()> {
507    let target = options.path.as_path();
508    let source = validated_backup(options, replacement)?;
509    let staging = temp_path(target)?;
510    let staged = (|| -> Result<()> {
511        online_backup_sized(options, &source, &staging, None)?;
512        drop(source);
513        remove_database_files_except_main(target)
514            .map_err(|e| DbError::Backup(format!("clear live sidecars: {e}")))?;
515        std::fs::rename(&staging, target)
516            .map_err(|e| DbError::Backup(format!("publish replacement: {e}")))?;
517        if let Some(parent) = target.parent().filter(|p| !p.as_os_str().is_empty()) {
518            rightkit_fs::atomic::sync_dir(parent);
519        }
520        Ok(())
521    })();
522    if staged.is_err() {
523        let _ = std::fs::remove_file(&staging);
524    }
525    staged
526}
527
528fn remove_database_files_except_main(path: &Path) -> std::io::Result<()> {
529    let mut first_error = None;
530    for suffix in ["-wal", "-shm", "-journal"] {
531        let mut name = path.as_os_str().to_os_string();
532        name.push(suffix);
533        if let Err(e) = std::fs::remove_file(PathBuf::from(name)) {
534            if e.kind() != std::io::ErrorKind::NotFound && first_error.is_none() {
535                first_error = Some(e);
536            }
537        }
538    }
539    first_error.map_or(Ok(()), Err)
540}