1use 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 fn step(&mut self, remaining: i32) -> bool;
22}
23
24pub 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 let key = options.key_pragma_value()?;
58 check_deadline()?;
59 let (temp, created) = create_private_staging(target)?;
60 let result = (|| -> Result<()> {
61 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 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 _ => {
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 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 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
137fn 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
158fn 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
171pub 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 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 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
259pub 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
283pub 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#[derive(Debug)]
298pub struct StoreLock {
299 lock: rightkit_fs::FileLock,
300}
301
302impl StoreLock {
303 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 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 pub fn path(&self) -> &Path {
318 self.lock.path()
319 }
320}
321
322pub 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
351pub 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#[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 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}