@@ -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<Unit> {
|
||||
val gate = InteractiveImapGate()
|
||||
val entered = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
// 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<Unit> {
|
||||
val gate = InteractiveImapGate()
|
||||
val entered = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
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<Unit> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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 {
|
||||
|
||||
+10
-1
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<MailBackfiller>,
|
||||
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")
|
||||
|
||||
@@ -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<String>(), any<String>()) } 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<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,13 @@ class BackfillWorkerTest {
|
||||
private val cacheGuard = mockk<EncryptedCacheGuard>()
|
||||
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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user