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:
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() ?? ()