Full-history backfill (#12) chained bounded slices back-to-back with no gap, and on a large mailbox moreWork never clears — so a single BackfillWorker run paged flat-out for the whole session, keeping the account's IMAP connection saturated. That background load is what starves interactive message-opens (#355) and worsens provider throttling (#360) (2026-07-05 Pixel 10 Pro XL perf run).
This adds proactive pacing so backfill fills history steadily and leaves the account headroom.
Approach
New BackfillPacer — a small in-process @Singleton primitive (same shape as InteractiveImapGate / AccountThrottleGate) that BackfillWorker delegates its slice-chaining loop to. Two gentle, configurable levers:
Inter-slice cooldown — a fixed 30 s idle between chained slices.
Per-run slice cap — at most 4 slices per run, then the run ends and defers to the 30-min periodic cadence (and backfillNow()), so one run can't monopolise the account.
Policy is constant (not adaptive), aside from the interactive-skip below. At ~85 s/slice (measured) a run is ~4×85 s + 3×30 s ≈ 7 min then idle to the next 30-min tick — roughly a ~23% duty cycle vs. the previous ~100% flat-out, with a backfill-free window every 30 s.
Composes with the two sibling mechanisms (does not duplicate/fight them)
#355InteractiveImapGate — the cooldown is skipped while an interactive fetch is active: the next slice already parks at its per-page yield point (MailBackfiller.yieldToInteractive), so a fixed delay on top would only double the idle. Exactly one mechanism gates any gap → no pathological double-delay.
#360AccountThrottleGate — a slice whose only outstanding work is a throttled account returns moreWork=false, so the loop stops and no cooldown is spent spinning on a backed-off provider.
The cooldown is a cancellable delay and the loop rechecks !isStopped before each slice, so a WorkManager stop / teardown ends a run promptly. All breadcrumbs are PII-free (durations/counts only) via AppLog.
Tests
BackfillPacerTest (JVM, coroutines-test virtual time, no real sleep): cooldown applied between slices; per-run cap; cancellation mid-cooldown; #355 skip (no double-delay); #360 done-slice burns no cooldown; shouldContinue=false respected.
BackfillWorkerTest: worker caps a flat-out run (4 slices) + logs the cap breadcrumb.
BackfillPacerInstrumentedTest (androidTest, real runtime, mock-free): forward progress across successive paced runs (history keeps filling despite the cap); interactive-skip; prompt cancellation mid-cooldown.
Gate (Windows, JDK 21)
assembleDebug + testDebugUnitTest + jacocoTestCoverageVerification + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt — all green. Local emulator preflight skipped (flaky on Windows); CI matrix is authoritative for the instrumented test.
## What & why
Full-history backfill (#12) chained bounded slices **back-to-back with no gap**, and on a large mailbox `moreWork` never clears — so a single `BackfillWorker` run paged **flat-out for the whole session**, keeping the account's IMAP connection saturated. That background load is what starves interactive message-opens (#355) and worsens provider throttling (#360) (2026-07-05 Pixel 10 Pro XL perf run).
This adds **proactive pacing** so backfill fills history steadily and leaves the account headroom.
## Approach
New `BackfillPacer` — a small in-process `@Singleton` primitive (same shape as `InteractiveImapGate` / `AccountThrottleGate`) that `BackfillWorker` delegates its slice-chaining loop to. Two gentle, configurable levers:
- **Inter-slice cooldown** — a fixed **30 s** idle between chained slices.
- **Per-run slice cap** — at most **4 slices per run**, then the run ends and defers to the 30-min periodic cadence (and `backfillNow()`), so one run can't monopolise the account.
Policy is **constant** (not adaptive), aside from the interactive-skip below. At ~85 s/slice (measured) a run is ~4×85 s + 3×30 s ≈ 7 min then idle to the next 30-min tick — roughly a **~23% duty cycle** vs. the previous ~100% flat-out, with a backfill-free window every 30 s.
## Composes with the two sibling mechanisms (does not duplicate/fight them)
- **#355 `InteractiveImapGate`** — the cooldown is **skipped while an interactive fetch is active**: the next slice already parks at its per-page yield point (`MailBackfiller.yieldToInteractive`), so a fixed delay on top would only double the idle. Exactly one mechanism gates any gap → **no pathological double-delay**.
- **#360 `AccountThrottleGate`** — a slice whose only outstanding work is a throttled account returns `moreWork=false`, so the loop stops and **no cooldown is spent spinning** on a backed-off provider.
The cooldown is a **cancellable `delay`** and the loop rechecks `!isStopped` before each slice, so a WorkManager stop / teardown ends a run promptly. All breadcrumbs are **PII-free** (durations/counts only) via `AppLog`.
## Tests
- **`BackfillPacerTest`** (JVM, coroutines-test virtual time, no real sleep): cooldown applied between slices; per-run cap; cancellation mid-cooldown; #355 skip (no double-delay); #360 done-slice burns no cooldown; `shouldContinue=false` respected.
- **`BackfillWorkerTest`**: worker caps a flat-out run (4 slices) + logs the cap breadcrumb.
- **`BackfillPacerInstrumentedTest`** (androidTest, real runtime, mock-free): forward progress across successive paced runs (history keeps filling despite the cap); interactive-skip; prompt cancellation mid-cooldown.
## Gate (Windows, JDK 21)
`assembleDebug` + `testDebugUnitTest` + `jacocoTestCoverageVerification` + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + `detekt` — **all green**. Local emulator preflight skipped (flaky on Windows); CI matrix is authoritative for the instrumented test.
Closes #356
You may have to fix your CI before adding the pull request to the queue again.
If you update this pull request, to fix the CI, it will automatically be requeued once the queue conditions match again.
If you think this was a flaky issue instead, you can requeue the pull request, without updating it, by posting a @mergifyio queue comment.
Requeued — the merge queue status continues in this comment ↓.
<!---
DO NOT EDIT
-*- Mergify Payload -*-
{"version": 1, "state": "dequeued", "queue_rule_name": "default", "queued_at": "2026-07-08T22:20:05.510920+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []}
-*- Mergify Payload End -*-
-->
# Merge Queue Status
- ✅ **Entered queue** — `2026-07-08 22:20 UTC` · Rule: `default` · triggered by merge protections
- ❌ **Checks failed** · on draft #465
- 🚫 **Left the queue** — `2026-07-08 22:49 UTC` · at `5c564ebeda5b3ee2194c655c27efe14db3aed94f`
This pull request spent **29 minutes 46 seconds** in the queue, including **24 minutes 3 seconds** running CI.
<details>
<summary><strong>Waiting for</strong></summary>
- [ ] `check-success = CI passed`
- [ ] any of: [🛡 GitHub branch protection]
- [ ] `check-neutral = CI passed`
- [ ] `check-skipped = CI passed`
- [ ] `check-success = CI passed`
- [ ] any of: [🛡 GitHub repository ruleset rule `main`]
- [ ] `check-neutral = @github-actions/CI passed`
- [ ] `check-skipped = @github-actions/CI passed`
- [ ] `check-success = @github-actions/CI passed`
</details>
<details>
<summary>All conditions</summary>
- [ ] `check-success = CI passed`
- [ ] any of [🛡 GitHub branch protection]:
- [ ] `check-neutral = CI passed`
- [ ] `check-skipped = CI passed`
- [ ] `check-success = CI passed`
- [ ] any of [🛡 GitHub repository ruleset rule `main`]:
- [ ] `check-neutral = @github-actions/CI passed`
- [ ] `check-skipped = @github-actions/CI passed`
- [ ] `check-success = @github-actions/CI passed`
- `-conflict`
- [X] #463
- `-draft`
- [X] #463
- [X] `base = main`
- `github-review-approved` [🛡 GitHub repository ruleset rule `main`]
- [X] #463
- `label != broken`
- [X] #463
- [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`
</details>
## Reason
The merge conditions cannot be satisfied due to failing checks
- `CI passed`
- `Debug build`
- `Unit tests`
- `@github-actions/CI passed`
Failing checks:
- ❌ [CI passed](https://github.com/JMR-dev/LibreMail/actions/runs/28980028281/job/85999895868) ([job log](https://github.com/JMR-dev/LibreMail/actions/runs/28980028281/job/85999895868))
## Hint
You may have to fix your CI before adding the pull request to the queue again.
If you update this pull request, to fix the CI, it will automatically be requeued once the queue conditions match again.
If you think this was a flaky issue instead, you can requeue the pull request, without updating it, by posting a `@mergifyio queue` comment.
Requeued — the merge queue status continues in [this comment ↓](https://github.com/JMR-dev/LibreMail/pull/463#issuecomment-4919962812).
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.
What & why
Full-history backfill (#12) chained bounded slices back-to-back with no gap, and on a large mailbox
moreWorknever clears — so a singleBackfillWorkerrun paged flat-out for the whole session, keeping the account's IMAP connection saturated. That background load is what starves interactive message-opens (#355) and worsens provider throttling (#360) (2026-07-05 Pixel 10 Pro XL perf run).This adds proactive pacing so backfill fills history steadily and leaves the account headroom.
Approach
New
BackfillPacer— a small in-process@Singletonprimitive (same shape asInteractiveImapGate/AccountThrottleGate) thatBackfillWorkerdelegates its slice-chaining loop to. Two gentle, configurable levers:backfillNow()), so one run can't monopolise the account.Policy is constant (not adaptive), aside from the interactive-skip below. At ~85 s/slice (measured) a run is ~4×85 s + 3×30 s ≈ 7 min then idle to the next 30-min tick — roughly a ~23% duty cycle vs. the previous ~100% flat-out, with a backfill-free window every 30 s.
Composes with the two sibling mechanisms (does not duplicate/fight them)
InteractiveImapGate— the cooldown is skipped while an interactive fetch is active: the next slice already parks at its per-page yield point (MailBackfiller.yieldToInteractive), so a fixed delay on top would only double the idle. Exactly one mechanism gates any gap → no pathological double-delay.AccountThrottleGate— a slice whose only outstanding work is a throttled account returnsmoreWork=false, so the loop stops and no cooldown is spent spinning on a backed-off provider.The cooldown is a cancellable
delayand the loop rechecks!isStoppedbefore each slice, so a WorkManager stop / teardown ends a run promptly. All breadcrumbs are PII-free (durations/counts only) viaAppLog.Tests
BackfillPacerTest(JVM, coroutines-test virtual time, no real sleep): cooldown applied between slices; per-run cap; cancellation mid-cooldown; #355 skip (no double-delay); #360 done-slice burns no cooldown;shouldContinue=falserespected.BackfillWorkerTest: worker caps a flat-out run (4 slices) + logs the cap breadcrumb.BackfillPacerInstrumentedTest(androidTest, real runtime, mock-free): forward progress across successive paced runs (history keeps filling despite the cap); interactive-skip; prompt cancellation mid-cooldown.Gate (Windows, JDK 21)
assembleDebug+testDebugUnitTest+jacocoTestCoverageVerification+compileDebugAndroidTestKotlin+lintDebug+ktlintCheck+detekt— all green. Local emulator preflight skipped (flaky on Windows); CI matrix is authoritative for the instrumented test.Closes #356
Merge Queue Status
2026-07-08 22:20 UTC· Rule:default· triggered by merge protections2026-07-08 22:49 UTC· at5c564ebeda5b3ee2194c655c27efe14db3aed94fThis pull request spent 29 minutes 46 seconds in the queue, including 24 minutes 3 seconds running CI.
Waiting for
check-success = CI passedcheck-neutral = CI passedcheck-skipped = CI passedcheck-success = CI passedmain]check-neutral = @github-actions/CI passedcheck-skipped = @github-actions/CI passedcheck-success = @github-actions/CI passedAll conditions
check-success = CI passedcheck-neutral = CI passedcheck-skipped = CI passedcheck-success = CI passedmain]:check-neutral = @github-actions/CI passedcheck-skipped = @github-actions/CI passedcheck-success = @github-actions/CI passed-conflict-draftbase = maingithub-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 testsReason
The merge conditions cannot be satisfied due to failing checks
CI passedDebug buildUnit tests@github-actions/CI passedFailing checks:
Hint
You may have to fix your CI before adding the pull request to the queue again.
If you update this pull request, to fix the CI, it will automatically be requeued once the queue conditions match again.
If you think this was a flaky issue instead, you can requeue the pull request, without updating it, by posting a
@mergifyio queuecomment.Requeued — the merge queue status continues in this comment ↓.
@Mergifyio requeue
Merge Queue Status
2026-07-08 23:03 UTC· Rule:default· triggered by @JMR-dev with the@mergifyio queuecommand2026-07-08 23:35 UTC· at5c564ebeda5b3ee2194c655c27efe14db3aed94f· mergeThis pull request spent 31 minutes 51 seconds in the queue, including 25 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