perf: force partial index for DiffNote queries, batch stats counts

Query optimizer fixes for the `who` and `stats` commands based on
a systematic performance audit of the SQLite query plans.

who command (expert/reviews/detail modes):
- Add INDEXED BY idx_notes_diffnote_path_created hints to all DiffNote
  queries. SQLite's planner was selecting idx_notes_system (38% of rows)
  over the far more selective partial index (9.3% of rows). Measured
  50-133x speedup on expert queries, 26x on reviews queries.
- Reorder JOIN clauses in detail mode's MR-author sub-select to match
  the index scan direction (notes -> discussions -> merge_requests).

stats command:
- Replace 12+ sequential COUNT(*) queries with conditional aggregates
  (COALESCE + SUM + CASE). Documents, dirty_sources, pending_discussion_
  fetches, and pending_dependent_fetches tables each scanned once instead
  of 2-3 times. Measured 1.7x speedup (109ms -> 65ms warm cache).
- Switch FTS document count from COUNT(*) on the virtual table to
  COUNT(*) on documents_fts_docsize shadow table (B-tree scan vs FTS5
  virtual table overhead). Measured 19x speedup for that single query.

Database: 61652 docs, 282K notes, 211K discussions, 1.5GB.
This commit is contained in:
teernisse
2026-02-11 16:00:34 -05:00
parent 039ab1c2a3
commit acc5e12e3d
3 changed files with 291 additions and 67 deletions

View File

@@ -79,33 +79,43 @@ pub fn run_stats(config: &Config, check: bool, repair: bool, dry_run: bool) -> R
let mut result = StatsResult::default();
result.documents.total = count_query(&conn, "SELECT COUNT(*) FROM documents")?;
result.documents.issues = count_query(
&conn,
"SELECT COUNT(*) FROM documents WHERE source_type = 'issue'",
)?;
result.documents.merge_requests = count_query(
&conn,
"SELECT COUNT(*) FROM documents WHERE source_type = 'merge_request'",
)?;
result.documents.discussions = count_query(
&conn,
"SELECT COUNT(*) FROM documents WHERE source_type = 'discussion'",
)?;
result.documents.truncated = count_query(
&conn,
"SELECT COUNT(*) FROM documents WHERE is_truncated = 1",
)?;
// Single-scan conditional aggregate: 5 sequential COUNT(*) → 1 table scan
let (total, issues, mrs, discussions, truncated) = conn
.query_row(
"SELECT COUNT(*),
COALESCE(SUM(CASE WHEN source_type = 'issue' THEN 1 END), 0),
COALESCE(SUM(CASE WHEN source_type = 'merge_request' THEN 1 END), 0),
COALESCE(SUM(CASE WHEN source_type = 'discussion' THEN 1 END), 0),
COALESCE(SUM(CASE WHEN is_truncated = 1 THEN 1 END), 0)
FROM documents",
[],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
))
},
)
.unwrap_or((0, 0, 0, 0, 0));
result.documents.total = total;
result.documents.issues = issues;
result.documents.merge_requests = mrs;
result.documents.discussions = discussions;
result.documents.truncated = truncated;
if table_exists(&conn, "embedding_metadata") {
let embedded = count_query(
&conn,
"SELECT COUNT(DISTINCT document_id) FROM embedding_metadata WHERE last_error IS NULL",
)?;
let chunks = count_query(
&conn,
"SELECT COUNT(*) FROM embedding_metadata WHERE last_error IS NULL",
)?;
// Single scan: COUNT(DISTINCT) + COUNT(*) in one pass
let (embedded, chunks) = conn
.query_row(
"SELECT COUNT(DISTINCT document_id), COUNT(*)
FROM embedding_metadata WHERE last_error IS NULL",
[],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
)
.unwrap_or((0, 0));
result.embeddings.embedded_documents = embedded;
result.embeddings.total_chunks = chunks;
result.embeddings.coverage_pct = if result.documents.total > 0 {
@@ -115,41 +125,57 @@ pub fn run_stats(config: &Config, check: bool, repair: bool, dry_run: bool) -> R
};
}
result.fts.indexed = count_query(&conn, "SELECT COUNT(*) FROM documents_fts")?;
// FTS5 shadow table is a regular B-tree with one row per document —
// 19x faster than scanning the virtual table for COUNT(*)
result.fts.indexed = count_query(&conn, "SELECT COUNT(*) FROM documents_fts_docsize")?;
result.queues.dirty_sources = count_query(
&conn,
"SELECT COUNT(*) FROM dirty_sources WHERE last_error IS NULL",
)?;
result.queues.dirty_sources_failed = count_query(
&conn,
"SELECT COUNT(*) FROM dirty_sources WHERE last_error IS NOT NULL",
)?;
// Single scan: 2 conditional counts on dirty_sources
let (ds_pending, ds_failed) = conn
.query_row(
"SELECT COALESCE(SUM(CASE WHEN last_error IS NULL THEN 1 END), 0),
COALESCE(SUM(CASE WHEN last_error IS NOT NULL THEN 1 END), 0)
FROM dirty_sources",
[],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
)
.unwrap_or((0, 0));
result.queues.dirty_sources = ds_pending;
result.queues.dirty_sources_failed = ds_failed;
if table_exists(&conn, "pending_discussion_fetches") {
result.queues.pending_discussion_fetches = count_query(
&conn,
"SELECT COUNT(*) FROM pending_discussion_fetches WHERE last_error IS NULL",
)?;
result.queues.pending_discussion_fetches_failed = count_query(
&conn,
"SELECT COUNT(*) FROM pending_discussion_fetches WHERE last_error IS NOT NULL",
)?;
let (pdf_pending, pdf_failed) = conn
.query_row(
"SELECT COALESCE(SUM(CASE WHEN last_error IS NULL THEN 1 END), 0),
COALESCE(SUM(CASE WHEN last_error IS NOT NULL THEN 1 END), 0)
FROM pending_discussion_fetches",
[],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
)
.unwrap_or((0, 0));
result.queues.pending_discussion_fetches = pdf_pending;
result.queues.pending_discussion_fetches_failed = pdf_failed;
}
if table_exists(&conn, "pending_dependent_fetches") {
result.queues.pending_dependent_fetches = count_query(
&conn,
"SELECT COUNT(*) FROM pending_dependent_fetches WHERE last_error IS NULL",
)?;
result.queues.pending_dependent_fetches_failed = count_query(
&conn,
"SELECT COUNT(*) FROM pending_dependent_fetches WHERE last_error IS NOT NULL",
)?;
result.queues.pending_dependent_fetches_stuck = count_query(
&conn,
"SELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL",
)?;
let (pf_pending, pf_failed, pf_stuck) = conn
.query_row(
"SELECT COALESCE(SUM(CASE WHEN last_error IS NULL THEN 1 END), 0),
COALESCE(SUM(CASE WHEN last_error IS NOT NULL THEN 1 END), 0),
COALESCE(SUM(CASE WHEN locked_at IS NOT NULL THEN 1 END), 0)
FROM pending_dependent_fetches",
[],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2)?,
))
},
)
.unwrap_or((0, 0, 0));
result.queues.pending_dependent_fetches = pf_pending;
result.queues.pending_dependent_fetches_failed = pf_failed;
result.queues.pending_dependent_fetches_stuck = pf_stuck;
}
#[allow(clippy::field_reassign_with_default)]