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
8 changed files with 223 additions and 28 deletions
@@ -86,7 +86,11 @@ class FakeMailRepository(
val movedToFolder = mutableListOf<Pair<List<String>, String>>()
val replyDrafts = mutableListOf<Pair<String, ReplyMode>>()
override fun observeMessages(): Flow<List<Message>> = flowOf(messages)
override fun observeFolderMessages(accountId: String, folder: String): Flow<List<Message>> =
flowOf(messages.filter { it.accountId == accountId && it.folder == folder })
override fun observeUnifiedFolderMessages(folder: String): Flow<List<Message>> =
flowOf(messages.filter { it.folder == folder })
override fun observeFolders(accountId: String): Flow<List<Folder>> = flowOf(
folders.filter {
@@ -24,6 +24,36 @@ interface MessageDao {
)
fun observeSummaries(): Flow<List<MessageSummary>>
/**
* Mailbox-list projection scoped in SQL to one account's [folder], newest-first. Unlike
* [observeSummaries] this pulls only that folder's rows and re-emits only when they actually
* change, so the mailbox list's cost scales with what's shown, not the whole cache (issue #86).
* `inInbox` is *not* filtered here so the one query serves both the normal list (caller keeps
* `inInbox = 1` rows) and search (caller keeps rows matching the query, including transient
* `inInbox = 0` server-search hits). Served by the existing `(accountId, folder, uid)` index (its
* `(accountId, folder)` prefix), so no new index — and no schema migration — is needed.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE accountId = :accountId AND folder = :folder ORDER BY timestampMillis DESC",
)
fun observeFolderSummaries(accountId: String, folder: String): Flow<List<MessageSummary>>
/**
* Unified-inbox projection: [folder] across every account, newest-first. Scoped in SQL like
* [observeFolderSummaries] but without an account predicate (issue #86). No existing index leads
* with `folder`, so this still scans in timestamp order — far cheaper than [observeSummaries] (it
* materializes only this folder's rows, not the whole cache) but not O(1); a large multi-account
* unified inbox is a candidate for a `(folder, inInbox, timestampMillis)` index + paging.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE folder = :folder ORDER BY timestampMillis DESC",
)
fun observeUnifiedFolderSummaries(folder: String): Flow<List<MessageSummary>>
/**
* Live per-(account, folder) unread counts for the drawer's folder badges and the bold styling of
* accounts with unread mail. Counts only folder-synced rows (`inInbox = 1`), so transient
@@ -60,9 +60,11 @@ class MailRepositoryImpl @Inject constructor(
private val signatureRepository: SignatureRepository,
) : MailRepository {
override fun observeMessages(): Flow<List<Message>> = messageDao.observeSummaries().map { rows ->
rows.map { it.toDomain() }
}
override fun observeFolderMessages(accountId: String, folder: String): Flow<List<Message>> =
messageDao.observeFolderSummaries(accountId, folder).map { rows -> rows.map { it.toDomain() } }
override fun observeUnifiedFolderMessages(folder: String): Flow<List<Message>> =
messageDao.observeUnifiedFolderSummaries(folder).map { rows -> rows.map { it.toDomain() } }
override fun observeFolders(accountId: String): Flow<List<Folder>> =
folderDao.observeForAccount(accountId).map { rows ->
@@ -17,7 +17,15 @@ import java.io.File
* The UI always reads from here; the cache is the single source of truth.
*/
interface MailRepository {
fun observeMessages(): Flow<List<Message>>
/**
* The cached messages of one account's [folder], newest-first — the scoped source for the mailbox
* list (issue #86). Includes transient server-search rows so search can surface them; the caller
* filters `inInbox`/query. Re-emits only when that folder's rows change, not on every cache write.
*/
fun observeFolderMessages(accountId: String, folder: String): Flow<List<Message>>
/** Like [observeFolderMessages] but for [folder] across every account (the unified inbox). */
fun observeUnifiedFolderMessages(folder: String): Flow<List<Message>>
/** The account's cached IMAP folders for the navigation drawer. */
fun observeFolders(accountId: String): Flow<List<Folder>>
@@ -117,21 +117,25 @@ class MailboxViewModel @Inject constructor(
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val messages: StateFlow<List<Message>> =
combine(
mailRepository.observeMessages(),
_selectedAccountId,
_selectedFolder,
_searchQuery,
) { all, accountId, folder, query ->
val q = query.trim()
all.filter { message ->
(accountId == null || message.accountId == accountId) &&
message.folder == folder &&
// Outside of search show only synced rows; while searching show every match in this
// folder, including transient server-search hits that aren't synced.
(if (q.isEmpty()) message.inInbox else message.matchesSearch(q))
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
combine(_selectedAccountId, _selectedFolder) { accountId, folder -> accountId to folder }
.distinctUntilChanged()
.flatMapLatest { (accountId, folder) ->
// Scope the query in SQL to the viewed account+folder (or [folder] across accounts for
// the unified inbox) so Room only re-queries/re-emits when those rows change, and the
// cost scales with the folder, not the whole cache (issue #86).
val scoped = if (accountId == null) {
mailRepository.observeUnifiedFolderMessages(folder)
} else {
mailRepository.observeFolderMessages(accountId, folder)
}
// The only remaining client-side pass distinguishes the normal list (synced rows) from
// an active search (any matching row, including transient server-search hits) — a match
// over the small folder-scoped set, never the whole cache.
combine(scoped, _searchQuery) { rows, query ->
val q = query.trim()
rows.filter { if (q.isEmpty()) it.inInbox else it.matchesSearch(q) }
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val draftCount: StateFlow<Int> = mailRepository.observeDrafts()
.map { it.size }
@@ -74,18 +74,19 @@ class MailRepositoryImplTest {
)
@Test
fun `observeMessages is empty when the cache is empty`() = runTest {
every { messageDao.observeSummaries() } returns flowOf(emptyList())
repository.observeMessages().test {
fun `observeFolderMessages is empty when the folder has no cached rows`() = runTest {
every { messageDao.observeFolderSummaries("acct", "INBOX") } returns flowOf(emptyList())
repository.observeFolderMessages("acct", "INBOX").test {
assertTrue(awaitItem().isEmpty())
awaitComplete()
}
}
@Test
fun `observeMessages maps cached entities`() = runTest {
every { messageDao.observeSummaries() } returns flowOf(listOf(messageSummary("1", "INBOX")))
repository.observeMessages().test {
fun `observeFolderMessages maps the folder's cached entities`() = runTest {
every { messageDao.observeFolderSummaries("acct", "INBOX") } returns
flowOf(listOf(messageSummary("1", "INBOX")))
repository.observeFolderMessages("acct", "INBOX").test {
val items = awaitItem()
assertEquals(1, items.size)
assertEquals("Ada", items.first().sender)
@@ -94,6 +95,16 @@ class MailRepositoryImplTest {
}
}
@Test
fun `observeUnifiedFolderMessages maps the folder's rows across accounts`() = runTest {
every { messageDao.observeUnifiedFolderSummaries("INBOX") } returns
flowOf(listOf(messageSummary("1", "INBOX")))
repository.observeUnifiedFolderMessages("INBOX").test {
assertEquals(1, awaitItem().size)
awaitComplete()
}
}
@Test
fun `observeFolders maps cached folders with their roles and server special-use flag`() = runTest {
every { folderDao.observeForAccount("acct") } returns flowOf(
@@ -167,7 +167,6 @@ class MailboxViewModelTest {
@Test
fun `folder list lags a drawer-account switch until the new folders arrive`() = runTest(testDispatcher) {
val repo = mockk<MailRepository>(relaxed = true)
every { repo.observeMessages() } returns MutableStateFlow(emptyList<Message>())
every { repo.observeDrafts() } returns flowOf(emptyList())
every { repo.observeOutbox() } returns flowOf(emptyList())
every { repo.observeFolders("imap:a") } returns
@@ -483,7 +482,17 @@ class MailboxViewModelTest {
repo: MailRepository = mockk(relaxed = true),
initialAccountId: String? = null,
): MailboxViewModel {
every { repo.observeMessages() } returns MutableStateFlow(messages)
// Mimic the SQL scoping: return the seed list narrowed to the queried account+folder / folder,
// exactly as MessageDao.observeFolderSummaries / observeUnifiedFolderSummaries do (issue #86).
every { repo.observeFolderMessages(any(), any()) } answers {
val accountId = firstArg<String>()
val folder = secondArg<String>()
MutableStateFlow(messages.filter { it.accountId == accountId && it.folder == folder })
}
every { repo.observeUnifiedFolderMessages(any()) } answers {
val folder = firstArg<String>()
MutableStateFlow(messages.filter { it.folder == folder })
}
every { repo.observeDrafts() } returns flowOf(emptyList())
every { repo.observeOutbox() } returns flowOf(emptyList())
every { repo.observeUnreadCounts() } returns MutableStateFlow(unreadCounts)
+127
View File
@@ -0,0 +1,127 @@
<!-- 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; optionally a `(folder, timestampMillis)` index to turn the scan into a
seek. (The per-account views are already flat, so Paging3 there is lower priority.)
- **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.