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:
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 {