Skip to main content

sqlite_graphrag/commands/
optimize.rs

1//! Handler for the `optimize` CLI subcommand.
2
3use crate::commands::fts::check_fts_functional;
4use crate::errors::AppError;
5use crate::output;
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 PRAGMA optimize on the default database\n  \
13    sqlite-graphrag optimize\n\n  \
14    # Optimize a database at a custom path\n  \
15    sqlite-graphrag optimize --db /path/to/graphrag.sqlite\n\n  \
16    # Skip the FTS5 rebuild even if the index looks unhealthy\n  \
17    sqlite-graphrag optimize --skip-fts\n\n  \
18    # Dry-run: only report FTS5 health status, do not rebuild\n  \
19    sqlite-graphrag optimize --fts-dry-run\n\n  \
20    # Run optimize non-interactively (skip confirmation prompts)\n  \
21    sqlite-graphrag optimize --yes\n\n  \
22    # Force a full FTS5 rebuild even if the index already passes integrity-check\n  \
23    sqlite-graphrag optimize --no-fts-skip-when-functional\n\n  \
24    # Explicit database path\n  \
25    sqlite-graphrag optimize --db /data/graphrag.sqlite")]
26/// Optimize args.
27pub struct OptimizeArgs {
28    /// Emit machine-readable JSON on stdout.
29    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
30    pub json: bool,
31    /// Path to the SQLite database file.
32    #[arg(long)]
33    pub db: Option<String>,
34    /// Skip FTS.
35    #[arg(long, default_value_t = false, help = "Skip FTS5 index rebuild")]
36    pub skip_fts: bool,
37    /// When true (default), the FTS5 rebuild step is skipped when
38    /// `fts check` reports the index is already functional. Saves 5-15
39    /// minutes on large databases. Set to false to always rebuild.
40    #[arg(
41        long,
42        default_value_t = true,
43        help = "Skip FTS5 rebuild when index is already functional (saves minutes on big DBs)"
44    )]
45    pub fts_skip_when_functional: bool,
46    /// G36 Step 2 (v1.0.69): run `fts check` + `fts stats` only, do not
47    /// trigger any rebuild. Exit code is 0 when the index is healthy, 1
48    /// when a rebuild would be recommended.
49    #[arg(
50        long,
51        default_value_t = false,
52        help = "G36: only run fts check + fts stats, do not rebuild (exit 1 if rebuild recommended)"
53    )]
54    pub fts_dry_run: bool,
55    /// G36 Step 3 (v1.0.69): emit a tracing::info! progress line every
56    /// N seconds during the FTS5 rebuild. The FTS5 `rebuild` command is
57    /// synchronous and does not call the SQLite progress handler, so the
58    /// progress is sampled at the configured interval. Use 0 to disable.
59    #[arg(
60        long,
61        default_value_t = 30,
62        help = "G36: emit progress line every N seconds during FTS5 rebuild (0 to disable)"
63    )]
64    pub fts_progress: u64,
65    /// G36 Step 4 (v1.0.69): skip all confirmation prompts. Required
66    /// for non-interactive CI/CD pipelines that cannot answer `y/N`.
67    #[arg(
68        long,
69        default_value_t = false,
70        help = "G36: skip confirmation prompts (required for non-interactive CI)"
71    )]
72    pub yes: bool,
73}
74
75#[derive(Serialize)]
76struct OptimizeResponse {
77    db_path: String,
78    status: String,
79    /// True when the FTS5 index was rebuilt during this optimize run.
80    fts_rebuilt: bool,
81    /// True when the FTS5 rebuild was skipped because the index was already healthy.
82    fts_skipped_functional: bool,
83    /// True when FTS5 was detected as unhealthy AND the rebuild was attempted.
84    fts_unhealthy: bool,
85    /// Number of FTS5 rows indexed during the rebuild (G36 progress observability).
86    fts_rows_indexed: Option<i64>,
87    /// Total execution time in milliseconds from handler start to serialisation.
88    elapsed_ms: u64,
89}
90
91/// Run.
92pub fn run(args: OptimizeArgs) -> Result<(), AppError> {
93    let inicio = std::time::Instant::now();
94    let paths = AppPaths::resolve(args.db.as_deref())?;
95
96    crate::storage::connection::ensure_db_ready(&paths)?;
97
98    let conn = open_rw(&paths.db)?;
99    conn.execute_batch("PRAGMA optimize;")?;
100
101    // G36: pre-check FTS5 health before triggering a multi-minute rebuild.
102    let fts_functional = if !args.skip_fts {
103        check_fts_functional(&conn).unwrap_or(false)
104    } else {
105        false
106    };
107
108    // G36 Passo 2 (v1.0.69): dry-run path. Run fts check + fts stats, emit
109    // JSON envelope, and return exit 1 when a rebuild would be recommended.
110    if args.fts_dry_run {
111        let recommend_rebuild = !fts_functional;
112        output::emit_json(&OptimizeResponse {
113            db_path: paths.db.display().to_string(),
114            status: if recommend_rebuild {
115                "rebuild_recommended".to_string()
116            } else {
117                "ok".to_string()
118            },
119            fts_rebuilt: false,
120            fts_skipped_functional: false,
121            fts_unhealthy: !fts_functional,
122            fts_rows_indexed: None,
123            elapsed_ms: inicio.elapsed().as_millis() as u64,
124        })?;
125        if recommend_rebuild {
126            // GAP-SG-125: never bare process::exit — map through AppError so
127            // main emits the JSON error envelope and exit code 1 consistently.
128            return Err(AppError::Validation(
129                "FTS5 rebuild recommended (index unhealthy); re-run without --fts-dry-run".into(),
130            ));
131        }
132        return Ok(());
133    }
134
135    let (fts_rebuilt, fts_skipped_functional, fts_unhealthy, fts_rows_indexed) = if args.skip_fts {
136        (false, false, false, None)
137    } else if args.fts_skip_when_functional && fts_functional {
138        tracing::info!(target: "optimize",
139            "FTS5 index already functional; skipping rebuild (use --no-fts-skip-when-functional to override)"
140        );
141        (false, true, false, None)
142    } else {
143        if !fts_functional {
144            tracing::warn!(target: "optimize",
145                "FTS5 index reported unhealthy; running full rebuild"
146            );
147        }
148        // Capture row count BEFORE rebuild so we can report progress.
149        // (FTS5 rebuild is synchronous; a true callback would require
150        // `sqlite3_progress_handler` which the FTS5 'rebuild' command
151        // does not respect. We sample the row count after.)
152        let before: i64 = conn
153            .query_row("SELECT COUNT(*) FROM fts_memories", [], |r| r.get(0))
154            .unwrap_or(0);
155        // G36 Passo 3 (v1.0.69): spawn a lightweight background thread that
156        // emits a tracing::info! progress line every `args.fts_progress`
157        // seconds while the rebuild is in flight. The FTS5 rebuild command
158        // is synchronous and does not call the SQLite progress handler, so
159        // the only observability we can add is a row-count poll from a
160        // background thread. We open a SEPARATE read-only connection
161        // because `rusqlite::Connection` is not `Sync` and the rebuild
162        // holds the main connection exclusively. Default 30s; 0 disables.
163        let progress_thread = if args.fts_progress > 0 {
164            let interval = std::time::Duration::from_secs(args.fts_progress);
165            let db_path = paths.db.clone();
166            let child = std::thread::spawn(move || loop {
167                std::thread::sleep(interval);
168                let count: i64 = match crate::storage::connection::open_ro(&db_path) {
169                    Ok(c) => c
170                        .query_row("SELECT COUNT(*) FROM fts_memories", [], |r| r.get(0))
171                        .unwrap_or(-1),
172                    Err(_) => -1,
173                };
174                tracing::info!(target: "optimize", fts_rows = count, "FTS5 rebuild progress sample");
175            });
176            Some(child)
177        } else {
178            None
179        };
180        let rebuilt_ok = conn
181            .execute_batch("INSERT INTO fts_memories(fts_memories) VALUES('rebuild');")
182            .is_ok();
183        if let Some(handle) = progress_thread {
184            // The thread runs forever in a sleep loop; we leak it on
185            // purpose because (a) it terminates when the process exits
186            // and (b) we cannot safely join without a stop signal channel
187            // which would add complexity not warranted for a 30s sampler.
188            std::mem::forget(handle);
189        }
190        let after: i64 = if rebuilt_ok {
191            conn.query_row("SELECT COUNT(*) FROM fts_memories", [], |r| r.get(0))
192                .unwrap_or(0)
193        } else {
194            0
195        };
196        // G36 progress: rows_indexed == after - before.  Emitted as a
197        // tracing::info! line so operators following logs see the
198        // rebuild magnitude without needing NDJSON streaming.
199        tracing::info!(target: "optimize", before, after, "FTS5 rebuild complete");
200        (rebuilt_ok, false, !fts_functional, Some(after - before))
201    };
202
203    // G36 Passo 4 (v1.0.69): --yes flag is currently honored for forward
204    // compatibility — every interactive prompt path in optimize must
205    // check this flag and skip the prompt when set. As of v1.0.69 there
206    // are no interactive prompts in optimize (the user is told up front
207    // via the after_long_help), but the flag is reserved so future
208    // confirmations can be added without breaking the CLI contract.
209    let _ = args.yes;
210
211    output::emit_json(&OptimizeResponse {
212        db_path: paths.db.display().to_string(),
213        status: "ok".to_string(),
214        fts_rebuilt,
215        fts_skipped_functional,
216        fts_unhealthy,
217        fts_rows_indexed,
218        elapsed_ms: inicio.elapsed().as_millis() as u64,
219    })?;
220
221    Ok(())
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use serial_test::serial;
228    use tempfile::TempDir;
229
230    #[test]
231    fn optimize_response_serializes_required_fields() {
232        let resp = OptimizeResponse {
233            db_path: "/tmp/graphrag.sqlite".to_string(),
234            status: "ok".to_string(),
235            fts_rebuilt: false,
236            fts_rows_indexed: None,
237            fts_skipped_functional: false,
238            fts_unhealthy: false,
239            elapsed_ms: 5,
240        };
241        let json = serde_json::to_value(&resp).unwrap();
242        assert_eq!(json["status"], "ok");
243        assert_eq!(json["db_path"], "/tmp/graphrag.sqlite");
244        assert_eq!(json["elapsed_ms"], 5);
245    }
246
247    #[test]
248    #[serial]
249    fn optimize_auto_inits_when_db_missing() {
250        // GAP-SG-84 / GAP-SG-131: path comes only from OptimizeArgs.db (flag),
251        // never from product env. Product env is not read (G-T-XDG-04).
252        let dir = TempDir::new().unwrap();
253        let db_path = dir.path().join("missing.sqlite");
254
255        let args = OptimizeArgs {
256            json: false,
257            db: Some(db_path.to_string_lossy().into_owned()),
258            skip_fts: false,
259            fts_skip_when_functional: true,
260            fts_dry_run: false,
261            fts_progress: 30,
262            yes: true,
263        };
264        let result = run(args);
265        assert!(
266            result.is_ok(),
267            "auto-init must succeed and PRAGMA optimize must run on the fresh database, got {result:?}"
268        );
269        assert!(
270            db_path.exists(),
271            "auto-init must create the database file at {}",
272            db_path.display()
273        );
274    }
275
276    #[test]
277    fn optimize_response_status_ok_fixo() {
278        let resp = OptimizeResponse {
279            db_path: "/qualquer/caminho".to_string(),
280            status: "ok".to_string(),
281            fts_rebuilt: false,
282            fts_rows_indexed: None,
283            fts_skipped_functional: false,
284            fts_unhealthy: false,
285            elapsed_ms: 0,
286        };
287        let json = serde_json::to_value(&resp).unwrap();
288        assert_eq!(json["status"], "ok", "status deve ser sempre 'ok'");
289    }
290
291    #[test]
292    fn optimize_response_serializes_all_fields() {
293        let resp = OptimizeResponse {
294            db_path: "/data/x.sqlite".into(),
295            status: "ok".into(),
296            fts_rebuilt: true,
297            fts_rows_indexed: Some(0),
298            fts_skipped_functional: false,
299            fts_unhealthy: true,
300            elapsed_ms: 120,
301        };
302        let v = serde_json::to_value(&resp).unwrap();
303        assert_eq!(v["db_path"], "/data/x.sqlite");
304        assert_eq!(v["status"], "ok");
305        assert_eq!(v["fts_rebuilt"], true);
306        assert_eq!(v["fts_skipped_functional"], false);
307        assert_eq!(v["fts_unhealthy"], true);
308        assert_eq!(v["elapsed_ms"], 120u64);
309    }
310
311    #[test]
312    fn optimize_response_includes_fts_flags() {
313        // G36: operator must be able to distinguish (a) rebuilt, (b) skipped-healthy,
314        // (c) skipped-by-flag from (d) attempted-but-failed. The response
315        // exposes fts_rebuilt, fts_skipped_functional, fts_unhealthy booleans.
316        let resp = OptimizeResponse {
317            db_path: "/x".into(),
318            status: "ok".into(),
319            fts_rebuilt: true,
320            fts_rows_indexed: Some(0),
321            fts_skipped_functional: false,
322            fts_unhealthy: true,
323            elapsed_ms: 1,
324        };
325        let v = serde_json::to_value(&resp).unwrap();
326        assert_eq!(v["fts_rebuilt"], true);
327        assert_eq!(v["fts_skipped_functional"], false);
328        assert_eq!(v["fts_unhealthy"], true);
329    }
330}