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}