From f3fd86185efcafdc80b97dc3d0519b1e67b3671b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 08:46:45 +0000 Subject: [PATCH] perf(storage): composite (profile_id, created_at DESC) index on transcripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration v15 adds a composite index covering the dominant transcripts query path: SELECT ... FROM transcripts WHERE profile_id = ? ORDER BY created_at DESC LIMIT ? Previously SQLite had to choose between idx_transcripts_profile_id (filter by profile, then in-memory sort by date) and idx_transcripts_created (scan dates and filter on profile). Both work fine at hundreds of rows and degrade past a few thousand. `migration_v15_creates_profile_created_index` asserts (a) the index exists and (b) `EXPLAIN QUERY PLAN` shows the planner picks it for the canonical profile-scoped, date-ordered list query. Test count assertions in `test_migrations_run_on_empty_db` and `test_migrations_idempotent` bumped 14 → 15. https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb --- crates/storage/src/migrations.rs | 62 ++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index aa286d9..20ff74f 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -463,6 +463,20 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ ALTER TABLE transcripts ADD COLUMN llm_tags TEXT NOT NULL DEFAULT ''; "#, ), + ( + 15, + "transcripts: composite (profile_id, created_at DESC) index", + r#" + -- Performance index for the dominant transcripts query path: + -- WHERE profile_id = ? ORDER BY created_at DESC LIMIT ? + -- The standalone idx_transcripts_profile_id and idx_transcripts_created + -- forced SQLite to either filter by profile then sort, or scan the date + -- index and filter — fine at hundreds of rows, painful past a few thousand. + -- A composite index covers both predicates in one ordered seek. + CREATE INDEX IF NOT EXISTS idx_transcripts_profile_created + ON transcripts(profile_id, created_at DESC); + "#, + ), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. @@ -612,7 +626,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 14); + assert_eq!(count, 15); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -631,7 +645,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 14); + assert_eq!(count, 15); } #[tokio::test] @@ -1124,4 +1138,48 @@ mod tests { .expect("query"); assert_eq!(auto, 0, "pre-existing completed rows must default to 0"); } + + #[tokio::test] + async fn migration_v15_creates_profile_created_index() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("pool"); + run_migrations(&pool).await.expect("migrate"); + + // Index exists by name. + let names: Vec = sqlx::query_scalar( + "SELECT name FROM sqlite_master \ + WHERE type = 'index' AND tbl_name = 'transcripts'", + ) + .fetch_all(&pool) + .await + .expect("read indexes"); + assert!( + names.iter().any(|n| n == "idx_transcripts_profile_created"), + "expected composite (profile_id, created_at) index, got {names:?}", + ); + + // Query planner picks the composite index for the dominant + // profile-scoped, date-ordered list query. EXPLAIN QUERY PLAN + // returns (id, parent, notused, detail) — we want detail. + let plan_rows = sqlx::query( + "EXPLAIN QUERY PLAN \ + SELECT id FROM transcripts \ + WHERE profile_id = ? ORDER BY created_at DESC LIMIT 50", + ) + .bind(crate::DEFAULT_PROFILE_ID) + .fetch_all(&pool) + .await + .expect("explain"); + let plan: Vec = plan_rows + .iter() + .map(|r| r.get::("detail")) + .collect(); + assert!( + plan.iter().any(|row| row.contains("idx_transcripts_profile_created")), + "planner should use the composite index, got plan: {plan:?}", + ); + } }