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