Follow-up to #310/#320. MailBackfiller.persistBatch refreshed each pre-existing backfilled header with a per-row updateHeaderContent, each in its own implicit transaction — the same N-commits-per-page anti-pattern #310 fixed in MailSyncer. This routes the whole pre-existing subset through the already-in-place batched MessageDao.updateHeaderContents(List)@Transaction, so a page costs one commit instead of one fsync per message (amplified on the encrypted cache).
What changed
Call site (MailBackfiller.persistBatch): the toRefresh.forEach { messageDao.updateHeaderContent(...) } loop is replaced by a single messageDao.updateHeaderContents(toRefresh).
DAO method: MessageDao.updateHeaderContents(List) already existed (added in #310) — no DAO change, no schema change.
Added a PII-free, counts-onlyAppLog breadcrumb at the persist point: backfill persist: fetched=<n> refreshed=<m>.
Semantic equivalence (perf-only, not a behavior change)
updateHeaderContents applies updateHeaderContent to each row in list order, so the same rows get the same values and the same casefold (*Fold) columns.
The if (toRefresh.isNotEmpty()) guard is preserved — an empty refresh batch is still skipped.
Brand-new rows stay insert-only (only the pre-existing subset is refreshed) — unchanged.
No @Transaction boundary is widened beyond the per-page header refresh; insertNew/markSynced are untouched.
Tests
MailBackfillerTest (JVM): the refresh now routes through the batched updateHeaderContents and never the per-row updateHeaderContent (exactly = 0); a partial page refreshes only its pre-existing subset in exactly one batched call; an all-new page skips the batch entirely (empty boundary); the counts-only breadcrumb is asserted.
MessageDaoTest (real Room): the batched update writes byte-for-byte the same row as the per-row path (correctness); an empty batch is a no-op.
Gate
Local fast gate green: assembleDebug + testDebugUnitTest + jacocoTestCoverageVerification (0.84 floor held) + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt. Instrumented E2E runs via CI's matrix.
Follow-up to #310/#320. `MailBackfiller.persistBatch` refreshed each pre-existing backfilled header with a per-row `updateHeaderContent`, each in its own implicit transaction — the same N-commits-per-page anti-pattern #310 fixed in `MailSyncer`. This routes the whole pre-existing subset through the already-in-place batched `MessageDao.updateHeaderContents(List)` `@Transaction`, so a page costs **one** commit instead of one fsync per message (amplified on the encrypted cache).
## What changed
- **Call site** (`MailBackfiller.persistBatch`): the `toRefresh.forEach { messageDao.updateHeaderContent(...) }` loop is replaced by a single `messageDao.updateHeaderContents(toRefresh)`.
- **DAO method**: `MessageDao.updateHeaderContents(List)` already existed (added in #310) — no DAO change, no schema change.
- Added a **PII-free, counts-only** `AppLog` breadcrumb at the persist point: `backfill persist: fetched=<n> refreshed=<m>`.
## Semantic equivalence (perf-only, not a behavior change)
- `updateHeaderContents` applies `updateHeaderContent` to each row in **list order**, so the same rows get the same values and the same casefold (`*Fold`) columns.
- The `if (toRefresh.isNotEmpty())` guard is preserved — an empty refresh batch is still skipped.
- Brand-new rows stay insert-only (only the pre-existing subset is refreshed) — unchanged.
- No `@Transaction` boundary is widened beyond the per-page header refresh; `insertNew`/`markSynced` are untouched.
## Tests
- **`MailBackfillerTest`** (JVM): the refresh now routes through the batched `updateHeaderContents` and **never** the per-row `updateHeaderContent` (`exactly = 0`); a **partial** page refreshes only its pre-existing subset in exactly **one** batched call; an **all-new** page skips the batch entirely (empty boundary); the counts-only breadcrumb is asserted.
- **`MessageDaoTest`** (real Room): the batched update writes **byte-for-byte the same row** as the per-row path (correctness); an **empty** batch is a no-op.
## Gate
Local fast gate green: `assembleDebug` + `testDebugUnitTest` + `jacocoTestCoverageVerification` (0.84 floor held) + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + `detekt`. Instrumented E2E runs via CI's matrix.
Closes #322
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Follow-up to #310/#320.
MailBackfiller.persistBatchrefreshed each pre-existing backfilled header with a per-rowupdateHeaderContent, each in its own implicit transaction — the same N-commits-per-page anti-pattern #310 fixed inMailSyncer. This routes the whole pre-existing subset through the already-in-place batchedMessageDao.updateHeaderContents(List)@Transaction, so a page costs one commit instead of one fsync per message (amplified on the encrypted cache).What changed
MailBackfiller.persistBatch): thetoRefresh.forEach { messageDao.updateHeaderContent(...) }loop is replaced by a singlemessageDao.updateHeaderContents(toRefresh).MessageDao.updateHeaderContents(List)already existed (added in #310) — no DAO change, no schema change.AppLogbreadcrumb at the persist point:backfill persist: fetched=<n> refreshed=<m>.Semantic equivalence (perf-only, not a behavior change)
updateHeaderContentsappliesupdateHeaderContentto each row in list order, so the same rows get the same values and the same casefold (*Fold) columns.if (toRefresh.isNotEmpty())guard is preserved — an empty refresh batch is still skipped.@Transactionboundary is widened beyond the per-page header refresh;insertNew/markSyncedare untouched.Tests
MailBackfillerTest(JVM): the refresh now routes through the batchedupdateHeaderContentsand never the per-rowupdateHeaderContent(exactly = 0); a partial page refreshes only its pre-existing subset in exactly one batched call; an all-new page skips the batch entirely (empty boundary); the counts-only breadcrumb is asserted.MessageDaoTest(real Room): the batched update writes byte-for-byte the same row as the per-row path (correctness); an empty batch is a no-op.Gate
Local fast gate green:
assembleDebug+testDebugUnitTest+jacocoTestCoverageVerification(0.84 floor held) +compileDebugAndroidTestKotlin+lintDebug+ktlintCheck+detekt. Instrumented E2E runs via CI's matrix.Closes #322
Merge Queue Status
2026-07-08 05:11 UTC· Rule:default· triggered by merge protections2026-07-08 06:04 UTC· at1d17f76b15faa92191e21de33352810f2630f27f· mergeThis pull request spent 53 minutes 18 seconds in the queue, including 20 minutes 56 seconds running CI.
Required conditions to merge
-conflict-draftbase = maincheck-success = CI passedgithub-review-approved[🛡 GitHub repository ruleset rulemain]label != brokencheck-success = Debug buildcheck-neutral = Debug buildcheck-skipped = Debug buildcheck-success = Unit testscheck-neutral = Unit testscheck-skipped = Unit testscheck-success = CI passedcheck-neutral = CI passedcheck-skipped = CI passedmain]:check-success = @github-actions/CI passedcheck-neutral = @github-actions/CI passedcheck-skipped = @github-actions/CI passed