Skip to content

Inserted child record silently dropped when batched with a conflicting parent #315

@lukaskubanek

Description

@lukaskubanek

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:

  1. Devices A and B both start with an empty reminders list titled “List” with the ID 80ef064a-3dd8-4406-9a7b-548bedd18c65.

  2. Both devices go offline.

  3. 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'))
  1. 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')
  1. 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.

  1. 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 │
  └──────────┴────────────────┴─────────────────────────────────────────────────────┘
  1. 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 serverRecordChanged error. However, the save of the newly inserted child reminder is also rejected, this time with a batchRequestFailed error.
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.

  1. 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"
  1. 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"
  1. 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 │
  └──────────┴────────────────┴─────────────────────────────────────────────────────┘
  1. 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 main branch 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

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