diff --git a/app/src/androidTest/kotlin/org/libremail/data/sync/BackfillPacerInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/data/sync/BackfillPacerInstrumentedTest.kt new file mode 100644 index 0000000..126633e --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/sync/BackfillPacerInstrumentedTest.kt @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.atomic.AtomicInteger + +/** + * On-device proof of issue #356's backfill pacing, on the REAL Android coroutine runtime (not + * coroutines-test virtual time) across the CI API matrix. A real [BackfillPacer] — the primitive that + * bounds how hard one [BackfillWorker] run drives [MailBackfiller] — must: + * + * - keep making forward progress across successive paced runs, so a large mailbox still fills fully even + * though the per-run cap ends each run early (the DoD ask: history keeps filling across runs); + * - skip its inter-slice cooldown while an interactive fetch is active, so it never stacks a fixed delay on + * top of #355's per-page park (no pathological double-delay); + * - end a run promptly when cancelled mid-cooldown, so a WorkManager stop / teardown is never blocked. + * + * Deliberately mock-free (no `mockk`, no framework `Context`): the pacer's only collaborator is a real + * [InteractiveImapGate] and a "slice" is a plain lambda whose result the test scripts, so this exercises + * the genuine pacing on real threads and is maximally portable across API 29-37 (and dodges the + * mockk-on-framework-types landmines). The JVM [BackfillPacerTest] covers the exact cooldown *timing* and + * cap arithmetic under virtual time; this proves the same contract survives the real dispatcher. + * + * Tests that must not pay the real 30 s cooldown hold an interactive fetch active for their duration (which + * legitimately suppresses the cooldown), so they finish in milliseconds; the one test that deliberately + * lets a real cooldown start cancels it long before it elapses. + */ +@RunWith(AndroidJUnit4::class) +class BackfillPacerInstrumentedTest { + + @Test + fun backfillFillsAllHistoryAcrossSuccessivePacedRunsDespiteThePerRunCap() = runBlocking { + val gate = InteractiveImapGate() + val entered = CompletableDeferred() + val release = CompletableDeferred() + // Hold an interactive fetch for the whole test so the pacer legitimately skips its real cooldown — + // keeping the run loop fast while still exercising the cap + cross-run continuation on real threads. + val interactive = launch(Dispatchers.Default) { + gate.withInteractive { + entered.complete(Unit) + release.await() + } + } + entered.await() + + val pacer = BackfillPacer(gate) + val remaining = AtomicInteger(TOTAL_PAGES) + val slicesRun = AtomicInteger(0) + // Each slice fills one page; true == more pages remain (exactly MailBackfiller.runBackfill's contract). + val slice: suspend () -> Boolean = { + slicesRun.incrementAndGet() + remaining.decrementAndGet() > 0 + } + + var runs = 0 + var moreWork = true + while (moreWork && runs < MAX_RUNS_GUARD) { + moreWork = withTimeout(HAND_OFF_TIMEOUT_MS) { pacer.runPaced(shouldContinue = { true }, slice = slice) } + runs++ + } + + assertFalse("history must finish across successive paced runs", moreWork) + assertEquals("every page is filled exactly once — no pacing-induced gap", TOTAL_PAGES, slicesRun.get()) + assertTrue("the per-run cap must force more than one run for a $TOTAL_PAGES-page mailbox", runs > 1) + + release.complete(Unit) + interactive.join() + } + + @Test + fun interactiveActivitySkipsTheInterSliceCooldownSoAPacedRunIsNotDoubleDelayed() = runBlocking { + val gate = InteractiveImapGate() + val entered = CompletableDeferred() + val release = CompletableDeferred() + val interactive = launch(Dispatchers.Default) { + gate.withInteractive { + entered.complete(Unit) + release.await() + } + } + entered.await() + + val pacer = BackfillPacer(gate) + val results = ArrayDeque(listOf(true, true, false)) // two inter-slice gaps + val slice: suspend () -> Boolean = { results.removeFirst() } + + // If the cooldown were NOT skipped while interactive, two real 30 s waits would blow this bound; + // skipping them makes the run return in milliseconds. + val moreWork = withTimeout(HAND_OFF_TIMEOUT_MS) { + pacer.runPaced(shouldContinue = { true }, slice = slice) + } + + assertFalse("the run still chains its slices to completion", moreWork) + assertTrue("all scripted slices ran", results.isEmpty()) + + release.complete(Unit) + interactive.join() + } + + @Test + fun aCancelledRunEndsPromptlyWhileParkedInTheCooldownInsteadOfBlockingTeardown() = runBlocking { + // Idle gate: the cooldown is NOT skipped here, so the run genuinely parks in the ~30 s delay. + val pacer = BackfillPacer(InteractiveImapGate()) + val slicesRun = AtomicInteger(0) + val slice: suspend () -> Boolean = { + slicesRun.incrementAndGet() + true // always more work + } + + val job = launch(Dispatchers.Default) { pacer.runPaced(shouldContinue = { true }, slice = slice) } + + delay(PARK_PROBE_MS) // let the first slice run and the run settle into the cooldown delay + assertEquals("one slice ran, then the run parked in the cooldown", 1, slicesRun.get()) + + job.cancel() + // Must return far sooner than the 30 s cooldown — proof the cooldown is a cancellable delay. + withTimeout(HAND_OFF_TIMEOUT_MS) { job.join() } + + assertEquals("cancelling during the cooldown must not start another slice", 1, slicesRun.get()) + assertTrue("the run ended by cancellation, not by running flat-out", job.isCancelled) + } + + private companion object { + /** A mailbox of enough pages that the per-run cap (4) must span several runs to fill it. */ + const val TOTAL_PAGES = 10 + + /** Stops the run loop if a pacing regression somehow never reports done. */ + const val MAX_RUNS_GUARD = 20 + + /** Slack given to a run to settle into its cooldown before we probe / cancel. */ + const val PARK_PROBE_MS = 300L + + /** Generous bound; only a real pacing/cancellation regression (a stuck cooldown) approaches it. */ + const val HAND_OFF_TIMEOUT_MS = 5_000L + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/sync/WorkerCacheLockDeferralInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/data/sync/WorkerCacheLockDeferralInstrumentedTest.kt index c222401..07c3985 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/sync/WorkerCacheLockDeferralInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/sync/WorkerCacheLockDeferralInstrumentedTest.kt @@ -143,7 +143,14 @@ class WorkerCacheLockDeferralInstrumentedTest { appContext: Context, workerClassName: String, workerParameters: WorkerParameters, - ) = BackfillWorker(appContext, workerParameters, lazyBackfiller, cacheGuard) + ) = BackfillWorker( + appContext, + workerParameters, + lazyBackfiller, + cacheGuard, + // A real pacer (#356); never reached here — the run defers on the locked cache first. + BackfillPacer(InteractiveImapGate()), + ) } private companion object { diff --git a/app/src/androidTest/kotlin/org/libremail/debug/FetchGateReceiverInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/debug/FetchGateReceiverInstrumentedTest.kt index 4d7430d..2777730 100644 --- a/app/src/androidTest/kotlin/org/libremail/debug/FetchGateReceiverInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/debug/FetchGateReceiverInstrumentedTest.kt @@ -31,9 +31,11 @@ import org.libremail.data.security.EncryptedCacheGuard import org.libremail.data.security.PassphraseSession import org.libremail.data.settings.AppSettings import org.libremail.data.settings.SettingsRepository +import org.libremail.data.sync.BackfillPacer import org.libremail.data.sync.BackfillWorker import org.libremail.data.sync.DebugFetchGate import org.libremail.data.sync.FetchScope +import org.libremail.data.sync.InteractiveImapGate import org.libremail.data.sync.MailBackfiller import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -173,7 +175,14 @@ class FetchGateReceiverInstrumentedTest { appContext: Context, workerClassName: String, workerParameters: WorkerParameters, - ) = BackfillWorker(appContext, workerParameters, lazyBackfiller, cacheGuard) + ) = BackfillWorker( + appContext, + workerParameters, + lazyBackfiller, + cacheGuard, + // A real pacer (#356); the gate-deferral tests never reach it. + BackfillPacer(InteractiveImapGate()), + ) } private companion object { diff --git a/app/src/main/kotlin/org/libremail/data/sync/BackfillPacer.kt b/app/src/main/kotlin/org/libremail/data/sync/BackfillPacer.kt new file mode 100644 index 0000000..db53ea7 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/BackfillPacer.kt @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import kotlinx.coroutines.delay +import org.libremail.reporting.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Proactive pacing for the full-history backfill (issue #356): the single place that bounds how hard one + * [BackfillWorker] run drives [MailBackfiller], so background backfill fills history *steadily* instead of + * running flat-out and keeping the account's IMAP session saturated for a whole session (the 2026-07-05 + * on-device finding: a large mailbox reports `moreWork=true` forever, so the worker's slice-chaining loop + * never idles). + * + * Two levers, both deliberately gentle and configurable: + * - **Inter-slice cooldown.** A fixed [cooldownMillis] idle *between* chained slices, so even within a run + * backfill breathes and leaves the account headroom rather than paging back-to-back. + * - **Per-run slice cap.** At most [maxSlicesPerRun] slices per run; once hit, the run ends and defers to + * the 30-min periodic cadence (and `backfillNow()`), so one run can never monopolise the account for the + * whole session. + * + * This is the *proactive* counterpart to the two *reactive* mechanisms it composes with — it does not + * duplicate or fight them: + * - #355 ([InteractiveImapGate]): while an interactive, user-facing fetch is in flight, the *next* slice + * already parks at [MailBackfiller.yieldToInteractive]'s per-page yield point. Stacking a fixed cooldown + * on top of that park would only double the idle for no gain, so the cooldown is **skipped** whenever an + * interactive fetch is active and the park is left as the sole backpressure — exactly one mechanism gates + * any given gap (no pathological double-delay). + * - #360 ([AccountThrottleGate]): a slice whose only outstanding work is a throttled account reports + * `moreWork=false`, so [runPaced] stops and no cooldown is burned spinning on a backed-off provider; the + * provider backoff window elapses on its own and a later scheduled run resumes. + * + * The cooldown is a plain cancellable [delay] and [runPaced] rechecks its `shouldContinue` predicate (the + * worker's `!isStopped`) before every slice, so a WorkManager stop / teardown ends a run promptly — the + * cooldown never blocks cancellation. Every breadcrumb is PII-free: durations and counts only. + */ +@Singleton +class BackfillPacer internal constructor( + private val interactiveGate: InteractiveImapGate, + private val cooldownMillis: Long, + private val maxSlicesPerRun: Int, +) { + /** Production wiring: the tuned steady-state cooldown and per-run cap. */ + @Inject + constructor(interactiveGate: InteractiveImapGate) : + this(interactiveGate, INTER_SLICE_COOLDOWN_MS, MAX_SLICES_PER_RUN) + + /** + * Drives one worker run's worth of paced backfill. Runs [slice] — one bounded backfill slice, returning + * `true` while an immediate follow-up has more work — repeatedly while [shouldContinue] holds, cooling + * down between slices ([coolDownBetweenSlices]) and stopping after [maxSlicesPerRun] slices. Returns + * whether history still has more to fill (informational; a capped or stopped run is resumed by the + * periodic cadence). Cancellation propagates out of the cooldown so a stop ends the run at once. + */ + suspend fun runPaced(shouldContinue: () -> Boolean, slice: suspend () -> Boolean): Boolean { + var slices = 0 + while (shouldContinue()) { + val moreWork = slice() + slices++ + if (!moreWork) return false + if (slices >= maxSlicesPerRun) { + AppLog.i(TAG, "backfill run capped at $maxSlicesPerRun slice(s); deferring to periodic cadence") + return true + } + coolDownBetweenSlices() + } + return true + } + + /** + * Idles [cooldownMillis] before the next slice — unless an interactive fetch is active (#355), in which + * case the cooldown is skipped and the next slice's per-page park provides the backpressure, so the two + * mechanisms never stack into a double delay. + */ + private suspend fun coolDownBetweenSlices() { + if (interactiveGate.isInteractiveActive()) { + AppLog.i(TAG, "backfill cooldown skipped: interactive fetch in flight") + return + } + AppLog.i(TAG, "backfill cooldown ${cooldownMillis}ms before next slice") + delay(cooldownMillis) + } + + companion object { + private const val TAG = "BackfillPacer" + + /** + * Fixed idle between chained slices. Gentle relative to a ~85 s slice (the on-device measurement) + * yet long enough to give an interactive open a clear, backfill-free window — on top of #355's park. + * `internal` so the worker test can assert the production cadence without a magic number. + */ + internal const val INTER_SLICE_COOLDOWN_MS = 30_000L + + /** + * Slices one run may chain before deferring to the periodic cadence. Bounds a run to a few minutes + * (a handful of ~85 s slices plus their cooldowns) — comfortably under WorkManager's ~10-min + * execution window — so a big mailbox fills over many short runs instead of one endless session. + * `internal` so the worker test can assert the cap without a magic number. + */ + internal const val MAX_SLICES_PER_RUN = 4 + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/BackfillWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/BackfillWorker.kt index 8a5ea04..3c94449 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/BackfillWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/BackfillWorker.kt @@ -29,6 +29,9 @@ class BackfillWorker @AssistedInject constructor( // fails fast instead of parking this thread on an unsatisfiable passphrase await (mirrors SyncWorker). private val backfiller: Lazy, private val cacheGuard: EncryptedCacheGuard, + // Proactive pacing (#356): bounds this run's slices and cools down between them. A lightweight + // in-process singleton (no DB graph), so it is safe to inject eagerly alongside the cache-lock gate. + private val pacer: BackfillPacer, ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { @@ -49,11 +52,14 @@ class BackfillWorker @AssistedInject constructor( return Result.retry() } return runCatching { - // Chain bounded slices back-to-back while history remains, so a large mailbox isn't limited - // to one slice per periodic run. runBackfill() returns true while any folder still has pages - // left; isStopped lets WorkManager end a long run gracefully (the periodic schedule resumes). + // Chain bounded slices while history remains, but PACE them (#356): a large mailbox reports + // moreWork=true forever, so an un-paced loop would page flat-out for the whole run and keep the + // account's IMAP session saturated (the background load that starves interactive opens, #355). + // BackfillPacer cools down between slices and caps the slices per run, then defers to the 30-min + // periodic cadence; isStopped ends a long run gracefully (WorkManager stop), and the cooldown is + // a cancellable delay so a stop is never blocked. runBackfill() returns true while pages remain. val mailBackfiller = backfiller.get() - while (mailBackfiller.runBackfill() && !isStopped) { /* page the next slice */ } + pacer.runPaced(shouldContinue = { !isStopped }, slice = { mailBackfiller.runBackfill() }) }.fold( onSuccess = { AppLog.i(TAG, "backfill worker: success") diff --git a/app/src/test/kotlin/org/libremail/data/sync/BackfillPacerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/BackfillPacerTest.kt new file mode 100644 index 0000000..b8a19fb --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/sync/BackfillPacerTest.kt @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.util.Log +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.reporting.AppLog +import org.libremail.reporting.RingLogBuffer +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [BackfillPacer] (issue #356) must pace one backfill run: cool down between chained slices, cap the + * slices per run, and stop promptly on cancellation — all proven against coroutines-test virtual time + * so no test ever real-sleeps. It must also *compose* with the two sibling mechanisms rather than fight + * them: skip its fixed cooldown while an interactive fetch is active (#355, so it never double-delays on + * top of that mechanism's park) and burn no cooldown when a slice reports done because its only account + * was throttled (#360). Every breadcrumb it logs is PII-free (durations and counts only). + * + * Deliberately mock-free: the pacer's only collaborator is the real [InteractiveImapGate], and a slice is + * modelled by a plain lambda whose return value the test scripts — so a pass is attributable purely to the + * pacing logic. Mirrors [AccountThrottleGateTest]'s virtual-clock idiom. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class BackfillPacerTest { + + private val logBuffer = RingLogBuffer() + private val interactiveGate = InteractiveImapGate() + + private fun pacer( + cooldownMillis: Long = COOLDOWN_MS, + maxSlicesPerRun: Int = MAX_SLICES, + gate: InteractiveImapGate = interactiveGate, + ) = BackfillPacer(gate, cooldownMillis, maxSlicesPerRun) + + @Before + fun setUp() { + // AppLog forwards to android.util.Log, a throwing no-op stub under plain JVM unit tests. + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + AppLog.install(logBuffer) + } + + @After + fun tearDown() = unmockkAll() + + @Test + fun `cools down for the configured delay between each chained slice`() = runTest { + val results = ArrayDeque(listOf(true, true, false)) // 3 slices → 2 inter-slice gaps + + val more = pacer(maxSlicesPerRun = 10).runPaced({ true }) { results.removeFirst() } + + assertFalse(more, "a slice reporting no more work ends the run as done") + assertTrue(results.isEmpty(), "every scripted slice ran") + assertEquals(2 * COOLDOWN_MS, testScheduler.currentTime, "exactly two cooldowns separate the three slices") + assertEquals( + 2, + logBuffer.snapshot().count { it.message == "backfill cooldown ${COOLDOWN_MS}ms before next slice" }, + "each inter-slice gap logs one PII-free cooldown breadcrumb", + ) + } + + @Test + fun `caps the slices per run and reports history still has more to fill`() = runTest { + var calls = 0 + + // The slice never reports done, so only the cap can stop the run. + val more = pacer(maxSlicesPerRun = 3).runPaced({ true }) { true.also { calls++ } } + + assertTrue(more, "a run stopped by the cap still has history to fill") + assertEquals(3, calls, "the run stops exactly at the per-run cap") + assertEquals( + 2 * COOLDOWN_MS, + testScheduler.currentTime, + "two cooldowns between the three slices; none after the cap", + ) + val capMsg = "backfill run capped at 3 slice(s); deferring to periodic cadence" + assertTrue( + logBuffer.snapshot().any { it.message == capMsg }, + "capping logs a PII-free breadcrumb", + ) + } + + @Test + fun `a slice that reports done burns no cooldown (composes with the throttle-skip in #360)`() = runTest { + var calls = 0 + + // Mirrors #360: a slice whose only outstanding work was a throttled account returns moreWork=false. + val more = pacer().runPaced({ true }) { false.also { calls++ } } + + assertFalse(more) + assertEquals(1, calls, "the run ends after the single done slice") + assertEquals(0L, testScheduler.currentTime, "no cooldown is spent when there is no follow-up slice") + assertTrue(logBuffer.snapshot().none { it.message.startsWith("backfill cooldown") }, "and none is logged") + } + + @Test + fun `skips the cooldown while an interactive fetch is active (composes with #355, no double-delay)`() = runTest { + val gate = InteractiveImapGate() + val started = CompletableDeferred() + val release = CompletableDeferred() + val holder = launch { + gate.withInteractive { + started.complete(Unit) + release.await() + } + } + started.await() // the gate now reports an interactive fetch in flight + + val results = ArrayDeque(listOf(true, true, false)) + val more = pacer(maxSlicesPerRun = 10, gate = gate).runPaced({ true }) { results.removeFirst() } + + assertFalse(more) + assertTrue(results.isEmpty(), "the run still chains its slices") + assertEquals( + 0L, + testScheduler.currentTime, + "no fixed cooldown while interactive — #355's per-page park is the sole backpressure", + ) + assertEquals( + 2, + logBuffer.snapshot().count { it.message == "backfill cooldown skipped: interactive fetch in flight" }, + "each skipped gap logs a PII-free breadcrumb", + ) + release.complete(Unit) + holder.join() + } + + @Test + fun `a cancellation during the cooldown ends the run promptly without another slice`() = runTest { + var calls = 0 + val job = launch { pacer(maxSlicesPerRun = 10).runPaced({ true }) { true.also { calls++ } } } + + runCurrent() // the first slice runs, then the run parks in the cooldown delay + assertEquals(1, calls, "one slice ran, now parked in the cooldown") + + job.cancel() + advanceUntilIdle() + + assertEquals(1, calls, "cancelling during the cooldown must not start another slice") + assertTrue(job.isCancelled, "the cooldown is a cancellable delay, so teardown is never blocked") + } + + @Test + fun `attempts no slice at all when told not to continue (respects the worker's isStopped)`() = runTest { + var calls = 0 + + val more = pacer().runPaced(shouldContinue = { false }) { true.also { calls++ } } + + assertTrue(more, "no work attempted, so history may still remain") + assertEquals(0, calls, "an already-stopped run pages nothing") + } + + private companion object { + const val COOLDOWN_MS = 30_000L + const val MAX_SLICES = 4 + } +} diff --git a/app/src/test/kotlin/org/libremail/data/sync/BackfillWorkerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/BackfillWorkerTest.kt index 8652c05..bcf39e3 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/BackfillWorkerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/BackfillWorkerTest.kt @@ -36,7 +36,13 @@ class BackfillWorkerTest { private val cacheGuard = mockk() private val logBuffer = RingLogBuffer() - private fun worker() = BackfillWorker(mockk(relaxed = true), mockk(relaxed = true), lazyBackfiller, cacheGuard) + // A real pacer (#356) with a real, idle InteractiveImapGate: the worker's slice-chaining loop runs + // through it, so these tests exercise the actual cooldown/cap wiring. Cooldowns are virtual under + // runTest, so they cost the tests no wall-clock time. + private val pacer = BackfillPacer(InteractiveImapGate()) + + private fun worker() = + BackfillWorker(mockk(relaxed = true), mockk(relaxed = true), lazyBackfiller, cacheGuard, pacer) @Before fun setUp() { @@ -79,6 +85,25 @@ class BackfillWorkerTest { coVerify(exactly = 2) { backfiller.runBackfill(any()) } } + @Test + fun `paces the run by capping its slices instead of paging flat-out forever (issue #356)`() = runTest { + coEvery { cacheGuard.isCacheLocked() } returns false + // A large mailbox reports moreWork forever; the pacer's per-run cap must stop the run anyway so it + // can't monopolise the account for the whole session (the flat-out storm #356 fixes). + coEvery { backfiller.runBackfill(any()) } returns true + + assertEquals(Result.success(), worker().doWork()) + + val cap = BackfillPacer.MAX_SLICES_PER_RUN + coVerify(exactly = cap) { backfiller.runBackfill(any()) } + assertTrue( + logBuffer.snapshot().any { + it.message == "backfill run capped at $cap slice(s); deferring to periodic cadence" + }, + "the capped run logs a PII-free breadcrumb", + ) + } + @Test fun `retries when backfilling throws`() = runTest { coEvery { cacheGuard.isCacheLocked() } returns false @@ -123,9 +148,10 @@ class BackfillWorkerTest { worker().doWork() - val entry = logBuffer.snapshot().single() + // The pacer logs one inter-slice cooldown breadcrumb between the two slices (#356), so the run's + // final line — not the only line — is the success breadcrumb. + val entry = logBuffer.snapshot().single { it.message == "backfill worker: success" } assertEquals('I', entry.level) - assertEquals("backfill worker: success", entry.message) } @Test diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 72bbd0b..6458d26 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -73,6 +73,8 @@ style: # #360 throttle gate: onThrottle/onSuccess breadcrumb through AppLog, which forwards to # android.util.Log (a throwing JVM stub), so this suite mockkStatic(Log) too. - '**/data/sync/AccountThrottleGateTest.kt' + # #356 backfill pacer: cooldown/cap/skip breadcrumbs through AppLog, so this suite mockkStatic(Log) too. + - '**/data/sync/BackfillPacerTest.kt' # Reader-path perf logging (issue #358): the repository's openMessage and the reader ViewModel # log via AppLog, so their unit tests mockkStatic(Log) too. - '**/data/repository/MailRepositoryImplTest.kt'