perf(mailbox): scope the message-list query to the viewed folder in SQL #123

Merged
JMR-dev merged 2 commits from perf-mailbox-message-loading into main 2026-07-02 12:46:19 +00:00
JMR-dev commented 2026-07-02 12:16:58 +00:00 (Migrated from github.com)

Summary

Validates and fixes the mailbox-list slowness in #86. The list observed the whole messages
table (observeSummaries, no WHERE/LIMIT), mapped every cached row to a domain Message, and
filtered down to the visible account+folder in MailboxViewModel — so its cost scaled with the
total cache and re-ran on every write to messages (IDLE delivery, a read/star toggle, a
backfill page, any folder sync).

The fix pushes the account/folder filter into SQL and flatMapLatestes the ViewModel over the
selected account+folder; the only remaining client-side pass separates the normal list from an
active search over the small folder-scoped set.

Profiling first (the ticket asked for validation before a fix)

Measured on an emulator (API 29) against 1k / 5k / 20k-row caches with a fixed 150-row visible page.
Full report: docs/perf/issue-86-profiling.md.

total rows CURRENT whole-table + filter SCOPED acct+folder speedup
1 000 ~5 ms ~1.5 ms ~3×
5 000 ~25 ms ~1.5 ms ~17×
20 000 ~125 ms ~1.5 ms ~80×
  • Theory confirmed: the current path is O(total cache); the scoped query is flat.
  • No index, no migration. EXPLAIN QUERY PLAN shows the account-scoped query is already served
    by the existing (accountId, folder, uid) index (added in v13). The ticket's proposed
    (accountId, folder, inInbox, timestampMillis) composite index moves the timing only within noise
    (80× → 87×) and SQLite's planner doesn't even prefer it when present. So this PR adds no schema
    change
    — the cache DB stays at v14, and there is no coordination needed with #118's v15.
  • Second contributor: the unified "All inboxes" view is folder-only (no leading index) so it
    stays an O(N) scan — still 5.6× better here (materializes only INBOX rows), but its real fix is
    paging (± a (folder, timestampMillis) index), deferred with a note. IMAP round-trip latency
    on folder open is a separate, unmeasured network concern.

Changes

  • MessageDao: observeFolderSummaries(account, folder) + observeUnifiedFolderSummaries(folder)
    (SQL-scoped, inInbox-agnostic so one query serves both list and search).
  • MailRepository: observeMessages() → observeFolderMessages / observeUnifiedFolderMessages.
  • MailboxViewModel.messages: flatMapLatest over the scoped flow; client filter reduced to
    inInbox vs matchesSearch over the folder-scoped set.
  • Tests updated (MailboxViewModelTest, MailRepositoryImplTest, FakeMailRepository).
  • Throwaway benchmark probe removed after measuring (its methodology + numbers live in the doc); it
    wasn't kept because it would burden the 8-device CI E2E matrix.

Testing

  • Fast gate green: assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt
    and compileDebugAndroidTestKotlin.
  • MailboxScreenTest (11 tests) run on the emulator to verify the refactored flow renders end-to-end.

Closes #86

🤖 Generated with Claude Code

## Summary Validates and fixes the mailbox-list slowness in #86. The list observed the **whole** `messages` table (`observeSummaries`, no `WHERE`/`LIMIT`), mapped every cached row to a domain `Message`, and filtered down to the visible account+folder in `MailboxViewModel` — so its cost scaled with the total cache and re-ran on **every** write to `messages` (IDLE delivery, a read/star toggle, a backfill page, any folder sync). The fix pushes the account/folder filter into SQL and `flatMapLatest`es the ViewModel over the selected account+folder; the only remaining client-side pass separates the normal list from an active search over the small folder-scoped set. ## Profiling first (the ticket asked for validation before a fix) Measured on an emulator (API 29) against 1k / 5k / 20k-row caches with a fixed 150-row visible page. Full report: [`docs/perf/issue-86-profiling.md`](docs/perf/issue-86-profiling.md). | total rows | CURRENT whole-table + filter | SCOPED acct+folder | speedup | |-----------:|-----------------------------:|-------------------:|--------:| | 1 000 | ~5 ms | ~1.5 ms | ~3× | | 5 000 | ~25 ms | ~1.5 ms | ~17× | | 20 000 | ~125 ms | ~1.5 ms | **~80×** | - **Theory confirmed:** the current path is O(total cache); the scoped query is flat. - **No index, no migration.** `EXPLAIN QUERY PLAN` shows the account-scoped query is already served by the **existing** `(accountId, folder, uid)` index (added in v13). The ticket's proposed `(accountId, folder, inInbox, timestampMillis)` composite index moves the timing only within noise (80× → 87×) and SQLite's planner doesn't even prefer it when present. So this PR adds **no schema change** — the cache DB stays at **v14**, and there is **no coordination needed with #118's v15**. - **Second contributor:** the unified "All inboxes" view is folder-only (no leading index) so it stays an O(N) scan — still 5.6× better here (materializes only INBOX rows), but its real fix is **paging** (± a `(folder, timestampMillis)` index), deferred with a note. IMAP round-trip latency on folder open is a separate, unmeasured network concern. ## Changes - `MessageDao`: `observeFolderSummaries(account, folder)` + `observeUnifiedFolderSummaries(folder)` (SQL-scoped, `inInbox`-agnostic so one query serves both list and search). - `MailRepository`: `observeMessages()` → `observeFolderMessages` / `observeUnifiedFolderMessages`. - `MailboxViewModel.messages`: `flatMapLatest` over the scoped flow; client filter reduced to `inInbox` vs `matchesSearch` over the folder-scoped set. - Tests updated (`MailboxViewModelTest`, `MailRepositoryImplTest`, `FakeMailRepository`). - Throwaway benchmark probe removed after measuring (its methodology + numbers live in the doc); it wasn't kept because it would burden the 8-device CI E2E matrix. ## Testing - Fast gate green: `assembleDebug` + `testDebugUnitTest` + `lintDebug` + `ktlintCheck` + `detekt` and `compileDebugAndroidTestKotlin`. - `MailboxScreenTest` (11 tests) run on the emulator to verify the refactored flow renders end-to-end. Closes #86 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.