perf(db): covering index for unified-inbox summary scans #285

Merged
JMR-dev merged 13 commits from perf-187-covering-index into main 2026-07-05 19:31:22 +00:00
JMR-dev commented 2026-07-04 05:15:04 +00:00 (Migrated from github.com)

Closes #187.

What

Adds a covering index for the unified-inbox summary scan and the Room migration + schema export it requires. Pure additive index — no column/table changes, no data transformation.

Index: index_messages_folder_inInbox_timestampMillis on messages (folder, inInbox, timestampMillis).

The paged "All inboxes" query — MessageDao.pagingUnifiedFolderSummaries — filters WHERE folder = ? AND inInbox = 1 ORDER BY timestampMillis DESC. No index led with folder, so the planner walked the whole table via index_messages_timestampMillis and filtered folder/inInbox per row. The new index makes the two equality predicates an index seek and supplies the timestampMillis ordering, so the whole-table SCAN becomes a bounded SEARCH with no temp B-tree sort. The column order (folder, inInbox, timestampMillis) is a perfect prefix match: two equalities then the sort column.

EXPLAIN QUERY PLAN (before / after)

Captured with sqlite3 against the exact v19 messages DDL + indices from the exported schema, for the production query:

BEFORE (v19 indices only):
  SCAN messages USING INDEX index_messages_timestampMillis

AFTER (+ index_messages_folder_inInbox_timestampMillis):
  SEARCH messages USING INDEX index_messages_folder_inInbox_timestampMillis (folder=? AND inInbox=?)

The "before" matches the plan already documented in docs/perf/issue-124-unified-inbox-paging.md. The instrumented MigrationTest re-asserts the "after" plan on the real Android SQLite (SEARCH on the new index, no USE TEMP B-TREE), so the index is verified genuinely covering the filter+order, not merely present.

Migration + schema

  • MIGRATION_19_20 = CREATE INDEX IF NOT EXISTS ... (folder, inInbox, timestampMillis), registered in DatabaseModule.
  • DB version bumped 19 → 20; matching @Index added to MessageEntity.
  • New schema exported and committed: app/schemas/.../20.json (its only diff from 19.json is the added index).

Tests

  • New MigrationTest.migrate19To20_addsUnifiedInboxCoveringIndexUsedByTheSummaryScan: runs the migration, asserts the index exists over exactly (folder, inInbox, timestampMillis), cached rows survive, and the summary query now plans as a SEARCH on the index with no temp B-tree.
  • The existing auto-discovering chain tests (migrationsFormOneUnbrokenChainUpToTheLatestExportedSchema, migratingFromV7ReplaysEveryMigrationAndPreservesData) pick up 19→20 automatically and validate the migrated schema against 20.json.
  • Ran locally on the API 36 emulator (connectedDebugAndroidTest, MigrationTest) plus the compile/unit/static gate (testDebugUnitTest, compileDebugAndroidTestKotlin, ktlintCheck, detekt).

🤖 Generated with Claude Code

Closes #187. ## What Adds a covering index for the unified-inbox summary scan and the Room migration + schema export it requires. Pure additive index — no column/table changes, no data transformation. **Index:** `index_messages_folder_inInbox_timestampMillis` on `messages (folder, inInbox, timestampMillis)`. The paged "All inboxes" query — `MessageDao.pagingUnifiedFolderSummaries` — filters `WHERE folder = ? AND inInbox = 1 ORDER BY timestampMillis DESC`. No index led with `folder`, so the planner walked the whole table via `index_messages_timestampMillis` and filtered `folder`/`inInbox` per row. The new index makes the two equality predicates an index seek and supplies the `timestampMillis` ordering, so the whole-table `SCAN` becomes a bounded `SEARCH` with no temp B-tree sort. The column order `(folder, inInbox, timestampMillis)` is a perfect prefix match: two equalities then the sort column. ## EXPLAIN QUERY PLAN (before / after) Captured with `sqlite3` against the exact v19 `messages` DDL + indices from the exported schema, for the production query: ``` BEFORE (v19 indices only): SCAN messages USING INDEX index_messages_timestampMillis AFTER (+ index_messages_folder_inInbox_timestampMillis): SEARCH messages USING INDEX index_messages_folder_inInbox_timestampMillis (folder=? AND inInbox=?) ``` The "before" matches the plan already documented in `docs/perf/issue-124-unified-inbox-paging.md`. The instrumented `MigrationTest` re-asserts the "after" plan on the real Android SQLite (SEARCH on the new index, no `USE TEMP B-TREE`), so the index is verified genuinely covering the filter+order, not merely present. ## Migration + schema - `MIGRATION_19_20` = `CREATE INDEX IF NOT EXISTS ... (folder, inInbox, timestampMillis)`, registered in `DatabaseModule`. - DB version bumped 19 → 20; matching `@Index` added to `MessageEntity`. - New schema exported and committed: `app/schemas/.../20.json` (its only diff from `19.json` is the added index). ## Tests - New `MigrationTest.migrate19To20_addsUnifiedInboxCoveringIndexUsedByTheSummaryScan`: runs the migration, asserts the index exists over exactly `(folder, inInbox, timestampMillis)`, cached rows survive, and the summary query now plans as a `SEARCH` on the index with no temp B-tree. - The existing auto-discovering chain tests (`migrationsFormOneUnbrokenChainUpToTheLatestExportedSchema`, `migratingFromV7ReplaysEveryMigrationAndPreservesData`) pick up 19→20 automatically and validate the migrated schema against `20.json`. - Ran locally on the API 36 emulator (`connectedDebugAndroidTest`, `MigrationTest`) plus the compile/unit/static gate (`testDebugUnitTest`, `compileDebugAndroidTestKotlin`, `ktlintCheck`, `detekt`). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
JMR-dev commented 2026-07-04 09:06:53 +00:00 (Migrated from github.com)

Coordinator handoff (deferred to maintainer): the schema-version test fix for the v20 bump is done and pushed (DatabaseEncryptionTest now asserts 20). Remaining: this PR conflicts with main in DatabaseModule.kt — #320 (merged) refactored migration registration to .addMigrations(*ALL_MIGRATIONS) with an introspectable ALL_MIGRATIONS list + a test asserting registered==declared. To land: git merge origin/main, then add MIGRATION_19_20 to the ALL_MIGRATIONS list (so #320's databaseModuleRegistersEveryDeclaredMigration test stays green), and re-arm. Auto-merge disabled so it doesn't land half-resolved. It's a perf optimization (covering index, #187), not a review finding.

Coordinator handoff (deferred to maintainer): the schema-version test fix for the v20 bump is done and pushed (`DatabaseEncryptionTest` now asserts 20). Remaining: this PR conflicts with `main` in **DatabaseModule.kt** — #320 (merged) refactored migration registration to `.addMigrations(*ALL_MIGRATIONS)` with an introspectable `ALL_MIGRATIONS` list + a test asserting registered==declared. To land: `git merge origin/main`, then add `MIGRATION_19_20` to the `ALL_MIGRATIONS` list (so #320's `databaseModuleRegistersEveryDeclaredMigration` test stays green), and re-arm. Auto-merge disabled so it doesn't land half-resolved. It's a perf optimization (covering index, #187), not a review finding.
Sign in to join this conversation.