Files
LibreMail/docs/perf/issue-86-profiling.md
T
JMR-dev d0ed168fcb refactor(data): tighten data-core atomicity, chunking, and dead code (#313)
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
2026-07-08 08:09:12 -05:00

9.4 KiB
Raw Blame History

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 flatMapLatestes 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-table observeSummaries() was removed (issue #313).

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.