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