crossmate

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

commit dd54c58cbc1993836b39ae625dd1c265d63dc5f1
parent 9268ce328a22b85134d4fc9a2b4cb8a066f0f11a
Author: Michael Camilleri <[email protected]>
Date:   Mon,  1 Jun 2026 16:59:20 +0900

Settle redundant journal uploads instead of retrying

Diffstat:
MCrossmate/Sync/SyncEngine.swift | 32+++++++++++++++++++++++++++++---
1 file changed, 29 insertions(+), 3 deletions(-)

diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1425,11 +1425,12 @@ actor SyncEngine { } let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - let (failureMessages, orphanedZones, resolvedDecisions): - ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>) = ctx.performAndWait { + let (failureMessages, orphanedZones, resolvedDecisions, settledJournals): + ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>) = ctx.performAndWait { var messages: [String] = [] var orphaned = Set<CKRecordZone.ID>() var settledDecisions = Set<CKRecord.ID>() + var settledJournals = Set<CKRecord.ID>() for record in event.savedRecords { self.writeBackSystemFields(record: record, in: ctx) let savedName = record.recordID.recordName @@ -1461,6 +1462,23 @@ actor SyncEngine { // immutable record doesn't retry-loop forever. settledDecisions.insert(failure.record.recordID) messages.append("send: decision \(name) already present — settled") + } else if name.hasPrefix("journal-"), + err.domain == CKErrorDomain, + err.code == CKError.serverRecordChanged.rawValue, + let (gid, _, _) = RecordSerializer.parseJournalRecordName(name) { + // Journals are write-once at completion: "record to insert + // already exists" means this device's journal is already + // durable server-side (a backstop re-enqueue, or a first + // upload on a build predating the `journalUploaded` flag). + // There is no system-fields archive to adopt for an update + // and the content is frozen, so the re-send is a no-op — + // settle it like a Decision: drop the pending change and + // mark the game uploaded so `reconcilePendingJournalUploads` + // stops re-enqueuing it. Without this the save fails every + // sweep and `Pending Changes` never drains. + settledJournals.insert(failure.record.recordID) + self.markJournalUploaded(gameID: gid, in: ctx) + messages.append("send: journal \(name) already present — settled") } else if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) { messages.append( "send: recovered stale system fields for \(name) from CloudKit server record" @@ -1483,7 +1501,7 @@ actor SyncEngine { ) } } - return (messages, orphaned, settledDecisions) + return (messages, orphaned, settledDecisions, settledJournals) } if !orphanedZones.isEmpty { await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate) @@ -1493,6 +1511,14 @@ actor SyncEngine { pendingRecordZoneChanges: resolvedDecisions.map { .saveRecord($0) } ) } + if !settledJournals.isEmpty { + // Drop from whichever engine owns the zone (private for solo games, + // shared for collaborations) — unlike decisions, journals ride both. + let engine = isPrivate ? privateEngine : sharedEngine + engine?.state.remove( + pendingRecordZoneChanges: settledJournals.map { .saveRecord($0) } + ) + } for message in failureMessages { await trace(message) }