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
346 .query_row(
347 "SELECT COUNT(*) FROM relationships WHERE relation = ?1",
348 [crate::parsers::GENERIC_RELATION],
349 |r| r.get(0),
350 )
351 .unwrap_or(0);
352 let at_ratio = if applies_count > 0 {
353 Some(applies_count as f64 / relationships_count as f64)
354 } else {
355 None
356 };
357
358 let concentration_warning = if top_ratio > 0.40 {
359 Some(format!(
360 "relation '{}' dominates graph at {:.1}% ({}/{} total); consider running prune-relations --relation {} --dry-run",
361 top_rel,
362 top_ratio * 100.0,
363 top_count,
364 relationships_count,
365 top_rel,
366 ))
367 } else {
368 None
369 };
370
371 (
372 Some(top_rel),
373 Some(top_ratio),
374 at_ratio,
375 concentration_warning,
376 )
377 } else {
378 (None, None, None, None)
379 };
380
381 let status = "ok";
382
383 let schema_version: u32 = conn
384 .query_row(
385 "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
386 [],
387 |r| r.get::<_, i64>(0),
388 )
389 .unwrap_or(0) as u32;
390
391 let schema_ok = schema_version > 0;
392
393 let (vec_entities_ok, vec_entities_missing) = entity_embedding_health(&conn);
396 let (vec_chunks_ok, vec_chunks_missing) = chunk_embedding_health(&conn);
397
398 let memories_total_global: i64 = conn.query_row(
401 "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
402 [],
403 |r| r.get(0),
404 )?;
405 let chunks_total: i64 = conn
406 .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
407 .unwrap_or(0);
408 let vec_memories_coverage_pct =
409 coverage_pct(vec_memories_ok, memories_total_global, vec_memories_missing);
410 let vec_entities_coverage_pct =
411 coverage_pct(vec_entities_ok, entities_count, vec_entities_missing);
412 let vec_chunks_coverage_pct = coverage_pct(vec_chunks_ok, chunks_total, vec_chunks_missing);
413
414 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");
415 let fts_ok = table_exists(&conn, "fts_memories");
416
417 let fts_query_ok = if fts_ok {
419 conn.query_row(
420 "SELECT COUNT(*) FROM fts_memories WHERE fts_memories MATCH 'a' LIMIT 1",
421 [],
422 |r| r.get::<_, i64>(0),
423 )
424 .is_ok()
425 } else {
426 false
427 };
428
429 tracing::info!(target: "health", fts_ok = %fts_ok, fts_query_ok = %fts_query_ok, "FTS5 checks complete");
430
431 let sqlite_version: String = conn
433 .query_row("SELECT sqlite_version()", [], |r| r.get(0))
434 .unwrap_or_else(|_| "unknown".to_string());
435
436 let mut missing_entities: Vec<String> = Vec::with_capacity(4);
438 let mut stmt = conn.prepare_cached(
439 "SELECT DISTINCT me.entity_id
440 FROM memory_entities me
441 LEFT JOIN entities e ON e.id = me.entity_id
442 WHERE e.id IS NULL",
443 )?;
444 let orphans: Vec<i64> = stmt
445 .query_map([], |r| r.get(0))?
446 .collect::<Result<Vec<_>, _>>()?;
447 for id in orphans {
448 missing_entities.push(format!("entity_id={id}"));
449 }
450
451 let journal_mode: String = conn
452 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
453 .unwrap_or_else(|_| "unknown".to_string());
454
455 let wal_size_mb = fs::metadata(format!("{}-wal", paths.db.display()))
456 .map(|m| m.len() as f64 / 1024.0 / 1024.0)
457 .unwrap_or(0.0);
458
459 let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
461
462 let model_ok = crate::config::resolve_api_key("openrouter", None).is_some();
467 tracing::info!(target: "health", model_ok = %model_ok, "OpenRouter key availability check complete");
468
469 let mut checks: Vec<HealthCheck> = Vec::with_capacity(8);
471
472 checks.push(HealthCheck {
474 name: "integrity".to_string(),
475 ok: true,
476 detail: None,
477 });
478
479 checks.push(HealthCheck {
480 name: "schema_version".to_string(),
481 ok: schema_ok,
482 detail: if schema_ok {
483 None
484 } else {
485 Some(format!("schema_version={schema_version} (expected >0)"))
486 },
487 });
488
489 checks.push(HealthCheck {
490 name: "vec_memories".to_string(),
491 ok: vec_memories_ok,
492 detail: if vec_memories_ok {
493 None
494 } else {
495 Some("memory_embeddings/vec_memories table missing from sqlite_master".to_string())
496 },
497 });
498
499 checks.push(HealthCheck {
500 name: "vec_entities".to_string(),
501 ok: vec_entities_ok,
502 detail: if vec_entities_ok {
503 None
504 } else {
505 Some("entity_embeddings/vec_entities table missing from sqlite_master".to_string())
506 },
507 });
508
509 checks.push(HealthCheck {
510 name: "vec_chunks".to_string(),
511 ok: vec_chunks_ok,
512 detail: if vec_chunks_ok {
513 None
514 } else {
515 Some("chunk_embeddings/vec_chunks table missing from sqlite_master".to_string())
516 },
517 });
518
519 checks.push(HealthCheck {
520 name: "fts_memories".to_string(),
521 ok: fts_ok,
522 detail: if fts_ok {
523 None
524 } else {
525 Some("fts_memories table missing from sqlite_master".to_string())
526 },
527 });
528
529 checks.push(HealthCheck {
530 name: "fts_query".to_string(),
531 ok: fts_query_ok,
532 detail: if fts_query_ok {
533 None
534 } else {
535 Some("FTS5 MATCH query failed — run 'sqlite-graphrag fts rebuild'".to_string())
536 },
537 });
538
539 checks.push(HealthCheck {
540 name: "embedding_key".to_string(),
547 ok: model_ok,
548 detail: if model_ok {
549 None
550 } else {
551 Some(
556 "no OpenRouter API key reachable; store one with \
557 `sqlite-graphrag config add-key --provider openrouter --from-stdin` \
558 or pass --openrouter-api-key — embedding generation is REST-only"
559 .to_string(),
560 )
561 },
562 });
563
564 let (non_normalized_count, normalization_warning) = {
566 let mut stmt = conn.prepare_cached("SELECT name FROM entities")?;
567 let names: Vec<String> = stmt
568 .query_map([], |r| r.get(0))?
569 .filter_map(|r| r.ok())
570 .collect();
571 let count = names
572 .iter()
573 .filter(|n| crate::parsers::normalize_entity_name(n) != **n)
574 .count() as i64;
575 let warning = if count > 0 {
576 Some(format!(
577 "run 'normalize-entities --yes' to fix {count} non-normalized entities"
578 ))
579 } else {
580 None
581 };
582 (Some(count), warning)
583 };
584
585 let (super_hub_count, super_hub_warning) = {
587 let (count, warning) = super_hub_stats(&conn)?;
588 (Some(count), warning)
589 };
590
591 let (top_hub_entity, top_hub_degree, hub_warning) = {
593 let result: Option<(String, i64)> = conn
594 .query_row(
595 "SELECT e.name, COUNT(r.id) AS degree
596 FROM entities e
597 LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id
598 GROUP BY e.id
599 ORDER BY degree DESC
600 LIMIT 1",
601 [],
602 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
603 )
604 .ok();
605 match result {
606 Some((name, degree)) => {
607 let warning = if degree > 50 {
608 Some(format!(
609 "entity '{name}' has {degree} connections; consider splitting or using --max-neighbors-per-hop"
610 ))
611 } else {
612 None
613 };
614 (Some(name), Some(degree), warning)
615 }
616 None => (None, None, None),
617 }
618 };
619
620 let llm_slots = llm_slot_info();
621 let response = HealthResponse {
622 status: status.to_string(),
623 namespace: namespace_filter.clone(),
624 integrity,
625 integrity_ok,
626 schema_ok,
627 vec_memories_ok,
628 vec_memories_missing,
629 vec_memories_orphaned,
630 vec_entities_ok,
631 vec_entities_missing,
632 vec_chunks_ok,
633 vec_chunks_missing,
634 vec_memories_coverage_pct,
635 vec_entities_coverage_pct,
636 vec_chunks_coverage_pct,
637 fts_ok,
638 fts_query_ok,
639 model_ok,
640 counts: HealthCounts {
641 memories: memories_count,
642 memories_total: memories_count,
643 entities: entities_count,
644 relationships: relationships_count,
645 vec_memories: vec_memories_count,
646 },
647 db_path: paths.db.display().to_string(),
648 db_size_bytes,
649 schema_version,
650 sqlite_version,
651 missing_entities,
652 wal_size_mb,
653 journal_mode,
654 mentions_ratio,
655 mentions_warning,
656 top_relation,
657 top_relation_ratio,
658 applies_to_ratio,
659 relation_concentration_warning,
660 non_normalized_count,
661 normalization_warning,
662 super_hub_count,
663 super_hub_warning,
664 top_hub_entity,
665 top_hub_degree,
666 hub_warning,
667 llm_slots_total: Some(llm_slots.0),
668 llm_slots_occupied: Some(llm_slots.1),
669 llm_slots_stale: Some(llm_slots.2),
670 checks,
671 elapsed_ms: start.elapsed().as_millis() as u64,
672 };
673 output::emit_json(&response)?;
674 Ok(())
675}
676fn super_hub_stats(conn: &rusqlite::Connection) -> Result<(i64, Option<String>), AppError> {
684 let threshold = crate::constants::HEALTH_SUPER_HUB_DEGREE_THRESHOLD;
685 let count: i64 = conn.query_row(
686 "SELECT COUNT(*) FROM ( \
687 SELECT e.id FROM entities e \
688 LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id \
689 GROUP BY e.id HAVING COUNT(r.id) > ?1 \
690 )",
691 rusqlite::params![threshold],
692 |r| r.get(0),
693 )?;
694 if count == 0 {
695 return Ok((0, None));
696 }
697
698 let sample_limit =
699 i64::try_from(crate::constants::HEALTH_SUPER_HUB_SAMPLE_LIMIT).unwrap_or(i64::MAX);
700 let mut stmt = conn.prepare_cached(
701 "SELECT e.name, COUNT(r.id) as deg FROM entities e \
702 LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id \
703 GROUP BY e.id HAVING deg > ?1 ORDER BY deg DESC LIMIT ?2",
704 )?;
705 let names: Vec<String> = stmt
706 .query_map(rusqlite::params![threshold, sample_limit], |r| {
707 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
708 })?
709 .filter_map(|r| r.ok())
710 .map(|(n, d)| format!("{n} (degree {d})"))
711 .collect();
712
713 Ok((
714 count,
715 Some(format!(
716 "super-hubs detected ({count} total, showing {}): {}",
717 names.len(),
718 names.join(", ")
719 )),
720 ))
721}
722
723#[cfg(test)]
724#[path = "../health_tests.rs"]
725mod tests;