fix(sync): bound the foreground fetch window by the age cutoff in age retention #253
@@ -25,6 +25,11 @@ import javax.inject.Singleton
|
||||
* Precedence with the #12 backfill is guaranteed two ways: backfill stops paging at the same
|
||||
* retention floor this pruner deletes below (their working sets are disjoint), and both jobs share
|
||||
* [MailMaintenanceGate] so they never run at once.
|
||||
*
|
||||
* Foreground sync ([MailSyncer]) is aligned the same way in BOTH retention modes so it never
|
||||
* re-inserts what this pruner deletes: it caps its fetch window to the retention count AND drops
|
||||
* anything older than the age cutoff before persisting (#193). The two limits are independent, so
|
||||
* both bounds apply together.
|
||||
*/
|
||||
@Singleton
|
||||
class MailPruner @Inject constructor(
|
||||
|
||||
@@ -87,11 +87,19 @@ class MailSyncer @Inject constructor(
|
||||
private suspend fun syncFolderHeaders(account: Account, folder: String, notify: Boolean): Result<Int> =
|
||||
runCatching {
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val policy = accountSettingsRepository.effectiveRetention(settingsRepository, account.id)
|
||||
// Never fetch more of the recent window than device-only retention (#13) would keep. Without
|
||||
// this, a count limit BELOW the window would make foreground sync re-download the same rows
|
||||
// the pruner just trimmed, on every sync — an endless re-download/re-prune fight.
|
||||
val fetched = imapClient.fetchRecent(params, folder, recentWindowFor(account)) // cancellable network I/O
|
||||
val window = policy.countLimit?.let { minOf(FETCH_LIMIT, it) } ?: FETCH_LIMIT
|
||||
val fetched = imapClient.fetchRecent(params, folder, window) // cancellable network I/O
|
||||
// Age-based retention (#193): drop anything older than the age cutoff before persisting. On a
|
||||
// low-traffic mailbox the newest-N can extend PAST the cutoff, so without this a sync re-inserts
|
||||
// rows the age pruner just deleted and the next prune deletes them again — a churn loop. Count/
|
||||
// unlimited modes have a null cutoff and keep the full window, so their behavior is unchanged.
|
||||
val cutoff = policy.ageCutoffMillis(System.currentTimeMillis())
|
||||
val entities = fetched.map { it.toEntity(account.id, folder) }
|
||||
.let { mapped -> if (cutoff == null) mapped else mapped.filter { it.timestampMillis >= cutoff } }
|
||||
|
||||
// Persist and notify atomically with respect to cancellation: an IDLE renewal that cancels
|
||||
// mid-sync must not drop a notification (the rows would then look "already seen" next time).
|
||||
@@ -104,9 +112,11 @@ class MailSyncer @Inject constructor(
|
||||
entities.filter { it.id !in existingIds && !it.isRead }
|
||||
}
|
||||
|
||||
if (entities.isEmpty()) {
|
||||
if (fetched.isEmpty()) {
|
||||
// An empty recent window means the server folder itself is empty, so nothing (not
|
||||
// even backfilled history) should remain cached for it.
|
||||
// even backfilled history) should remain cached for it. Keyed on the raw fetch, not the
|
||||
// age-filtered set: a folder holding only mail older than the age cutoff is NOT empty on
|
||||
// the server, so its stale local rows are left to the pruner rather than wiped here.
|
||||
messageDao.deleteSyncedByAccountFolder(account.id, folder)
|
||||
} else {
|
||||
val ids = entities.map { it.id }
|
||||
@@ -147,16 +157,6 @@ class MailSyncer @Inject constructor(
|
||||
fetched.size
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of recent headers to fetch: the standard [FETCH_LIMIT], but capped by the account's
|
||||
* effective device-only retention count so foreground sync never re-downloads rows the pruner
|
||||
* would immediately trim. Age-only or unlimited retention leaves the full window in place.
|
||||
*/
|
||||
private suspend fun recentWindowFor(account: Account): Int {
|
||||
val policy = accountSettingsRepository.effectiveRetention(settingsRepository, account.id)
|
||||
return policy.countLimit?.let { minOf(FETCH_LIMIT, it) } ?: FETCH_LIMIT
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggressively pre-caches each not-yet-fetched message's full content (body + attachments) per the
|
||||
* user's fetch policy, pausing at low battery regardless of policy — see
|
||||
|
||||
@@ -46,6 +46,9 @@ class MailSyncerTest {
|
||||
/** The IMAP client of the most recently built [syncer], for verifying the fetch window size. */
|
||||
private lateinit var lastImapClient: ImapClient
|
||||
|
||||
/** The MessageDao of the most recently built [syncer], for verifying what got persisted. */
|
||||
private lateinit var lastMessageDao: MessageDao
|
||||
|
||||
/** A syncer whose header sync is a no-op (no server messages) so tests focus on the prefetch step. */
|
||||
private fun syncer(
|
||||
policy: FetchPolicy,
|
||||
@@ -54,14 +57,16 @@ class MailSyncerTest {
|
||||
accountSettings: AccountSettings = AccountSettings("acct"),
|
||||
globalSettings: AppSettings = AppSettings(),
|
||||
battery: BatteryStatus = BatteryStatus(percent = 100, isCharging = false),
|
||||
fetched: List<FetchedMessage> = emptyList(),
|
||||
): MailSyncer {
|
||||
val accountDao = mockk<AccountDao>()
|
||||
coEvery { accountDao.getById("acct") } returns account
|
||||
val messageDao = mockk<MessageDao>(relaxed = true)
|
||||
coEvery { messageDao.getSyncedIds(any(), any()) } returns emptyList()
|
||||
coEvery { messageDao.getUnfetchedIds("acct", "INBOX") } returns listOf("acct:INBOX:1")
|
||||
lastMessageDao = messageDao
|
||||
val imapClient = mockk<ImapClient>()
|
||||
coEvery { imapClient.fetchRecent(any(), any(), any()) } returns emptyList()
|
||||
coEvery { imapClient.fetchRecent(any(), any(), any()) } returns fetched
|
||||
lastImapClient = imapClient
|
||||
val connectionFactory = mockk<MailConnectionFactory>()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns mockk<ImapConnectionParams>()
|
||||
@@ -137,6 +142,30 @@ class MailSyncerTest {
|
||||
coVerify { lastImapClient.fetchRecent(any(), "INBOX", 50) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `age retention does not re-insert fetched messages older than the cutoff`() = runTest {
|
||||
val repo = mockk<MailRepository>(relaxed = true)
|
||||
val now = System.currentTimeMillis()
|
||||
val day = 24L * 60 * 60 * 1000
|
||||
val recentTs = now - 10 * day // well within a 6-month window
|
||||
val oldTs = now - 400 * day // well past a 6-month window — the pruner would delete it
|
||||
val fetched = listOf(
|
||||
FetchedMessage("2", "New", "new@example.org", "recent", recentTs, isRead = true, isFlagged = false),
|
||||
FetchedMessage("1", "Old", "old@example.org", "stale", oldTs, isRead = true, isFlagged = false),
|
||||
)
|
||||
|
||||
syncer(
|
||||
FetchPolicy.ON_DEMAND,
|
||||
repo,
|
||||
accountSettings = AccountSettings("acct", retentionMonths = 6),
|
||||
fetched = fetched,
|
||||
).syncFolder("acct", "INBOX")
|
||||
|
||||
// Only the in-window message is persisted; the past-cutoff one is never re-inserted, so the age
|
||||
// pruner won't just delete it again next cycle (#193 — no re-download/re-prune churn loop).
|
||||
coVerify { lastMessageDao.insertNew(match { batch -> batch.map { it.timestampMillis } == listOf(recentTs) }) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WIFI_ONLY prefetches on an unmetered network`() = runTest {
|
||||
val repo = mockk<MailRepository>()
|
||||
|
||||
Reference in New Issue
Block a user