WIP: merge queue: checking main (ca62f77) and #463 together #466

Closed
mergify[bot] wants to merge 2 commits from mergify/merge-queue/3eaf079a11 into main
8 changed files with 480 additions and 9 deletions
@@ -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
}
}
@@ -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 {
@@ -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
+2
View File
@@ -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'