Skip to content

CloudKit sync can stall when syncing large datasets, resulting in missing records #565

Closed

Description

@jsutula

Description

CloudKit sync can stall midway through processing a large record set. This can occur both on initial sync and incremental sync if the number of records needing to be pulled down is high enough (on the order of thousands). The end result when this occurs is older database records being missing indefinitely, despite new data syncing in as expected. There is no recovery path to restore the missing records, and restarting the app does not help.

Root cause (as discussed on Slack): missing transient error handling and recovery around SyncEngine batched record download call CKDatabase.records(...) (SyncEngine.swift:1565). The primary transient error thrown which triggers this issue is HTTP 429 / CKError.requestRateLimited

Earlier reports: this issue is related to #432, created in March 2026 and since closed due to some uncertainty about the source of the problem. I'm opening a new issue to act as a clean slate since we now know the source of the issue is in SQLiteData.

Checklist

  • I wrote this in my own words, and aimed to be as succinct as possible, even if I used AI to help research its content.
  • I have determined whether this bug is also reproducible in a vanilla SwiftUI project.
  • I have determined whether this bug is also reproducible in a vanilla GRDB project.
  • If possible, I've reproduced the issue using the main branch of this package.
  • This issue hasn't been addressed in an existing GitHub issue or discussion.

Expected behavior

Syncing large datasets always completes with full record history, recovering as needed when encountering any transient errors.

Actual behavior

Sync can stall midway through processing a large dataset, resulting in records being missing indefinitely.

SQLiteData version information

1.12.1

Activity

  1. jsutula commented on Oct 27, 2026

    @jsutula
    ContributorAuthor

    @mbrandonw has started working on this with #564

  2. jonphillips commented on Nov 2, 2026

    @jonphillips

    We hit this on a production app (about 50k records, mostly in single-parent NOT NULL REFERENCES child tables) and worked around it in a vendored copy. Here's one data point on what drives the 429s, in case it's useful for the fix stacked on #564.

    What we saw. A fresh device's first sync stopped with 11,753 rows in sqlitedata_icloud_unsyncedRecordIDs. Child tables were at 0.4–15% of the other device's counts and stopped moving for over a week, including across relaunches. In the device log, each fetch cycle produced one SQLiteData failure, then CloudKit's requestRateLimited ended the engine's own fetch 2–3 seconds later.

    Why the re-fetch causes the throttling (SyncEngine.swift L1531–1581 at 1.12.1):

    1. On every fetched batch, handleFetchedRecordZoneChanges re-downloads every parked record ID with records(for:), 150 per request, before it applies the batch. The request count grows with the parked set, not with the batch: 11,753 parked is about 79 requests per incoming batch.
    2. On a first sync the parked set keeps growing, because zone changes arrive in server order and children routinely come before their parents.
    3. The whole loop runs inside one withErrorReporting. A single 429 throws away everything the loop had already fetched and returns []. The batch then parks more children, and the next burst is larger.

    So retrying on requestRateLimited would stop the loss. But each batch would still cost O(parked) requests, which is what provokes the limit in the first place.

    The network fetch isn't needed in the common case. upsertFromServerRecord (L1925) writes the record's SyncMetadata, with the full server record in lastKnownServerRecord, before the upsert that fails on the foreign key. A parked record's server copy is therefore already on the device. One catch: the FK-failure branch (L1984–1996) doesn't refresh that saved copy. If a newer version of a still-parked record arrives, the older one stays.

    Our workaround, in case the shape helps (not proposing it as the upstream fix):

    • In the FK-failure branch, also setLastKnownServerRecord(serverRecord), so the saved copy is always the newest version received.
    • Retry parked records from their saved copies, merged into the batch's parents-first sort and applied in the same write. On an ordinary batch, only retry records whose table has a foreign key to a table that received records in that batch. On the first batch after the engine starts, retry every parked record once.
    • Go to the network only for records whose saved copy is missing or unusable (for example, a CKAsset whose file is gone): at most one 150-record request per batch. An error there ends the fallback for that batch only and never discards the local retries.

    With that, the same device finished its first sync in about 15 minutes, and every table's count matched the other device. The patch and its tests (MockCloudDatabase request counting plus a simulated throttle) are in jonphillips/yes-chef@ab47f8f, under Vendor/sqlite-data. Happy to help with a reproduction against the rate-limited mock once #564 lands.

  3. added a commit that references this issue on Nov 2, 2026
  4. mbrandonw commented on Nov 4, 2026

    @mbrandonw
    Member

    Hi @jsutula and @jonphillips, would y'all be able to give #569 a test? We think we have this problem fixed.

  5. jsutula commented on Nov 5, 2026

    @jsutula
    ContributorAuthor

    I've just finished testing it with my repro scenario (Release build, fresh install + initial sync of thousands of records, 100% repro on 1.12.1):

    Pinned to #569 I can no longer reproduce the stall and resultant missing records. In 4/4 attempts all records are eventually pulled down after leaving the app in the foreground long enough.

    One caveat: it appears the first part of the fix (#567 Cache Unsynced Records) completely eliminates occurrence of CKError.requestRateLimited in my repro scenario, so my testing did not actually exercise the rate limit error handling portion of the fix.

  6. mbrandonw commented on Nov 5, 2026

    @mbrandonw
    Member

    Hi @jsutula, great thanks for testing it out.

    And yeah, fwiw, #567 is the real fix but #569 is necessary as a migration step for people who have only cached record IDs instead full records, and also just overall rate limiting is a good thing for us to have in various places.

  7. mbrandonw commented on Nov 5, 2026

    @mbrandonw
    Member

    Ok, this has been merged and will be released later today/tomorrow, so going to close this out for now. Please do report back if you find any issues!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions