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.
26pub fn online_backup_to(
27    options: &DbOptions,
28    source: &Connection,
29    target: &Path,
30    mut progress: Option<&mut dyn BackupProgress>,
31) -> Result<()> {
32    let key = options.key_pragma_value()?;
33    let temp = temp_path(target)?;
34    let result = (|| -> Result<()> {
35        let mut dst = Connection::open(&temp)?;
36        dst.busy_timeout(options.busy_timeout)?;
37        apply_key_first(&dst, key.as_ref().map(|k| k.as_str()))?;
38        {
39            let backup = Backup::new(source, &mut dst)?;
40            loop {
41                let step = backup.step(512)?;
42                if let Some(sink) = progress.as_deref_mut() {
43                    if !sink.step(backup.progress().remaining) {
44                        return Err(DbError::Backup("cancelled".into()));
45                    }
46                }
47                match step {
48                    StepResult::Done => break,
49                    StepResult::More => {}
50                    // Source busy/locked: back off briefly and retry.
51                    _ => std::thread::sleep(Duration::from_millis(25)),
52                }
53            }
54        }
55        // Leave one self-contained file: fold any WAL into the main file and drop WAL mode.
56        let _ = dst.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()));
57        dst.query_row("PRAGMA journal_mode=DELETE", [], |row| {
58            row.get::<_, String>(0)
59        })?;
60        let report: String = dst.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
61        if !report.trim().eq_ignore_ascii_case("ok") {
62            return Err(DbError::Backup(format!(
63                "quick_check on the backup said: {report}"
64            )));
65        }
66        drop(dst);
67        rightkit_fs::restrict_owner_only(&temp)?;
68        // Windows FlushFileBuffers needs a writable handle; a read-only open fails "Access is denied".
69        std::fs::OpenOptions::new()
70            .write(true)
71            .open(&temp)
72            .and_then(|f| f.sync_all())
73            .map_err(|e| DbError::Backup(format!("fsync backup: {e}")))?;
74        std::fs::rename(&temp, target)
75            .map_err(|e| DbError::Backup(format!("publish backup: {e}")))?;
76        if let Some(parent) = target.parent().filter(|p| !p.as_os_str().is_empty()) {
77            rightkit_fs::atomic::sync_dir(parent);
78        }
79        Ok(())
80    })();
81    if result.is_err() {
82        remove_with_sidecars(&temp);
83    }
84    result
85}
86
87/// Validate `backup_path` before copying it into an already-open `destination`.
88/// Uses the SQLite backup API, including committed WAL contents. `options` supplies
89/// the backup's key and timeout; its path is ignored. Invalid backups never touch
90/// `destination`. The destination must be writable and outside a transaction.
91pub fn restore_from(
92    options: &DbOptions,
93    destination: &mut Connection,
94    backup_path: &Path,
95) -> Result<()> {
96    let source = validated_backup(options, backup_path)?;
97    restore_validated(options, &source, destination)
98}
99
100impl DbOptions {
101    /// Validate the backup first, then open the existing live database without
102    /// bootstrap and restore it. Neither missing backup nor missing live DB is created.
103    /// The backup must use the same SQLCipher key as these options.
104    pub fn restore_from(&self, backup_path: impl AsRef<Path>) -> Result<()> {
105        let source = validated_backup(self, backup_path.as_ref())?;
106        let mut destination = self.open_existing_writer()?;
107        restore_validated(self, &source, &mut destination)
108    }
109}
110
111pub(crate) fn validated_backup(options: &DbOptions, path: &Path) -> Result<Connection> {
112    let source = options.open_existing_at(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
113    // Keep the checked snapshot stable until Backup has finished reading it.
114    source.execute_batch("BEGIN;")?;
115    let messages = {
116        let mut statement = source.prepare("PRAGMA integrity_check")?;
117        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
118        rows.collect::<rusqlite::Result<Vec<_>>>()?
119    };
120    if messages.is_empty()
121        || messages
122            .iter()
123            .any(|message| !message.trim().eq_ignore_ascii_case("ok"))
124    {
125        return Err(DbError::Integrity(messages.join("; ")));
126    }
127    Ok(source)
128}
129
130fn restore_validated(
131    options: &DbOptions,
132    source: &Connection,
133    destination: &mut Connection,
134) -> Result<()> {
135    let backup = Backup::new(source, destination)?;
136    let started = Instant::now();
137    loop {
138        match backup.step(512)? {
139            StepResult::Done => return Ok(()),
140            StepResult::More => {}
141            StepResult::Busy | StepResult::Locked => {
142                if started.elapsed() >= options.busy_timeout {
143                    return Err(DbError::Backup("restore busy timeout expired".into()));
144                }
145                std::thread::sleep(Duration::from_millis(25));
146            }
147            _ => return Err(DbError::Backup("unexpected restore step result".into())),
148        }
149    }
150}
151
152fn temp_path(target: &Path) -> Result<PathBuf> {
153    let name = target
154        .file_name()
155        .ok_or(DbError::InvalidDatabasePath)?
156        .to_string_lossy();
157    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
158    Ok(target.with_file_name(format!(
159        ".{name}{}{}-{n}",
160        rightkit_fs::atomic::TEMP_MARKER,
161        std::process::id()
162    )))
163}
164
165fn remove_with_sidecars(path: &Path) {
166    let _ = remove_database_files(path);
167}
168
169/// Delete a database file and its `-wal`, `-shm` and `-journal` sidecars.
170///
171/// A file that does not exist is not an error. Every file is attempted even if an earlier one
172/// fails; the first real failure is returned. On Windows a file still open by a connection
173/// cannot be deleted, so drop the writer connection and close the read pool first
174/// (`ReadPool::close_and_wait` releases every pooled reader).
175pub fn remove_database_files(path: &Path) -> std::io::Result<()> {
176    let mut first_error = None;
177    let mut remove = |p: &Path| {
178        if let Err(e) = std::fs::remove_file(p) {
179            if e.kind() != std::io::ErrorKind::NotFound && first_error.is_none() {
180                first_error = Some(e);
181            }
182        }
183    };
184    remove(path);
185    for suffix in ["-wal", "-shm", "-journal"] {
186        let mut name = path.as_os_str().to_os_string();
187        name.push(suffix);
188        remove(&PathBuf::from(name));
189    }
190    first_error.map_or(Ok(()), Err)
191}
192
193/// The lock file that guards the whole store behind the database at `db_path`:
194/// `<db_path>.store-lock`.
195pub fn store_lock_path(db_path: &Path) -> PathBuf {
196    let mut name = db_path.as_os_str().to_os_string();
197    name.push(".store-lock");
198    PathBuf::from(name)
199}
200
201/// A held store-wide lock (see [`store_lock_path`]). Released on drop.
202///
203/// Processes that use the database (daemon, shell) hold a [`StoreLock::shared`] guard for as long
204/// as they keep connections open; [`replace_database`] takes the [`StoreLock::exclusive`] guard,
205/// so a replace runs only when no cooperating process is using the store. The lock is advisory
206/// and cooperative: a process that never takes the shared guard is not excluded.
207#[derive(Debug)]
208pub struct StoreLock {
209    lock: rightkit_fs::FileLock,
210}
211
212impl StoreLock {
213    /// Shared guard: any number of holders, excluded only by an exclusive holder. Waits per
214    /// `options.timeout` and returns `DbError::Fs` (locked or timed out) when busy.
215    pub fn shared(db_path: &Path, options: &rightkit_fs::LockOptions) -> Result<Self> {
216        let lock = rightkit_fs::FileLock::acquire_shared(&store_lock_path(db_path), options)?;
217        Ok(Self { lock })
218    }
219
220    /// Exclusive guard: excludes every shared and exclusive holder. Waits per `options.timeout`.
221    pub fn exclusive(db_path: &Path, options: &rightkit_fs::LockOptions) -> Result<Self> {
222        let lock = rightkit_fs::FileLock::acquire(&store_lock_path(db_path), options)?;
223        Ok(Self { lock })
224    }
225
226    /// Path of the lock file.
227    pub fn path(&self) -> &Path {
228        self.lock.path()
229    }
230}
231
232/// Atomically replace the database at `options.path` with the database at `replacement`.
233///
234/// Under the store-wide exclusive lock ([`StoreLock::exclusive`], waiting per `lock`):
235/// 1. `replacement` is opened read-only with `options`' key and every `PRAGMA integrity_check`
236///    row must be `ok` (`DbError::Integrity` otherwise; the live database is untouched).
237/// 2. Its committed contents (WAL included) are copied through SQLite's backup API into a
238///    staging file beside the live database, checked, narrowed to owner-only and synced.
239/// 3. The live database's `-wal`, `-shm` and `-journal` sidecars are removed, so a stale log
240///    can never be replayed onto the new file, and the staging file is renamed onto the path.
241///
242/// A failure in step 1 or 2 leaves the live database untouched and removes the staging file. The
243/// only crash window is between removing the sidecars and the rename in step 3: the old main
244/// file is then still in place without its uncheckpointed log, and the staging file remains
245/// (reclaimed by `rightkit_fs::sweep_stale_temps`). The replacement keeps the options' key, so
246/// it must be encrypted with the same key.
247///
248/// Every connection to the live database, in this process and in any other, must be closed
249/// before the call: other processes by holding only [`StoreLock::shared`] guards (the exclusive
250/// guard cannot be taken while one is held), this process by dropping its writer and calling
251/// `ReadPool::close_and_wait`. On Windows an open connection also makes the rename fail.
252pub fn replace_database(
253    options: &DbOptions,
254    replacement: &Path,
255    lock: &rightkit_fs::LockOptions,
256) -> Result<()> {
257    let _guard = StoreLock::exclusive(&options.path, lock)?;
258    replace_database_locked(options, replacement)
259}
260
261/// [`replace_database`] for a caller that already holds the exclusive [`StoreLock`] for
262/// `options.path` (for example to do more work under the same lock).
263pub fn replace_database_locked(options: &DbOptions, replacement: &Path) -> Result<()> {
264    let target = options.path.as_path();
265    let source = validated_backup(options, replacement)?;
266    let staging = temp_path(target)?;
267    let staged = (|| -> Result<()> {
268        online_backup_to(options, &source, &staging, None)?;
269        drop(source);
270        remove_database_files_except_main(target)
271            .map_err(|e| DbError::Backup(format!("clear live sidecars: {e}")))?;
272        std::fs::rename(&staging, target)
273            .map_err(|e| DbError::Backup(format!("publish replacement: {e}")))?;
274        if let Some(parent) = target.parent().filter(|p| !p.as_os_str().is_empty()) {
275            rightkit_fs::atomic::sync_dir(parent);
276        }
277        Ok(())
278    })();
279    if staged.is_err() {
280        let _ = std::fs::remove_file(&staging);
281    }
282    staged
283}
284
285fn remove_database_files_except_main(path: &Path) -> std::io::Result<()> {
286    let mut first_error = None;
287    for suffix in ["-wal", "-shm", "-journal"] {
288        let mut name = path.as_os_str().to_os_string();
289        name.push(suffix);
290        if let Err(e) = std::fs::remove_file(PathBuf::from(name)) {
291            if e.kind() != std::io::ErrorKind::NotFound && first_error.is_none() {
292                first_error = Some(e);
293            }
294        }
295    }
296    first_error.map_or(Ok(()), Err)
297}