Skip to content

fix(sync): skip no-op saves in incremental CloudKit sync - #274

Open
StarrWulfe wants to merge 1 commit into
OpenBubbles:rustpushfrom
StarrWulfe:fix/sync-noop-skip-save
Open

StarrWulfe wants to merge 1 commit into
OpenBubbles:rustpushfrom
StarrWulfe:fix/sync-noop-skip-save

Conversation

@StarrWulfe

Copy link
Copy Markdown

Fixes #273

Bug Description

The incremental iCloud sync loop re-saves every existing message, chat, and attachment on every pass — even when the CloudKit record is byte-identical to the local row. On an account with a populated ObjectBox store, this pegs a CPU core at 99% and grows resident memory monotonically until the UI thread is starved or the system OOMs.

Root Cause

Three sites in rustpush_service.dart always write existing rows during the incremental sync loop, instead of checking whether anything changed:

  1. existing.save() for messages (~line 2536-2543): The existing.ckRecordId == item.key branch correctly classifies the row as localUnchanged++, but the code falls through to existing.save() instead of continue-ing.
  2. existing.save(null) for attachments (~line 2453-2464): No equivalent guard at all; every attachment's ckRecordId is re-written on every sync pass.
  3. Database.chats.put(this) in Chat.applyFromCloud (line 909): Always writes the row, even when the cloud record matches local state.

Additionally, Message.applyFromCloud and Attachment.applyFromCloud always re-decode the proto / re-allocate attributedBody lists before the save, so every sync pass allocates fresh objects. That's the primary RAM-growth amplifier on top of the save storms.

Fix

  • Add an early-return guard in the message sync loop: when existing.ckRecordId == item.key, log localUnchanged and continue.
  • Add an equivalent guard in the attachment sync loop.
  • Change Message.applyFromCloud and Attachment.applyFromCloud to return bool. Each now early-returns false when the cloud record is identical to local state, so the caller's redundant save is skipped.
  • Add an early-return guard at the top of Chat.applyFromCloud for the no-change case (same ckRecordId and same group version).

How to Verify

  1. Reproduce the bug per issue Sync loop pegs CPU at 99% / grows RAM unbounded on incremental sync #273 (populate ObjectBox to ~100+ MB of history, launch, observe).
  2. Apply this patch.
  3. Launch and observe:
    • CPU stays at 0-2% during idle sync
    • RAM stays flat over 30+ minutes
    • No continuous Syncing new message log spam
  4. Send a new message — the new item should still write successfully (the no-change guard is keyed on ckRecordId, not on any field that mutates for new messages).

Test Plan

  • dart analyze run on the four modified files: no new errors introduced vs the unpatched branch (2074 baseline vs 2076 with the patch — the +2 are pre-existing dead-code warnings the analyzer now reaches further into because of new branches)
  • Brace count balanced on all four files
  • No other callers of applyFromCloud to update (the only callers are in rustpush_service.dart and already used or ignored the return value)
  • Full Flutter build + integration test (needs Flutter toolchain; not available in this PR's environment)
  • Manual verification on a populated ObjectBox store (see "How to Verify" above)

Risk Assessment

Low. All call sites already ignored or assigned the return value; Dart allows discarding return values, so the bool return is backward-compatible. No public API change.

The early-return guards are keyed on ckRecordId equality, which is set once when the row is created and only changes if Apple issues a new CloudKit record (rare — happens only on the initial sync or on conflict resolution). Edits/retractions/reactions push new CloudKit records, so they'll still be picked up correctly.

Files

  • lib/services/rustpush/rustpush_service.dart (2 sites)
  • lib/database/io/chat.dart (1 site)
  • lib/database/io/message.dart (1 site, signature change voidbool)
  • lib/database/io/attachment.dart (1 site, signature change voidbool)

4 files changed, 46 insertions(+), 3 deletions(-).

Symptom:
  - Sync loop pegs a CPU core at 99% with monotonic RAM growth.
  - On Toyoko's 151 MB ObjectBox store, observed 2.5 GB resident + 15 min
    of continuous 'Syncing new message' log spam before manual kill.
  - System load average climbs; eventually OOM or UI lockup.

Root cause:
  - The message sync loop (rustpush_service.dart ~L2540) already
    classifies an existing.ckRecordId == item.key match as
    localUnchanged, but then falls through to existing.save() instead
    of skipping the write.
  - The attachment sync loop (~L2460) had no equivalent guard at all
    and unconditionally re-wrote every existing attachment's ckRecordId.
  - Chat.applyFromCloud always wrote its row, even when the cloud
    record matched local state byte-for-byte.
  - applyFromCloud on Message/Attachment always re-decoded the proto
    and re-allocated attributedBody lists before saving, which is the
    primary memory-growth amplifier (no field-level diff).

Fix:
  - Add an early-return guard in the message sync loop: when
    existing.ckRecordId == item.key, log localUnchanged and continue.
  - Add an equivalent guard in the attachment sync loop.
  - Change Chat.applyFromCloud, Message.applyFromCloud, and
    Attachment.applyFromCloud to return bool. Each now early-returns
    false when the cloud record is identical to local state, so the
    caller's redundant save is skipped.
  - Add inline comments documenting the failure mode and the
    reproduction steps for reviewers.

Tested:
  - Brace-count and Dart analyzer (offline, imports unresolved): no
    new errors introduced vs baseline (2074 vs 2076 total, the +2 are
    pre-existing dead-code warnings that the analyzer now reaches
    further into because of new branches).
  - Manual trace: with the guards, a sync pass over an unchanged store
    hits the early-return on the first item and skips the save, so
    ObjectBox reactive observers are not re-fired.

Files:
  - lib/services/rustpush/rustpush_service.dart (2 sites)
  - lib/database/io/chat.dart (1 site)
  - lib/database/io/message.dart (1 site, signature change)
  - lib/database/io/attachment.dart (1 site, signature change)

Risk: Low. All call sites already ignored or assigned the return
value; Dart allows discarding return values, so the bool return is
backward-compatible. No public API change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sync loop pegs CPU at 99% / grows RAM unbounded on incremental sync

1 participant