commit 57207100d37d1bf528b9438adf40097942e8cbc5
parent df6b54d694a0a9ecc944cf7508c60fa470efcf9e
Author: Michael Camilleri <[email protected]>
Date: Sun, 14 Jun 2026 05:51:18 +0900
Re-enqueue recovered Game/Moves/Player saves after a lost tag race
recoverServerChangedSave heals a non-decision save that lost the
change-tag race by writing the server record's system fields back to
Core Data, but it never re-added the change to the engine's pending
state — the same omission the versioned-Decision path had. A
serverRecordChanged failure leaves nothing pending, so the healed
record only re-sends when some other activity happens to re-enqueue
it.
For keystroke-cadence Moves/Player writes that next enqueue is
effectively immediate, so the gap was masked. A Game record (share
metadata, a completion transition) has no such steady churn: on an idle
or finished game the local change could strand until the game next
changes. No data loss was observed, but relying on incidental
re-enqueueing for correctness is the same assumption that silently
dropped nickname renames.
This commit threads the recovered record IDs out of the send-failure
batch and re-adds .saveRecord to the engine that owns the sent event,
then nudge the send loop — mirroring the decision fix. Re-adding is
idempotent, and it converges: each retry's recoverServerChangedSave
adopts the latest server tag, so once no concurrent writer remains the
save lands.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
1 file changed, 25 insertions(+), 3 deletions(-)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -1763,10 +1763,11 @@ actor SyncEngine {
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let (failureMessages, orphanedZones, resolvedDecisions, settledJournals,
- resolvedAccountAddresses, resolvedAccountSecrets, decisionWins):
+ resolvedAccountAddresses, resolvedAccountSecrets, decisionWins, recoveredSaves):
([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>,
[String], [(secret: String, version: Int64)],
- [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)]) = ctx.performAndWait {
+ [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)],
+ [CKRecord.ID]) = ctx.performAndWait {
var messages: [String] = []
var orphaned = Set<CKRecordZone.ID>()
var settledDecisions = Set<CKRecord.ID>()
@@ -1777,6 +1778,15 @@ actor SyncEngine {
// version: (decision state key, server system fields to adopt for
// the overwrite retry).
var decisionWins: [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)] = []
+ // Game/Moves/Player saves that lost a change-tag race and had the
+ // server's system fields written back: re-enqueued below. Like
+ // decisions, a `serverRecordChanged` failure leaves nothing pending,
+ // so without the re-add these heal only when unrelated activity
+ // happens to re-enqueue the record — reliable enough for the
+ // keystroke-cadence Moves/Player writes, but a Game record (share
+ // metadata, completion) can strand on an idle game until its next
+ // change.
+ var recoveredSaves: [CKRecord.ID] = []
for record in event.savedRecords {
self.writeBackSystemFields(record: record, in: ctx)
let savedName = record.recordID.recordName
@@ -1867,6 +1877,7 @@ actor SyncEngine {
settled = true
messages.append("send: journal \(name) already present — settled")
} else if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) {
+ recoveredSaves.append(failure.record.recordID)
messages.append(
"send: recovered stale system fields for \(name) from CloudKit server record"
)
@@ -1890,7 +1901,7 @@ actor SyncEngine {
}
}
return (messages, orphaned, settledDecisions, settledJournals,
- accountAddresses, accountSecrets, decisionWins)
+ accountAddresses, accountSecrets, decisionWins, recoveredSaves)
}
if !orphanedZones.isEmpty {
await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate)
@@ -1925,6 +1936,17 @@ actor SyncEngine {
}
sendChangesDetached(on: decisionEngine)
}
+ // Re-enqueue Game/Moves/Player saves whose stale system fields we just
+ // healed: a `serverRecordChanged` failure leaves nothing pending (see
+ // `decisionWins`), so the now-current record must be re-added or the
+ // local change waits for the next unrelated enqueue. The engine for
+ // this sent-event is the same one decisions ride.
+ if !recoveredSaves.isEmpty, let engine = isPrivate ? privateEngine : sharedEngine {
+ engine.state.add(
+ pendingRecordZoneChanges: recoveredSaves.map { .saveRecord($0) }
+ )
+ sendChangesDetached(on: engine)
+ }
if let onAccountPushAddress {
for address in resolvedAccountAddresses {
await onAccountPushAddress(address)