Fix incorrect transaction batching during conversation delete.

This commit is contained in:
Cody Henthorne
2026-03-03 09:46:39 -05:00
committed by Greyson Parrelli
parent 7fbcd17759
commit e23d575460
8 changed files with 304 additions and 60 deletions

View File

@@ -141,6 +141,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceUtil
import org.thoughtcrime.securesms.sms.GroupV2UpdateMessageUtil
import org.thoughtcrime.securesms.stories.Stories.isFeatureEnabled
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.SignalTrace
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
import org.thoughtcrime.securesms.util.RemoteConfig
@@ -228,6 +229,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
const val QUOTE_TARGET_MISSING_ID = -1L
const val ADDRESSABLE_MESSAGE_LIMIT = 5
private const val DELETE_BATCH_SIZE = 1000
const val PARENT_STORY_MISSING_ID = -1L
const val PIN_FOREVER = Long.MAX_VALUE
@@ -3972,14 +3974,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return 0
}
writableDatabase.withinTransaction { db ->
SignalDatabase.messageSearch.dropAfterMessageDeleteTrigger()
SignalDatabase.messageLog.dropAfterMessageDeleteTrigger()
for (threadId in threadsWithPossibleDeletes) {
val subSelect = "SELECT ${TABLE_NAME}.$ID FROM $TABLE_NAME WHERE ${TABLE_NAME}.$THREAD_ID = $threadId $extraWhere LIMIT 1000"
do {
// Bulk deleting FK tables for large message delete efficiency
SignalTrace.beginSection("MessageTable#deleteMessagesInThread")
for (threadId in threadsWithPossibleDeletes) {
val subSelect = "SELECT ${TABLE_NAME}.$ID FROM $TABLE_NAME WHERE ${TABLE_NAME}.$THREAD_ID = $threadId $extraWhere LIMIT $DELETE_BATCH_SIZE"
var deletedCount: Int
do {
deletedCount = writableDatabase.withinTransaction { db ->
db.delete(StorySendTable.TABLE_NAME)
.where("${StorySendTable.TABLE_NAME}.${StorySendTable.MESSAGE_ID} IN ($subSelect)")
.run()
@@ -3992,23 +3992,28 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
.where("${CallTable.TABLE_NAME}.${CallTable.MESSAGE_ID} IN ($subSelect)")
.run()
// Must delete rows from FTS table before deleting from main table due to FTS requirement when deleting by rowid
db.delete(SearchTable.FTS_TABLE_NAME)
.where("${SearchTable.FTS_TABLE_NAME}.${SearchTable.ID} IN ($subSelect)")
db.delete(AttachmentTable.TABLE_NAME)
.where("${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID} IN ($subSelect)")
.run()
// Actually delete messages
val deletedCount = db.delete(TABLE_NAME)
db.delete(GroupReceiptTable.TABLE_NAME)
.where("${GroupReceiptTable.TABLE_NAME}.${GroupReceiptTable.MMS_ID} IN ($subSelect)")
.run()
db.delete(MentionTable.TABLE_NAME)
.where("${MentionTable.TABLE_NAME}.${MentionTable.MESSAGE_ID} IN ($subSelect)")
.run()
// Delete the messages themselves
db.delete(TABLE_NAME)
.where("$ID IN ($subSelect)")
.run()
}
totalDeletedCount += deletedCount
} while (deletedCount > 0)
}
SignalDatabase.messageSearch.restoreAfterMessageDeleteTrigger()
SignalDatabase.messageLog.restoreAfterMessageDeleteTrigger()
totalDeletedCount += deletedCount
} while (deletedCount > 0)
}
SignalTrace.endSection()
return totalDeletedCount
}

View File

@@ -68,6 +68,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.SignalTrace
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.JsonUtils.SaneJSONObject
import org.thoughtcrime.securesms.util.TextSecurePreferences
@@ -1315,46 +1316,43 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
fun deleteConversations(selectedConversations: Set<Long>, syncThreadDeletes: Boolean = true) {
SignalTrace.beginSection("ThreadTable#deleteConversations")
Log.d(TAG, "[deleteConversations] Deleting ${selectedConversations.size} chats syncThreadDeletes: $syncThreadDeletes")
val recipientIds = getRecipientIdsForThreadIds(selectedConversations)
val addressableMessages = mutableListOf<ThreadDeleteSyncInfo>()
val queries: List<SqlUtil.Query> = SqlUtil.buildCollectionQuery(ID, selectedConversations)
Log.d(TAG, "[deleteConversations] Enter transaction")
writableDatabase.withinTransaction { db ->
if (syncThreadDeletes) {
for (threadId in selectedConversations) {
val mostRecentMessages = messages.getMostRecentAddressableMessages(threadId, excludeExpiring = false)
val mostRecentNonExpiring = if (mostRecentMessages.size == MessageTable.ADDRESSABLE_MESSAGE_LIMIT && mostRecentMessages.any { it.expiresIn > 0 }) {
messages.getMostRecentAddressableMessages(threadId, excludeExpiring = true)
} else {
emptySet()
}
addressableMessages += ThreadDeleteSyncInfo(threadId, mostRecentMessages, mostRecentNonExpiring)
// Phase 1: Collect sync info (reads only, before any deletion)
if (syncThreadDeletes) {
for (threadId in selectedConversations) {
val mostRecentMessages = messages.getMostRecentAddressableMessages(threadId, excludeExpiring = false)
val mostRecentNonExpiring = if (mostRecentMessages.size == MessageTable.ADDRESSABLE_MESSAGE_LIMIT && mostRecentMessages.any { it.expiresIn > 0 }) {
messages.getMostRecentAddressableMessages(threadId, excludeExpiring = true)
} else {
emptySet()
}
Log.d(TAG, "[deleteConversations] Retrieved sync thread delete addressable messages (${addressableMessages.size})")
} else {
Log.d(TAG, "[deleteConversations] No addressable messages needed")
}
Log.d(TAG, "[deleteConversations] Deactivating threads")
addressableMessages += ThreadDeleteSyncInfo(threadId, mostRecentMessages, mostRecentNonExpiring)
}
Log.d(TAG, "[deleteConversations] Retrieved sync thread delete addressable messages (${addressableMessages.size})")
} else {
Log.d(TAG, "[deleteConversations] No addressable messages needed")
}
// Phase 2: Delete messages (per-batch transactions, write lock released between batches)
Log.d(TAG, "[deleteConversations] Deleting messages in thread")
messages.deleteMessagesInThread(selectedConversations)
// Phase 3: Final lightweight transaction (deactivate threads, clear drafts, update cache)
val queries: List<SqlUtil.Query> = SqlUtil.buildCollectionQuery(ID, selectedConversations)
Log.d(TAG, "[deleteConversations] Deactivating threads and cleaning up")
writableDatabase.withinTransaction { db ->
for (query in queries) {
db.deactivateThread(query)
}
Log.d(TAG, "[deleteConversations] Deleting messages in thread")
messages.deleteMessagesInThread(selectedConversations)
Log.d(TAG, "[deleteConversations] Trimming attachments")
attachments.trimAllAbandonedAttachments()
Log.d(TAG, "[deleteConversations] Deleting abandoned group receipts")
groupReceipts.deleteAbandonedRows()
Log.d(TAG, "[deleteConversations] Deleting abandoned mentions")
mentions.deleteAbandonedMentions()
Log.d(TAG, "[deleteConversations] Clearing drafts")
drafts.clearDrafts(selectedConversations)
Log.d(TAG, "[deleteConversations] Updating threadId cache")
synchronized(threadIdCache) {
for (recipientId in recipientIds) {
threadIdCache.remove(recipientId)
@@ -1378,6 +1376,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
ConversationUtil.clearShortcuts(context, recipientIds)
OptimizeMessageSearchIndexJob.enqueue()
SignalTrace.endSection()
}
@SuppressLint("DiscouragedApi")