Merge pull request #52 from JMR-dev/fix/message-list-cursorwindow-overflow
fix(mailbox): project message list to avoid CursorWindow overflow
This commit was merged in pull request #52.
This commit is contained in:
@@ -53,7 +53,7 @@ class DatabaseEncryptionTest {
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
assertTrue("file must not read as plaintext once encrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
openEncrypted().apply {
|
||||
assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id })
|
||||
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
|
||||
close()
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class DatabaseEncryptionTest {
|
||||
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
|
||||
assertFalse("file must be plaintext again after decrypt", DatabaseEncryption.isEncrypted(dbFile))
|
||||
openPlaintext().apply {
|
||||
assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id })
|
||||
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,10 +102,30 @@ class LibreMailDatabaseTest {
|
||||
assertEquals(listOf("acct:1"), messageDao.getSyncedIds("acct", "INBOX"))
|
||||
|
||||
messageDao.deleteSearchRows()
|
||||
val remaining = messageDao.observeAll().first().map { it.id }
|
||||
val remaining = messageDao.observeSummaries().first().map { it.id }
|
||||
assertEquals(listOf("acct:1"), remaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observeSummariesReadsRowsWhoseBodiesExceedTheCursorWindow() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
// Each body is larger than SQLite's shared (~2 MB) CursorWindow. The old list query did
|
||||
// SELECT * and dragged these bodies through the window, overflowing it with
|
||||
// "Couldn't read row … from CursorWindow" (issue #51). observeSummaries omits body, so the
|
||||
// rows stay tiny and read fine.
|
||||
val hugeBody = "x".repeat(3 * 1024 * 1024)
|
||||
messageDao.insertNew(
|
||||
listOf(
|
||||
message("acct:1", body = hugeBody),
|
||||
message("acct:2", body = hugeBody),
|
||||
),
|
||||
)
|
||||
|
||||
val ids = messageDao.observeSummaries().first().map { it.id }.toSet()
|
||||
|
||||
assertEquals(setOf("acct:1", "acct:2"), ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foldersAreStoredOrderedAndReplaceablePerAccount() = runBlocking {
|
||||
val folderDao = db.folderDao()
|
||||
@@ -146,7 +166,7 @@ class LibreMailDatabaseTest {
|
||||
messageDao.deleteSyncedNotIn("acct", "INBOX", listOf("acct:INBOX:1"))
|
||||
assertEquals(
|
||||
setOf("acct:INBOX:1", "acct:Archive:1"),
|
||||
messageDao.observeAll().first().map { it.id }.toSet(),
|
||||
messageDao.observeSummaries().first().map { it.id }.toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.DraftEntity
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.MessageSummary
|
||||
import org.libremail.data.local.entity.OutboxEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.domain.model.Account
|
||||
@@ -103,6 +104,28 @@ internal fun MessageEntity.toDomain(): Message = Message(
|
||||
bodyFetched = bodyFetched,
|
||||
)
|
||||
|
||||
/**
|
||||
* Maps a mailbox-list projection to the domain model. [Message.body]/[Message.isHtml] are left
|
||||
* empty because the list never renders them — the reader loads the body on demand (see
|
||||
* [MessageSummary]).
|
||||
*/
|
||||
internal fun MessageSummary.toDomain(): Message = Message(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
sender = sender,
|
||||
senderEmail = senderEmail,
|
||||
subject = subject,
|
||||
snippet = snippet,
|
||||
body = "",
|
||||
isHtml = false,
|
||||
timestampMillis = timestampMillis,
|
||||
isRead = isRead,
|
||||
isStarred = isStarred,
|
||||
folder = folder,
|
||||
inInbox = inInbox,
|
||||
bodyFetched = bodyFetched,
|
||||
)
|
||||
|
||||
internal fun FetchedMessage.toEntity(accountId: String, folder: String, inInbox: Boolean = true): MessageEntity =
|
||||
MessageEntity(
|
||||
id = "$accountId:$folder:$uid",
|
||||
|
||||
@@ -7,11 +7,21 @@ import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.MessageSummary
|
||||
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
@Query("SELECT * FROM messages ORDER BY timestampMillis DESC")
|
||||
fun observeAll(): Flow<List<MessageEntity>>
|
||||
/**
|
||||
* Mailbox-list projection ordered newest-first. Deliberately omits the large `body`/`isHtml`
|
||||
* columns: the list observes every cached message at once, and pulling full bodies through
|
||||
* SQLite's shared ~2 MB CursorWindow overflows it once enough large bodies are cached
|
||||
* (issue #51). Bodies are loaded lazily per-message via [getById] when a message is opened.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
|
||||
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages ORDER BY timestampMillis DESC",
|
||||
)
|
||||
fun observeSummaries(): Flow<List<MessageSummary>>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
|
||||
suspend fun getById(id: String): MessageEntity?
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
/**
|
||||
* Lightweight projection of [MessageEntity] for the mailbox list: every column the list renders
|
||||
* or searches, but *not* the potentially large `body`/`isHtml`.
|
||||
*
|
||||
* The list observes every cached message at once, so selecting full HTML bodies would drag them
|
||||
* all through SQLite's shared (~2 MB) CursorWindow and overflow it once enough large bodies are
|
||||
* cached — crashing with "Couldn't read row N from CursorWindow" (issue #51). Bodies are read
|
||||
* lazily, one message at a time, via `MessageDao.getById` when a message is opened.
|
||||
*/
|
||||
data class MessageSummary(
|
||||
val id: String,
|
||||
val accountId: String,
|
||||
val sender: String,
|
||||
val senderEmail: String,
|
||||
val subject: String,
|
||||
val snippet: String,
|
||||
val timestampMillis: Long,
|
||||
val isRead: Boolean,
|
||||
val isStarred: Boolean,
|
||||
val folder: String,
|
||||
val inInbox: Boolean,
|
||||
val bodyFetched: Boolean,
|
||||
)
|
||||
@@ -57,7 +57,7 @@ class MailRepositoryImpl @Inject constructor(
|
||||
private val signatureRepository: SignatureRepository,
|
||||
) : MailRepository {
|
||||
|
||||
override fun observeMessages(): Flow<List<Message>> = messageDao.observeAll().map { rows ->
|
||||
override fun observeMessages(): Flow<List<Message>> = messageDao.observeSummaries().map { rows ->
|
||||
rows.map { it.toDomain() }
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.DraftEntity
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.MessageSummary
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
@@ -73,7 +74,7 @@ class MailRepositoryImplTest {
|
||||
|
||||
@Test
|
||||
fun `observeMessages is empty when the cache is empty`() = runTest {
|
||||
every { messageDao.observeAll() } returns flowOf(emptyList())
|
||||
every { messageDao.observeSummaries() } returns flowOf(emptyList())
|
||||
repository.observeMessages().test {
|
||||
assertTrue(awaitItem().isEmpty())
|
||||
awaitComplete()
|
||||
@@ -82,8 +83,7 @@ class MailRepositoryImplTest {
|
||||
|
||||
@Test
|
||||
fun `observeMessages maps cached entities`() = runTest {
|
||||
val entity = messageEntity("1", "INBOX")
|
||||
every { messageDao.observeAll() } returns flowOf(listOf(entity))
|
||||
every { messageDao.observeSummaries() } returns flowOf(listOf(messageSummary("1", "INBOX")))
|
||||
repository.observeMessages().test {
|
||||
val items = awaitItem()
|
||||
assertEquals(1, items.size)
|
||||
@@ -421,6 +421,22 @@ class MailRepositoryImplTest {
|
||||
bodyFetched = bodyFetched,
|
||||
)
|
||||
|
||||
private fun messageSummary(id: String, folder: String, accountId: String = "acct", bodyFetched: Boolean = false) =
|
||||
MessageSummary(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "snippet",
|
||||
timestampMillis = 1_000L,
|
||||
isRead = false,
|
||||
isStarred = false,
|
||||
folder = folder,
|
||||
inInbox = true,
|
||||
bodyFetched = bodyFetched,
|
||||
)
|
||||
|
||||
private fun accountEntity(id: String = "acct", email: String = "ada@example.org") = AccountEntity(
|
||||
id = id,
|
||||
email = email,
|
||||
|
||||
Reference in New Issue
Block a user