crossmate

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

commit 66547492b54fbf359c3d21b8b8f8b7b87958d3cf
parent e5f5fdc8a5ec555784791c4b3f402f7a2467b11a
Author: Michael Camilleri <[email protected]>
Date:   Thu, 14 May 2026 14:24:35 +0900

Catch up Game/Moves after background pushes

A device-pair log showed a private database push waking the receiving device,
but the background path only ran the session scan. That scan intentionally
fetches Ping and Player records, so it can surface activity notifications but
cannot update library thumbnails. The thumbnail did not refresh until a later
foreground CKSyncEngine fetch finally delivered the Moves modification.

Background remote notifications now schedule a per-scope Game/Moves catch-up
after the immediate session scan. The task is debounced by database scope so a
burst of pushes cancels and replaces the pending work, then runs one short
delayed pass and one longer delayed pass. The delay covers the common ordering
where a Player cursor save triggers the silent push before the corresponding
Moves save is visible in CloudKit.

SyncEngine now has a narrower direct fetch for this path. It scans known
incomplete zones, fetches each zone's Game record plus recent Moves records,
and applies them through the existing direct record merge path so cell caches,
open-game refresh and roster notifications keep using the same inbound logic.
Player records are skipped here because the background session scan already
covers them.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 210 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -39,6 +39,8 @@ final class AppServices { /// open-puzzle live-fetch loop poll quickly during a collaborator burst /// and back off when nothing has arrived for a while. private var lastRemoteNotificationAt: Date? + private var privatePushCatchUpTask: Task<Void, Never>? + private var sharedPushCatchUpTask: Task<Void, Never>? init() { let preferences = PlayerPreferences() @@ -376,6 +378,7 @@ final class AppServices { supersedingPings: result.0 ) } + scheduleBackgroundPushCatchUp(scope: scope) await refreshSnapshot() return } @@ -419,6 +422,61 @@ final class AppServices { await refreshSnapshot() } + private func scheduleBackgroundPushCatchUp(scope: CKDatabase.Scope) { + switch scope { + case .private: + privatePushCatchUpTask?.cancel() + privatePushCatchUpTask = makeBackgroundPushCatchUpTask(scope: scope, label: "private") + case .shared: + sharedPushCatchUpTask?.cancel() + sharedPushCatchUpTask = makeBackgroundPushCatchUpTask(scope: scope, label: "shared") + case .public: + return + @unknown default: + return + } + } + + private func makeBackgroundPushCatchUpTask( + scope: CKDatabase.Scope, + label: String + ) -> Task<Void, Never> { + syncMonitor.note("\(label) game/moves catch-up scheduled") + return Task { @MainActor in + await runBackgroundPushCatchUp( + scope: scope, + label: label, + delayNanoseconds: 3_000_000_000, + phaseSuffix: "short" + ) + await runBackgroundPushCatchUp( + scope: scope, + label: label, + delayNanoseconds: 20_000_000_000, + phaseSuffix: "long" + ) + } + } + + private func runBackgroundPushCatchUp( + scope: CKDatabase.Scope, + label: String, + delayNanoseconds: UInt64, + phaseSuffix: String + ) async { + do { + try await Task.sleep(nanoseconds: delayNanoseconds) + } catch { + return + } + guard !Task.isCancelled else { return } + guard await ensureICloudSyncStarted() else { return } + await syncMonitor.run("remote-notification \(label) game/moves catch-up \(phaseSuffix)") { + _ = try await syncEngine.fetchKnownGameMovesDirect(scope: scope) + } + await refreshSnapshot() + } + private func activeGameID(in scope: CKDatabase.Scope) -> UUID? { guard let entity = store.currentEntity, let gameID = entity.id diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -996,6 +996,150 @@ actor SyncEngine { return handled } + /// Background-push catch-up for library freshness. Unlike + /// `fetchKnownZoneUpdatesDirect`, this intentionally skips Player records + /// because the immediate background session scan already covers presence. + /// The delayed caller exists to catch the common ordering where a cursor + /// save triggers the silent push before the corresponding Moves record is + /// visible in CloudKit. + @discardableResult + func fetchKnownGameMovesDirect(scope: CKDatabase.Scope) async throws -> Int { + let database: CKDatabase + let scopeValue: Int16 + let label: String + switch scope { + case .private: + database = container.privateCloudDatabase + scopeValue = 0 + label = "private" + case .shared: + database = container.sharedCloudDatabase + scopeValue = 1 + label = "shared" + case .public: + return 0 + @unknown default: + return 0 + } + + let ctx = persistence.container.newBackgroundContext() + let zones = incompleteKnownZones(forScope: scopeValue, in: ctx) + guard !zones.isEmpty else { + await trace("\(label) game/moves catch-up: no incomplete zones") + return 0 + } + + struct PerZoneGameMoves: Sendable { + let records: [CKRecord] + let gameCount: Int + let moveCount: Int + let orphanedZone: CKRecordZone.ID? + } + let perZone = await withTaskGroup(of: PerZoneGameMoves.self) { group in + for zone in zones { + group.addTask { [weak self] in + guard let self else { + return PerZoneGameMoves( + records: [], + gameCount: 0, + moveCount: 0, + orphanedZone: nil + ) + } + do { + let checkpointKey = "\(scopeValue):\(zone.gameID.uuidString)" + let since = await self.liveQueryCheckpoints[checkpointKey]? + .addingTimeInterval(-self.liveQueryCheckpointOverlap) + let gameRecordID = CKRecord.ID( + recordName: RecordSerializer.recordName(forGameID: zone.gameID), + zoneID: zone.zoneID + ) + async let gameResultsTask = database.records( + for: [gameRecordID], + desiredKeys: ["title", "completedAt", "shareRecordName"] + ) + async let movesTask = self.queryLiveRecords( + type: "Moves", + database: database, + zoneID: zone.zoneID, + since: since, + desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] + ) + let (gameResults, moves) = try await (gameResultsTask, movesTask) + + var records = moves + let gameCount: Int + if case .success(let record)? = gameResults[gameRecordID] { + records.append(record) + gameCount = 1 + } else { + gameCount = 0 + } + + if let latestModification = records.compactMap(\.modificationDate).max() { + await self.setLiveQueryCheckpoint( + latestModification, + scopeValue: scopeValue, + gameID: zone.gameID + ) + } + + return PerZoneGameMoves( + records: records, + gameCount: gameCount, + moveCount: moves.count, + orphanedZone: nil + ) + } catch { + let orphan: CKRecordZone.ID? + if scope == .shared, + self.isInvalidSharedZoneOwnerError(error as NSError) { + orphan = zone.zoneID + } else { + orphan = nil + } + await self.trace( + "\(label) game/moves catch-up: zone \(zone.zoneID.zoneName) failed: " + + "\(error.localizedDescription)" + ) + return PerZoneGameMoves( + records: [], + gameCount: 0, + moveCount: 0, + orphanedZone: orphan + ) + } + } + } + + var all: [PerZoneGameMoves] = [] + for await result in group { + all.append(result) + } + return all + } + + let records = perZone.flatMap(\.records) + await applyDirectRecordZoneChanges( + records: records, + deletions: [], + scopeValue: scopeValue + ) + + let orphans = Set(perZone.compactMap(\.orphanedZone)) + if !orphans.isEmpty { + await applyZoneOrphaning(orphans, isPrivate: scope == .private) + } + + let gameCount = perZone.reduce(0) { $0 + $1.gameCount } + let moveCount = perZone.reduce(0) { $0 + $1.moveCount } + await trace( + "\(label) game/moves catch-up: zones=\(zones.count), " + + "game=\(gameCount), moves=\(moveCount)" + ) + return records.count + } + private func queryLiveRecords( type: CKRecord.RecordType, database: CKDatabase, @@ -1031,6 +1175,14 @@ actor SyncEngine { return records } + private func setLiveQueryCheckpoint( + _ date: Date, + scopeValue: Int16, + gameID: UUID + ) { + liveQueryCheckpoints["\(scopeValue):\(gameID.uuidString)"] = date + } + private func deleteRecords( withIDs recordIDs: [CKRecord.ID], in database: CKDatabase