Skip to main content

musefs_db/
maintenance.rs

1//! Store maintenance operations: compaction (`VACUUM` + WAL checkpoint).
2
3use crate::{Db, DbError, ReadWrite, Result};
4
5impl Db<ReadWrite> {
6    /// Compact the store: reclaim free pages left by deletions, then truncate
7    /// the WAL. Runs a full `VACUUM` (rewrites the whole database — transiently
8    /// needs free disk roughly equal to the store size) followed by
9    /// `PRAGMA wal_checkpoint(TRUNCATE)`. The TRUNCATE checkpoint *after* VACUUM
10    /// is what actually shrinks the main `.db` file on disk and zeroes the
11    /// `-wal`. A busy/locked store (e.g. a live mount) maps to
12    /// [`DbError::StoreInUse`].
13    pub fn vacuum(&self) -> Result<()> {
14        self.conn.execute_batch("VACUUM").map_err(map_vacuum_err)?;
15        self.conn
16            .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")
17            .map_err(map_vacuum_err)?;
18        Ok(())
19    }
20}
21
22/// Translate a VACUUM/checkpoint error: a SQLite busy/locked failure means the
23/// store is open elsewhere (a mount or scan), surfaced as the actionable
24/// [`DbError::StoreInUse`]; everything else flows through the transparent
25/// rusqlite variant.
26fn map_vacuum_err(err: rusqlite::Error) -> DbError {
27    if let rusqlite::Error::SqliteFailure(e, _) = &err
28        && matches!(
29            e.code,
30            rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
31        )
32    {
33        return DbError::StoreInUse(err);
34    }
35    DbError::Sqlite(err)
36}
37
38#[cfg(test)]
39mod tests {
40    use super::map_vacuum_err;
41    use crate::models::NewArt;
42    use crate::{Db, DbError};
43
44    #[test]
45    fn vacuum_shrinks_file_and_truncates_wal_after_deletion() {
46        let dir = tempfile::tempdir().unwrap();
47        let path = dir.path().join("t.db");
48        let db = Db::open(&path).unwrap();
49
50        // Allocate many pages: 16 distinct 256 KiB art blobs (~4 MiB).
51        for i in 0..16u8 {
52            db.upsert_art(&NewArt {
53                mime: "image/png".into(),
54                width: None,
55                height: None,
56                data: vec![i; 256 * 1024],
57            })
58            .unwrap();
59        }
60        // None are linked to a track, so they are all orphan: free their pages.
61        assert_eq!(db.gc_orphan_art().unwrap(), 16);
62
63        // Settle the WAL so the pre-vacuum main-file size reflects the deletes.
64        db.conn
65            .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")
66            .unwrap();
67        let before = std::fs::metadata(&path).unwrap().len();
68
69        db.vacuum().unwrap();
70
71        let after = std::fs::metadata(&path).unwrap().len();
72        assert!(after < before, "expected shrink: {before} -> {after}");
73
74        let freelist: i64 = db
75            .conn
76            .query_row("PRAGMA freelist_count", [], |r| r.get(0))
77            .unwrap();
78        assert_eq!(freelist, 0, "vacuum must leave no free pages");
79
80        // The TRUNCATE checkpoint inside vacuum() must drain the WAL: a
81        // subsequent checkpoint reports 0 frames in the log (column 1 of
82        // `PRAGMA wal_checkpoint` is the WAL frame count). Deterministic, and
83        // unlike a `-wal` file-size check it does not depend on WAL internals.
84        // Without the in-method checkpoint, VACUUM's frames are still pending
85        // here, so this is non-zero and the checkpoint-removal mutant dies.
86        let wal_frames: i64 = db
87            .conn
88            .query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |r| r.get(1))
89            .unwrap();
90        assert_eq!(wal_frames, 0, "vacuum must checkpoint the WAL");
91    }
92
93    #[test]
94    fn vacuum_on_empty_store_is_ok() {
95        let dir = tempfile::tempdir().unwrap();
96        let db = Db::open(dir.path().join("t.db")).unwrap();
97        db.vacuum().unwrap();
98    }
99
100    #[test]
101    fn map_vacuum_err_maps_busy_and_locked_to_store_in_use() {
102        use rusqlite::{Error, ffi};
103        let busy = Error::SqliteFailure(ffi::Error::new(ffi::SQLITE_BUSY), None);
104        assert!(matches!(map_vacuum_err(busy), DbError::StoreInUse(_)));
105        let locked = Error::SqliteFailure(ffi::Error::new(ffi::SQLITE_LOCKED), None);
106        assert!(matches!(map_vacuum_err(locked), DbError::StoreInUse(_)));
107    }
108
109    #[test]
110    fn map_vacuum_err_passes_through_other_errors() {
111        use rusqlite::{Error, ffi};
112        let corrupt = Error::SqliteFailure(ffi::Error::new(ffi::SQLITE_CORRUPT), None);
113        assert!(matches!(map_vacuum_err(corrupt), DbError::Sqlite(_)));
114    }
115}