perf(data): route MailBackfiller.persistBatch through batched updateHeaderContents #429

Merged
JMR-dev merged 3 commits from perf-322-batched-persistbatch into main 2026-07-08 06:04:46 +00:00
JMR-dev commented 2026-07-08 04:43:23 +00:00 (Migrated from github.com)

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

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
mergify[bot] commented 2026-07-08 05:11:30 +00:00 (Migrated from github.com)

Merge Queue Status

  • ✅ Entered queue — 2026-07-08 05:11 UTC · Rule: default · triggered by merge protections
  • ✅ Checks passed · in-place
  • ✅ Merged — 2026-07-08 06:04 UTC · at 1d17f76b15faa92191e21de33352810f2630f27f · merge

This pull request spent 53 minutes 18 seconds in the queue, including 20 minutes 56 seconds running CI.

Required conditions to merge
<!--- DO NOT EDIT -*- Mergify Payload -*- {"version": 1, "state": "merged", "queue_rule_name": "default", "queued_at": "2026-07-08T05:11:28.657845+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - ✅ **Entered queue** — `2026-07-08 05:11 UTC` · Rule: `default` · triggered by merge protections - ✅ **Checks passed** · in-place - ✅ **Merged** — `2026-07-08 06:04 UTC` · at `1d17f76b15faa92191e21de33352810f2630f27f` · merge This pull request spent **53 minutes 18 seconds** in the queue, including **20 minutes 56 seconds** running CI. <details> <summary>Required conditions to merge</summary> - `-conflict` - [X] #429 - `-draft` - [X] #429 - [X] `base = main` - [X] `check-success = CI passed` - `github-review-approved` [🛡 GitHub repository ruleset rule `main`] - [X] #429 - `label != broken` - [X] #429 - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Debug build` - [ ] `check-neutral = Debug build` - [ ] `check-skipped = Debug build` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Unit tests` - [ ] `check-neutral = Unit tests` - [ ] `check-skipped = Unit tests` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = CI passed` - [ ] `check-neutral = CI passed` - [ ] `check-skipped = CI passed` - [X] any of [🛡 GitHub repository ruleset rule `main`]: - [X] `check-success = @github-actions/CI passed` - [ ] `check-neutral = @github-actions/CI passed` - [ ] `check-skipped = @github-actions/CI passed` </details>
Sign in to join this conversation.