commit e1d073ca8449828b99a5a1e25b787417028ddc03
parent 7bb3505c2b5947202e333bd64d06aeeeb26387b3
Author: Michael Camilleri <[email protected]>
Date: Thu, 14 May 2026 15:39:40 +0900
Coalesce Game List freshness checks
Device logs showed the new Game List freshness path working, but still doing
duplicated CloudKit work. On launch and foreground, appeared and foreground
freshens could overlap; later foreground cycles could start while a previous
private/shared freshen was still finishing. Remote pushes could also overlap
with delayed Game/Moves catch-ups.
AppServices now coalesces freshenGameList work per database scope. Private and
shared refreshes can still run in parallel, but a second private or shared
freshen logs that it was coalesced into the in-flight pass instead of running
duplicate zone discovery and Game/Moves queries.
The delayed remote catch-up path now participates in the same coalescing. The
5-second pass returns the number of Moves records fetched; if it finds Moves,
the 20-second safety pass is skipped. If the short pass finds no Moves, the
long pass still runs to cover the case where a Player-triggered push arrived
before the Moves save became visible in CloudKit.
Also adjust fetchKnownGameMovesDirect to return the fetched Moves count rather
than total Game+Moves records, so callers can distinguish useful move catch-up
from metadata-only refreshes.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
2 files changed, 78 insertions(+), 10 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -59,6 +59,8 @@ final class AppServices {
private var sharedPushCatchUpTask: Task<Void, Never>?
private var isHandlingPrivateRemoteNotification = false
private var isHandlingSharedRemoteNotification = false
+ private var isFresheningPrivateGameList = false
+ private var isFresheningSharedGameList = false
private var isGameListVisible = false
init() {
@@ -373,6 +375,11 @@ final class AppServices {
reason: GameListFreshenReason
) async {
let reasonLabel = reason.diagnosticLabel
+ guard beginGameListFreshen(scope: scope, label: label, reason: reasonLabel) else {
+ return
+ }
+ defer { endGameListFreshen(scope: scope) }
+
await syncMonitor.run("freshen game list \(reasonLabel): \(label) discovery") {
_ = try await self.syncEngine.discoverNewZonesDirect(scope: scope)
}
@@ -381,6 +388,46 @@ final class AppServices {
}
}
+ private func beginGameListFreshen(
+ scope: CKDatabase.Scope,
+ label: String,
+ reason: String
+ ) -> Bool {
+ switch scope {
+ case .private:
+ guard !isFresheningPrivateGameList else {
+ syncMonitor.note("freshen game list \(reason): \(label) coalesced into in-flight freshen")
+ return false
+ }
+ isFresheningPrivateGameList = true
+ return true
+ case .shared:
+ guard !isFresheningSharedGameList else {
+ syncMonitor.note("freshen game list \(reason): \(label) coalesced into in-flight freshen")
+ return false
+ }
+ isFresheningSharedGameList = true
+ return true
+ case .public:
+ return false
+ @unknown default:
+ return false
+ }
+ }
+
+ private func endGameListFreshen(scope: CKDatabase.Scope) {
+ switch scope {
+ case .private:
+ isFresheningPrivateGameList = false
+ case .shared:
+ isFresheningSharedGameList = false
+ case .public:
+ return
+ @unknown default:
+ return
+ }
+ }
+
/// 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
@@ -592,16 +639,23 @@ final class AppServices {
) -> Task<Void, Never> {
syncMonitor.note("\(label) game/moves catch-up scheduled")
return Task { @MainActor in
- await runBackgroundPushCatchUp(
+ let shortMoveCount = await runBackgroundPushCatchUp(
scope: scope,
label: label,
delayNanoseconds: 5_000_000_000,
phaseSuffix: "short"
)
- await runBackgroundPushCatchUp(
+ guard !Task.isCancelled else { return }
+ guard shortMoveCount == 0 else {
+ syncMonitor.note(
+ "\(label) game/moves catch-up long skipped after short fetched \(shortMoveCount) move record(s)"
+ )
+ return
+ }
+ _ = await runBackgroundPushCatchUp(
scope: scope,
label: label,
- delayNanoseconds: 20_000_000_000,
+ delayNanoseconds: 15_000_000_000,
phaseSuffix: "long"
)
}
@@ -612,18 +666,28 @@ final class AppServices {
label: String,
delayNanoseconds: UInt64,
phaseSuffix: String
- ) async {
+ ) async -> Int {
do {
try await Task.sleep(nanoseconds: delayNanoseconds)
} catch {
- return
+ return 0
}
- 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)
+ guard !Task.isCancelled else { return 0 }
+ guard await ensureICloudSyncStarted() else { return 0 }
+ guard beginGameListFreshen(
+ scope: scope,
+ label: label,
+ reason: "remote \(phaseSuffix) catch-up"
+ ) else {
+ return 0
+ }
+ defer { endGameListFreshen(scope: scope) }
+
+ let moveCount = await syncMonitor.run("remote-notification \(label) game/moves catch-up \(phaseSuffix)") {
+ try await syncEngine.fetchKnownGameMovesDirect(scope: scope)
}
await refreshSnapshot()
+ return moveCount ?? 0
}
private func activeGameID(in scope: CKDatabase.Scope) -> UUID? {
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -1002,6 +1002,10 @@ actor SyncEngine {
/// 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.
+ ///
+ /// Returns the number of Moves records fetched. Game records are always
+ /// fetched for metadata freshness, but delayed push catch-up uses the
+ /// Moves count to decide whether a later safety pass is still useful.
@discardableResult
func fetchKnownGameMovesDirect(scope: CKDatabase.Scope) async throws -> Int {
let database: CKDatabase
@@ -1137,7 +1141,7 @@ actor SyncEngine {
"\(label) game/moves catch-up: zones=\(zones.count), " +
"game=\(gameCount), moves=\(moveCount)"
)
- return records.count
+ return moveCount
}
private func queryLiveRecords(