Addresses the below-cut data-core review nits from #313: - SignatureRepository.delete: wrap delete + default-promotion in one SignatureDao @Transaction (deletePromotingDefault) so a crash between them can't leave an account with signatures but no default; log the promotion (PII-free). - SignatureRepository.create: move the count-then-default check-then-act into a SignatureDao @Transaction (insertMakingFirstDefault) so two concurrent first-creates can't both become default. - AccountSettingsRepository.update: route the read-modify-write through an AccountSettingsDao @Transaction (readModifyWrite) so concurrent per-field setters can't clobber each other. - MailRepositoryImpl expunge/move/move-by-role: chunk the unbounded getRoutingByIds/deleteByIds IN(:ids) queries (500/chunk) like MailPruner, removing the latent SQLITE_MAX_VARIABLE_NUMBER crash. - MessageDao.observeSummaries: remove the dead whole-table projection (superseded by Paging #124/#214); migrate test/debug-probe callers to getById or the paged query (which now guards the #51 CursorWindow regression). - AccountDataMigrator: fix stale KDoc (schema is v2 with sortOrder, not v1). - DatabaseEncryption.migrate: also sweep the stale -journal sidecar (journal_mode = DELETE), matching AccountDataMigrator's sweep. Unit tests updated for the repository delegations; instrumented DAO tests cover the new @Transaction behaviour; MailRepositoryImplTest covers the chunk split; DatabaseEncryptionTest covers the -journal sweep. Closes #313
9.4 KiB
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()→ wholemessagestable →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-scopedWHERE 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 tomessages(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'saccountId-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.syncFolderon 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
LibreMailDatabasein-memory (real entities, real indices, real generatedMessageDao), 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.benchmarkwas 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 toAndroidBenchmarkRunner, 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 shippedobserveFolderSummaries/observeUnifiedFolderSummaries.)
- 25 measured iterations**, reporting median and min wall-clock ms with a GC between phases.
- 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 + theMailboxViewModelfilter 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'sEXPLAIN QUERY PLANwas 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 compositeidx_afit, SQLite keeps choosingindex_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-inInboxunified query) would turn it into a seek — deferred.
Answers to the ticket's questions
- 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.
- Scope the list query in SQL instead of Kotlin? Yes — implemented (below).
- 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. - 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)andobserveUnifiedFolderSummaries(folder)— the SQL-scoped, newest-first list projections (inInbox-agnostic so one query serves both the normal list and search).MailRepository.observeFolderMessages/observeUnifiedFolderMessagesreplace the whole-tableobserveMessages().MailboxViewModel.messagesnowflatMapLatestes 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.- The #51 CursorWindow regression guard now targets the paged
pagingUnifiedFolderSummaries()projection in the DB tests; the superseded whole-tableobserveSummaries()was removed (issue #313).
Deferred follow-ups
- Unified inbox:
add Room— done in #124 (PagingSource/Paging3 so the unified list's cost scales with the screen, not the total inbox countdocs/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.