Fix permanent sync divergence when userModificationTime stamps tie#506
Fix permanent sync divergence when userModificationTime stamps tie#506notcome wants to merge 2 commits into
Conversation
Two devices writing the same field with identical microsecond stamps could diverge permanently: the merge guard's <= let each replica's base value overwrite the incoming committed server value on a tie (both sides conclude "mine wins"), and the fetch path never enqueued an upload for locally preserved values, so the losing device silently self-certified as synced while disagreeing with the server forever. - Inbound merges now require the base field stamp to be strictly newer to overrule an incoming committed server value; on a tie the server's committed value wins, using CloudKit's CAS commit order as the deterministic tie-breaker shared by all replicas. Outgoing record construction keeps <= so local edits whose stamp equals the base stamp still apply. - When a fetched record's merge overrules incoming committed field values (base field strictly newer, e.g. clock skew between writers), a save of that record is now enqueued, mirroring the serverRecordChanged conflict path, so the surviving value is re-asserted to the server rather than silently retained. The enqueue is suppressed for stale replays (incoming record older than the stored base) and does not fire for row-level preservation of pending local edits, whose upload is already owned by the change triggers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
equalTimestampConflictConvergesToServerValue reproduces the original divergence: a fetched record carrying a different value at an equal field stamp must be adopted, leaving device, server, and merge base converged on the committed value. olderTimestampFetchReassertsLocalWinToServer covers the enqueue-on- override path: a later commit carrying an older field stamp loses the merge, and the surviving local value must be pushed back to the server so replicas that never saw it (or fresh syncs) converge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Human message: so Fable was doing some automatic integration testing by hand and hit this issue. At its core it is not hard to understand: if two devices modify at the same time, we will have a tie, and our code is essentially local always wins. We should end up with a ping pong situation, but the reconciliation code on the original winner masks it. The fix is essentially, if values differ and field timestamp coincide, we always treat server record as winner (Change 1). We additionally add an re-enqueue write on conflict from syncing (Change 2). Change 1 is very understandable to me, but the diff is still bigger than I would hope. And Change 2 is arguably a bit over defensive, but it could rescue existing deployment from this bug – if such bug ever materializes, which would be so unlucky. I tried my best to understand your codebase but this is a very intricate matter, so please take everything other than the idea a huge grain of salt. I did do the PR under a fresher context, if that helps boost your confidence. |
|
Hi @notcome, thanks for disclosing your AI use to open this PR and providing a shorter analysis of the situation. But just to confirm, you didn't actually encounter this on real devices, right? It is of course trivial to demonstrate the problem with the test suite because time is controlled, and so easy to perform two saves at the same time, but this happening on two real devices is about as likely as winning the lottery. That doesn't mean this shouldn't be investigated, but I do want to make it clear that any time we put into this is almost certainly chasing a problem that will never happen in reality. Can you explain more how you ran into the problem in an integration test? And as for this PR, I haven't looked at the implementation, but I was interested to see what the tests were trying to show. Unfortunately the tests aren't really testing a real life situation. The first one is emulating a record being updated locally and sync'd to the server, and then a remote client downloads that record and edits it at the same time and sends it back to the server. The chances of that are even less than two devices editing at the same microsecond. And the second test is also doing something illegitimate in that it is directly editing the timestamp in the record rather than going through the I do think a few (legitimate) tests in the suite with some |
Summary
Two devices writing the same field with identical microsecond
userModificationTimestamps can diverge permanently and silently: one device keeps its own value forever while the server and all other devices converge on a different value, and the stuck device reports fully synced. We hit this reproducibly (8/9 scripted trials) with near-simultaneous same-primary-key inserts from two processes — concurrent writers land nanosecond-identical stamps often, since the clock has microsecond resolution and timers coalesce.Mechanism
Two behaviors compound:
CloudKit+StructuredQueries.swift(setValue/setAsset/removeValue) use<=, so on a per-field stamp tie the local/base value overwrites the incoming committed server value. Self-preference is not a total order: both replicas of a tie each conclude "mine wins" and resolve the same tie oppositely.handleFetchedRecordZoneChanges→upsertFromServerRecordpreserves local values over an incoming committed record, no upload is enqueued — the.serverRecordChangedconflict path re-enqueues (SyncEngine.swift), but the fetch path does not.End to end: A and B insert the same key with stamp t. A commits first. B's save conflicts; the tie keeps B's value; the conflict path re-enqueues; B's retry commits — B and the server agree. A then fetches B's commit: the tie keeps A's value, the merge-mutated record (server change tag around A's values) is stored as A's base, and nothing is enqueued. A's row now differs from its base on every future delivery, so the
isRowValueModifiedbranch discards every subsequent server value while advancing the base — a silent absorbing state.The fix
<for inbound merges. The three setters gain arequireStrictlyNewerparameter (defaultfalse, so outgoing record construction — where an equal-stamp local edit must still apply — is unchanged). The inbound merge passestrue: a base field overrules an incoming committed value only when strictly newer. On a tie the committed value wins. CloudKit's CAS chain already totally orders each record's history and replays it identically to every replica, so commit order serves as a deterministic tie-breaker shared by all replicas — no schema change, and safe in a mixed fleet of patched and unpatched clients (a patched device defers on ties; an unpatched peer's conflict path pushes its value; everyone converges on it)..saveRecordis enqueued for that record, mirroring the conflict path, so the surviving value is re-asserted to the server and replicas that never saw it converge. Two deliberate scope limits, both shaped by the existing test suite: stale replays (incoming older than the stored base) are suppressed, since a replay legitimately loses and must not cause churn; and row-level preservation of pending local edits (isRowValueModified) does not enqueue, since those uploads are already owned by the change triggers.Tests
Two regression tests in
MergeConflictTestsusing the existing mocks:equalTimestampConflictConvergesToServerValue: a fetched record carrying a different value at an equal field stamp must be adopted, leaving row, server, and base converged.olderTimestampFetchReassertsLocalWinToServer: a later commit carrying an older field stamp loses the merge, and the surviving local value is enqueued and pushed so the server converges on the LWW winner.Full suite passes (241 tests). With
Sources/reverted tomainand the tests kept, both fail with the expected signatures (device keeps"Mine"over committed"Theirs"; the.saveRecordnever enqueued).One observation for maintainers, made while validating the red case:
MockCloudDatabase.modifyRecordsstores and returns the sameCKRecordinstance (MockCloudDatabase.swift:204-205), so a merge that mutates a fetched record in place also mutates mock server storage — under the buggy code the mock "server" appears to adopt the stale device's value when real CloudKit would not. Happy to file that separately.🤖 Generated with Claude Code