crossmate

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

commit c9914c16a1a68b99111130e13e1d7ee0c1df4c7a
parent dc7d434668f1bfc308fedb4da9eb1e03d2476bf6
Author: Michael Camilleri <[email protected]>
Date:   Tue, 26 May 2026 18:31:18 +0900

Simplify sync design

This commit removes the 5s/60s open-puzzle polling loop and the bespoke
per-game CloudKit query it drove, since WebSocket-based engagements now cover
live co-solving. The foreground catch-up path that remains routes through
CKSyncEngine: scenePhase .active and view-appear call freshenPuzzleGrid, which
now dispatches a zone-scoped CKSyncEngine.fetchChanges instead of running
parallel Game/Moves/Player queries against a private checkpoint cursor.

A new SyncEngine.fetchChangesForGame helper performs the zone-scoped fetch
using CKSyncEngine.FetchChangesOptions; records flow through the existing
fetchedRecordZoneChanges delegate path and the engine's change token is the
only checkpoint. fetchLiveGameDirect and the dead fetchKnownZoneUpdatesDirect
(its only fan-out caller) are removed, along with their orphaned helpers
(knownGameIDs, the in-loop FreshenReason.poll case, hadRecentRemoteNotification).

The 5-minute in-session read-cursor heartbeat is also dropped: the lease
firehose was abandoned earlier, Player.readAt drives unread-badge agreement,
and the scenePhase .active/.background republishes still cover open/close
transitions. Ping fast-paths and the library/push catch-up paths are
unchanged and remain for a later cleanup tied to the push-notification
redesign.

Diffstat:
MCrossmate/CrossmateApp.swift | 50++------------------------------------------------
MCrossmate/Services/AppServices.swift | 22++++++----------------
MCrossmate/Sync/CloudQuery.swift | 169++-----------------------------------------------------------------------------
MCrossmate/Sync/CloudZones.swift | 21---------------------
MCrossmate/Sync/SyncEngine.swift | 36++++++++++++++++++++++++++++++++++++
5 files changed, 47 insertions(+), 251 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -363,23 +363,6 @@ private extension UIApplication { /// Loads a game when navigated to. private struct PuzzleDisplayView: View { - /// Tight cadence used while a collaborator burst is in progress (a silent - /// push has arrived recently). Most updates ride the push/socket path - /// directly; this poll just covers the rare cases where CKSyncEngine drops - /// one or no engagement socket is live. - private static let activePollingInterval: Duration = .seconds(5) - /// Long safety-belt cadence used when no pushes have arrived for a while. - /// Trades latency-on-missed-push for far fewer CloudKit reads at idle. - private static let idlePollingInterval: Duration = .seconds(60) - /// Opening a puzzle is the highest-probability moment for an engagement - /// handshake. Simulators and same-account devices do not always deliver a - /// useful CloudKit push, so keep the catch-up poll tight briefly before - /// falling back to the idle cadence. - private static let openPuzzleWarmupInterval: TimeInterval = 2 * 60 - /// How recent a push must be to count as "active". Slightly longer than - /// the active interval so a burst with brief pauses keeps tight polling. - private static let activityWindow: TimeInterval = 30 - private static let readLeaseRefreshInterval: TimeInterval = 5 * 60 /// When opened from an `.invite` ping notification, the game's local /// `GameEntity` does not exist yet: the join path accepts the pending /// CKShare and fetches its zone. Keep showing the join spinner and retry @@ -458,8 +441,8 @@ private struct PuzzleDisplayView: View { .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .task(id: syncedID) { - guard syncedID != nil else { return } - await pollOpenSyncedPuzzle() + guard let scope = syncedScope else { return } + await services.freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .appeared) } .task(id: gameID) { openPuzzleFollowUpTask?.cancel() @@ -717,33 +700,4 @@ private struct PuzzleDisplayView: View { } } - private func pollOpenSyncedPuzzle() async { - guard let scope = syncedScope else { return } - var lastReadLeaseRefresh = Date.distantPast - let openedAt = Date() - await services.freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .appeared) - while !Task.isCancelled { - let isWarm = Date().timeIntervalSince(openedAt) <= Self.openPuzzleWarmupInterval - let interval = isWarm || services.hadRecentRemoteNotification(within: Self.activityWindow) - ? Self.activePollingInterval - : Self.idlePollingInterval - do { - try await Task.sleep(for: interval) - } catch { - break - } - guard !Task.isCancelled else { break } - guard let scope = syncedScope else { break } - let now = Date() - if scenePhase == .active, - now.timeIntervalSince(lastReadLeaseRefresh) >= Self.readLeaseRefreshInterval { - await services.publishReadCursor(for: gameID, mode: .activeLease) - lastReadLeaseRefresh = now - } - if services.engagementStatus.isLive(gameID: gameID) { - continue - } - await services.freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .poll) - } - } } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -30,7 +30,6 @@ final class AppServices { case appeared case foreground case manual - case poll case remote var diagnosticLabel: String { @@ -38,7 +37,6 @@ final class AppServices { case .appeared: return "appeared" case .foreground: return "foreground" case .manual: return "manual" - case .poll: return "poll" case .remote: return "remote" } } @@ -104,9 +102,9 @@ final class AppServices { private var isReadyForShareAcceptance = false private var isProcessingShareAcceptanceQueue = false private var pendingShareMetadatas: [CKShare.Metadata] = [] - /// Wall-clock timestamp of the most recent inbound silent push. Lets the - /// open-puzzle live-fetch loop poll quickly during a collaborator burst - /// and back off when nothing has arrived for a while. + /// Wall-clock timestamp of the most recent inbound silent push. Bypasses + /// the game-list freshen cooldown when a push has arrived since the last + /// freshen, so a collaborator burst isn't held off by debounce. private var lastRemoteNotificationAt: Date? private var privatePushCatchUpTask: Task<Void, Never>? private var sharedPushCatchUpTask: Task<Void, Never>? @@ -946,15 +944,6 @@ final class AppServices { } } - /// True if a silent push has arrived within `window` seconds. Drives the - /// open-puzzle poll cadence — a recent push suggests a collaborator - /// burst, so polling stays tight; otherwise it backs off to a long - /// safety-belt interval. - func hadRecentRemoteNotification(within window: TimeInterval) -> Bool { - guard let last = lastRemoteNotificationAt else { return false } - return Date().timeIntervalSince(last) <= window - } - func freshenPuzzleGrid( gameID: UUID, scope: CKDatabase.Scope, @@ -969,9 +958,10 @@ final class AppServices { defer { endPuzzleGridFreshen(gameID: gameID, scope: scope) } await syncMonitor.run("freshen puzzle grid \(label)") { - let handled = try await syncEngine.fetchLiveGameDirect( + let handled = try await syncEngine.fetchChangesForGame( scope: scope, - gameID: gameID + gameID: gameID, + source: "puzzle grid \(label)" ) if !handled { try await syncEngine.fetchChanges(source: "puzzle grid \(label)") diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -3,96 +3,6 @@ import CoreData import Foundation extension SyncEngine { - func fetchLiveGameDirect(scope: CKDatabase.Scope, gameID: UUID) async throws -> Bool { - 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 false - @unknown default: - return false - } - - let ctx = persistence.container.newBackgroundContext() - guard let info = zoneInfo(forGameID: gameID, in: ctx), - info.scope == scopeValue - else { - await trace("\(label) live query skipped: no active game in scope") - return false - } - - let checkpointKey = "\(scopeValue):\(gameID.uuidString)" - let since = liveQueryCheckpoints[checkpointKey]? - .addingTimeInterval(-liveQueryCheckpointOverlap) - - let gameRecordID = CKRecord.ID( - recordName: RecordSerializer.recordName(forGameID: gameID), - zoneID: info.zoneID - ) - // The Game fetch and the Moves/Player queries are independent CK - // round-trips. Fire them in parallel so total latency is bounded by - // the slowest of the three rather than their sum. - async let gameResultsTask = database.records( - for: [gameRecordID], - desiredKeys: ["title", "completedAt", "shareRecordName"] - ) - async let movesTask = queryLiveRecords( - type: "Moves", - database: database, - zoneID: info.zoneID, - since: since, - desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] - ) - async let playersTask = queryLiveRecords( - type: "Player", - database: database, - zoneID: info.zoneID, - since: since, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt"] - ) - let gameResults = try await gameResultsTask - let moves = try await movesTask - let players = try await playersTask - - var records: [CKRecord] = [] - let fetchedGameRecord: Bool - if case .success(let record)? = gameResults[gameRecordID] { - records.append(record) - fetchedGameRecord = true - } else { - fetchedGameRecord = false - } - records.append(contentsOf: moves) - records.append(contentsOf: players) - - await applyDirectRecordZoneChanges( - records: records, - deletions: [], - scopeValue: scopeValue - ) - - let latestModification = records.compactMap(\.modificationDate).max() - if let latestModification { - liveQueryCheckpoints[checkpointKey] = latestModification - } - - await trace( - "\(label) live query fetch \(gameID.uuidString.prefix(8)): " + - "game=\(fetchedGameRecord ? 1 : 0), " + - "moves=\(moves.count), players=\(players.count)" - ) - return true - } - /// Background-wake fast path for surfacing collaborator notifications. /// Queries every known zone in the given scope for Ping records modified /// since the per-scope checkpoint and feeds them to `onPings`. Bypasses @@ -496,82 +406,9 @@ extension SyncEngine { return zonesWithGame } - /// Pulls incremental updates for every game the device already knows - /// about in the given scope, bypassing CKSyncEngine. Pairs with - /// `discoverNewZonesDirect` so that pull-to-refresh covers both halves - /// of "what might have changed elsewhere": new zones *and* updates to - /// existing ones. Each game is dispatched to the existing - /// `fetchLiveGameDirect`, which uses the `liveQueryCheckpoints` - /// cursor so we only pull Moves/Player records newer than the last - /// direct fetch. Per-game errors are caught and traced so one bad zone - /// doesn't abort the rest. - /// - /// Returns the number of games for which the direct fetch reported - /// records were applied. - @discardableResult - func fetchKnownZoneUpdatesDirect(scope: CKDatabase.Scope) async throws -> Int { - let scopeValue: Int16 - let label: String - switch scope { - case .private: - scopeValue = 0 - label = "private" - case .shared: - scopeValue = 1 - label = "shared" - case .public: - return 0 - @unknown default: - return 0 - } - - let ctx = persistence.container.newBackgroundContext() - let gameIDs = knownGameIDs(forScope: scopeValue, in: ctx) - guard !gameIDs.isEmpty else { - await trace("\(label) known-zone refresh: no known games") - return 0 - } - - // Fan the per-game fetches out concurrently. Each fetchLiveGameDirect - // call hits a different zone with a different checkpoint key, so they - // don't race on shared state. The actor still serializes access to - // liveQueryCheckpoints at non-await points, but the actual CK round- - // trips overlap, turning a serial 1s-per-game wait into a single - // parallel batch. - let handled = await withTaskGroup(of: Bool.self) { group in - for gameID in gameIDs { - group.addTask { [weak self] in - guard let self else { return false } - do { - return try await self.fetchLiveGameDirect( - scope: scope, - gameID: gameID - ) - } catch { - await self.trace( - "\(label) known-zone refresh: game " + - "\(gameID.uuidString.prefix(8)) failed: " + - "\(error.localizedDescription)" - ) - return false - } - } - } - var count = 0 - for await result in group where result { - count += 1 - } - return count - } - await trace( - "\(label) known-zone refresh: games=\(gameIDs.count), handled=\(handled)" - ) - 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. + /// Background-push catch-up for library freshness. 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. diff --git a/Crossmate/Sync/CloudZones.swift b/Crossmate/Sync/CloudZones.swift @@ -35,27 +35,6 @@ extension SyncEngine { } } - /// Game UUIDs for every locally-known *in-progress* game in the given - /// database scope. Used by the known-zone refresh path so each game can - /// be routed through `fetchLiveGameDirect`. Games with a non-nil - /// `completedAt` are excluded: once a game is completed no further moves - /// or player updates can arrive, so refreshing those zones is wasted - /// round-trips. - nonisolated func knownGameIDs( - forScope scope: Int16, - in ctx: NSManagedObjectContext - ) -> [UUID] { - ctx.performAndWait { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate( - format: "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO", - scope - ) - guard let entities = try? ctx.fetch(req) else { return [] } - return entities.compactMap(\.id) - } - } - /// Enumerates every known game zone for the given database scope, paired /// with the `createdAt` of the corresponding GameEntity. The createdAt /// timestamp is used as the per-zone floor for the ping fast path: pings diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -842,6 +842,42 @@ actor SyncEngine { _ = try await (p, s) } + /// Zone-scoped fetch for a single game. Returns `false` if the game's zone + /// isn't known locally (e.g. a freshly-invited share before its zone has + /// landed) so the caller can fall back to a full `fetchChanges`. Records + /// arrive via the normal `fetchedRecordZoneChanges` delegate path; the + /// engine's change token is the only checkpoint. + func fetchChangesForGame( + scope: CKDatabase.Scope, + gameID: UUID, + source: String = "manual" + ) async throws -> Bool { + let engine: CKSyncEngine? + let scopeValue: Int16 + switch scope { + case .private: + engine = privateEngine + scopeValue = 0 + case .shared: + engine = sharedEngine + scopeValue = 1 + case .public: + return false + @unknown default: + return false + } + guard let engine else { return false } + let ctx = persistence.container.newBackgroundContext() + guard let info = zoneInfo(forGameID: gameID, in: ctx), + info.scope == scopeValue + else { return false } + currentFetchSource = source + defer { currentFetchSource = nil } + let options = CKSyncEngine.FetchChangesOptions(scope: .zoneIDs([info.zoneID])) + try await engine.fetchChanges(options) + return true + } + func pushChanges() async throws { async let p: Void = privateEngine?.sendChanges() ?? () async let s: Void = sharedEngine?.sendChanges() ?? ()