perf(sync): pace backfill with an inter-slice cooldown and per-run cap #463

Merged
JMR-dev merged 1 commits from feat-356-backfill-cooldown into main 2026-07-08 23:35:35 +00:00
JMR-dev commented 2026-07-08 21:57:51 +00:00 (Migrated from github.com)

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

## 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
mergify[bot] commented 2026-07-08 22:20:06 +00:00 (Migrated from github.com)

Merge Queue Status

This pull request spent 29 minutes 46 seconds in the queue, including 24 minutes 3 seconds running CI.

Waiting for
  • 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
All conditions

Reason

The merge conditions cannot be satisfied due to failing checks

  • CI passed
  • Debug build
  • Unit tests
  • @github-actions/CI passed

Failing 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 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).
JMR-dev commented 2026-07-08 23:03:39 +00:00 (Migrated from github.com)

@Mergifyio requeue

@Mergifyio requeue
mergify[bot] commented 2026-07-08 23:03:48 +00:00 (Migrated from github.com)

Merge Queue Status

This pull request spent 31 minutes 51 seconds in the queue, including 25 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-08T23:03:46.501455+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - ✅ **Entered queue** — `2026-07-08 23:03 UTC` · Rule: `default` · triggered by @JMR-dev with the [`@mergifyio queue` command](https://github.com/JMR-dev/LibreMail/pull/463#issuecomment-4919962051) - ✅ **Checks passed** · on draft #466 - ✅ **Merged** — `2026-07-08 23:35 UTC` · at `5c564ebeda5b3ee2194c655c27efe14db3aed94f` · merge This pull request spent **31 minutes 51 seconds** in the queue, including **25 minutes 56 seconds** running CI. <details> <summary>Required conditions to merge</summary> - `-conflict` - [X] #463 - `-draft` - [X] #463 - [X] `base = main` - [X] `check-success = CI passed` - `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` - [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.