sqlite_graphrag/commands/
optimize.rs1use 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")]
26pub struct OptimizeArgs {
28 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
30 pub json: bool,
31 #[arg(long)]
33 pub db: Option<String>,
34 #[arg(long, default_value_t = false, help = "Skip FTS5 index rebuild")]
36 pub skip_fts: bool,
37 #[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 #[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 #[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 #[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 #[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 fts_rebuilt: bool,
98 fts_skipped_functional: bool,
100 fts_unhealthy: bool,
102 fts_rows_indexed: Option<i64>,
104 elapsed_ms: u64,
106}
107
108pub 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 let fts_functional = if !args.skip_fts {
120 check_fts_functional(&conn).unwrap_or(false)
121 } else {
122 false
123 };
124
125 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 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 let before: i64 = conn
170 .query_row("SELECT COUNT(*) FROM fts_memories", [], |r| r.get(0))
171 .unwrap_or(0);
172 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 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 tracing::info!(target: "optimize", before, after, "FTS5 rebuild complete");
217 (rebuilt_ok, false, !fts_functional, Some(after - before))
218 };
219
220 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 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 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}