Configurable CloudKit Conflict Resolution Strategy #272
Replies: 1 comment 3 replies
-
|
@lukaskubanek Thanks for the discussion and for thoroughly exploring the library as it exists so far 😄 We are definitely open to configurable conflict resolution. We just felt that per-field last-wins works for 99% of the use cases out there (and is an improvement over SwiftData's all-fields last-wins strategy). If you'd like to explore contributing support for such a feature, we'd love to work with you to help land it! This is probably the place that would need to be instrumented to support customization: sqlite-data/Sources/SQLiteData/CloudKit/SyncEngine.swift Lines 1516 to 1519 in 8874760 And leveraging
The docs don't seem to suggest that's the case, so I did a quick test and So it looks like we have all the necessary data to handle a 3-way merge today. Let us know if you have any questions along the way! |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
SQLiteData in its initial version (1.2.0) handles conflicts arising from CloudKit sync using a per-field last-write-wins (LWW) strategy. The docs mention the possibility of supporting custom conflict resolution in the future. I’m not sure about your upcoming plans or general timeline, but I’d like to kick off a discussion about how this could be approached.
While the per-field LWW strategy serves as a solid default (kudos for going the extra mile compared to SwiftData!), it would be helpful to be able to hook into the conflict resolution with a custom strategy.
Granularity
The first question is what granularity conflicts should be handled at? In other words, what should be considered an atomic unit from the conflict’s point of view? This could be a field, a row, or the entire database. While all levels could be useful depending on the app’s logic and data integrity requirements, I’d argue that handling conflicts at the row level is the right approach, as this is how CloudKit itself sees and reports conflicts. Furthermore, it’s in line with the default strategy already implemented in SQLiteData.
2-Way vs. 3-Way Merge
This implies there could be a hook configured per table via the
SyncEngine, executed whenever a conflict is detected, with the hook producing a merged row using client-provided logic. The hook would receive at least the two conflicting versions: client and server. While such a 2-way merge already enables a lot of flexibility, it would be even more useful to include the common ancestor version as well, allowing for proper 3-way merges.The tricky part is that CloudKit doesn’t provide the ancestor version. TheTurns out, the fields of the ancestor record get populated after all. See this comment for more details.ancestorRecordfound in theuserInfoof aserverRecordChangedCKErroronly includes system fields, while the actual data fields are not populated.Things get even more complicated, because these server rejections are not the only way conflicts can arise. There’s also the inverse scenario: a local row is modified, but before it’s synced with the server, a remote change is received for that same row. (I’m not sure whether this case is already handled in SQLiteData using the default strategy or whether one of the versions gets overwritten by the other.)
Long story short, to support ancestor-based conflict resolution, the ancestor version would need to be tracked locally for each row with pending changes and discarded once the row is successfully synced.
It might remind you of the Git-like branching model from Forked, but in SQLiteData’s case, it’s not necessary to keep track of the entire history, as this is taken care of by CloudKit. The ancestor would only need to be stored temporarily for the purpose of conflict resolution.
TableTypes vs. RawCKRecordsAssuming the conflict resolution hook receives the ancestor, client, and server versions, the next question is whether it should operate on the decoded
Tabletype or on the rawCKRecord.My initial thought was that decoding could cause issues when clients run different schema versions. But as mentioned in your recent episode, SQLiteData preserves unknown fields and reapplies them after migrating the schema. So, having the conflict resolution logic operate on the decoded
Tabletype should be safe and shouldn’t differ from any other update to the row.On the other hand, working with raw
CKRecords would allow for more flexibility, but it could feel too detached, as one might want to express the merge logic in terms of the database schema and model types.Hook Registration
There are other CloudKit abstraction frameworks out there that attach a static conflict resolution method to the model type, which would correspond to
SynchronizableTablein SQLiteData. But I don’t think this is a great idea, as it would tie all tables to a specific conflict resolution strategy. A better approach could be a per-table registry at theSyncEnginelevel, allowing only selected tables to participate. (This could be done in the initializer or the recently introducedSyncEngineDelegate). The consequence is that SQLiteData would only need to track ancestor records for those opted-in tables.@mbrandonw @stephencelis Think of this as an idea dump after briefly exploring the initial version of your library with CloudKit support. I’d love to hear whether this aligns with your vision for custom conflict resolution, or whether you have something else in mind.
Beta Was this translation helpful? Give feedback.
All reactions