-
Notifications
You must be signed in to change notification settings - Fork 69
Description
Description
After reporting #274, I’ve encountered another incorrect CloudKit sync behavior. When a device sends a batch containing (a) an update to a parent record and (b) an inserted child record referencing that parent, and the parent has a newer version on the server, the child record is silently dropped and never synced again.
I wasn’t able to reproduce this in any built-in sample apps, so I slightly tweaked the Reminders example. The modification lives on this branch and adds a toolbar button for triggering the database operations. See also this diff.
Here is a step-by-step description of the scenario:
-
Devices A and B both start with an empty reminders list titled “List” with the ID
80ef064a-3dd8-4406-9a7b-548bedd18c65. -
Both devices go offline.
-
Device B updates the title of the list to “List changed on B”.
UPDATE "remindersLists"
SET "title" = 'List changed on B'
WHERE ("remindersLists"."id") IN (('80ef064a-3dd8-4406-9a7b-548bedd18c65'))- Device A updates the title to “List changed on A” and inserts a new reminder titled “Reminder added on A” with ID
48107186-4283-41a7-ad75-23bbf018438b.
UPDATE "remindersLists"
SET "title" = 'List changed on A'
WHERE ("remindersLists"."id") IN (('80ef064a-3dd8-4406-9a7b-548bedd18c65'))
INSERT INTO "reminders" ("id", "dueDate", "isFlagged", "notes", "position", "priority", "remindersListID", "status", "title")
VALUES (NULL, NULL, 0, '', 0, NULL, '80ef064a-3dd8-4406-9a7b-548bedd18c65', 0, 'Reminder added on A')- Both devices go online.
Warning
At this point, a race begins between the two devices. The issue occurs only if device B sends its changes first, and device A attempts to send its changes before fetching B’s updates from the server. Due to the nondeterminism in CKSyncEngine, I haven’t found a reliable way to force this exact ordering, so it may take a few attempts. The following steps assume this sequence.
- Device B sends its changes to the server.
SQLiteData (private.db) nextRecordZoneChangeBatch: scheduled
┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ event ┃ recordType ┃ recordName ┃
┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ ➡️ Sending │ remindersLists │ 80ef064a-3dd8-4406-9a7b-548bedd18c65:remindersLists │
└────────────┴────────────────┴─────────────────────────────────────────────────────┘
SQLiteData (private.db) sentRecordZoneChanges
┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ action ┃ recordType ┃ recordName ┃
┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ ✅ Saved │ remindersLists │ 80ef064a-3dd8-4406-9a7b-548bedd18c65:remindersLists │
└──────────┴────────────────┴─────────────────────────────────────────────────────┘
- Device A then attempts to send its changes in a single batch. Because a newer parent record now exists on the server, the update to the reminders list is rejected with a
serverRecordChangederror. However, the save of the newly inserted child reminder is also rejected, this time with abatchRequestFailederror.
SQLiteData (private.db) nextRecordZoneChangeBatch: scheduled
┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ event ┃ recordType ┃ recordName ┃
┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ ➡️ Sending │ reminders │ 48107186-4283-41a7-ad75-23bbf018438b:reminders │
│ ➡️ Sending │ remindersLists │ 80ef064a-3dd8-4406-9a7b-548bedd18c65:remindersLists │
└────────────┴────────────────┴─────────────────────────────────────────────────────┘
SQLiteData (private.db) sentRecordZoneChanges
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ action ┃ recordType ┃ recordName ┃ error ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 🛑 Save failed │ reminders │ 48107186-4283-41a7-ad75-23bbf018438b:reminders │ batchRequestFailed (22) │
│ 🛑 Save failed │ remindersLists │ 80ef064a-3dd8-4406-9a7b-548bedd18c65:remindersLists │ serverRecordChanged (14) │
└────────────────┴────────────────┴─────────────────────────────────────────────────────┴──────────────────────────┘
I’m not entirely sure about this behavior, but it appears that CloudKit refuses to save a child record when its parent record is considered outdated on the server.
- Device A upserts the reminders list from the fetched server record, but because the local title holds a newer timestamp, nothing changes. (Since not all fields were excluded from updates, this schedules another save.)
INSERT INTO "remindersLists" ("id", "color", "position", "title")
VALUES ('80ef064a-3dd8-4406-9a7b-548bedd18c65', 1167716351, 2, 'List changed on B') ON CONFLICT("id") DO UPDATE SET "position" = "excluded"."position"- Device A fetches changes from the server and attempts to apply them. Again, as the local reminders list’s title is newer, the one coming from the server isn’t applied.
SQLiteData (private.db) fetchedDatabaseChanges
┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
┃ action ┃ zoneName ┃ ownerName ┃
┡━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
│ ✅ Modified │ co.pointfree.SQLiteData.defaultZone │ __defaultOwner__ │
└─────────────┴─────────────────────────────────────┴──────────────────┘
SQLiteData (private.db) fetchedRecordZoneChanges
┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ action ┃ recordType ┃ recordName ┃
┡━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ ✅ Modified │ remindersLists │ 80ef064a-3dd8-4406-9a7b-548bedd18c65:remindersLists │
└─────────────┴────────────────┴─────────────────────────────────────────────────────┘
INSERT INTO "remindersLists" ("id", "color", "position", "title")
VALUES ('80ef064a-3dd8-4406-9a7b-548bedd18c65', 1167716351, 2, 'List changed on B')
ON CONFLICT("id") DO UPDATE SET "color" = "excluded"."color", "position" = "excluded"."position"- Device A sends the “changed” reminder list to the server.
SQLiteData (private.db) nextRecordZoneChangeBatch: scheduled
┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ event ┃ recordType ┃ recordName ┃
┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ ➡️ Sending │ remindersLists │ 80ef064a-3dd8-4406-9a7b-548bedd18c65:remindersLists │
└────────────┴────────────────┴─────────────────────────────────────────────────────┘
SQLiteData (private.db) sentRecordZoneChanges
┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ action ┃ recordType ┃ recordName ┃
┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ ✅ Saved │ remindersLists │ 80ef064a-3dd8-4406-9a7b-548bedd18c65:remindersLists │
└──────────┴────────────────┴─────────────────────────────────────────────────────┘
- Both devices end up with a synced reminders list, but the inserted reminder never makes it to the server. Since it was rejected in the original batch and never retried on its own, it remains unsynced until it’s modified again.
The chosen example in the Reminders sample app may look a bit constructed, but in my project, creating a child record while modifying its parent (with a high likelihood of hitting a conflict) is a common and intentional pattern. This is just a minimal repro to demonstrate the problem. The key issue is that the child insert is permanently dropped unless it’s modified again, which leads to data divergence similar to #274.
Checklist
- 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
mainbranch of this package. - This issue hasn't been addressed in an existing GitHub issue or discussion.
SQLiteData version information
main
Sharing version information
2.7.4
GRDB version information
7.8.0
Destination operating system
iOS 26
Xcode version information
26.1 (17B54)
Swift Compiler version information
swift-driver version: 1.127.14.1 Apple Swift version 6.2 (swiftlang-6.2.0.19.9 clang-1700.3.19.1)
Target: arm64-apple-macosx15.0