This document captures how CloudKit sync currently behaves in SQLiteData as of version 1.4, focusing on the implementation of the built-in “field-wise last edit wins” conflict resolution strategy. In particular, it documents how last-known server records and timestamps are handled today. The goal is to provide a clear reference for the current semantics as a first step towards making the conflict resolution customizable per the discussion in #272 and to highlight areas that will need to change in order to support a proper three-way merge and custom merge strategies.
At a high level, SQLiteData’s CloudKit sync operates in two directions, both mediated by an underlying CKSyncEngine:
- Client → Server: Changes are first written to the local SQLite database and later picked up by the sync engine, translated into
CKRecords, and sent to the server. When an upload succeeds, the local database already reflects the desired state, so sync primarily updates metadata. - Server → Client: Changes arriving from the server are applied to the local database using upsert logic, which inserts new rows or updates existing ones. In this direction, incoming server state is reconciled with both the local database state and any pending client changes using the built-in “field-wise last edit wins” strategy. Sync metadata is updated accordingly.
Conflicts arise when client-side and server-side changes affect the same record concurrently. A conflict-on-send scenario is handled explicitly by reacting to a .serverRecordChanged error and performing an upsert. A conflict-on-fetch scenario, on the other hand, is handled implicitly by applying the same upsert logic without an explicit record-level conflict detection.1
The following sections describe the data structures and timestamps that underpin this flow, before diving into concrete send, fetch, and conflict scenarios.
SQLiteData with syncing enabled manages a SyncMetadata table in the metadatabase with a row for each row in the user tables. Data stored in that table is used when interfacing with CloudKit. Among others, the table contains the following columns:
lastKnownServerRecord: The last knownCKRecordreceived from the server serialized viaCKRecord.encodeSystemFields(with:)and thus containing only the system fields._lastKnownServerRecordAllFields: The last knownCKRecordreceived from the server serialized viaCKRecord.encode(with:)and thus containing all fields, including per-field modification timestamps (explained in the next section). As it stands, the last-known server record doesn’t necessarily reflect the server state all the time. There are some nuances that are explained later in the document.userModificationTime: A timestamp indicating when the user last modified the record. This value gets updated whenever the row in the user table is updated, be it by a manual edit or due to a change coming from the sync engine.
SQLiteData’s built-in “field-wise last edit wins” strategy employed during conflict resolution relies on keeping track of modification timestamps for individual fields. This allows SQLiteData to reason about which values should win when the same record has been edited concurrently.
These timestamps are stored directly on the CKRecord. Each field is accompanied by a corresponding modification timestamp, which SQLiteData reads from and writes to using the encryptedValues[at: key] API. In addition, the record carries an overall userModificationTime that reflects the maximum of all per-field timestamps. This should not be confused with CKRecord.modificationDate, which contains the time that CloudKit persisted the record to the server and has no equivalent on the client.
A simplified snapshot of a record with per-field timestamps looks like this:
CKRecord(
recordType: "reminders",
title: "Buy milk",
isCompleted: 1,
sqlitedata_icloud_userModificationTime_title: 60,
sqlitedata_icloud_userModificationTime_isCompleted: 30,
sqlitedata_icloud_userModificationTime: 60
)This section walks through concrete sync scenarios to illustrate how SQLiteData behaves in practice and where assumptions start to break down.
This scenario describes a local change to a row that is sent to the server without any concurrent server-side edits.
- The record is fully in sync. The local database row and the last-known server record reflect the same server state.
- The user modifies the record locally.
- The change is written to the local database.
SyncMetadata.userModificationTimeis updated via a trigger.- In a trigger, the modification is recorded with the sync engine as pending for upload.
- The sync engine picks up the pending change in
SyncEngine.nextRecordZoneChangeBatch(…)and prepares the record to send.- The
CKRecordis created fromSyncMetadata._lastKnownServerRecordAllFields. If no last-known server record exists, a freshCKRecordis created instead. - The local row is then applied to this record using
CKRecord.update(with:userModificationTime:), stamping each modified field with the currentuserModificationTimewhile leaving unchanged fields with their existing timestamps. Even if individual fields were changed at different times, only the latest timestamp is used, so all modified fields end up sharing a single modification time despite timestamps being tracked per field in records.
- The
- Even before the upload is confirmed, SQLiteData attempts to persist the constructed
CKRecordinstance as the last-known server record by callingrefreshLastKnownServerRecord(…). This update only succeeds if no previous last-known server record exists, i.e. when uploading a record for the first time. For previously synced records, the stored last-known server record and the constructed upload record share the samemodificationDate, causing the refresh to be skipped.
Warning
While this behavior does not cause immediate issues in this scenario, it is conceptually incorrect to treat a pending upload record as a record known to the server. To support conflict resolution via a proper three-way merge, the last-known server record must reflect server-acknowledged state, not client intent.
This issue becomes visible in tests, where the mock server does not populate modificationDate when confirming a sent record. As a result, the last-known server record gets updated with an in-flight record, which then breaks when a conflict is encountered. But really, a much bigger problem is that the test setup diverges from production behavior.
- The record is sent to the server and the result is reported in
SyncEngine.handleSentRecordZoneChanges(…).- Since there are no concurrent server-side changes in this scenario, the upload succeeds without conflict.
- The confirmed server record is stored as the new last-known server record, now reflecting state that has been acknowledged by the server.
SyncMetadata.userModificationTimeis updated from the server-confirmed record, but effectively retains the same value that was used when constructing the upload.- No changes are applied to the local database row, as it already reflects the desired state.
This scenario describes a server-side change that is received and applied locally when there are no pending client-side modifications for the record.
- The record is fully in sync. The local database row and the last-known server record reflect the same server state.
- The record is modified on the server from another device and now contains updated field values, updated per-field modification timestamps, and an updated overall user modification timestamp.
- The updated record is delivered to the client and processed in
SyncEngine.handleFetchedRecordZoneChanges(…)as a modification, which is routed toSyncEngine.upsertFromServerRecord(…)to apply the upsert logic.- A corresponding
SyncMetadatarow is ensured to exist, with a populated last-known server record. - The server record’s overall
userModificationTimeis overwritten with the locally stored value fromSyncMetadata, effectively discarding the incoming server-provided timestamp. - The server record is reconciled with both the last-known server record and the current row fetched from the database in
CKRecord.update(with:row:columnNames:parentForeignKey:). This method restores field values from the last-known server record when they (a) differ and (b) their timestamp is newer or equal to those in the incoming server record. It also narrows down the set of columns to be written by excluding columns whose values were restored (didSetflag) as well as columns with pending local edits (isRowValueModifiedflag). In this scenario, however, there are no local edits and the last-known server record does not contain any newer values, so no columns are excluded. - Next, the selected columns are updated on the local database row using values from the server record.
- After this update, a trigger fires and sets
SyncMetadata.userModificationTimeto the current time. - The (potentially mutated) server record is then persisted as the new last-known server record.
- Finally,
SyncMetadata.userModificationTimeis updated again, this time by copying theuserModificationTimefrom the server record.
- A corresponding
This scenario describes a local change to a row that is sent to the server while a concurrent server-side edit exists, resulting in a .serverRecordChanged error.
- The record is fully in sync. The local database row and the last-known server record reflect the same server state.
- The record is modified on the server from another device and now contains updated field values, updated per-field modification timestamps, and an updated overall user modification timestamp.
- The user modifies the record locally, potentially editing different fields than the server change.
- The change is written to the local database.
SyncMetadata.userModificationTimeis updated via a trigger.- In a trigger, the modification is recorded with the sync engine as pending for upload.
- The sync engine picks up the pending change in
SyncEngine.nextRecordZoneChangeBatch(…)and prepares the record to send.- The
CKRecordis created fromSyncMetadata._lastKnownServerRecordAllFields. - The local row is then applied to this record using
CKRecord.update(with:userModificationTime:), stamping each modified field with the currentuserModificationTimewhile leaving unchanged fields with their existing timestamps. refreshLastKnownServerRecord(…)is then invoked with the constructed record. In production, this call is skipped because the existing last-known server record has the samemodificationDate. In tests, however, the check succeeds, causing the client-constructed upload record to be incorrectly persisted as the last-known server record (see 2.1).
- The
- The result is reported in
SyncEngine.handleSentRecordZoneChanges(…)where the send fails with a.serverRecordChangederror that includes the latest server record. This error is handled by routing the server record intoSyncEngine.upsertFromServerRecord(…), which applies the same upsert logic as in section 2.2, but this time, it resolves the conflict between the server state and the pending client change.- The server record’s overall
userModificationTimeis overwritten with the client’s value. - The server record is reconciled with both the last-known server record and the current row fetched from the database in
CKRecord.update(with:row:columnNames:parentForeignKey:). - Inside this method, for each field, two flags are computed:
didSet: Attempts to restore the last-known server record’s value and timestamp to the incoming server record. Returnstrueonly if (a) the values differ and (b) the last-known server record’s timestamp for this field is great than or equal to the server’s timestamp. In production with a clean last-known server record, this typically returnsfalsebecause the last-known server record has older timestamps, so the incoming server’s value is kept.isRowValueModified: Compares the current database row value to the last-known server record’s value, ignoring timestamps. Returnstrueif they differ, indicating pending client edit exists for this field.
- If either
didSetorisRowValueModifiedistrue, the field is excluded from the set of columns to be written into the database. - The marked columns are updated for the database row with values from the (potentially mutated) server record.
- After this update, a trigger fires and sets
SyncMetadata.userModificationTimeto the current time. - The (potentially mutated) server record is saved as the new last-known server record and the
SyncMetadata.userModificationTimeis overwritten with value from the server record.
- The server record’s overall
Warning
In tests, the last-known server record can be polluted with client-constructed state before uploads are confirmed (see section 2.1), causing didSet equal to true to occur more frequently during conflict resolution.
It was observed that didSet being equal to true can occur in production when concurrent edits happen at the exact same timestamp. Because the comparison uses <= (less than or equal), equal timestamps cause the last-known server record’s value to win. This leads to non-deterministic outcomes depending on which device’s change was processed first.
Warning
It’s conceptually inappropriate to save a mutated server record as the last-known one acknowledged by the server. It’s optimistically counting on the server to accept on the next send, but another conflict might be encountered with no access to a clean last-known server record.
Unlike the noop last-known server record refresh on send described in section 2.1, this premature refresh happens in both production and test environments.
- After the conflict is resolved, the failed record is re-queued for upload.
- The sync engine picks up the re-queued record for upload in
SyncEngine.nextRecordZoneChangeBatch(…)using the same logic as step 4. It constructs aCKRecordfrom the updated last-known server record and applies values for fields with pending edits to it. - The sync engine reports the result in
SyncEngine.handleSentRecordZoneChanges(…). If the save succeeds, the sync engine updates the metadata by storing the reported record as the last-known server record.
TODO
Footnotes
-
In contrast to conflict-on-send, conflict-on-fetch scenarios are not explicitly signaled by CloudKit. Detecting such conflicts would require SQLiteData to infer them based on the presence of pending local changes for the record. ↩