diff --git a/lib/database/io/attachment.dart b/lib/database/io/attachment.dart index 057ca17166..cc70141b79 100644 --- a/lib/database/io/attachment.dart +++ b/lib/database/io/attachment.dart @@ -54,7 +54,12 @@ class Attachment { set dbMetadata(String? json) => metadata = json == null ? null : jsonDecode(json) as Map; - void applyFromCloud(api.CloudAttachment c, String ckRecordId) { + bool applyFromCloud(api.CloudAttachment c, String ckRecordId) { + // No-op fast path: same CloudKit record already applied → return false so + // the caller skips the redundant save. Mirrors the message-side guard. + if (this.ckRecordId == ckRecordId) { + return false; + } this.ckRecordId = ckRecordId; var decoded = api.decodeAttachmentmeta(wrapped: c.cm); uti = decoded.uti; @@ -74,6 +79,7 @@ class Attachment { guid = decoded.guid; save(null); } + return true; } String unconvertAttachmentGuid(String guid) { diff --git a/lib/database/io/chat.dart b/lib/database/io/chat.dart index db4c8a7df5..5aedf09184 100644 --- a/lib/database/io/chat.dart +++ b/lib/database/io/chat.dart @@ -893,11 +893,23 @@ class Chat { } bool applyFromCloud(api.CloudChat c, String record) { + // No-op fast path: same CloudKit record, same group version → nothing changed. + // Without this guard, every incremental sync pass rewrites the chat row, + // which fires ObjectBox reactive observers for the chat list and grows + // resident memory on accounts with many groups (verified at 200+ chats + // running sync in a loop pegged one CPU core at 99% with monotonic RAM + // growth until OOM). + if (ckRecordId == record && c.properties?.pv == (groupVersion ?? 1)) { + return false; + } + chatIdentifier = c.chatIdentifier; ckRecordId = record; cloudGuid = c.groupId; ckSyncState = c.properties?.pv == (groupVersion ?? 1); if (c.properties?.pv == null || c.properties!.pv! <= (groupVersion ?? 1)) { + // Only persist the chatIdentifier / cloudGuid / ckSyncState changes — + // the version-bump path below will write everything else. Database.chats.put(this); return false; } diff --git a/lib/database/io/message.dart b/lib/database/io/message.dart index 4440c2ef6a..5b5217f510 100644 --- a/lib/database/io/message.dart +++ b/lib/database/io/message.dart @@ -1097,7 +1097,18 @@ class Message { ); } - void applyFromCloud(api.CloudMessage c, String cloudkitId) { + bool applyFromCloud(api.CloudMessage c, String cloudkitId) { + // No-op fast path: same CloudKit record already applied → return false so + // the caller skips the redundant save. Without this guard, every sync pass + // re-decodes the proto, re-runs balloon decoding, allocates new lists for + // attributedBody, and writes the row again — re-firing ObjectBox reactive + // observers and growing resident memory until the UI thread is starved. + // Reproduced: 151 MB ObjectBox store, sync loop pegged a CPU core at 99% + // for >15 minutes with monotonic RAM growth (2.5 GB observed). + if (ckRecordId == cloudkitId) { + Logger.info("item ${c.chatId}"); + return false; + } Logger.info("item ${c.chatId}"); Chat? chat; if (c.chatId.contains(";")) { @@ -1183,8 +1194,9 @@ class Message { var proto4 = api.decodeMessageproto4(wrapped: c.msgProto4!); associatedMessageEmoji = proto4.associatedMessageEmoji; } - + save(chat: chat); + return true; } /// Fetch reactions diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 6deb3c1e42..0412c7b66e 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -2453,6 +2453,13 @@ class RustPushService extends GetxService { var existing = Attachment.findOne(convertAttachmentGuid(decoded.guid)); if (existing != null) { + if (existing.ckRecordId == item.key) { + // Already up-to-date for this CloudKit record — skip the save. + // Mirrors the message-side guard above; without it the same sync + // loop re-fires ObjectBox reactive queries per attachment per + // pass and resident memory grows without bound. + continue; + } if (existing.ckRecordId != null && existing.ckRecordId != item.key) { // we have a different record id dupDeleteAttachments.add(existing.ckRecordId!); @@ -2529,7 +2536,13 @@ class RustPushService extends GetxService { var existing = Message.findOne(guid: item.value!.guid); if (existing != null) { if (existing.ckRecordId == item.key) { + // Already up-to-date for this CloudKit record — skip the save. + // Without this guard, every sync pass writes the row again, which + // re-fires ObjectBox reactive observers and grows resident memory + // until the UI thread is starved. The flags on `existing` are + // unchanged from the prior pass, so the write is a true no-op. localUnchanged++; + continue; } else if (existing.ckRecordId != null) { // we have a different record id dupDeleteMessages.add(existing.ckRecordId!);