1use crate::errors::AppError;
4use crate::output;
5use crate::paths::AppPaths;
6use crate::storage::connection::open_ro;
7use serde::Serialize;
8use std::fs;
9use std::time::Instant;
10
11mod embed_stats;
12mod tables;
13
14use embed_stats::{
15 chunk_embedding_health, coverage_pct, entity_embedding_health, llm_slot_info,
16 memory_embedding_health,
17};
18use tables::table_exists;
19
20#[derive(clap::Args)]
21#[command(after_long_help = "EXAMPLES:\n \
22 # Check database health (connectivity, integrity, vector index)\n \
23 sqlite-graphrag health\n\n \
24 # Check health of a database at a custom path\n \
25 sqlite-graphrag health --db /path/to/graphrag.sqlite\n\n \
26 # Explicit database path\n \
27 sqlite-graphrag health --db /data/graphrag.sqlite")]
28pub struct HealthArgs {
30 #[arg(long)]
32 pub db: Option<String>,
33 #[arg(long, default_value_t = false)]
35 pub json: bool,
36 #[arg(long, value_parser = ["json", "text"], hide = true)]
38 pub format: Option<String>,
39 #[arg(long)]
43 pub namespace: Option<String>,
44}
45
46#[derive(Serialize, schemars::JsonSchema)]
48pub struct HealthCounts {
49 memories: i64,
50 memories_total: i64,
52 entities: i64,
53 relationships: i64,
54 vec_memories: i64,
55}
56
57#[derive(Serialize, schemars::JsonSchema)]
59pub struct HealthCheck {
60 name: String,
61 ok: bool,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 detail: Option<String>,
64}
65
66#[derive(Serialize, schemars::JsonSchema)]
68pub struct HealthResponse {
69 status: String,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 namespace: Option<String>,
73 integrity: String,
74 integrity_ok: bool,
75 schema_ok: bool,
76 vec_memories_ok: bool,
77 vec_memories_missing: i64,
78 vec_memories_orphaned: i64,
79 vec_entities_ok: bool,
80 vec_entities_missing: i64,
84 vec_chunks_ok: bool,
85 vec_chunks_missing: i64,
87 vec_memories_coverage_pct: f64,
91 vec_entities_coverage_pct: f64,
92 vec_chunks_coverage_pct: f64,
93 fts_ok: bool,
94 fts_query_ok: bool,
96 model_ok: bool,
97 counts: HealthCounts,
98 db_path: String,
99 db_size_bytes: u64,
100 schema_version: u32,
104 missing_entities: Vec<String>,
107 wal_size_mb: f64,
109 journal_mode: String,
111 sqlite_version: String,
113 #[serde(skip_serializing_if = "Option::is_none")]
116 mentions_ratio: Option<f64>,
117 #[serde(skip_serializing_if = "Option::is_none")]
120 mentions_warning: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none")]
124 top_relation: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
128 top_relation_ratio: Option<f64>,
129 #[serde(skip_serializing_if = "Option::is_none")]
132 applies_to_ratio: Option<f64>,
133 #[serde(skip_serializing_if = "Option::is_none")]
136 relation_concentration_warning: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 non_normalized_count: Option<i64>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 normalization_warning: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 super_hub_count: Option<i64>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 super_hub_warning: Option<String>,
149 #[serde(skip_serializing_if = "Option::is_none")]
152 top_hub_entity: Option<String>,
153 #[serde(skip_serializing_if = "Option::is_none")]
156 top_hub_degree: Option<i64>,
157 #[serde(skip_serializing_if = "Option::is_none")]
160 hub_warning: Option<String>,
161 #[serde(skip_serializing_if = "Option::is_none")]
163 llm_slots_total: Option<u32>,
164 #[serde(skip_serializing_if = "Option::is_none")]
166 llm_slots_occupied: Option<u32>,
167 #[serde(skip_serializing_if = "Option::is_none")]
169 llm_slots_stale: Option<u32>,
170 checks: Vec<HealthCheck>,
171 elapsed_ms: u64,
172}
173
174pub fn run(args: HealthArgs) -> Result<(), AppError> {
176 let start = Instant::now();
177 let _ = args.json; let _ = args.format; let paths = AppPaths::resolve(args.db.as_deref())?;
180 let namespace_filter = match args.namespace.as_deref() {
183 Some(ns) => Some(crate::namespace::resolve_namespace(Some(ns))?),
184 None => None,
185 };
186
187 if !paths.db.exists() {
191 let msg = format!(
192 "database not found at {}; `health` does not auto-create the database — \
193 run `sqlite-graphrag init --db {}` first or pass an existing path",
194 paths.db.display(),
195 paths.db.display(),
196 );
197 tracing::warn!(target: "health", db_path = %paths.db.display(), "database path does not exist; refusing to bootstrap");
198 output::emit_json(&serde_json::json!({
199 "error": true,
200 "code": 4,
201 "message": msg,
202 "db_path": paths.db.display().to_string(),
203 }))?;
204 return Err(AppError::NotFound(msg));
205 }
206
207 let conn = open_ro(&paths.db)?;
208
209 let integrity: String = conn.query_row("PRAGMA integrity_check;", [], |r| r.get(0))?;
210 let integrity_ok = integrity == "ok";
211 tracing::info!(target: "health", integrity_ok = %integrity_ok, "PRAGMA integrity_check complete");
212
213 if !integrity_ok {
214 let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
215 output::emit_json(&HealthResponse {
216 status: "degraded".to_string(),
217 namespace: None,
218 integrity: integrity.clone(),
219 integrity_ok: false,
220 schema_ok: false,
221 vec_memories_ok: false,
222 vec_memories_missing: 0,
223 vec_memories_orphaned: 0,
224 vec_entities_ok: false,
225 vec_entities_missing: 0,
226 vec_chunks_ok: false,
227 vec_chunks_missing: 0,
228 vec_memories_coverage_pct: 0.0,
229 vec_entities_coverage_pct: 0.0,
230 vec_chunks_coverage_pct: 0.0,
231 fts_ok: false,
232 fts_query_ok: false,
233 model_ok: false,
234 counts: HealthCounts {
235 memories: 0,
236 memories_total: 0,
237 entities: 0,
238 relationships: 0,
239 vec_memories: 0,
240 },
241 db_path: paths.db.display().to_string(),
242 db_size_bytes,
243 schema_version: 0,
244 sqlite_version: "unknown".to_string(),
245 missing_entities: vec![],
246 wal_size_mb: 0.0,
247 journal_mode: "unknown".to_string(),
248 mentions_ratio: None,
249 mentions_warning: None,
250 top_relation: None,
251 top_relation_ratio: None,
252 applies_to_ratio: None,
253 relation_concentration_warning: None,
254 non_normalized_count: None,
255 normalization_warning: None,
256 super_hub_count: None,
257 super_hub_warning: None,
258 top_hub_entity: None,
259 top_hub_degree: None,
260 hub_warning: None,
261 llm_slots_total: None,
262 llm_slots_occupied: None,
263 llm_slots_stale: None,
264 checks: vec![HealthCheck {
265 name: "integrity".to_string(),
266 ok: false,
267 detail: Some(integrity),
268 }],
269 elapsed_ms: start.elapsed().as_millis() as u64,
270 })?;
271 return Err(AppError::Database(rusqlite::Error::SqliteFailure(
272 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CORRUPT),
273 Some("integrity check failed".to_string()),
274 )));
275 }
276
277 let memories_count: i64 = match &namespace_filter {
279 Some(ns) => conn.query_row(
280 "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL AND namespace = ?1",
281 rusqlite::params![ns],
282 |r| r.get(0),
283 )?,
284 None => conn.query_row(
285 "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
286 [],
287 |r| r.get(0),
288 )?,
289 };
290 let entities_count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
291 let relationships_count: i64 =
292 conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?;
293 let (vec_memories_ok, vec_memories_count, vec_memories_missing, vec_memories_orphaned) =
294 memory_embedding_health(&conn);
295
296 let mentions_count: i64 = conn.query_row(
297 "SELECT COUNT(*) FROM relationships WHERE relation = 'mentions'",
298 [],
299 |r| r.get(0),
300 )?;
301 let (mentions_ratio, mentions_warning) = if relationships_count > 0 {
302 let ratio = mentions_count as f64 / relationships_count as f64;
303 let warning = if ratio > 0.5 {
304 Some(format!(
305 "mentions relationships dominate graph at {:.1}% ({}/{} total); consider running prune-relations --relation mentions --dry-run",
306 ratio * 100.0,
307 mentions_count,
308 relationships_count
309 ))
310 } else {
311 None
312 };
313 (Some(ratio), warning)
314 } else {
315 (None, None)
316 };
317
318 let (top_relation, top_relation_ratio, applies_to_ratio, relation_concentration_warning) =
320 if relationships_count > 0 {
321 let (top_rel, top_count): (String, i64) = conn
323 .query_row(
324 "SELECT relation, COUNT(*) AS cnt
325 FROM relationships
326 GROUP BY relation
327 ORDER BY cnt DESC
328 LIMIT 1",
329 [],
330 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
331 )
332 .unwrap_or_else(|_| ("unknown".to_string(), 0));
333
334 let top_ratio = top_count as f64 / relationships_count as f64;
335
336 let applies_count: i64 = conn
338 .query_row(
339 "SELECT COUNT(*) FROM relationships WHERE relation = 'applies_to'",
340 [],
341 |r| r.get(0),
342 )
343 .unwrap_or(0);
344 let at_ratio = if applies_count > 0 {
345 Some(applies_count as f64 / relationships_count as f64)
346 } else {
347 None
348 };
349
350 let concentration_warning = if top_ratio > 0.40 {
351 Some(format!(
352 "relation '{}' dominates graph at {:.1}% ({}/{} total); consider running prune-relations --relation {} --dry-run",
353 top_rel,
354 top_ratio * 100.0,
355 top_count,
356 relationships_count,
357 top_rel,
358 ))
359 } else {
360 None
361 };
362
363 (
364 Some(top_rel),
365 Some(top_ratio),
366 at_ratio,
367 concentration_warning,
368 )
369 } else {
370 (None, None, None, None)
371 };
372
373 let status = "ok";
374
375 let schema_version: u32 = conn
376 .query_row(
377 "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
378 [],
379 |r| r.get::<_, i64>(0),
380 )
381 .unwrap_or(0) as u32;
382
383 let schema_ok = schema_version > 0;
384
385 let (vec_entities_ok, vec_entities_missing) = entity_embedding_health(&conn);
388 let (vec_chunks_ok, vec_chunks_missing) = chunk_embedding_health(&conn);
389
390 let memories_total_global: i64 = conn.query_row(
393 "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
394 [],
395 |r| r.get(0),
396 )?;
397 let chunks_total: i64 = conn
398 .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
399 .unwrap_or(0);
400 let vec_memories_coverage_pct =
401 coverage_pct(vec_memories_ok, memories_total_global, vec_memories_missing);
402 let vec_entities_coverage_pct =
403 coverage_pct(vec_entities_ok, entities_count, vec_entities_missing);
404 let vec_chunks_coverage_pct = coverage_pct(vec_chunks_ok, chunks_total, vec_chunks_missing);
405
406 tracing::info!(target: "health", vec_memories_ok = %vec_memories_ok, vec_entities_ok = %vec_entities_ok, vec_missing = vec_memories_missing, vec_orphaned = vec_memories_orphaned, "vector table checks complete");
407 let fts_ok = table_exists(&conn, "fts_memories");
408
409 let fts_query_ok = if fts_ok {
411 conn.query_row(
412 "SELECT COUNT(*) FROM fts_memories WHERE fts_memories MATCH 'a' LIMIT 1",
413 [],
414 |r| r.get::<_, i64>(0),
415 )
416 .is_ok()
417 } else {
418 false
419 };
420
421 tracing::info!(target: "health", fts_ok = %fts_ok, fts_query_ok = %fts_query_ok, "FTS5 checks complete");
422
423 let sqlite_version: String = conn
425 .query_row("SELECT sqlite_version()", [], |r| r.get(0))
426 .unwrap_or_else(|_| "unknown".to_string());
427
428 let mut missing_entities: Vec<String> = Vec::with_capacity(4);
430 let mut stmt = conn.prepare_cached(
431 "SELECT DISTINCT me.entity_id
432 FROM memory_entities me
433 LEFT JOIN entities e ON e.id = me.entity_id
434 WHERE e.id IS NULL",
435 )?;
436 let orphans: Vec<i64> = stmt
437 .query_map([], |r| r.get(0))?
438 .collect::<Result<Vec<_>, _>>()?;
439 for id in orphans {
440 missing_entities.push(format!("entity_id={id}"));
441 }
442
443 let journal_mode: String = conn
444 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
445 .unwrap_or_else(|_| "unknown".to_string());
446
447 let wal_size_mb = fs::metadata(format!("{}-wal", paths.db.display()))
448 .map(|m| m.len() as f64 / 1024.0 / 1024.0)
449 .unwrap_or(0.0);
450
451 let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
453
454 let model_ok = crate::commands::ingest_claude::find_claude_binary(None).is_ok()
458 || crate::commands::ingest_codex::find_codex_binary(None).is_ok();
459 tracing::info!(target: "health", model_ok = %model_ok, "LLM CLI availability check complete");
460
461 let mut checks: Vec<HealthCheck> = Vec::with_capacity(8);
463
464 checks.push(HealthCheck {
466 name: "integrity".to_string(),
467 ok: true,
468 detail: None,
469 });
470
471 checks.push(HealthCheck {
472 name: "schema_version".to_string(),
473 ok: schema_ok,
474 detail: if schema_ok {
475 None
476 } else {
477 Some(format!("schema_version={schema_version} (expected >0)"))
478 },
479 });
480
481 checks.push(HealthCheck {
482 name: "vec_memories".to_string(),
483 ok: vec_memories_ok,
484 detail: if vec_memories_ok {
485 None
486 } else {
487 Some("memory_embeddings/vec_memories table missing from sqlite_master".to_string())
488 },
489 });
490
491 checks.push(HealthCheck {
492 name: "vec_entities".to_string(),
493 ok: vec_entities_ok,
494 detail: if vec_entities_ok {
495 None
496 } else {
497 Some("entity_embeddings/vec_entities table missing from sqlite_master".to_string())
498 },
499 });
500
501 checks.push(HealthCheck {
502 name: "vec_chunks".to_string(),
503 ok: vec_chunks_ok,
504 detail: if vec_chunks_ok {
505 None
506 } else {
507 Some("chunk_embeddings/vec_chunks table missing from sqlite_master".to_string())
508 },
509 });
510
511 checks.push(HealthCheck {
512 name: "fts_memories".to_string(),
513 ok: fts_ok,
514 detail: if fts_ok {
515 None
516 } else {
517 Some("fts_memories table missing from sqlite_master".to_string())
518 },
519 });
520
521 checks.push(HealthCheck {
522 name: "fts_query".to_string(),
523 ok: fts_query_ok,
524 detail: if fts_query_ok {
525 None
526 } else {
527 Some("FTS5 MATCH query failed — run 'sqlite-graphrag fts rebuild'".to_string())
528 },
529 });
530
531 checks.push(HealthCheck {
532 name: "llm_cli".to_string(),
533 ok: model_ok,
534 detail: if model_ok {
535 None
536 } else {
537 Some(
538 "no LLM CLI found on PATH; install 'claude' (Claude Code) or 'codex' \
539 (Codex CLI) — required for embedding generation since v1.0.76"
540 .to_string(),
541 )
542 },
543 });
544
545 let (non_normalized_count, normalization_warning) = {
547 let mut stmt = conn.prepare_cached("SELECT name FROM entities")?;
548 let names: Vec<String> = stmt
549 .query_map([], |r| r.get(0))?
550 .filter_map(|r| r.ok())
551 .collect();
552 let count = names
553 .iter()
554 .filter(|n| crate::parsers::normalize_entity_name(n) != **n)
555 .count() as i64;
556 let warning = if count > 0 {
557 Some(format!(
558 "run 'normalize-entities --yes' to fix {count} non-normalized entities"
559 ))
560 } else {
561 None
562 };
563 (Some(count), warning)
564 };
565
566 let (super_hub_count, super_hub_warning) = {
568 let mut stmt = conn.prepare_cached(
569 "SELECT e.name, COUNT(r.id) as deg FROM entities e \
570 LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id \
571 GROUP BY e.id HAVING deg > 50 ORDER BY deg DESC LIMIT 5",
572 )?;
573 let hubs: Vec<(String, i64)> = stmt
574 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
575 .filter_map(|r| r.ok())
576 .collect();
577 let count = hubs.len() as i64;
578 let warning = if count > 0 {
579 let names: Vec<String> = hubs
580 .iter()
581 .map(|(n, d)| format!("{n} (degree {d})"))
582 .collect();
583 Some(format!("super-hubs detected: {}", names.join(", ")))
584 } else {
585 None
586 };
587 (Some(count), warning)
588 };
589
590 let (top_hub_entity, top_hub_degree, hub_warning) = {
592 let result: Option<(String, i64)> = conn
593 .query_row(
594 "SELECT e.name, COUNT(r.id) AS degree
595 FROM entities e
596 LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id
597 GROUP BY e.id
598 ORDER BY degree DESC
599 LIMIT 1",
600 [],
601 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
602 )
603 .ok();
604 match result {
605 Some((name, degree)) => {
606 let warning = if degree > 50 {
607 Some(format!(
608 "entity '{name}' has {degree} connections; consider splitting or using --max-neighbors-per-hop"
609 ))
610 } else {
611 None
612 };
613 (Some(name), Some(degree), warning)
614 }
615 None => (None, None, None),
616 }
617 };
618
619 let llm_slots = llm_slot_info();
620 let response = HealthResponse {
621 status: status.to_string(),
622 namespace: namespace_filter.clone(),
623 integrity,
624 integrity_ok,
625 schema_ok,
626 vec_memories_ok,
627 vec_memories_missing,
628 vec_memories_orphaned,
629 vec_entities_ok,
630 vec_entities_missing,
631 vec_chunks_ok,
632 vec_chunks_missing,
633 vec_memories_coverage_pct,
634 vec_entities_coverage_pct,
635 vec_chunks_coverage_pct,
636 fts_ok,
637 fts_query_ok,
638 model_ok,
639 counts: HealthCounts {
640 memories: memories_count,
641 memories_total: memories_count,
642 entities: entities_count,
643 relationships: relationships_count,
644 vec_memories: vec_memories_count,
645 },
646 db_path: paths.db.display().to_string(),
647 db_size_bytes,
648 schema_version,
649 sqlite_version,
650 missing_entities,
651 wal_size_mb,
652 journal_mode,
653 mentions_ratio,
654 mentions_warning,
655 top_relation,
656 top_relation_ratio,
657 applies_to_ratio,
658 relation_concentration_warning,
659 non_normalized_count,
660 normalization_warning,
661 super_hub_count,
662 super_hub_warning,
663 top_hub_entity,
664 top_hub_degree,
665 hub_warning,
666 llm_slots_total: Some(llm_slots.0),
667 llm_slots_occupied: Some(llm_slots.1),
668 llm_slots_stale: Some(llm_slots.2),
669 checks,
670 elapsed_ms: start.elapsed().as_millis() as u64,
671 };
672 output::emit_json(&response)?;
673 Ok(())
674}
675#[cfg(test)]
676#[path = "../health_tests.rs"]
677mod tests;