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    ///
29    /// `default_value_t = true` on a `bool` gives `ArgAction::SetTrue` over a
30    /// default that is already `true`, so this flag cannot change the outcome by
31    /// itself. `--no-checkpoint` is what turns the checkpoints off, following
32    /// the `--auto-describe` / `--no-auto-describe` pair in `ingest`.
33    #[arg(long, default_value_t = true, overrides_with = "no_checkpoint")]
34    pub checkpoint: bool,
35    /// Skip the WAL checkpoints that bracket `VACUUM`.
36    #[arg(
37        long = "no-checkpoint",
38        default_value_t = false,
39        help = "Skip the WAL checkpoint before and after VACUUM"
40    )]
41    pub no_checkpoint: bool,
42    /// Output format.
43    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
44    pub format: JsonOutputFormat,
45    /// Path to the SQLite database file.
46    #[arg(long)]
47    pub db: Option<String>,
48}
49
50#[derive(Serialize)]
51struct VacuumResponse {
52    db_path: String,
53    size_before_bytes: u64,
54    size_after_bytes: u64,
55    /// Bytes reclaimed by VACUUM (size_before_bytes - size_after_bytes), saturating to zero.
56    /// Derived field added in v1.0.34 so callers do not have to compute the delta themselves.
57    reclaimed_bytes: u64,
58    status: String,
59    /// Total execution time in milliseconds from handler start to serialisation.
60    elapsed_ms: u64,
61}
62
63/// Run.
64pub fn run(args: VacuumArgs) -> Result<(), AppError> {
65    let start = std::time::Instant::now();
66    let _ = args.format;
67    let paths = AppPaths::resolve(args.db.as_deref())?;
68
69    crate::storage::connection::ensure_db_ready(&paths)?;
70
71    let size_before_bytes = std::fs::metadata(&paths.db)
72        .map(|meta| meta.len())
73        .unwrap_or(0);
74    let conn = open_rw(&paths.db)?;
75    let checkpoint = args.checkpoint && !args.no_checkpoint;
76    if checkpoint {
77        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
78    }
79    conn.execute_batch("VACUUM;")?;
80    if checkpoint {
81        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
82    }
83    drop(conn);
84    let size_after_bytes = std::fs::metadata(&paths.db)
85        .map(|meta| meta.len())
86        .unwrap_or(0);
87
88    output::emit_json(&VacuumResponse {
89        db_path: paths.db.display().to_string(),
90        size_before_bytes,
91        size_after_bytes,
92        reclaimed_bytes: size_before_bytes.saturating_sub(size_after_bytes),
93        status: "ok".to_string(),
94        elapsed_ms: start.elapsed().as_millis() as u64,
95    })?;
96
97    Ok(())
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn vacuum_response_serializes_all_fields() {
106        let resp = VacuumResponse {
107            db_path: "/home/user/.local/share/sqlite-graphrag/db.sqlite".to_string(),
108            size_before_bytes: 32768,
109            size_after_bytes: 16384,
110            reclaimed_bytes: 16384,
111            status: "ok".to_string(),
112            elapsed_ms: 55,
113        };
114        let json = serde_json::to_value(&resp).expect("serialization failed");
115        assert_eq!(
116            json["db_path"],
117            "/home/user/.local/share/sqlite-graphrag/db.sqlite"
118        );
119        assert_eq!(json["size_before_bytes"], 32768u64);
120        assert_eq!(json["size_after_bytes"], 16384u64);
121        assert_eq!(json["reclaimed_bytes"], 16384u64);
122        assert_eq!(json["status"], "ok");
123        assert_eq!(json["elapsed_ms"], 55u64);
124    }
125
126    #[test]
127    fn vacuum_response_size_after_less_than_or_equal_to_before() {
128        let resp = VacuumResponse {
129            db_path: "/data/db.sqlite".to_string(),
130            size_before_bytes: 65536,
131            size_after_bytes: 32768,
132            reclaimed_bytes: 32768,
133            status: "ok".to_string(),
134            elapsed_ms: 100,
135        };
136        let json = serde_json::to_value(&resp).expect("serialization failed");
137        let before = json["size_before_bytes"].as_u64().unwrap();
138        let after = json["size_after_bytes"].as_u64().unwrap();
139        let reclaimed = json["reclaimed_bytes"].as_u64().unwrap();
140        assert!(
141            after <= before,
142            "size_after_bytes must be <= size_before_bytes after VACUUM"
143        );
144        assert_eq!(
145            reclaimed,
146            before - after,
147            "reclaimed_bytes must equal size_before_bytes - size_after_bytes"
148        );
149    }
150
151    #[test]
152    fn vacuum_response_status_ok() {
153        let resp = VacuumResponse {
154            db_path: "/data/db.sqlite".to_string(),
155            size_before_bytes: 0,
156            size_after_bytes: 0,
157            reclaimed_bytes: 0,
158            status: "ok".to_string(),
159            elapsed_ms: 0,
160        };
161        let json = serde_json::to_value(&resp).expect("serialization failed");
162        assert_eq!(json["status"], "ok");
163    }
164
165    #[test]
166    fn vacuum_response_elapsed_ms_present_and_non_negative() {
167        let resp = VacuumResponse {
168            db_path: "/data/db.sqlite".to_string(),
169            size_before_bytes: 1024,
170            size_after_bytes: 1024,
171            reclaimed_bytes: 0,
172            status: "ok".to_string(),
173            elapsed_ms: 0,
174        };
175        let json = serde_json::to_value(&resp).expect("serialization failed");
176        assert!(
177            json.get("elapsed_ms").is_some(),
178            "elapsed_ms field must be present"
179        );
180        assert!(
181            json["elapsed_ms"].as_u64().is_some(),
182            "elapsed_ms must be a non-negative integer"
183        );
184    }
185}