Skip to main content

bhtune_db/
backup.rs

1//! Full-database backup and restore: a single portable SQLite file out, and back in.
2//!
3//! Independent of `history-retention`'s age-based deletion of old runs — this is about
4//! moving or protecting an *entire* installation (support diagnostics, migrating to a new
5//! machine, or a safety net immediately before a risky operation), not about pruning
6//! individual runs. Exposed to both the CLI and the GUI.
7
8use std::{
9    ffi::OsStr,
10    path::{Path, PathBuf},
11    time::Duration,
12};
13
14use chrono::{DateTime, Utc};
15use sqlx::{
16    SqlitePool,
17    sqlite::{SqliteConnectOptions, SqlitePoolOptions},
18};
19
20use crate::{
21    error::{DbError, DbResult},
22    pool::connect,
23};
24
25/// How long the pre-restore exclusivity probe waits on a lock before concluding another
26/// connection is genuinely still using the database, rather than just finishing up.
27///
28/// Deliberately much shorter than [`crate::pool::connect`]'s own busy timeout (10 seconds):
29/// that timeout exists so ordinary contended *work* (a retention sweep, a large export) has
30/// room to finish rather than erroring out. This one exists only to answer "is anyone here
31/// right now", so it should fail fast — a restore command that appears to hang for ten
32/// seconds every time a read happened moments earlier would be a poor, confusing experience
33/// for what is meant to be a quick, decisive check. A short grace window still lets a
34/// genuinely fleeting reader (one that's already committing) avoid a false positive.
35const EXCLUSIVITY_PROBE_TIMEOUT: Duration = Duration::from_millis(200);
36
37/// Writes a complete, consistent, compacted snapshot of `pool`'s database to `dest`.
38///
39/// Uses SQLite's own `VACUUM INTO`: an online operation (it doesn't block `pool`'s other
40/// readers/writers) that produces a plain, single-file, non-WAL database — the most portable
41/// on-disk form, with no `-wal`/`-shm` sidecar files that would also need copying.
42///
43/// `dest` must not already exist. `VACUUM INTO` refuses to overwrite a file, and that's the
44/// right behavior for a backup command: silently clobbering a previous backup because of a
45/// reused filename would be its own data-loss bug, not a convenience worth having.
46pub async fn backup_to(pool: &SqlitePool, dest: &Path) -> DbResult<()> {
47    if dest.exists() {
48        return Err(DbError::BackupDestinationExists(dest.to_path_buf()));
49    }
50
51    // `VACUUM INTO`'s target is a string expression, not a path — SQLite has no notion of
52    // `std::path::Path`. A lossy conversion (rather than requiring valid UTF-8) is fine here:
53    // it only affects the extremely rare non-UTF-8 filename, and failing loudly for that
54    // vanishingly unlikely case isn't worth the extra fallible API surface.
55    sqlx::query("VACUUM INTO ?")
56        .bind(dest.display().to_string())
57        .execute(pool)
58        .await
59        .map_err(DbError::Query)?;
60
61    validate_backup_file(dest).await
62}
63
64/// The result of a successful [`restore_from`]: the fresh pool to use going forward, and
65/// where the previously-live database was safety-copied to before being overwritten.
66#[derive(Debug)]
67pub struct RestoreOutcome {
68    pub pool: SqlitePool,
69    /// `None` only when `db_path` didn't exist yet (a fresh install being restored into for
70    /// the first time) — there was nothing to safety-copy.
71    pub pre_restore_backup: Option<PathBuf>,
72}
73
74/// Restores `db_path` from a [`backup_to`]-produced file, replacing its entire contents.
75///
76/// Takes `pool` **by value**, not `&SqlitePool`: restoring means the file underneath every
77/// existing connection is about to be replaced out from under them, so the caller's old pool
78/// must be given up, not merely borrowed. The type system then enforces that the old handle
79/// can't accidentally go on being used afterward — every caller must switch to
80/// [`RestoreOutcome::pool`].
81///
82/// Safety, in order:
83/// 1. `backup_path` is validated (`PRAGMA integrity_check`, plus confirming a real
84///    `tune_runs` table exists) *before* anything about the live database is touched, so a
85///    corrupt or unrelated file never gets a chance to destroy good data.
86/// 2. The caller's own connections to `db_path` are closed first, so step 3's exclusivity
87///    check isn't confused by this process's own still-open pool.
88/// 3. If `db_path` already exists, [`exclusive_pre_restore_snapshot`] both confirms no other
89///    connection — in this process or another — still holds it open, and, while that's
90///    proven true, takes a consistent `VACUUM INTO` copy of it (see
91///    [`RestoreOutcome::pre_restore_backup`]). Restoring the wrong backup, or restoring when
92///    a fresh backup was what was actually wanted, is still recoverable afterward. Using
93///    `VACUUM INTO` here rather than a raw file copy means the safety copy can never be
94///    silently missing data that was still sitting in a WAL file — the same reason
95///    [`backup_to`] uses it.
96/// 4. The backup is copied into place via write-to-a-temp-file-then-rename, so a crash or a
97///    full disk mid-copy leaves the original `db_path` untouched rather than half-overwritten
98///    (rename onto an existing path is atomic on the same filesystem, which a same-directory
99///    temp file guarantees; renaming a file onto an existing file — as opposed to a
100///    directory — replaces it on Windows too, via `MOVEFILE_REPLACE_EXISTING`, so no
101///    Windows-specific fallback is needed here).
102/// 5. Any stale `-wal`/`-shm` sidecar files left over from the old `db_path` are removed —
103///    they describe uncommitted changes to a database that, after step 4, no longer exists
104///    at that path.
105/// 6. `db_path` is reopened via [`connect`], which reapplies the standard pragmas and runs
106///    any migrations the backup predates forward — restoring an older backup transparently
107///    upgrades its schema, exactly as opening an old database file normally would.
108///
109/// Restoring while *another* bhtune process (for instance `bhtune-server`, running
110/// alongside the CLI) has `db_path` open returns [`DbError::DatabaseInUse`] instead of
111/// proceeding — see [`exclusive_pre_restore_snapshot`] for how that's detected and its
112/// residual, deliberately-accepted race.
113pub async fn restore_from(
114    pool: SqlitePool,
115    db_path: &Path,
116    backup_path: &Path,
117    now: DateTime<Utc>,
118) -> DbResult<RestoreOutcome> {
119    validate_backup_file(backup_path).await?;
120
121    // Wait for every connection to be gracefully closed (not just dropped) before touching
122    // the file at the OS level — an open file handle can otherwise block the rename/delete
123    // calls below outright, especially on Windows, and so the exclusivity probe below isn't
124    // confused by this process's own still-open connections.
125    pool.close().await;
126
127    let pre_restore_backup = if db_path.exists() {
128        Some(exclusive_pre_restore_snapshot(db_path, now).await?)
129    } else {
130        None
131    };
132
133    let tmp_path = sibling_path(db_path, ".restoring-tmp");
134    std::fs::copy(backup_path, &tmp_path).map_err(DbError::Io)?;
135    std::fs::rename(&tmp_path, db_path).map_err(DbError::Io)?;
136
137    for suffix in ["-wal", "-shm"] {
138        let sidecar = sibling_path(db_path, suffix);
139        if sidecar.exists() {
140            std::fs::remove_file(&sidecar).map_err(DbError::Io)?;
141        }
142    }
143
144    let pool = connect(db_path).await?;
145    Ok(RestoreOutcome {
146        pool,
147        pre_restore_backup,
148    })
149}
150
151/// Confirms nothing else still holds `db_path` open, and — while that exclusivity is
152/// proven — takes a `VACUUM INTO` safety copy of it before [`restore_from`] overwrites it.
153///
154/// The check is `PRAGMA wal_checkpoint(TRUNCATE)`'s own `busy` column: fully truncating the
155/// WAL requires that no other connection, in this process or any other, is still reading or
156/// writing the database, so a nonzero `busy` result is SQLite's own proof that something
157/// else has it open. No separate lock file or advisory-lock scheme is needed to get that
158/// answer.
159///
160/// This is a point-in-time check, not a held lock: nothing stops a different process from
161/// opening `db_path` in the moment between this returning and [`restore_from`]'s later file
162/// replacement. That residual race is accepted deliberately — it's the "honest fix for the
163/// multi-process case" this was designed for, not a claim of a full distributed lock. A
164/// truly exclusive, lock-held-for-the-whole-restore guarantee would need every bhtune
165/// process to cooperate through a shared lock file from the moment it opens the database,
166/// which is a larger change than this finding's scope.
167///
168/// Deliberately does not go through [`connect`]: that runs migrations, which this must not
169/// do against a database that's about to be discarded and replaced wholesale.
170async fn exclusive_pre_restore_snapshot(db_path: &Path, now: DateTime<Utc>) -> DbResult<PathBuf> {
171    let options = SqliteConnectOptions::new()
172        .filename(db_path)
173        .busy_timeout(EXCLUSIVITY_PROBE_TIMEOUT);
174    let probe_pool = SqlitePoolOptions::new()
175        .max_connections(1)
176        .connect_with(options)
177        .await
178        .map_err(DbError::Connect)?;
179
180    let (busy, _log, _checkpointed): (i64, i64, i64) =
181        sqlx::query_as("PRAGMA wal_checkpoint(TRUNCATE)")
182            .fetch_one(&probe_pool)
183            .await
184            .map_err(DbError::Query)?;
185    if busy != 0 {
186        probe_pool.close().await;
187        return Err(DbError::DatabaseInUse(db_path.to_path_buf()));
188    }
189
190    let sidecar = pre_restore_backup_path(db_path, now);
191    sqlx::query("VACUUM INTO ?")
192        .bind(sidecar.display().to_string())
193        .execute(&probe_pool)
194        .await
195        .map_err(DbError::Query)?;
196
197    probe_pool.close().await;
198    validate_backup_file(&sidecar).await?;
199    Ok(sidecar)
200}
201
202/// Opens `path` read-only and confirms it's a usable bhtune database: SQLite's own
203/// `PRAGMA integrity_check`, plus a real `tune_runs` table (a cheap proxy for "this is a
204/// bhtune database", not just any SQLite file). Read-only so validating a backup can never
205/// itself be the thing that corrupts or migrates it.
206async fn validate_backup_file(path: &Path) -> DbResult<()> {
207    if !path.exists() {
208        return Err(DbError::InvalidBackup(format!(
209            "{} does not exist",
210            path.display()
211        )));
212    }
213
214    let options = SqliteConnectOptions::new().filename(path).read_only(true);
215    let check_pool = SqlitePoolOptions::new()
216        .max_connections(1)
217        .connect_with(options)
218        .await
219        .map_err(|error| {
220            DbError::InvalidBackup(format!("failed to open as a SQLite database: {error}"))
221        })?;
222
223    let (integrity,): (String,) = sqlx::query_as("PRAGMA integrity_check")
224        .fetch_one(&check_pool)
225        .await
226        .map_err(|error| {
227            DbError::InvalidBackup(format!("failed to run integrity_check: {error}"))
228        })?;
229    if integrity != "ok" {
230        return Err(DbError::InvalidBackup(format!(
231            "integrity_check reported: {integrity}"
232        )));
233    }
234
235    let tune_runs_table_count: i64 = sqlx::query_scalar(
236        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tune_runs'",
237    )
238    .fetch_one(&check_pool)
239    .await
240    .map_err(|error| DbError::InvalidBackup(format!("failed to inspect its schema: {error}")))?;
241    if tune_runs_table_count == 0 {
242        return Err(DbError::InvalidBackup(
243            "missing the tune_runs table -- doesn't look like a bhtune database".to_string(),
244        ));
245    }
246
247    Ok(())
248}
249
250/// A backup taken immediately before `db_path` was overwritten by [`restore_from`], named
251/// `<db file name>.pre-restore-<UTC timestamp>.bak` in the same directory.
252fn pre_restore_backup_path(db_path: &Path, now: DateTime<Utc>) -> PathBuf {
253    sibling_path(
254        db_path,
255        &format!(".pre-restore-{}.bak", now.format("%Y%m%dT%H%M%SZ")),
256    )
257}
258
259/// `db_path` with `suffix` appended directly to its file name (not its extension replaced —
260/// SQLite's own `-wal`/`-shm` sidecar convention is a literal suffix, e.g. `bhtune.db-wal`,
261/// which [`Path::with_extension`] cannot express).
262fn sibling_path(db_path: &Path, suffix: &str) -> PathBuf {
263    let file_name = db_path
264        .file_name()
265        .and_then(OsStr::to_str)
266        .unwrap_or("bhtune.db");
267    db_path.with_file_name(format!("{file_name}{suffix}"))
268}
269
270#[cfg(test)]
271mod tests {
272    use bhtune_core::built_in_templates;
273
274    use super::*;
275    use crate::models::{DcsTemplateRow, TemplateOrigin};
276
277    async fn template_names(pool: &SqlitePool) -> Vec<String> {
278        let mut names: Vec<String> = sqlx::query_scalar("SELECT name FROM dcs_templates")
279            .fetch_all(pool)
280            .await
281            .unwrap();
282        names.sort();
283        names
284    }
285
286    async fn seed_one_template(pool: &SqlitePool, name: &str, now: DateTime<Utc>) {
287        let mut template = built_in_templates().into_iter().next().unwrap();
288        template.name = name.to_string();
289        DcsTemplateRow::insert(pool, &template, TemplateOrigin::Builtin, now)
290            .await
291            .unwrap();
292    }
293
294    fn expect_invalid_backup(error: DbError) -> String {
295        match error {
296            DbError::InvalidBackup(message) => message,
297            other => panic!("expected InvalidBackup, got {other:?}"),
298        }
299    }
300
301    #[tokio::test]
302    async fn backup_to_produces_a_reopenable_file_with_the_same_data() {
303        let dir = tempfile::tempdir().unwrap();
304        let now = Utc::now();
305        let pool = connect(&dir.path().join("live.db")).await.unwrap();
306        seed_one_template(&pool, "Backed Up Template", now).await;
307
308        let dest = dir.path().join("backup.db");
309        backup_to(&pool, &dest).await.unwrap();
310        assert!(dest.exists());
311
312        let reopened = connect(&dest).await.unwrap();
313        assert_eq!(
314            template_names(&reopened).await,
315            vec!["Backed Up Template".to_string()]
316        );
317    }
318
319    #[tokio::test]
320    async fn backup_to_refuses_to_overwrite_an_existing_destination() {
321        let dir = tempfile::tempdir().unwrap();
322        let pool = connect(&dir.path().join("live.db")).await.unwrap();
323
324        let dest = dir.path().join("backup.db");
325        std::fs::write(&dest, b"already here").unwrap();
326
327        let err = backup_to(&pool, &dest).await.unwrap_err();
328        assert!(matches!(err, DbError::BackupDestinationExists(path) if path == dest));
329        // The pre-existing file must be left exactly as it was, not touched or truncated.
330        assert_eq!(std::fs::read(&dest).unwrap(), b"already here");
331    }
332
333    #[tokio::test]
334    async fn restore_from_replaces_the_live_database_with_the_backups_contents() {
335        let dir = tempfile::tempdir().unwrap();
336        let now = Utc::now();
337
338        let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
339        seed_one_template(&backup_source_pool, "From Backup", now).await;
340        let backup_path = dir.path().join("backup.db");
341        backup_to(&backup_source_pool, &backup_path).await.unwrap();
342
343        let live_path = dir.path().join("live.db");
344        let live_pool = connect(&live_path).await.unwrap();
345        seed_one_template(&live_pool, "Still Live", now).await;
346
347        let outcome = restore_from(live_pool, &live_path, &backup_path, now)
348            .await
349            .unwrap();
350
351        assert_eq!(
352            template_names(&outcome.pool).await,
353            vec!["From Backup".to_string()],
354            "restoring must replace the live data with the backup's, not merge the two"
355        );
356    }
357
358    #[tokio::test]
359    async fn restore_from_writes_a_pre_restore_safety_copy_of_the_previous_live_file() {
360        let dir = tempfile::tempdir().unwrap();
361        let now = Utc::now();
362
363        let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
364        let backup_path = dir.path().join("backup.db");
365        backup_to(&backup_source_pool, &backup_path).await.unwrap();
366
367        let live_path = dir.path().join("live.db");
368        let live_pool = connect(&live_path).await.unwrap();
369        seed_one_template(&live_pool, "About To Be Overwritten", now).await;
370
371        let outcome = restore_from(live_pool, &live_path, &backup_path, now)
372            .await
373            .unwrap();
374
375        let safety_copy = outcome
376            .pre_restore_backup
377            .expect("a live db existed before the restore, so a safety copy must be made");
378        assert!(safety_copy.exists());
379        let safety_copy_pool = connect(&safety_copy).await.unwrap();
380        assert_eq!(
381            template_names(&safety_copy_pool).await,
382            vec!["About To Be Overwritten".to_string()],
383            "the safety copy must preserve the old data, not the newly restored data"
384        );
385    }
386
387    #[tokio::test]
388    async fn restore_from_reports_no_pre_restore_backup_when_db_path_is_a_fresh_install() {
389        let dir = tempfile::tempdir().unwrap();
390        let now = Utc::now();
391
392        let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
393        let backup_path = dir.path().join("backup.db");
394        backup_to(&backup_source_pool, &backup_path).await.unwrap();
395
396        // A path that has never been connect()'d, matching a fresh install choosing to
397        // "restore" as its very first action instead of starting empty. `restore_from`
398        // doesn't require its `pool` argument to already be open against `db_path` — it
399        // only needs a pool it can close before touching the file — so an unrelated
400        // in-memory pool exercises this branch just as well as a real one would.
401        let live_path = dir.path().join("never-existed.db");
402        let live_pool = crate::pool::connect_in_memory().await.unwrap();
403
404        let outcome = restore_from(live_pool, &live_path, &backup_path, now)
405            .await
406            .unwrap();
407        assert_eq!(outcome.pre_restore_backup, None);
408    }
409
410    #[tokio::test]
411    async fn restore_from_rejects_an_invalid_backup_file_without_touching_the_live_database() {
412        let dir = tempfile::tempdir().unwrap();
413        let now = Utc::now();
414
415        let live_path = dir.path().join("live.db");
416        let live_pool = connect(&live_path).await.unwrap();
417        seed_one_template(&live_pool, "Must Survive", now).await;
418
419        let bogus_backup = dir.path().join("not-a-database.db");
420        std::fs::write(&bogus_backup, b"not a sqlite file at all").unwrap();
421
422        let err = restore_from(live_pool, &live_path, &bogus_backup, now)
423            .await
424            .unwrap_err();
425        assert!(matches!(err, DbError::InvalidBackup(_)));
426
427        // Validation must run before the live database is touched at all.
428        let live_pool_again = connect(&live_path).await.unwrap();
429        assert_eq!(
430            template_names(&live_pool_again).await,
431            vec!["Must Survive".to_string()]
432        );
433    }
434
435    // The remaining tests exercise `validate_backup_file`'s four distinct failure branches
436    // directly, since `restore_from`/`backup_to` collapse all of them to the same
437    // `DbError::InvalidBackup` variant and none of these specific underlying causes are
438    // otherwise reachable through the public API alone.
439
440    #[tokio::test]
441    async fn validate_backup_file_reports_a_path_that_does_not_exist() {
442        let dir = tempfile::tempdir().unwrap();
443        let missing = dir.path().join("never-written.db");
444
445        let err = validate_backup_file(&missing).await.unwrap_err();
446        assert!(expect_invalid_backup(err).contains("does not exist"));
447    }
448
449    #[tokio::test]
450    async fn validate_backup_file_reports_a_path_that_cannot_be_opened_as_sqlite_at_all() {
451        let dir = tempfile::tempdir().unwrap();
452        // A directory exists, so the earlier `path.exists()` check passes, but SQLite
453        // cannot open a directory as a database file — this fails at connection time,
454        // before any query (including `PRAGMA integrity_check`) is ever issued.
455        let err = validate_backup_file(dir.path()).await.unwrap_err();
456        assert!(expect_invalid_backup(err).contains("failed to open as a SQLite database"));
457    }
458
459    #[tokio::test]
460    async fn validate_backup_file_reports_a_database_that_fails_its_integrity_check() {
461        let dir = tempfile::tempdir().unwrap();
462        let db_path = dir.path().join("corrupt.db");
463
464        let pool = connect(&db_path).await.unwrap();
465        pool.close().await;
466
467        // SQLite's file header stores "total number of freelist pages" at byte offset 36
468        // (big-endian u32); a fresh database has no freed pages, so the linked list of
469        // freelist trunk pages is empty and this count is 0. Declaring a nonzero count here
470        // (without actually creating any freelist pages) is a direct, header-only
471        // inconsistency that `PRAGMA integrity_check` detects deterministically -- it
472        // doesn't depend on the table schema or how much data is in the file, unlike
473        // corrupting page content, which is fragile across page-layout and free-space
474        // details `integrity_check` may or may not happen to walk over.
475        use std::io::{Seek, SeekFrom, Write};
476        let mut file = std::fs::OpenOptions::new()
477            .write(true)
478            .open(&db_path)
479            .unwrap();
480        file.seek(SeekFrom::Start(36)).unwrap();
481        file.write_all(&5u32.to_be_bytes()).unwrap();
482        drop(file);
483
484        let err = validate_backup_file(&db_path).await.unwrap_err();
485        assert!(expect_invalid_backup(err).contains("integrity_check reported"));
486    }
487
488    #[tokio::test]
489    async fn validate_backup_file_reports_a_valid_sqlite_file_with_no_tune_runs_table() {
490        let dir = tempfile::tempdir().unwrap();
491        let db_path = dir.path().join("not-a-bhtune-db.db");
492
493        // A genuine, valid, empty SQLite database that never went through bhtune's own
494        // `connect()`/migrations -- passes `integrity_check` cleanly, but has no schema at
495        // all, let alone a `tune_runs` table.
496        let options = SqliteConnectOptions::new()
497            .filename(&db_path)
498            .create_if_missing(true);
499        let pool = SqlitePoolOptions::new()
500            .max_connections(1)
501            .connect_with(options)
502            .await
503            .unwrap();
504        pool.close().await;
505
506        let err = validate_backup_file(&db_path).await.unwrap_err();
507        assert!(expect_invalid_backup(err).contains("tune_runs"));
508    }
509
510    #[test]
511    fn invalid_backup_assertion_fails_clearly_for_another_database_error() {
512        let panic = std::panic::catch_unwind(|| {
513            expect_invalid_backup(DbError::DatabaseInUse(PathBuf::from("live.db")))
514        })
515        .unwrap_err();
516        assert!(
517            panic
518                .downcast_ref::<String>()
519                .is_some_and(|message| message.contains("InvalidBackup"))
520        );
521    }
522
523    #[tokio::test]
524    async fn restore_from_removes_orphaned_wal_and_shm_sidecars_with_no_backing_connection() {
525        let dir = tempfile::tempdir().unwrap();
526        let now = Utc::now();
527
528        let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
529        seed_one_template(&backup_source_pool, "From Backup", now).await;
530        let backup_path = dir.path().join("backup.db");
531        backup_to(&backup_source_pool, &backup_path).await.unwrap();
532
533        let live_path = dir.path().join("live.db");
534        let live_pool = connect(&live_path).await.unwrap();
535
536        // A graceful `Pool::close()` on the *last* connection to a database already makes
537        // SQLite clean up its own real WAL/SHM sidecars (confirmed empirically), so they
538        // can never be observed as "stale" by the time `restore_from` reaches its own
539        // removal loop. Closing here first, then writing garbage bytes to the same sidecar
540        // paths, simulates the case the removal loop actually exists for: files orphaned
541        // by something with no live connection at all (an unclean shutdown, a killed
542        // process, or a previous restore interrupted between steps) — not ordinary ones a
543        // clean close already disposes of.
544        live_pool.close().await;
545        let wal_path = sibling_path(&live_path, "-wal");
546        let shm_path = sibling_path(&live_path, "-shm");
547        std::fs::write(&wal_path, b"orphaned wal content").unwrap();
548        std::fs::write(&shm_path, b"orphaned shm content").unwrap();
549        assert!(wal_path.exists());
550        assert!(shm_path.exists());
551
552        // `Pool::close()` is safe to call again inside `restore_from` on an already-closed
553        // pool, so the same (closed) handle can still be moved in by value here.
554        let outcome = restore_from(live_pool, &live_path, &backup_path, now)
555            .await
556            .unwrap();
557
558        // The proactive removal in `restore_from` (rather than relying on SQLite's own
559        // salt-mismatch detection to safely ignore an incompatible WAL) means the restored
560        // database reopens cleanly with exactly the backup's data — not blocked, and not
561        // confused by the stale bytes that were sitting at the same sidecar paths.
562        assert_eq!(
563            template_names(&outcome.pool).await,
564            vec!["From Backup".to_string()]
565        );
566    }
567
568    #[tokio::test]
569    async fn restore_from_removes_orphaned_sidecars_even_when_db_path_itself_is_missing() {
570        let dir = tempfile::tempdir().unwrap();
571        let now = Utc::now();
572
573        let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
574        seed_one_template(&backup_source_pool, "From Backup", now).await;
575        let backup_path = dir.path().join("backup.db");
576        backup_to(&backup_source_pool, &backup_path).await.unwrap();
577
578        // `db_path` itself has never existed -- so the pre-restore exclusivity check and
579        // safety snapshot are skipped entirely, there being nothing live to check or copy
580        // -- but stale `-wal`/`-shm` sidecar files sit at the paths it would use anyway,
581        // e.g. left behind by something that removed just the main file by hand. These
582        // must still be cleared before the restored file is written there, or the freshly
583        // restored database would be confused by unrelated WAL content sitting beside it.
584        // This is the one path that can reach the sidecar-removal loop directly: whenever
585        // `db_path` already exists, [`exclusive_pre_restore_snapshot`]'s own
586        // open-checkpoint-close sequence already disposes of any stale sidecars as a side
587        // effect, before the removal loop ever runs.
588        let live_path = dir.path().join("live.db");
589        let wal_path = sibling_path(&live_path, "-wal");
590        let shm_path = sibling_path(&live_path, "-shm");
591        std::fs::write(&wal_path, b"orphaned wal content").unwrap();
592        std::fs::write(&shm_path, b"orphaned shm content").unwrap();
593
594        let live_pool = crate::pool::connect_in_memory().await.unwrap();
595        let outcome = restore_from(live_pool, &live_path, &backup_path, now)
596            .await
597            .unwrap();
598
599        // `connect()`'s own final reopen legitimately creates a fresh `-wal` file for the
600        // new pool (ordinary WAL-mode operation), so the sidecar path may exist again by
601        // now -- what the removal loop must guarantee is that the *garbage* content is
602        // gone, not that no file is ever present at that path again.
603        if let Ok(contents) = std::fs::read(&wal_path) {
604            assert_ne!(contents, b"orphaned wal content");
605        }
606        assert_eq!(
607            template_names(&outcome.pool).await,
608            vec!["From Backup".to_string()]
609        );
610    }
611
612    #[tokio::test]
613    async fn restore_from_preserves_the_live_database_when_staging_the_replacement_fails() {
614        let dir = tempfile::tempdir().unwrap();
615        let now = Utc::now();
616        let live_path = dir.path().join("live.db");
617        let backup_path = dir.path().join("backup.db");
618        let pool = connect(&live_path).await.unwrap();
619        seed_one_template(&pool, "Live Template", now).await;
620        backup_to(&pool, &backup_path).await.unwrap();
621
622        // `restore_from` stages the replacement beside the live database. A directory at
623        // that exact staging path makes the copy fail before the atomic rename can touch
624        // the live file.
625        std::fs::create_dir(sibling_path(&live_path, ".restoring-tmp")).unwrap();
626        let error = restore_from(pool, &live_path, &backup_path, now)
627            .await
628            .unwrap_err();
629        assert!(matches!(error, DbError::Io(_)));
630
631        let reopened = connect(&live_path).await.unwrap();
632        assert_eq!(template_names(&reopened).await, vec!["Live Template"]);
633    }
634
635    #[tokio::test]
636    async fn restore_from_refuses_when_another_connection_still_holds_the_live_database_open() {
637        let dir = tempfile::tempdir().unwrap();
638        let now = Utc::now();
639
640        let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
641        let backup_path = dir.path().join("backup.db");
642        backup_to(&backup_source_pool, &backup_path).await.unwrap();
643
644        let live_path = dir.path().join("live.db");
645        let live_pool = connect(&live_path).await.unwrap();
646        seed_one_template(&live_pool, "Must Survive The Refusal", now).await;
647
648        // A second, independent connection to the same file with an open read transaction,
649        // simulating another bhtune process (e.g. `bhtune-server` running alongside the
650        // CLI) that still has the database open. `restore_from` only closes the pool *it*
651        // was handed, so this second connection is exactly what the exclusivity probe
652        // inside `exclusive_pre_restore_snapshot` exists to notice. A `SELECT` (not just
653        // `BEGIN`) is required to actually establish a WAL read snapshot -- an empty
654        // transaction holds no lock a checkpoint would need to wait on.
655        let blocker_pool = connect(&live_path).await.unwrap();
656        let mut blocker_tx = blocker_pool.begin().await.unwrap();
657        sqlx::query("SELECT COUNT(*) FROM tune_runs")
658            .fetch_one(&mut *blocker_tx)
659            .await
660            .unwrap();
661
662        let err = restore_from(live_pool, &live_path, &backup_path, now)
663            .await
664            .unwrap_err();
665        assert!(matches!(err, DbError::DatabaseInUse(path) if path == live_path));
666
667        // The live database must be left completely untouched by the refused attempt.
668        drop(blocker_tx);
669        blocker_pool.close().await;
670        let live_pool_again = connect(&live_path).await.unwrap();
671        assert_eq!(
672            template_names(&live_pool_again).await,
673            vec!["Must Survive The Refusal".to_string()]
674        );
675    }
676
677    #[tokio::test]
678    async fn restore_from_succeeds_once_the_blocking_connection_is_released() {
679        let dir = tempfile::tempdir().unwrap();
680        let now = Utc::now();
681
682        let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
683        seed_one_template(&backup_source_pool, "From Backup", now).await;
684        let backup_path = dir.path().join("backup.db");
685        backup_to(&backup_source_pool, &backup_path).await.unwrap();
686
687        let live_path = dir.path().join("live.db");
688        let live_pool = connect(&live_path).await.unwrap();
689
690        let blocker_pool = connect(&live_path).await.unwrap();
691        let mut blocker_tx = blocker_pool.begin().await.unwrap();
692        sqlx::query("SELECT COUNT(*) FROM tune_runs")
693            .fetch_one(&mut *blocker_tx)
694            .await
695            .unwrap();
696
697        // `restore_from` already closed `live_pool` before discovering the blocker, so that
698        // handle is spent regardless of the outcome -- a real caller (e.g. a CLI command)
699        // would need to reconnect before trying again, exactly as this test does below.
700        let err = restore_from(live_pool, &live_path, &backup_path, now)
701            .await
702            .unwrap_err();
703        assert!(matches!(err, DbError::DatabaseInUse(_)));
704
705        drop(blocker_tx);
706        blocker_pool.close().await;
707
708        let live_pool_retry = connect(&live_path).await.unwrap();
709        let outcome = restore_from(live_pool_retry, &live_path, &backup_path, now)
710            .await
711            .unwrap();
712        assert_eq!(
713            template_names(&outcome.pool).await,
714            vec!["From Backup".to_string()],
715            "once the other connection releases its read snapshot, the check must clear and the restore proceed"
716        );
717    }
718}