crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit df6b54d694a0a9ecc944cf7508c60fa470efcf9e
parent a7ada168088a59eaa5400ac6ff0edd3b9df649c6
Author: Michael Camilleri <[email protected]>
Date:   Sun, 14 Jun 2026 05:34:36 +0900

Re-enqueue versioned Decision saves after a lost tag race

A versioned Decision write (e.g. a nickname rename) that loses the
change-tag race fails with serverRecordChanged. The recovery adopted the
server record's system fields and nudged a resend, but never re-added
the .saveRecord to the engine's pending changes — on the assumption,
stated in the old comment, that a serverRecordChanged failure stays
pending.

It does not. CKSyncEngine auto-retries only transient errors; for an
application-specific error like serverRecordChanged it drops the change
and expects the app to resolve and re-add it (this is what Apple's own
sample-cloudkit-sync-engine does). So the nudged resend went out with an
empty batch: the tag was adopted, nothing was re-sent, 'Pending Changes'
fell to 0, and the overwrite was silently lost — the record stayed a
version behind on the server and the rename never reached the user's
other devices.

This commit carries the failed record's ID through decisionWins and
re-adds `.saveRecord(recordID)` before nudging the send loop. Re-adding
is idempotent (CKSyncEngine dedupes), so it is safe whether or not the
change somehow remained pending, and it converges: once the server
reaches the intended version the conflict falls to the `already present
— settled` branch.

The Game/Moves recovery (recoverServerChangedSave) has the same
omission but is masked, because those records are re-enqueued constantly
by ongoing edits and catch-up; a one-shot Decision has nothing to
re-enqueue it. Left unchanged here.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Sync/SyncEngine.swift | 27+++++++++++++++------------
1 file changed, 15 insertions(+), 12 deletions(-)

diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1766,7 +1766,7 @@ actor SyncEngine { resolvedAccountAddresses, resolvedAccountSecrets, decisionWins): ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>, [String], [(secret: String, version: Int64)], - [(stateKey: String, systemFields: Data)]) = ctx.performAndWait { + [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)]) = ctx.performAndWait { var messages: [String] = [] var orphaned = Set<CKRecordZone.ID>() var settledDecisions = Set<CKRecord.ID>() @@ -1776,7 +1776,7 @@ actor SyncEngine { // Versioned decisions that lost the change-tag race but win on // version: (decision state key, server system fields to adopt for // the overwrite retry). - var decisionWins: [(stateKey: String, systemFields: Data)] = [] + var decisionWins: [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)] = [] for record in event.savedRecords { self.writeBackSystemFields(record: record, in: ctx) let savedName = record.recordID.recordName @@ -1817,11 +1817,12 @@ actor SyncEngine { // A deliberate, newer write (e.g. a rotated push secret // or a rename) that lost only the change-tag race. Adopt // the server's tag so the next build overwrites instead - // of re-colliding, and *keep* the pending change: a - // serverRecordChanged failure stays pending, so the retry - // rides the normal send loop — same shape as - // recoverServerChangedSave for Game/Moves. - decisionWins.append((stateKey, serverFields)) + // of re-colliding, then re-enqueue the save below: a + // `serverRecordChanged` failure does NOT leave the change + // in CKSyncEngine's pending state (the original code + // assumed it did), so the retry must re-add it explicitly + // or the overwrite is silently dropped. + decisionWins.append((failure.record.recordID, stateKey, serverFields)) settled = true messages.append( "send: decision \(name) lost tag race but wins on version " + @@ -1912,15 +1913,17 @@ actor SyncEngine { persistPendingDecisionVersions() } // Adopt the server's tag for each versioned decision we're overwriting, - // then nudge the send loop so the retry (now carrying the tag) goes out + // re-enqueue the save (the failed change is no longer pending), then + // nudge the send loop so the retry (now carrying the tag) goes out // promptly rather than on CKSyncEngine's own cadence. - if !decisionWins.isEmpty { + if !decisionWins.isEmpty, let decisionEngine { for win in decisionWins { decisionSystemFields[win.stateKey] = win.systemFields + decisionEngine.state.add( + pendingRecordZoneChanges: [.saveRecord(win.recordID)] + ) } - if let decisionEngine { - sendChangesDetached(on: decisionEngine) - } + sendChangesDetached(on: decisionEngine) } if let onAccountPushAddress { for address in resolvedAccountAddresses {