crossmate

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

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:
MCrossmate/Sync/SyncEngine.swift | 28+++++++++++++++++++++++++---
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)