commit 74ddd12ec4b45a0c07881f0dfd3a1daa98050963
parent 8fc500bb4f657dbf927ee6600cbfae0f8be44dc6
Author: Michael Camilleri <[email protected]>
Date: Fri, 29 May 2026 15:56:43 +0900
Reduce CloudKit polling after fetch fan-out changes
This commit finishes demoting Ping from a live collaboration bus to
durable bootstrap state. Game-list refreshes now run private and shared
scope work serially instead of stacking both scopes at once, and each
scope performs a narrow sequence: discover zones, catch up game/move
records, then scan only friend zones for .invite pings. That keeps
re-invites working without reviving broad Ping polling across every game
zone.
Direct zone discovery is also cheaper. It probes each candidate zone for
its Game record first and only reads Moves/Player when the zone actually
contains a game, avoiding the previous multi-query fan-out against
non-game zones. Background notification handling now reads only Player
records for session presence; Ping records no longer participate in the
background/live notification path.
The .friend Ping remains normal CKSyncEngine bootstrap state, while
.join and .hail stay parseable as legacy system-only records. The app no
longer presents those legacy pings as user notifications, and new
share-join handling skips writing .join entirely.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
2 files changed, 126 insertions(+), 57 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -1154,21 +1154,19 @@ final class AppServices {
func freshenGameList(reason: FreshenReason) async {
guard await ensureICloudSyncStarted() else { return }
- // Private and shared hit different CloudKit databases, so their
- // direct-fetch phases run as an independent pair. Within each
- // scope, discovery still completes before known-zone updates so
- // any zone discovery just added is included in the same refresh.
- async let privatePhase: Void = freshenGameListScope(
+ // The game list is a foreground-visible freshness path, not the live
+ // collaboration path. Keep the two database scopes serialized so list
+ // appearance and foreground transitions do not create a read burst.
+ await freshenGameListScope(
.private,
label: "private",
reason: reason
)
- async let sharedPhase: Void = freshenGameListScope(
+ await freshenGameListScope(
.shared,
label: "shared",
reason: reason
)
- _ = await (privatePhase, sharedPhase)
await refreshSnapshot()
}
@@ -1212,6 +1210,9 @@ final class AppServices {
let catchUpResult: Int? = await syncMonitor.run("freshen game list \(reasonLabel): \(label) game/moves") {
try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope)
}
+ await syncMonitor.run("freshen game list \(reasonLabel): \(label) invites") {
+ _ = try await self.syncEngine.fetchFriendInvitesDirect(scope: scope)
+ }
if catchUpResult != nil {
noteGameListFreshenCompleted(scope: scope)
}
@@ -1421,18 +1422,15 @@ final class AppServices {
try await syncEngine.fetchBackgroundSessionsDirect(scope: scope)
}
if let result {
- await presentPings(result.0)
- if !result.0.isEmpty {
- await syncMonitor.run("remote-notification background ping push") {
- try await syncEngine.pushChanges()
- }
- }
// Session-start notifications now ride on sender-side APNs
// (see `publishSessionBeginPush`); the receiver-side
// `presentBegins` path is no longer wired up. The catch-up
// banner that summarises peer adds/clears still consumes the
// SessionMonitor buckets via `consumeOnOpen` — see
// `handlePuzzleOpened`.
+ if result.isEmpty {
+ syncMonitor.note("remote-notification background session scan: no active sessions")
+ }
}
scheduleBackgroundPushCatchUp(scope: scope)
await refreshSnapshot()
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -3,13 +3,9 @@ import CoreData
import Foundation
extension SyncEngine {
- /// 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
- /// `CKSyncEngine.fetchChanges()` because that path can return without
- /// delivering events from a silent-push wake (same Apple quirk that
- /// motivated `fetchLiveGameDirect`). Moves / Player / Game records are
- /// deliberately left for the engine-driven or foreground fetch.
+ /// Manual/diagnostic fallback for durable Ping records. Normal
+ /// collaboration no longer polls pings on foreground, push, or puzzle
+ /// open; invites and friendship bootstrap ride normal zone application.
@discardableResult
func fetchPushPingsDirect(scope: CKDatabase.Scope) async throws -> Int {
let database: CKDatabase
@@ -148,8 +144,12 @@ extension SyncEngine {
return pings.count
}
+ /// Narrow fallback for game-list invite delivery. Re-invites live in
+ /// pairwise friend zones, so game-list Game/Moves refreshes will never
+ /// see them. This scans only friend zones and only `.invite` records,
+ /// leaving broad Ping polling out of the live collaboration path.
@discardableResult
- func fetchBackgroundSessionsDirect(scope: CKDatabase.Scope) async throws -> ([Ping], [Session]) {
+ func fetchFriendInvitesDirect(scope: CKDatabase.Scope) async throws -> Int {
let database: CKDatabase
let scopeValue: Int16
let label: String
@@ -163,22 +163,109 @@ extension SyncEngine {
scopeValue = 1
label = "shared"
case .public:
- return ([], [])
+ return 0
@unknown default:
- return ([], [])
+ return 0
+ }
+
+ let zoneIDs = friendZoneIDs(forScope: scopeValue)
+ guard !zoneIDs.isEmpty else {
+ await trace("\(label) invite sync: no friend zones")
+ return 0
+ }
+
+ struct PerZoneInvites: Sendable {
+ let pings: [Ping]
+ let orphanedZone: CKRecordZone.ID?
+ }
+ let perZone = await withTaskGroup(of: PerZoneInvites.self) { group in
+ for zoneID in zoneIDs {
+ group.addTask { [weak self] in
+ guard let self else {
+ return PerZoneInvites(pings: [], orphanedZone: nil)
+ }
+ do {
+ let records = try await self.queryRecords(
+ type: "Ping",
+ database: database,
+ zoneID: zoneID,
+ predicate: NSPredicate(format: "kind == %@", PingKind.invite.rawValue),
+ desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload", "addressee"]
+ )
+ return PerZoneInvites(
+ pings: records.compactMap(Ping.parseRecord),
+ orphanedZone: nil
+ )
+ } catch {
+ let orphan: CKRecordZone.ID?
+ if scope == .shared,
+ self.isInvalidSharedZoneOwnerError(error as NSError) {
+ orphan = zoneID
+ } else {
+ orphan = nil
+ }
+ await self.trace(
+ "\(label) invite sync: zone \(zoneID.zoneName) failed: " +
+ "\(error.localizedDescription)"
+ )
+ return PerZoneInvites(pings: [], orphanedZone: orphan)
+ }
+ }
+ }
+
+ var all: [PerZoneInvites] = []
+ for await result in group {
+ all.append(result)
+ }
+ return all
+ }
+
+ let orphans = Set(perZone.compactMap(\.orphanedZone))
+ if !orphans.isEmpty {
+ await applyZoneOrphaning(orphans, isPrivate: scope == .private)
+ }
+
+ let pings = perZone.flatMap(\.pings)
+ await trace("\(label) invite sync: zones=\(zoneIDs.count), invites=\(pings.count)")
+ if !pings.isEmpty, let onPings {
+ await onPings(pings)
+ }
+ return pings.count
+ }
+
+ /// Lightweight background read for session presence. This intentionally
+ /// reads only Player records; Ping records are durable bootstrap state,
+ /// not part of the live/background notification path.
+ @discardableResult
+ func fetchBackgroundSessionsDirect(scope: CKDatabase.Scope) async throws -> [Session] {
+ 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 []
+ @unknown default:
+ return []
}
let ctx = persistence.container.newBackgroundContext()
let zones = incompleteKnownZones(forScope: scopeValue, in: ctx)
guard !zones.isEmpty else {
await trace("\(label) background session scan: no incomplete zones")
- return ([], [])
+ return []
}
let since = Date().addingTimeInterval(-backgroundSessionLookback)
struct PerZoneActivity: Sendable {
let records: [CKRecord]
- let pings: [Ping]
let players: [Session]
let orphanedZone: CKRecordZone.ID?
}
@@ -187,30 +274,21 @@ extension SyncEngine {
for zone in zones {
group.addTask { [weak self] in
guard let self else {
- return PerZoneActivity(records: [], pings: [], players: [], orphanedZone: nil)
+ return PerZoneActivity(records: [], players: [], orphanedZone: nil)
}
do {
- async let pingRecords = self.queryLiveRecords(
- type: "Ping",
- database: database,
- zoneID: zone.zoneID,
- since: since,
- desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload", "addressee"]
- )
- async let playerRecords = self.queryLiveRecords(
+ let playerRecords = try await self.queryLiveRecords(
type: "Player",
database: database,
zoneID: zone.zoneID,
since: since,
desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt"]
)
- let (pings, players) = try await (pingRecords, playerRecords)
- let activities = players.compactMap { record in
+ let activities = playerRecords.compactMap { record in
Session.parseRecord(record, puzzleTitle: zone.title)
}
return PerZoneActivity(
- records: players,
- pings: pings.compactMap(Ping.parseRecord),
+ records: playerRecords,
players: activities,
orphanedZone: nil
)
@@ -228,7 +306,6 @@ extension SyncEngine {
)
return PerZoneActivity(
records: [],
- pings: [],
players: [],
orphanedZone: orphan
)
@@ -256,13 +333,12 @@ extension SyncEngine {
await applyZoneOrphaning(orphans, isPrivate: scope == .private)
}
- let pings = perZone.flatMap(\.pings)
let players = perZone.flatMap(\.players)
await trace(
"\(label) background session scan: zones=\(zones.count), " +
- "players=\(players.count), pings=\(pings.count)"
+ "players=\(players.count)"
)
- return (pings, players)
+ return players
}
/// Discovers games whose zones the device has never seen and pulls their
@@ -329,15 +405,10 @@ extension SyncEngine {
return 0
}
- // Two layers of concurrency. Outer: fan the per-zone work out
- // through a TaskGroup so N candidate zones don't serialize. Inner:
- // fire Game / Moves / Player against each zone with `async let` so
- // a single zone's three round-trips also overlap. The Game query
- // gates whether the zone hosts a Crossmate puzzle, but Moves and
- // Player against a non-puzzle zone simply return empty, so always
- // pulling all three in parallel and discarding when Game is empty
- // is cheaper than waiting on Game first. Per-zone errors are
- // caught and traced so one bad zone doesn't abort the rest.
+ // Probe Game first. Most candidate zones are expected to be Crossmate
+ // zones, but this path also sees friend/account/debug zones; fetching
+ // Moves and Player before proving there is a Game record turns zone
+ // discovery into a three-query fan-out for every non-game zone.
struct PerZoneResult: Sendable {
let records: [CKRecord]
let hasGame: Bool
@@ -349,13 +420,16 @@ extension SyncEngine {
return PerZoneResult(records: [], hasGame: false)
}
do {
- async let games = try await self.queryLiveRecords(
+ let games = try await self.queryLiveRecords(
type: "Game",
database: database,
zoneID: zoneID,
since: nil,
desiredKeys: ["title", "completedAt", "shareRecordName", "puzzleSource"]
)
+ guard !games.isEmpty else {
+ return PerZoneResult(records: [], hasGame: false)
+ }
async let moves = try await self.queryLiveRecords(
type: "Moves",
database: database,
@@ -370,11 +444,8 @@ extension SyncEngine {
since: nil,
desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt"]
)
- let (g, m, p) = try await (games, moves, players)
- guard !g.isEmpty else {
- return PerZoneResult(records: [], hasGame: false)
- }
- return PerZoneResult(records: g + m + p, hasGame: true)
+ let (m, p) = try await (moves, players)
+ return PerZoneResult(records: games + m + p, hasGame: true)
} catch {
await self.trace(
"\(label) zone discovery: zone \(zoneID.zoneName) failed: " +