perf(mailbox): page the unified "All inboxes" list (#124) #130

Merged
JMR-dev merged 2 commits from perf-unified-inbox-paging into main 2026-07-02 14:45:51 +00:00
JMR-dev commented 2026-07-02 13:50:46 +00:00 (Migrated from github.com)

Summary

Follow-up to #86. PR #123 flattened the per-account+folder list; this pages the unified "All inboxes" browse view — the remaining O(N) path that scanned in timestamp order (no folder-leading index) and materialized the entire unified inbox (~4k rows at a 20k cache) into memory on every emission.

  • MessageDao.pagingUnifiedFolderSummaries(folder) — a Paging 3 PagingSource<Int, MessageSummary> over WHERE folder = ? AND inInbox = 1 ORDER BY timestampMillis DESC (synced rows only; unified search keeps observeUnifiedFolderSummaries, which also surfaces transient inInbox = 0 hits).
  • MailRepository.pagedUnifiedFolderMessages(folder) — wraps it in a Pager (pageSize 40, initialLoadSize 120, no placeholders) and maps summaries to domain.
  • MailboxViewModel.pagedMessages — flatMapLatest to the paged flow while browsing the unified inbox (no account, no active search), else empty; cachedIn(viewModelScope). The messages list flow now stays empty while browsing the unified inbox, so the whole inbox is never pulled into memory; it still serves per-account views and unified search. Selection carries each row's accountId (captured at tap time) so Move still resolves the selection's account without a full in-memory list.
  • MailboxScreen renders the unified browse list via collectAsLazyPagingItems(); per-account/search render the flat list unchanged.

The per-account+folder path from #86/#123 is untouched (still flat — no regression).

Profiling — before / after

Measured on the libremail_api29 emulator with a temporary instrumented probe (removed after measuring, like #86's), seeded across 3 accounts × 5 folders (INBOX ≈ 20%). Full write-up in docs/perf/issue-124-unified-inbox-paging.md.

total rows INBOX rows CURRENT whole-inbox first-emit (med/min ms) PAGED first page 120 (med/min) PAGED deep page 40 (med/min)
5,000 1,000 6.80 / 4.93 5.44 / 4.64 3.67 / 2.83
20,000 4,000 24.56 / 18.89 6.82 / 5.67 12.86 / 10.52

The current query grows with inbox size; the paged first page is flat (~5–7 ms) regardless of total cache — ~3.6× at 20k, and it stays flat as the cache keeps growing (full-history backfill). Cross-checked on a physical Pixel (API 37) at 20k: 27.2 → 8.6 ms first page (same shape).

No index, no schema migration

EXPLAIN QUERY PLAN for the paged query (existing indices only):

SCAN TABLE messages USING INDEX index_messages_timestampMillis   (LIMIT 120)

The LIMIT lets the planner stop after a screenful, so the first page is already flat without a folder-leading index. A (folder, inInbox, timestampMillis) index would only help a deep scroll (large OFFSET), which is rare, bounded by scroll depth (not total cache), and already faster than the current whole-inbox load. So no index and no messages schema change — the cache DB stays at version 15, and there is no #118 version coordination needed. (Had an index been required, it would have been v15→v16 and would have collided with the in-flight #118 — that renumber dance is avoided entirely.)

Tests

  • MailRepositoryImplTest: pagedUnifiedFolderMessages maps paged summaries to domain (via asSnapshot over a fake PagingSource).
  • MailboxViewModelTest: unified browse keeps the list flow empty (paged path taken); a concrete account renders the flat scoped list; selection / canMove / selectAll updated for the accountId-capturing selection.
  • MailboxScreenTest (E2E) already drives the unified/paged path with long-press selection, archive, spam, delete, and move — 11/11 green on the emulator, confirming paged rendering + selection.

Fast gate (assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt + compileDebugAndroidTestKotlin) is green.

Closes #124

🤖 Generated with Claude Code

## Summary Follow-up to #86. PR #123 flattened the per-account+folder list; this pages the unified **"All inboxes"** browse view — the remaining O(N) path that scanned in timestamp order (no `folder`-leading index) and materialized the entire unified inbox (~4k rows at a 20k cache) into memory on every emission. - **`MessageDao.pagingUnifiedFolderSummaries(folder)`** — a Paging 3 `PagingSource<Int, MessageSummary>` over `WHERE folder = ? AND inInbox = 1 ORDER BY timestampMillis DESC` (synced rows only; unified **search** keeps `observeUnifiedFolderSummaries`, which also surfaces transient `inInbox = 0` hits). - **`MailRepository.pagedUnifiedFolderMessages(folder)`** — wraps it in a `Pager` (pageSize 40, initialLoadSize 120, no placeholders) and maps summaries to domain. - **`MailboxViewModel.pagedMessages`** — `flatMapLatest` to the paged flow while browsing the unified inbox (no account, no active search), else empty; `cachedIn(viewModelScope)`. The `messages` list flow now stays **empty** while browsing the unified inbox, so the whole inbox is never pulled into memory; it still serves per-account views and unified search. Selection carries each row's `accountId` (captured at tap time) so **Move** still resolves the selection's account without a full in-memory list. - **`MailboxScreen`** renders the unified browse list via `collectAsLazyPagingItems()`; per-account/search render the flat list unchanged. The per-account+folder path from #86/#123 is untouched (still flat — no regression). ## Profiling — before / after Measured on the `libremail_api29` emulator with a temporary instrumented probe (removed after measuring, like #86's), seeded across 3 accounts × 5 folders (INBOX ≈ 20%). Full write-up in `docs/perf/issue-124-unified-inbox-paging.md`. | total rows | INBOX rows | CURRENT whole-inbox first-emit (med/min ms) | PAGED first page 120 (med/min) | PAGED deep page 40 (med/min) | |---:|---:|---:|---:|---:| | 5,000 | 1,000 | 6.80 / 4.93 | 5.44 / 4.64 | 3.67 / 2.83 | | 20,000 | 4,000 | **24.56 / 18.89** | **6.82 / 5.67** | 12.86 / 10.52 | The current query grows with inbox size; the paged **first page is flat** (~5–7 ms) regardless of total cache — **~3.6× at 20k**, and it stays flat as the cache keeps growing (full-history backfill). Cross-checked on a physical Pixel (API 37) at 20k: 27.2 → 8.6 ms first page (same shape). ## No index, no schema migration `EXPLAIN QUERY PLAN` for the paged query (existing indices only): ``` SCAN TABLE messages USING INDEX index_messages_timestampMillis (LIMIT 120) ``` The `LIMIT` lets the planner stop after a screenful, so the first page is already flat **without** a `folder`-leading index. A `(folder, inInbox, timestampMillis)` index would only help a deep scroll (large `OFFSET`), which is rare, bounded by scroll depth (not total cache), and already faster than the current whole-inbox load. So **no index and no `messages` schema change** — the cache DB stays at **version 15**, and there is **no #118 version coordination needed**. (Had an index been required, it would have been v15→v16 and would have collided with the in-flight #118 — that renumber dance is avoided entirely.) ## Tests - `MailRepositoryImplTest`: `pagedUnifiedFolderMessages` maps paged summaries to domain (via `asSnapshot` over a fake `PagingSource`). - `MailboxViewModelTest`: unified browse keeps the list flow empty (paged path taken); a concrete account renders the flat scoped list; selection / `canMove` / `selectAll` updated for the accountId-capturing selection. - `MailboxScreenTest` (E2E) already drives the unified/paged path with long-press selection, archive, spam, delete, and move — **11/11 green on the emulator**, confirming paged rendering + selection. Fast gate (`assembleDebug` + `testDebugUnitTest` + `lintDebug` + `ktlintCheck` + `detekt` + `compileDebugAndroidTestKotlin`) is green. Closes #124 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.