@@ -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,
|
appContext: Context,
|
||||||
workerClassName: String,
|
workerClassName: String,
|
||||||
workerParameters: WorkerParameters,
|
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 {
|
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.security.PassphraseSession
|
||||||
import org.libremail.data.settings.AppSettings
|
import org.libremail.data.settings.AppSettings
|
||||||
import org.libremail.data.settings.SettingsRepository
|
import org.libremail.data.settings.SettingsRepository
|
||||||
|
import org.libremail.data.sync.BackfillPacer
|
||||||
import org.libremail.data.sync.BackfillWorker
|
import org.libremail.data.sync.BackfillWorker
|
||||||
import org.libremail.data.sync.DebugFetchGate
|
import org.libremail.data.sync.DebugFetchGate
|
||||||
import org.libremail.data.sync.FetchScope
|
import org.libremail.data.sync.FetchScope
|
||||||
|
import org.libremail.data.sync.InteractiveImapGate
|
||||||
import org.libremail.data.sync.MailBackfiller
|
import org.libremail.data.sync.MailBackfiller
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -173,7 +175,14 @@ class FetchGateReceiverInstrumentedTest {
|
|||||||
appContext: Context,
|
appContext: Context,
|
||||||
workerClassName: String,
|
workerClassName: String,
|
||||||
workerParameters: WorkerParameters,
|
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 {
|
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).
|
// fails fast instead of parking this thread on an unsatisfiable passphrase await (mirrors SyncWorker).
|
||||||
private val backfiller: Lazy<MailBackfiller>,
|
private val backfiller: Lazy<MailBackfiller>,
|
||||||
private val cacheGuard: EncryptedCacheGuard,
|
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) {
|
) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
@@ -49,11 +52,14 @@ class BackfillWorker @AssistedInject constructor(
|
|||||||
return Result.retry()
|
return Result.retry()
|
||||||
}
|
}
|
||||||
return runCatching {
|
return runCatching {
|
||||||
// Chain bounded slices back-to-back while history remains, so a large mailbox isn't limited
|
// Chain bounded slices while history remains, but PACE them (#356): a large mailbox reports
|
||||||
// to one slice per periodic run. runBackfill() returns true while any folder still has pages
|
// moreWork=true forever, so an un-paced loop would page flat-out for the whole run and keep the
|
||||||
// left; isStopped lets WorkManager end a long run gracefully (the periodic schedule resumes).
|
// 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()
|
val mailBackfiller = backfiller.get()
|
||||||
while (mailBackfiller.runBackfill() && !isStopped) { /* page the next slice */ }
|
pacer.runPaced(shouldContinue = { !isStopped }, slice = { mailBackfiller.runBackfill() })
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
AppLog.i(TAG, "backfill worker: success")
|
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 cacheGuard = mockk<EncryptedCacheGuard>()
|
||||||
private val logBuffer = RingLogBuffer()
|
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
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
@@ -79,6 +85,25 @@ class BackfillWorkerTest {
|
|||||||
coVerify(exactly = 2) { backfiller.runBackfill(any()) }
|
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
|
@Test
|
||||||
fun `retries when backfilling throws`() = runTest {
|
fun `retries when backfilling throws`() = runTest {
|
||||||
coEvery { cacheGuard.isCacheLocked() } returns false
|
coEvery { cacheGuard.isCacheLocked() } returns false
|
||||||
@@ -123,9 +148,10 @@ class BackfillWorkerTest {
|
|||||||
|
|
||||||
worker().doWork()
|
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('I', entry.level)
|
||||||
assertEquals("backfill worker: success", entry.message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ style:
|
|||||||
# #360 throttle gate: onThrottle/onSuccess breadcrumb through AppLog, which forwards to
|
# #360 throttle gate: onThrottle/onSuccess breadcrumb through AppLog, which forwards to
|
||||||
# android.util.Log (a throwing JVM stub), so this suite mockkStatic(Log) too.
|
# android.util.Log (a throwing JVM stub), so this suite mockkStatic(Log) too.
|
||||||
- '**/data/sync/AccountThrottleGateTest.kt'
|
- '**/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
|
# Reader-path perf logging (issue #358): the repository's openMessage and the reader ViewModel
|
||||||
# log via AppLog, so their unit tests mockkStatic(Log) too.
|
# log via AppLog, so their unit tests mockkStatic(Log) too.
|
||||||
- '**/data/repository/MailRepositoryImplTest.kt'
|
- '**/data/repository/MailRepositoryImplTest.kt'
|
||||||
|
|||||||
Reference in New Issue
Block a user