Merge pull request #429 from JMR-dev/perf-322-batched-persistbatch

perf(data): route MailBackfiller.persistBatch through batched updateHeaderContents
This commit was merged in pull request #429.
This commit is contained in:
mergify[bot]
2026-07-08 06:04:46 +00:00
committed by GitHub
3 changed files with 146 additions and 13 deletions
@@ -320,6 +320,62 @@ class MessageDaoTest {
assertEquals("cached-2", two.body)
}
/**
* The batched [MessageDao.updateHeaderContents] must write byte-for-byte the same row as the per-row
* [MessageDao.updateHeaderContent] it replaces on the backfill path (issue #322): the same refreshed
* fields and casefold columns, and the same untouched flags/body/membership. Two rows seeded
* identically and refreshed by the two paths with the same values must end up identical.
*/
@Test
fun updateHeaderContentsWritesTheSameResultAsThePerRowUpdate() = runBlocking {
dao.insertNew(
listOf(
message("perRow", isStarred = true, body = "cached"),
message("batch", isStarred = true, body = "cached"),
),
)
// Old path: the single-row update. New path: the batch, carrying identical field values.
dao.updateHeaderContent(
id = "perRow",
sender = "Refreshed",
senderEmail = "refreshed@example.org",
subject = "Fresh",
timestampMillis = 9_000L,
uid = 7L,
)
dao.updateHeaderContents(
listOf(
message(
"batch",
sender = "Refreshed",
senderEmail = "refreshed@example.org",
subject = "Fresh",
timestampMillis = 9_000L,
uid = 7L,
),
),
)
// Every stored column — refreshed and preserved alike — matches between the two paths.
val perRow = requireNotNull(dao.getById("perRow"))
val batch = requireNotNull(dao.getById("batch"))
assertEquals(perRow.copy(id = "id"), batch.copy(id = "id"))
}
/** An empty batch is a no-op — issue #322's empty-batch boundary at the DAO transaction level. */
@Test
fun updateHeaderContentsWithAnEmptyBatchWritesNothing() = runBlocking {
dao.insertNew(listOf(message("acct:1", subject = "Original", isRead = true, uid = 5)))
dao.updateHeaderContents(emptyList())
val row = requireNotNull(dao.getById("acct:1"))
assertEquals("Original", row.subject)
assertEquals(5L, row.uid)
assertTrue(row.isRead)
}
@Test
fun markSyncedPromotesSearchOnlyRowsIntoTheFolder() = runBlocking {
dao.insertNew(
@@ -198,17 +198,15 @@ class MailBackfiller @Inject constructor(
val toRefresh = entities.filter { it.id in preexisting }
if (toRefresh.isNotEmpty()) {
messageDao.markSynced(toRefresh.map { it.id })
toRefresh.forEach {
messageDao.updateHeaderContent(
id = it.id,
sender = it.sender,
senderEmail = it.senderEmail,
subject = it.subject,
timestampMillis = it.timestampMillis,
uid = it.uid,
)
}
// Refresh every pre-existing row's header in ONE transaction (issue #322) rather than a per-row
// UPDATE each in its own implicit transaction — the same batched path foreground sync uses
// (issue #310). N per-row commits fsync the journal once per message (amplified on the
// encrypted cache); routing the whole batch through updateHeaderContents collapses them into a
// single commit per page. Semantically identical: it applies updateHeaderContent to each row in
// list order, so the same rows get the same values (and the same casefold columns).
messageDao.updateHeaderContents(toRefresh)
}
AppLog.d(TAG, "backfill persist: fetched=${entities.size} refreshed=${toRefresh.size}")
}
private suspend fun markComplete(accountId: String, folder: String, nextBeforeUid: Long) {
@@ -378,10 +378,12 @@ class MailBackfillerTest {
/**
* A backfilled page whose ids already exist (e.g. former search-only rows) must be *refreshed*
* (markSynced + header update), not just IGNORE-inserted — this covers persistBatch's
* pre-existing-row branch, which the all-brand-new happy paths above never hit.
* pre-existing-row branch, which the all-brand-new happy paths above never hit. The refresh is
* routed through the BATCHED [MessageDao.updateHeaderContents] (issue #322), never the per-row
* [MessageDao.updateHeaderContent].
*/
@Test
fun `re-inserting a pre-existing header refreshes it rather than only inserting`() = runTest {
fun `re-inserting a pre-existing header refreshes it through the batched update`() = runTest {
appendMessages(60)
seedForegroundWindow()
val backfiller = backfiller(AccountSettings("acct"))
@@ -391,11 +393,88 @@ class MailBackfillerTest {
backfiller.runBackfill()
coVerify(atLeast = 1) { lastMessageDao!!.markSynced(any()) }
coVerify(atLeast = 1) {
// The whole batch is refreshed in one transaction — the per-row path is never taken (issue #322).
coVerify(atLeast = 1) { lastMessageDao!!.updateHeaderContents(any()) }
coVerify(exactly = 0) {
lastMessageDao!!.updateHeaderContent(any(), any(), any(), any(), any(), any())
}
}
// --- issue #322: batched persist ------------------------------------------------------------
/**
* persistBatch routes its pre-existing-row refreshes through the BATCHED
* [MessageDao.updateHeaderContents] — one transaction per page (issue #322) — and refreshes ONLY
* the rows that already existed: brand-new rows are written whole by insertNew, so re-updating them
* would be redundant. A partial page (some ids already present, some brand-new) exercises exactly
* that split, in one batched call, never the per-row [MessageDao.updateHeaderContent].
*/
@Test
fun `a partial page refreshes only its pre-existing rows through one batched update`() = runTest {
// One cached window row makes INBOX a backfill target with boundary UID 20.
cached += fetchedMessage(uid = "20").toEntity("acct", "INBOX")
// A single older page (UIDs 10..19); the next fetch (boundary 10) returns empty → folder complete.
val page = (10..19).map { fetchedMessage(uid = it.toString()) }
val imapClient = mockk<ImapClient>()
coEvery { imapClient.fetchOlderThan(any(), any(), any(), any()) } answers {
if (thirdArg<Long>() > 10L) page else emptyList()
}
val backfiller = backfiller(AccountSettings("acct"), imapClient = imapClient)
// Only UIDs 10..13 are reported as already present — the partial pre-existing subset.
val preexistingIds = (10..13).map { "acct:INBOX:$it" }.toSet()
coEvery { lastMessageDao!!.existingIds(any()) } answers {
firstArg<List<String>>().filter { it in preexistingIds }
}
val refreshedBatches = mutableListOf<List<MessageEntity>>()
coEvery { lastMessageDao!!.updateHeaderContents(any()) } answers {
refreshedBatches += firstArg<List<MessageEntity>>()
}
backfiller.runBackfill()
// Exactly one batched call for the page, carrying exactly the pre-existing subset (not the 6 new rows).
assertEquals(1, refreshedBatches.size, "one batched update per page")
assertEquals(preexistingIds, refreshedBatches.single().mapTo(HashSet()) { it.id })
// markSynced promotes exactly that subset; the per-row update path is never taken (issue #322).
coVerify(exactly = 1) { lastMessageDao!!.markSynced(match { it.toSet() == preexistingIds }) }
coVerify(exactly = 0) {
lastMessageDao!!.updateHeaderContent(any(), any(), any(), any(), any(), any())
}
// Counts-only persist breadcrumb (PII-free): 10 fetched, 4 refreshed.
assertTrue(
logBuffer.snapshot().any { it.message == "backfill persist: fetched=10 refreshed=4" },
"the persist breadcrumb logs page + refresh counts only",
)
}
/**
* The empty-refresh boundary: a page whose rows are ALL brand-new must skip the header-refresh
* transaction entirely — insertNew writes them whole, so neither markSynced nor the batched
* updateHeaderContents runs (issue #322 preserves persistBatch's isNotEmpty guard).
*/
@Test
fun `an all-new page skips the batched header update entirely`() = runTest {
cached += fetchedMessage(uid = "20").toEntity("acct", "INBOX")
val page = (10..19).map { fetchedMessage(uid = it.toString()) }
val imapClient = mockk<ImapClient>()
coEvery { imapClient.fetchOlderThan(any(), any(), any(), any()) } answers {
if (thirdArg<Long>() > 10L) page else emptyList()
}
// existingIds stays at the relaxed default (empty) → every fetched row is brand-new.
backfiller(AccountSettings("acct"), imapClient = imapClient).runBackfill()
coVerify(exactly = 0) { lastMessageDao!!.updateHeaderContents(any()) }
coVerify(exactly = 0) { lastMessageDao!!.markSynced(any()) }
coVerify(exactly = 0) {
lastMessageDao!!.updateHeaderContent(any(), any(), any(), any(), any(), any())
}
// The persist breadcrumb still records the page, with zero refreshed.
assertTrue(
logBuffer.snapshot().any { it.message == "backfill persist: fetched=10 refreshed=0" },
"an all-new page logs refreshed=0",
)
}
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
@Test