Files
LibreMail/docs/perf/issue-86-profiling.md
T
JMR-devandClaude Fable 5 f8d03a4343 perf(mailbox): page the unified "All inboxes" list (#124)
The unified inbox query (WHERE folder = ?, no accountId) has no folder-leading
index, so it scans in timestamp order and materializes the whole unified inbox
(~4k rows at a 20k cache) into memory on every emission. Apply Paging 3 to the
unified browse path so query, mapping, and recomposition cost scale with the
visible window, not the total cache.

- MessageDao.pagingUnifiedFolderSummaries: a PagingSource over the folder's
  synced rows (inInbox = 1); unified search keeps the whole-folder query so it
  can still surface transient server-search hits.
- MailRepository.pagedUnifiedFolderMessages: a Pager (pageSize 40, initialLoad
  120, no placeholders) mapping summaries to domain.
- MailboxViewModel.pagedMessages: paged while browsing the unified inbox, else
  empty; the messages list flow stays empty in that state so the whole cache is
  never materialized. Selection captures each row's accountId at tap time, so
  "Move" still resolves the selection's account without an in-memory list.
- MailboxScreen renders the unified browse list via collectAsLazyPagingItems;
  per-account and search views render the flat list unchanged (issue #86 stays
  flat).

Profiling (docs/perf/issue-124-unified-inbox-paging.md) on an api29 emulator:
current whole-inbox first-emit ~24.6 ms at a 20k cache vs. the paged first page
~6.8 ms and flat regardless of cache size (~3.6x). EXPLAIN QUERY PLAN shows the
paged query still stops early on the existing timestamp index, so no
(folder, ...) index and no schema migration are added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:49:52 -05:00

129 lines
9.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# Issue #86 — mailbox message-loading profiling
Empirical validation of the issue-#86 hypothesis that the mailbox list is slow because its data
path scales with the **total** cached message count, not with what's visible. Measured on an
emulator against a large seeded cache **before** committing a fix (as the ticket asked).
## TL;DR / verdict
- **The SQL-scoping theory holds, decisively.** The current path
(`MessageDao.observeSummaries()` → whole `messages` table → `MailRepositoryImpl.observeMessages()`
maps every row → `MailboxViewModel` `.filter{}`) is **O(total cache size)**: ~5 ms at 1k rows,
~25 ms at 5k, **~125 ms at 20k**. A SQL-scoped `WHERE accountId = ? AND folder = ?` query is
**~1.5 ms, flat** regardless of cache size — **~80× faster at 20k**. This cost is paid on **every**
re-emission, i.e. on every unrelated write to `messages` (IDLE delivery, a read/star toggle, a
backfill page, a sync of any other folder), because Room re-runs the whole-table query on any write
to the table.
- **No new index is needed, and no schema migration.** The account-scoped query is already served by
the **existing** `(accountId, folder, uid)` index (added in v13 for backfill). The ticket's proposed
`(accountId, folder, inInbox, timestampMillis)` composite index changes the timing only within noise
(80× → 87×) — and SQLite's planner doesn't even prefer it when both are present. So the fix is pure
SQL WHERE-scoping; **the v14→v15 migration (and the #118 coordination) is avoided entirely.**
- **Second contributor found: the unified "All inboxes" view.** With no account selected the query is
folder-only (`WHERE folder = 'INBOX'`), which no existing index leads with, so it still scans in
timestamp order — better than the whole-table path (it materializes only INBOX rows, ~5.6× at 20k)
but still **O(N)** and it returns the whole unified inbox (3.7k rows at 20k, vs ~15 on screen). The
real fix there is **paging** (± a `(folder, timestampMillis)` index), deferred below. Notably the
ticket's `accountId`-leading index would **not** help this case at all.
- **IMAP latency (the ticket's other suspected contributor) was not measured** — it is a separate
network path (`MailSyncer.syncFolder` on folder open) independent of the cached-list rendering this
benchmark covers. The cached-list bottleneck is real and confirmed on its own.
## Setup & method
- Device: Gradle-managed AVD `libremail_api29` (API 29, x86, google_apis), headless, animations off.
- Harness: a throwaway instrumented probe built the real `LibreMailDatabase` in-memory (real entities,
real indices, real generated `MessageDao`), seeded it, and timed each query variant with **8 warmup
+ 25 measured iterations**, reporting **median and min** wall-clock ms with a GC between phases.
`androidx.benchmark` was deliberately *not* used: on an emulator only the **relative** A/B result is
meaningful (absolute nanos aren't representative of a real device), and it would have forced the
module's global instrumentation runner to `AndroidBenchmarkRunner`, changing what CI's E2E jobs run
under. Warmup + median/min-of-many is robust to emulator scheduling/GC noise for an order-of-
magnitude comparison. (The probe was removed after measuring; the code path it exercised is now the
shipped `observeFolderSummaries` / `observeUnifiedFolderSummaries`.)
- Dataset: a fixed **150-row visible page** (`acct0` / `INBOX`) held constant while the total cache
grows to 1 000 / 5 000 / 20 000 rows, so the visible page is a shrinking fraction of the whole cache
(15 % → 3 % → 0.75 %) — modelling a long-lived, fully-backfilled account (#12/#13). The remaining
rows are distractors spread across 3 accounts × {INBOX, Sent, Archive, Spam}. Bodies are stored so
rows are realistically sized, but the summary projection never selects them.
- Variants per size: (a) **CURRENT** = `observeSummaries()` (whole table) + map-to-domain + the
`MailboxViewModel` filter down to the page; (b) **SCOPED acct+folder** = `WHERE accountId=? AND
folder=?`; (c) **UNIFIED folder-only** = `WHERE folder=?`. (b)/(c) were measured with the existing
indices and again after adding the candidate indexes, and each query's `EXPLAIN QUERY PLAN` was
captured with existing indices only.
## Results (median / min ms, lower is better)
| total rows | visible page | CURRENT (whole-table + filter) | SCOPED acct+folder (existing → +composite) | UNIFIED folder-only (existing → +folder idx) |
|-----------:|-------------:|-------------------------------:|:------------------------------------------:|:--------------------------------------------:|
| 1 000 | 150 | **4.98** / 4.20 | 1.83 → 1.61 | 2.48 → 1.89 |
| 5 000 | 150 | **25.17** / 22.37 | 1.43 → 1.45 | 5.46 → 4.75 |
| 20 000 | 150 | **127.6** / 122.8 | 1.62 → 1.46 | 23.0 → 18.6 |
Speedups @ 20 000 rows (vs current): **acct+folder 79× (existing index), 87× (+composite)**; unified
5.6× (existing), 6.9× (+folder index). The current path's cost is the whole-table SQL scan + cursor
materialization of all N rows plus mapping all N to domain objects — both eliminated by scoping.
(A second run against the exact shipped SQL reproduced these figures; its 20k *median* was GC-inflated
to 254 ms while its *min* stayed at 125 ms — the min is the stable floor, hence both are reported.)
## Query plans (`EXPLAIN QUERY PLAN`, existing indices only)
```
CURRENT whole-table : SCAN TABLE messages USING INDEX index_messages_timestampMillis
SCOPED acct+folder : SEARCH TABLE messages USING INDEX index_messages_accountId_folder_uid
(accountId=? AND folder=?) ; USE TEMP B-TREE FOR ORDER BY
UNIFIED folder-only : SCAN TABLE messages USING INDEX index_messages_timestampMillis
```
- CURRENT walks the timestamp index to avoid a sort but **reads every row** → O(N).
- SCOPED seeks the ~150 matching rows via the existing `(accountId, folder, uid)` index's
`(accountId, folder)` prefix, then sorts them with a temp b-tree — trivial for 150 rows (hence the
composite index, which would remove that tiny sort, makes no measurable difference). After adding the
composite `idx_afit`, **SQLite keeps choosing `index_messages_accountId_folder_uid`** — confirming the
new index is redundant.
- UNIFIED has no `folder`-leading index → scans. A `(folder, timestampMillis)` index (not the ticket's
`(folder, inInbox, …)`, which still needs the sort for the no-`inInbox` unified query) would turn it
into a seek — deferred.
## Answers to the ticket's questions
1. **Root cause confirmed via profiling on a large cache?** Yes — the whole-table observe + map + filter
is O(total cache) and dominates at scale; the SQL-scoped query is flat.
2. **Scope the list query in SQL instead of Kotlin?** Yes — implemented (below).
3. **Composite index needed?** **No.** The existing `(accountId, folder, uid)` index already captures
the account-scoped win; the ticket's index adds only noise and isn't even preferred by the planner.
No schema change / migration is made.
4. **Second contributor?** Yes — the unified inbox stays O(N) (folder-only scan) and returns the full
unified inbox; its real fix is paging (± a `(folder, timestampMillis)` index). Separately, IMAP
round-trip latency on folder open remains a distinct, unmeasured network concern.
## What was implemented
- `MessageDao.observeFolderSummaries(accountId, folder)` and `observeUnifiedFolderSummaries(folder)` —
the SQL-scoped, newest-first list projections (`inInbox`-agnostic so one query serves both the normal
list and search).
- `MailRepository.observeFolderMessages` / `observeUnifiedFolderMessages` replace the whole-table
`observeMessages()`.
- `MailboxViewModel.messages` now `flatMapLatest`es over the selected account+folder to the scoped
flow; the only remaining client-side pass distinguishes the normal list (`inInbox`) from an active
search (`matchesSearch`) over the small folder-scoped set — never the whole cache. `StateFlow`'s
built-in equality de-dup means an unrelated write now costs one cheap scoped re-query and no
recomposition.
- `observeSummaries()` is retained as the #51 CursorWindow regression-guard target in the DB tests.
## Deferred follow-ups
- **Unified inbox**: ~~add Room `PagingSource`/Paging3 so the unified list's cost scales with the
screen, not the total inbox count~~ — done in #124 (`docs/perf/issue-124-unified-inbox-paging.md`).
Paging alone captured the win (first page ~flat at ~5–7 ms vs. the current ~25 ms at a 20k cache);
the `(folder, timestampMillis)` index proved unnecessary, so no schema migration was added.
- **IMAP latency on folder open** is out of scope for this cached-render fix.
## Reproduce
Seed a large `messages` cache and compare `observeSummaries()` (+ a client filter) against
`observeFolderSummaries(account, folder)` on an emulator, timing first-emit at 1k/5k/20k rows and
inspecting `EXPLAIN QUERY PLAN`. The scoped query should be flat (~1.5 ms) and use
`index_messages_accountId_folder_uid`; the whole-table query should grow linearly.