Skip to content

Conversation

@mbrandonw
Copy link
Member

Fixes #315

There appears to be a bug in iCloud/CloudKit that causes .batchRequestFailed errors to be emitted even when operations are not atomic. We aren't sure of the exact conditions that causes the error to be emitted, but regardless let's start handling that error. We will simply re-enqueue those records to be saved/deleted, and then they should succeed just fine.

Copy link
Contributor

@lukaskubanek lukaskubanek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the issue from #315!

I’ve run manual tests both in the CloudKitDemo sample app and in my own project, where I originally discovered the issue, and everything worked fine!

Logs from CloudKitDemo

Device B: Update c39453bb & insert 6ee059ba

SQLiteData (private.db) nextRecordZoneChangeBatch: scheduled
  ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃ event     ┃ recordType ┃ recordName                                    ┃
  ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
  │ ➡️ Sending │ counters   │ 6ee059ba-3681-4625-b859-dff3ed59cfbd:counters │
  │ ➡️ Sending │ counters   │ c39453bb-076d-4498-a30b-1a58f995db2f:counters │
  └───────────┴────────────┴───────────────────────────────────────────────┘
Timestamp: 2025-12-13 08:53:32.321688+07:00

Device A: Update c39453bb & insert fd6a5d63

SQLiteData (private.db) nextRecordZoneChangeBatch: scheduled
  ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃ event     ┃ recordType ┃ recordName                                    ┃
  ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
  │ ➡️ Sending │ counters   │ fd6a5d63-f031-48fc-b661-b7aa1d7ceb87:counters │
  │ ➡️ Sending │ counters   │ c39453bb-076d-4498-a30b-1a58f995db2f:counters │
  └───────────┴────────────┴───────────────────────────────────────────────┘
Timestamp: 2025-12-13 08:53:32.608295+07:00

Device B: Save changes to c39453bb & 6ee059ba

SQLiteData (private.db) sentRecordZoneChanges
  ┏━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃ action  ┃ recordType ┃ recordName                                    ┃
  ┡━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
  │ ✅ Saved │ counters   │ 6ee059ba-3681-4625-b859-dff3ed59cfbd:counters │
  │ ✅ Saved │ counters   │ c39453bb-076d-4498-a30b-1a58f995db2f:counters │
  └─────────┴────────────┴───────────────────────────────────────────────┘
Timestamp: 2025-12-13 08:53:33.372963+07:00

Device A: Fail saving changes to c39453bb & fd6a5d63

SQLiteData (private.db) sentRecordZoneChanges
  ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃ action        ┃ recordType ┃ recordName                                    ┃ error                    ┃
  ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩
  │ 🛑 Save failed │ counters   │ c39453bb-076d-4498-a30b-1a58f995db2f:counters │ serverRecordChanged (14) │
  │ 🛑 Save failed │ counters   │ fd6a5d63-f031-48fc-b661-b7aa1d7ceb87:counters │ batchRequestFailed (22)  │
  └───────────────┴────────────┴───────────────────────────────────────────────┴──────────────────────────┘
Timestamp: 2025-12-13 08:53:34.554332+07:00

Device A: Fetch changes to c39453bb & 6ee059ba from server

SQLiteData (private.db) fetchedDatabaseChanges
  ┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
  ┃ action     ┃ zoneName                            ┃ ownerName        ┃
  ┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
  │ ✅ Modified │ co.pointfree.SQLiteData.defaultZone │ __defaultOwner__ │
  └────────────┴─────────────────────────────────────┴──────────────────┘
Timestamp: 2025-12-13 08:53:37.376031+07:00
SQLiteData (private.db) fetchedRecordZoneChanges
  ┏━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃ action     ┃ recordType ┃ recordName                                    ┃
  ┡━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
  │ ✅ Modified │ counters   │ c39453bb-076d-4498-a30b-1a58f995db2f:counters │
  │ ✅ Modified │ counters   │ 6ee059ba-3681-4625-b859-dff3ed59cfbd:counters │
  └────────────┴────────────┴───────────────────────────────────────────────┘
Timestamp: 2025-12-13 08:53:37.756558+07:00

Device A: Send changes to c39453bb & fd6a5d63

SQLiteData (private.db) nextRecordZoneChangeBatch: scheduled
  ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃ event     ┃ recordType ┃ recordName                                    ┃
  ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
  │ ➡️ Sending │ counters   │ fd6a5d63-f031-48fc-b661-b7aa1d7ceb87:counters │
  │ ➡️ Sending │ counters   │ c39453bb-076d-4498-a30b-1a58f995db2f:counters │
  └───────────┴────────────┴───────────────────────────────────────────────┘
Timestamp: 2025-12-13 08:53:37.776724+07:00
SQLiteData (private.db) sentRecordZoneChanges
  ┏━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃ action  ┃ recordType ┃ recordName                                    ┃
  ┡━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
  │ ✅ Saved │ counters   │ fd6a5d63-f031-48fc-b661-b7aa1d7ceb87:counters │
  │ ✅ Saved │ counters   │ c39453bb-076d-4498-a30b-1a58f995db2f:counters │
  └─────────┴────────────┴───────────────────────────────────────────────┘
Timestamp: 2025-12-13 08:53:38.434067+07:00

Device B: Fetch changes to all touched records

SQLiteData (private.db) fetchedDatabaseChanges
  ┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
  ┃ action     ┃ zoneName                            ┃ ownerName        ┃
  ┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
  │ ✅ Modified │ co.pointfree.SQLiteData.defaultZone │ __defaultOwner__ │
  └────────────┴─────────────────────────────────────┴──────────────────┘
Timestamp: 2025-12-13 08:53:39.692850+07:00
SQLiteData (private.db) fetchedRecordZoneChanges
  ┏━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃ action     ┃ recordType ┃ recordName                                    ┃
  ┡━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
  │ ✅ Modified │ counters   │ 6ee059ba-3681-4625-b859-dff3ed59cfbd:counters │
  │ ✅ Modified │ counters   │ fd6a5d63-f031-48fc-b661-b7aa1d7ceb87:counters │
  │ ✅ Modified │ counters   │ c39453bb-076d-4498-a30b-1a58f995db2f:counters │
  └────────────┴────────────┴───────────────────────────────────────────────┘
Timestamp: 2025-12-13 08:53:40.139455+07:00
I also skimmed through the implementation and the only thing that stood out is that SyncEngine is now aware of the atomicity flag. Since we never want to explicitly configure it to behave atomically, as CKSyncEngine does not guarantee how changes are batched together anyway, I wonder whether it would be more fitting to move the flag into SyncEngine.processPendingRecordZoneChanges(…) and use it to simulate CloudKit’s incorrect behavior in a more contained way, rather than having full support for the flag. That said, it’s more of a detail.

}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func deleteConflictAndNewRecord() async throws {
Copy link
Contributor

@lukaskubanek lukaskubanek Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deleteConflictAndNewRecord() test is failing for me. It’s missing inline snapshots for the assertions, and even after generating them, the second call to processPendingRecordZoneChanges(scope:) has no pending record zone changes to process. In other words, the batch for the composite modification succeeds, with record 1 deleted and record 2 inserted.

I initially planned to propose adding change tag comparison for deletes to the mock implementation of modifyRecords(…), similar to what’s done for saves. However, after testing the live behavior, it appears there’s no special handling for this case.

From my observation, a conflicting update and deletion behaves as follows:

  • Update first: if the update reaches the server first, a subsequent deletion is accepted no matter the change tag, resulting in deletion of the record.
  • Deletion first: if the deletion reaches the server first, a subsequent update results in an .unknownItem partial error. SQLiteData handles this by effectively re-saving the record from scratch and resurrecting the record.

This test falls into the first scenario, which is not expected to produce a partial error, so the test fails. If we want to test deletions as part of an atomicity test, I see two options:

  1. Keep the structure of this test, but swap the update and deletion of record 1. In the composite modification, this would hit the second scenario and produce the .unknownItem error.
  2. Move the conflict to a concurrent update of a record, include a deletion in the composite modification, and have it fail with a .batchRequestFailed partial error.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2. Move the conflict to a concurrent update of a record, include a deletion in the composite modification, and have it fail with a .batchRequestFailed partial error.

Yeah, this is the test I meant to write but did not finish it. I have just pushed an update.

@mbrandonw
Copy link
Member Author

Hi @lukaskubanek, taking the time to look through the PR.

I also skimmed through the implementation and the only thing that stood out is that SyncEngine is now aware of the atomicity flag. Since we never want to explicitly configure it to behave atomically, as CKSyncEngine does not guarantee how changes are batched together anyway, I wonder whether it would be more fitting to move the flag into SyncEngine.processPendingRecordZoneChanges(…) and use it to simulate CloudKit’s incorrect behavior in a more contained way, rather than having full support for the flag.

The atomic flag was just forced the sync engine into this state that we feel is actually buggy on Apple's side. It's not publicly exposed and only tweaked in tests. But, I do think pushing it to processPendingRecordZoneChanges works well too, so just pushed a change for that.

Copy link
Contributor

@lukaskubanek lukaskubanek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me!

I ran another manual test and everything seems to work as before.

return (saveResults: saveResults, deleteResults: deleteResults)
}

return (saveResults: saveResults, deleteResults: deleteResults)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can’t we directly return return storage.withValue { … } rather than storing the result of this expression in additional variables and returning those?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, we can do that now. Think previously we had work done after the withValue, but that's no longer the case. Thanks!

@mbrandonw mbrandonw merged commit 341468a into main Dec 15, 2025
5 checks passed
@mbrandonw mbrandonw deleted the batch-record-failed branch December 15, 2025 18:19
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.

Inserted child record silently dropped when batched with a conflicting parent

4 participants