perf(sync): pause background backfill while a message is opening (prioritize interactive IMAP fetches) #355

Closed
opened 2026-07-05 20:54:15 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-05 20:54:15 +00:00 (Migrated from github.com)

Summary

Opening a not-yet-cached message stalls ~35–74 s (avg 48 s) behind the reader's
loading spinner on a real device, because the on-demand body fetch has no priority
over the continuous full-history backfill
and loses the race for the account's IMAP
throughput. This ticket: make an interactive open pre-empt / pause background
backfill so the body the user is waiting on is fetched first.

Evidence (physical-device perf run, 2026-07-05)

Pixel 10 Pro XL, Android 17 / SDK 37, real Gmail (PASSWORD_IMAP) over cellular LTE,
debug build of origin/main 6118b6d.

  • Mailbox -> open an uncached message: avg 48,366 ms, range 34,992–73,658 ms (n=6;
    independent validation sample ~55–58 s). The reader navigates instantly; the body
    renders behind a spinner until MailRepositoryImpl.openMessage() returns.
  • Re-opening an already-cached message clears the spinner effectively instantly, and
    reader rendering is smooth (gfxinfo 50th pct 6 ms, 7% janky). So the stall is the
    first on-demand IMAP body fetch — not UI, navigation, or rendering.
  • Cold open is healthy (avg 324 ms) and out of scope.

Correlation with backfill (logcat_full_nav.txt):

  • BackfillWorker.doWork() chains slices back-to-back: while (mailBackfiller.runBackfill() && !isStopped) { }. MailBackfiller.runBackfill() returned moreWork=true for the
    entire ~50-min session — backfill slice: maxBatches=20 / slice done: moreWork=true
    roughly every 85 s from 14:55 to 15:40+, never clearing (full-history backfill, #12).
  • All six measured opens (device-local 15:22:44–15:27:38) overlapped an in-flight
    backfill slice. Worst case: open #4 (73.7 s, 15:25:20 -> 15:26:34) ran entirely inside
    the slice 15:25:10 -> 15:26:45.
  • Default FetchPolicy.WIFI_ONLY + metered LTE means SyncResourcePolicy.shouldPrefetchContent
    was false, so backfill was paging headers only (no body prefetch). Even pure header
    paging sufficed to starve the interactive fetch.

Root cause (confirmed in code)

  • ImapClient is connect-per-operation: every call does a fresh CONNECT+TLS+LOGIN then
    LOGOUT. The connection-reuse spike (#125) is OFF by default
    (ImapClient.@Inject constructor() : this(reuseConnections = false); no DI override).
  • openMessage() -> ImapClient.fetchBodyMarkingSeen() opens its own connection,
    independent of backfill's, and shares no in-process lock with it: MailMaintenanceGate.mutex
    only serializes backfill vs the pruner; MailSyncer has a separate mutex. Nothing gives the
    interactive fetch priority.
  • Net: sustained back-to-back backfill logins + FETCHes keep the Gmail account near its
    per-account IMAP throttle/bandwidth ceiling, and the un-prioritized reader fetch queues
    behind that background storm for tens of seconds.
  • NB this refines the perf write-up's "monopolize the account's single IMAP connection"
    phrasing: it is not one shared socket — it is connect-per-operation with zero
    interactive-vs-background prioritization
    , plus server-side throttling.

Proposed approach

Introduce a small process-wide coordinator (mirroring MailMaintenanceGate) that lets
interactive IMAP work signal "a user fetch is in flight" and makes backfill yield to it:

  • New InteractiveImapGate (@Singleton) exposing e.g. an active-interactive counter /
    Mutex or a withInteractive { } scope.
  • MailRepositoryImpl wraps the user-facing IMAP paths in withInteractive { }:
    openMessage, inlineImages, downloadAttachment, buildReplyDraft (and optionally
    star/delete/move).
  • MailBackfiller.backfillFolder() already has a natural yield point — it
    delay(BACKFILL_BATCH_DELAY_MS) and calls ensureActive() between pages. Before starting
    each page (and before starting a slice in runBackfill()), check the gate and park
    (suspend) while an interactive fetch is active, resuming when it clears. This is cooperative
    and needs no thread priorities.
  • Keep it best-effort: backfill parks between pages, so a page already in flight finishes; the
    interactive fetch still wins the next server round-trip. Combine with #356 (bounded backfill)
    for the case where the open lands mid-page.

Affected files

  • app/src/main/kotlin/org/libremail/data/sync/MailMaintenanceGate.kt (pattern to mirror)
  • app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt (yield to the gate between pages/slices)
  • app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt (openMessage, inlineImages, downloadAttachment, buildReplyDraft)
  • app/src/main/kotlin/org/libremail/mail/ImapClient.kt (interactive fetch entry points)
  • new: InteractiveImapGate (+ Hilt binding)

Definition of done (per repo DoD)

  • Unit test: with the gate held, MailBackfiller parks before its next page and resumes on
    release (Turbine/coroutines-test; GreenMail for the IMAP side).
  • E2E/instrumented test proving an interactive open completes while backfill is enqueued.
  • PII-free AppLog breadcrumb when backfill yields to / resumes after an interactive fetch
    (see #358).

Relates to

Root cause #12 (full-history backfill, closed/implemented). Sibling fixes: #356 (bound backfill),
#357 (fast first-open), #358 (observability). Prior perf work: #86, #125 (closed). Filed from the
2026-07-05 Pixel 10 Pro XL perf run.

## Summary Opening a not-yet-cached message stalls **~35–74 s (avg 48 s)** behind the reader's loading spinner on a real device, because the on-demand body fetch has **no priority over the continuous full-history backfill** and loses the race for the account's IMAP throughput. This ticket: make an interactive open **pre-empt / pause** background backfill so the body the user is waiting on is fetched first. ## Evidence (physical-device perf run, 2026-07-05) Pixel 10 Pro XL, Android 17 / SDK 37, real Gmail (`PASSWORD_IMAP`) over cellular LTE, debug build of origin/main `6118b6d`. - Mailbox -> open an uncached message: **avg 48,366 ms, range 34,992–73,658 ms** (n=6; independent validation sample ~55–58 s). The reader navigates instantly; the body renders behind a spinner until `MailRepositoryImpl.openMessage()` returns. - Re-opening an **already-cached** message clears the spinner effectively instantly, and reader rendering is smooth (gfxinfo 50th pct **6 ms**, 7% janky). So the stall is the first on-demand IMAP body fetch — not UI, navigation, or rendering. - Cold open is healthy (avg 324 ms) and out of scope. Correlation with backfill (`logcat_full_nav.txt`): - `BackfillWorker.doWork()` chains slices back-to-back: `while (mailBackfiller.runBackfill() && !isStopped) { }`. `MailBackfiller.runBackfill()` returned `moreWork=true` for the **entire ~50-min session** — `backfill slice: maxBatches=20` / `slice done: moreWork=true` roughly every 85 s from 14:55 to 15:40+, never clearing (full-history backfill, #12). - All six measured opens (device-local **15:22:44–15:27:38**) overlapped an in-flight backfill slice. Worst case: open #4 (73.7 s, 15:25:20 -> 15:26:34) ran entirely inside the slice 15:25:10 -> 15:26:45. - Default `FetchPolicy.WIFI_ONLY` + metered LTE means `SyncResourcePolicy.shouldPrefetchContent` was false, so backfill was paging **headers only** (no body prefetch). Even pure header paging sufficed to starve the interactive fetch. ## Root cause (confirmed in code) - `ImapClient` is **connect-per-operation**: every call does a fresh CONNECT+TLS+LOGIN then LOGOUT. The connection-reuse spike (#125) is **OFF** by default (`ImapClient.@Inject constructor() : this(reuseConnections = false)`; no DI override). - `openMessage()` -> `ImapClient.fetchBodyMarkingSeen()` opens its **own** connection, independent of backfill's, and shares **no in-process lock** with it: `MailMaintenanceGate.mutex` only serializes backfill vs the pruner; `MailSyncer` has a separate mutex. **Nothing gives the interactive fetch priority.** - Net: sustained back-to-back backfill logins + FETCHes keep the Gmail account near its per-account IMAP throttle/bandwidth ceiling, and the un-prioritized reader fetch queues behind that background storm for tens of seconds. - NB this refines the perf write-up's "monopolize the account's single IMAP connection" phrasing: it is **not** one shared socket — it is connect-per-operation with **zero interactive-vs-background prioritization**, plus server-side throttling. ## Proposed approach Introduce a small process-wide coordinator (mirroring `MailMaintenanceGate`) that lets interactive IMAP work signal "a user fetch is in flight" and makes backfill yield to it: - New `InteractiveImapGate` (`@Singleton`) exposing e.g. an active-interactive counter / `Mutex` or a `withInteractive { }` scope. - `MailRepositoryImpl` wraps the user-facing IMAP paths in `withInteractive { }`: `openMessage`, `inlineImages`, `downloadAttachment`, `buildReplyDraft` (and optionally star/delete/move). - `MailBackfiller.backfillFolder()` already has a natural yield point — it `delay(BACKFILL_BATCH_DELAY_MS)` and calls `ensureActive()` between pages. Before starting each page (and before starting a slice in `runBackfill()`), check the gate and **park** (suspend) while an interactive fetch is active, resuming when it clears. This is cooperative and needs no thread priorities. - Keep it best-effort: backfill parks between pages, so a page already in flight finishes; the interactive fetch still wins the next server round-trip. Combine with #356 (bounded backfill) for the case where the open lands mid-page. ## Affected files - `app/src/main/kotlin/org/libremail/data/sync/MailMaintenanceGate.kt` (pattern to mirror) - `app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt` (yield to the gate between pages/slices) - `app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt` (`openMessage`, `inlineImages`, `downloadAttachment`, `buildReplyDraft`) - `app/src/main/kotlin/org/libremail/mail/ImapClient.kt` (interactive fetch entry points) - new: `InteractiveImapGate` (+ Hilt binding) ## Definition of done (per repo DoD) - Unit test: with the gate held, `MailBackfiller` parks before its next page and resumes on release (Turbine/coroutines-test; GreenMail for the IMAP side). - E2E/instrumented test proving an interactive open completes while backfill is enqueued. - PII-free `AppLog` breadcrumb when backfill yields to / resumes after an interactive fetch (see #358). ## Relates to Root cause #12 (full-history backfill, closed/implemented). Sibling fixes: #356 (bound backfill), #357 (fast first-open), #358 (observability). Prior perf work: #86, #125 (closed). Filed from the 2026-07-05 Pixel 10 Pro XL perf run.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#355