Skip to main content

sqlite_graphrag/commands/
vacuum.rs

1//! Handler for the `vacuum` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::output;
5use crate::output::JsonOutputFormat;
6use crate::paths::AppPaths;
7use crate::storage::connection::open_rw;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11#[command(after_long_help = "EXAMPLES:\n  \
12    # Run VACUUM after WAL checkpoint (default)\n  \
13    sqlite-graphrag vacuum\n\n  \
14    # Vacuum a database at a custom path\n  \
15    sqlite-graphrag vacuum --db /path/to/graphrag.sqlite\n\n  \
16    # Explicit database path\n  \
17    sqlite-graphrag vacuum --db /data/graphrag.sqlite\n\n\
18NOTE:\n  \
19    reclaimed_bytes may report 0 even after `purge` if removed memories did not\n  \
20    span entire SQLite pages (page size = 4 KB). Run `vacuum` regularly only on\n  \
21    large databases (> 10 MB) for measurable gains.")]
22/// Vacuum args.
23pub struct VacuumArgs {
24    /// Emit machine-readable JSON on stdout.
25    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
26    pub json: bool,
27    /// Run a WAL checkpoint before and after `VACUUM`.
28    #[arg(long, default_value_t = true)]
29    pub checkpoint: bool,
30    /// Output format.
31    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
32    pub format: JsonOutputFormat,
33    /// Path to the SQLite database file.
34    #[arg(long)]
35    pub db: Option<String>,
36}
37
38#[derive(Serialize)]
39struct VacuumResponse {
40    db_path: String,
41    size_before_bytes: u64,
42    size_after_bytes: u64,
43    /// Bytes reclaimed by VACUUM (size_before_bytes - size_after_bytes), saturating to zero.
44    /// Derived field added in v1.0.34 so callers do not have to compute the delta themselves.
45    reclaimed_bytes: u64,
46    status: String,
47    /// Total execution time in milliseconds from handler start to serialisation.
48    elapsed_ms: u64,
49}
50
51/// Run.
52pub fn run(args: VacuumArgs) -> Result<(), AppError> {
53    let start = std::time::Instant::now();
54    let _ = args.format;
55    let paths = AppPaths::resolve(args.db.as_deref())?;
56
57    crate::storage::connection::ensure_db_ready(&paths)?;
58
59    let size_before_bytes = std::fs::metadata(&paths.db)
60        .map(|meta| meta.len())
61        .unwrap_or(0);
62    let conn = open_rw(&paths.db)?;
63    if args.checkpoint {
64        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
65    }
66    conn.execute_batch("VACUUM;")?;
67    if args.checkpoint {
68        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
69    }
70    drop(conn);
71    let size_after_bytes = std::fs::metadata(&paths.db)
72        .map(|meta| meta.len())
73        .unwrap_or(0);
74
75    output::emit_json(&VacuumResponse {
76        db_path: paths.db.display().to_string(),
77        size_before_bytes,
78        size_after_bytes,
79        reclaimed_bytes: size_before_bytes.saturating_sub(size_after_bytes),
80        status: "ok".to_string(),
81        elapsed_ms: start.elapsed().as_millis() as u64,
82    })?;
83
84    Ok(())
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn vacuum_response_serializes_all_fields() {
93        let resp = VacuumResponse {
94            db_path: "/home/user/.local/share/sqlite-graphrag/db.sqlite".to_string(),
95            size_before_bytes: 32768,
96            size_after_bytes: 16384,
97            reclaimed_bytes: 16384,
98            status: "ok".to_string(),
99            elapsed_ms: 55,
100        };
101        let json = serde_json::to_value(&resp).expect("serialization failed");
102        assert_eq!(
103            json["db_path"],
104            "/home/user/.local/share/sqlite-graphrag/db.sqlite"
105        );
106        assert_eq!(json["size_before_bytes"], 32768u64);
107        assert_eq!(json["size_after_bytes"], 16384u64);
108        assert_eq!(json["reclaimed_bytes"], 16384u64);
109        assert_eq!(json["status"], "ok");
110        assert_eq!(json["elapsed_ms"], 55u64);
111    }
112
113    #[test]
114    fn vacuum_response_size_after_less_than_or_equal_to_before() {
115        let resp = VacuumResponse {
116            db_path: "/data/db.sqlite".to_string(),
117            size_before_bytes: 65536,
118            size_after_bytes: 32768,
119            reclaimed_bytes: 32768,
120            status: "ok".to_string(),
121            elapsed_ms: 100,
122        };
123        let json = serde_json::to_value(&resp).expect("serialization failed");
124        let before = json["size_before_bytes"].as_u64().unwrap();
125        let after = json["size_after_bytes"].as_u64().unwrap();
126        let reclaimed = json["reclaimed_bytes"].as_u64().unwrap();
127        assert!(
128            after <= before,
129            "size_after_bytes must be <= size_before_bytes after VACUUM"
130        );
131        assert_eq!(
132            reclaimed,
133            before - after,
134            "reclaimed_bytes must equal size_before_bytes - size_after_bytes"
135        );
136    }
137
138    #[test]
139    fn vacuum_response_status_ok() {
140        let resp = VacuumResponse {
141            db_path: "/data/db.sqlite".to_string(),
142            size_before_bytes: 0,
143            size_after_bytes: 0,
144            reclaimed_bytes: 0,
145            status: "ok".to_string(),
146            elapsed_ms: 0,
147        };
148        let json = serde_json::to_value(&resp).expect("serialization failed");
149        assert_eq!(json["status"], "ok");
150    }
151
152    #[test]
153    fn vacuum_response_elapsed_ms_present_and_non_negative() {
154        let resp = VacuumResponse {
155            db_path: "/data/db.sqlite".to_string(),
156            size_before_bytes: 1024,
157            size_after_bytes: 1024,
158            reclaimed_bytes: 0,
159            status: "ok".to_string(),
160            elapsed_ms: 0,
161        };
162        let json = serde_json::to_value(&resp).expect("serialization failed");
163        assert!(
164            json.get("elapsed_ms").is_some(),
165            "elapsed_ms field must be present"
166        );
167        assert!(
168            json["elapsed_ms"].as_u64().is_some(),
169            "elapsed_ms must be a non-negative integer"
170        );
171    }
172}