Repository navigation
Description
Description
While experimenting with the library in preparation for #272, I ran into a case where a concurrently edited field diverged between devices.
Let’s consider a single TEXT column of a table (I was using Reminder.title in the Reminders example app) and two devices, A and B, editing the field of the same row concurrently. To simulate a real-world scenario, device A is put offline during the editing phase.
Scenario 1
In the first scenario, device B makes its edit before device A. Everything works as expected. When device A comes online, it’s prevented from sending its value until it downloads device B’s update. Since device A’s change is considered the last write, it’s then correctly propagated to device B.
| Device A | Device B |
|---|---|
foo |
foo |
| offline | |
fooB |
|
| send | |
fooA |
|
| online | |
| send 💥 | |
| fetch | |
| send | |
| fetch | |
fooA |
Scenario 2
In the second scenario, the steps are similar, only the order of edits is swapped. When device A comes online, it also fetches device B’s value. But this time, since device B’s edit is the last write, device A should adopt that value and drop its local change. However, this doesn’t happen. Device A keeps its local value, which causes the SQLite database to diverge from the CloudKit record, as the local value correctly isn’t synced with CloudKit anymore.
| Device A | Device B |
|---|---|
foo |
foo |
| offline | |
fooA |
|
fooB |
|
| send | |
| online | |
| send 💥 | |
| fetch |
|
| send |
|
| fetch |
It seems that device A’s local value is still considered a modification during the fetch. The expected behavior would be for the logic to recognize that the local change is outdated and let it be overwritten by the server value.
This might be better addressed with an abstraction built as part of #272, but I think it’s better to tackle it in piecemeal steps.
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.
Expected behavior
No response
Actual behavior
No response
Reproducing project
No response
SQLiteData version information
main
Sharing version information
No response
GRDB version information
No response
Destination operating system
iOS 18
Xcode version information
26.1 β3
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
Activity
lukaskubanek commentedon Nov 23, 2025
As part of elaborating on the customizable conflict-resolution work in #272, I looked deeper into the divergence described in this issue, especially scenario 2. I initially assumed that device A wouldn’t process the fetch correctly when it had a local modification and would stale it out, but it turns out that device A does send its change to the server. The divergence actually emerges on device B, which doesn’t accept the change when it arrives from the server.
Let’s zoom in on the scenario from the Reminders sample app:
Both devices start with a reminder with ID
3169aa9e-0b40-48af-8f0b-a50c3fb04a72and the title set tofoo. The server also holdsfoo.Device A goes offline.
Device A changes the title of the reminder to
fooA.fooB.fooBto the server. CloudKit then reports the same record back to the device (as it always does after a successful save), but this round-trip is effectively a no-op. The server now holdsfooB.Device A comes back online.
Device A schedules a send that includes its local change. Since it hasn’t yet seen device B’s update, it tries to apply
fooAover the last-known server record valuefoo. But because the server now holdsfooB, the save fails. (Depending on timing, this failure may occur before or after device A receives the server’s updates in the next step.)fooB. Because the local modification is still present andisRowValueModifiedistrue, the upsert logic inSyncEngine.upsertFromServerRecord(…)andCKRecord.update(with:row:columnNames:parentForeignKey:)skips applying the server value to the row in the database. The server now holdsfooB, device A’s last-known server record reflectsfooB, but the row itself still containsfooA.fooB, so the outgoing change is treated as updating fromfooBtofooA. This time the save succeeds. The server, device A’s last-known server record, and the row in the database all now containfooA.fooBin both the last-known server record as well as the row. Although a modification is reported, the upsert is a no-op: updating the title value viaCKRecord.setValue(…)on the fetched server record with values from device B’s last-known server record overwritesfooAwithfooB, becausefooBhas the newer timestamp. Since thedidSetflag is now set, thetitlecolumn is excluded from updates. As a result, the last-known server record ends up storingfooB(even though the server now holdsfooA), and the row also continues to containfooB.Obviously, ending up with diverged values is suboptimal. But the question is what the correct behavior should be here. According to my mental model, we’re running into a conflict in step 7 or 8, be it on send or on fetch, as the last-known server (aka ancestor) version (
foo), the server version (fooB), and the database row (aka client) version (fooA) differ. With a customizable three-way merge per #272, one could plug in any strategy to resolve it, e.g. a string-based diff that producesfooAB. We’re not there yet, but I’d say this logic applies to the current built-in per-field LWW strategy as well. In other words, receiving a newerfooBfrom the server in steps 7 or 8 should replace the local modification.@mbrandonw @stephencelis Does this make sense?
lukaskubanek commentedon Nov 24, 2025
Over in #272, I’ve broken down the exact (non-)conflict scenarios that get executed on each device, ultimately leading to the divergence described in this issue. It comes down to the combination of scenario C1 on device A and scenario NC4 on device B. I also propose some high-level adjustments to how the LWW strategy could be applied to avoid this class of divergence.
lukaskubanek commentedon Nov 11, 2026
Now that #414 is up for review, here is how scenario 2 from above maps onto its tests. The tests run a single sync engine playing device A, so device B’s edits appear as server edits, and “server newer” means device B’s edit is the newer one. Steps 1 to 6 are the shared setup. The tests differ in how device A processes the failed send and the fetch:
sameFieldChange_conflictOnSend_fetchBeforeRetry_serverNewersameFieldChange_conflictOnSend_retryBeforeFetch_serverNewersameFieldChange_conflictOnFetch_serverNewerThe first two orderings (conflict-on-send) pass since #574, as the failed send now stores the outgoing record as the last-known server record, which carries device A’s timestamp into the merge. So the sequence logged above no longer reproduces on
main. The third one (conflict-on-fetch) still fails and is marked as a known issue in #414. It is the case reported in #354, and I have a small fix ready for it that should close both issues.Scenario 1, where device A’s edit is newer, maps onto the three
sameFieldChange_*_clientNewersiblings, which all pass as expected.