commit 8fc500bb4f657dbf927ee6600cbfae0f8be44dc6
parent 7595556f9aabaccc7702da720321ab2416027258
Author: Michael Camilleri <[email protected]>
Date: Fri, 29 May 2026 15:32:19 +0900
Reduce CloudKit fetch fan-out during collaboration
Prior to this commit, share acceptance, foregrounding, visible-surface
refreshes, remote notifications, and Ping polling could all stack during
the same user moment. Accepting a share and opening the puzzle could
trigger broad CKSyncEngine fetches, direct zone discovery, game/moves
catch-up, ping fast-path scans, active puzzle refresh, and presence
writes within seconds. A server-side 503 in that burst could leave the
device throttled for tens of minutes, which is fatal to live
collaboration.
This commit makes visible surfaces own their own freshness. Foreground
sync now flushes/pushes local work, then skips the broad fetch when the
game list or an active puzzle is about to refresh itself. The puzzle
grid refresh uses a new direct single-game catch-up that reads only the
active zone's Game, Moves, and Player records, falling back to
CKSyncEngine only when the zone is not known locally. Remote
notifications now choose one narrow path: refresh the visible list,
refresh the active puzzle, or discover new zones when cold and defer
broader catch-up.
Share acceptance also stops doing a broad engine fetch. After
CKAcceptSharesOperation succeeds, it runs shared-zone discovery to
materialize the accepted game without asking both engines to synchronize
everything.
Ping is demoted back to durable bootstrap state instead of a live
notification bus. The automatic ping fast-path scans are removed from
foreground, puzzle-open, and remote-notification handling; Ping records
still flow through normal record application and remain manually
available from diagnostics. New clients no longer write .join pings,
and legacy .join/.hail records are treated as system-only. .invite
and .friend remain as the CloudKit fallback/bootstrap mechanisms.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
5 files changed, 129 insertions(+), 161 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -482,7 +482,7 @@ final class AppServices {
// a collaboration. Mirrors the owner path in `onShareSaved` so the
// app is in Settings > Notifications before any inbound moves.
await AppDelegate.requestNotificationAuthorizationIfNeeded()
- await self.enqueueDirectedPings(kind: .join, gameID: gameID)
+ self.syncMonitor.note("share joined: join ping skipped")
}
// PlayerNamePublisher fans out name changes to active shared/joined
@@ -531,30 +531,22 @@ final class AppServices {
if recoveredMoveCount > 0 {
syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue")
}
- await syncMonitor.run("foreground fetch") {
- try await syncEngine.fetchChanges(source: "foreground")
- }
- _ = await fetchPushPingsDirect(
- scope: .private,
- phase: "foreground private ping fast-path",
- pushAfterHandling: false
- )
- _ = await fetchPushPingsDirect(
- scope: .shared,
- phase: "foreground shared ping fast-path",
- pushAfterHandling: false
- )
await syncMonitor.run("foreground push") {
try await syncEngine.pushChanges()
}
if isGameListVisible {
+ syncMonitor.note("foreground fetch skipped: game list will refresh")
await freshenGameList(reason: .foreground)
return
}
if let (gameID, scope) = activePuzzleGridTarget() {
+ syncMonitor.note("foreground fetch skipped: active puzzle will refresh")
await freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .foreground)
return
}
+ await syncMonitor.run("foreground fetch") {
+ try await syncEngine.fetchChanges(source: "foreground")
+ }
await refreshSnapshot()
}
@@ -571,54 +563,6 @@ final class AppServices {
await movesUpdater.flush()
}
- /// Fans a player-facing ping out to every *other* roster player, one
- /// directed copy each (`addressee` = that authorID). Every player-facing
- /// kind goes through here so each recipient consume-deletes its own copy
- /// (see `presentPings`) — there is no central ping cleanup. nil roster
- /// (e.g. a brand-new joiner before the owner's Player record syncs) simply
- /// sends nothing; the lost notice is an accepted eventual-consistency cost.
- private func enqueueDirectedPings(
- kind: PingKind,
- gameID: UUID
- ) async {
- guard preferences.isICloudSyncEnabled,
- let localAuthorID = identity.currentID,
- !localAuthorID.isEmpty
- else { return }
- guard await ensureICloudSyncStarted() else { return }
-
- let ctx = persistence.container.newBackgroundContext()
- let recipients: [String] = ctx.performAndWait {
- let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
- gReq.fetchLimit = 1
- guard let game = try? ctx.fetch(gReq).first else { return [] }
- var authors = Set<String>()
- let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- pReq.predicate = NSPredicate(format: "game == %@", game)
- for p in (try? ctx.fetch(pReq)) ?? [] {
- guard let a = p.authorID else { continue }
- authors.insert(a)
- }
- authors.remove(localAuthorID)
- authors.remove(CKCurrentUserDefaultName)
- authors.remove("")
- return Array(authors)
- }
- guard !recipients.isEmpty else { return }
-
- let playerName = preferences.name
- for recipient in recipients {
- await syncEngine.enqueuePing(
- kind: kind,
- gameID: gameID,
- authorID: localAuthorID,
- playerName: playerName,
- addressee: recipient
- )
- }
- }
-
/// Completion fan-out, delivered through the push worker. Win sets
/// `completedAt`/`completedBy` on the local Game record; resign leaves
/// `completedBy` nil and reveals the remaining cells through the Moves
@@ -649,12 +593,10 @@ final class AppServices {
pushClient.setAddresses(result.addresses)
}
- /// Sender-side session-begin push. Replaces the receiver-side
- /// `SessionMonitor.presentBegins(...)` path: the opening device owns the
+ /// Sender-side session-begin push. The opening device owns the
/// notification timing, so peers get "Alice is solving X" the instant
- /// Alice opens the puzzle. Kind is `play` (rather than `join`) to avoid
- /// collision with the `PingKind.join` invite-accept ping — the user-facing
- /// event is "started playing", not "joined".
+ /// Alice opens the puzzle. Kind is `play` because the user-facing event is
+ /// "started playing", not the durable share-acceptance fact "joined".
func publishSessionBeginPush(gameID: UUID) async {
guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
syncMonitor.note("push(play): skipped (no authorID)")
@@ -1388,39 +1330,17 @@ final class AppServices {
defer { endPuzzleGridFreshen(gameID: gameID, scope: scope) }
await syncMonitor.run("freshen puzzle grid \(label)") {
- let handled = try await syncEngine.fetchChangesForGame(
+ let handled = try await syncEngine.fetchGameDirect(
scope: scope,
- gameID: gameID,
- source: "puzzle grid \(label)"
+ gameID: gameID
)
if !handled {
try await syncEngine.fetchChanges(source: "puzzle grid \(label)")
}
}
- _ = await fetchPushPingsDirect(
- scope: scope,
- phase: "freshen puzzle grid \(label) ping fast-path"
- )
await refreshSnapshot()
}
- @discardableResult
- private func fetchPushPingsDirect(
- scope: CKDatabase.Scope,
- phase: String,
- pushAfterHandling: Bool = true
- ) async -> Int {
- let count = await syncMonitor.run(phase) {
- try await syncEngine.fetchPushPingsDirect(scope: scope)
- } ?? 0
- if pushAfterHandling && count > 0 {
- await syncMonitor.run("\(phase) push") {
- try await syncEngine.pushChanges()
- }
- }
- return count
- }
-
private func beginPuzzleGridFreshen(
gameID: UUID,
scope: CKDatabase.Scope,
@@ -1487,14 +1407,6 @@ final class AppServices {
await syncMonitor.run("remote-notification fetch") {
try await syncEngine.fetchChanges(source: "push")
}
- _ = await fetchPushPingsDirect(
- scope: .private,
- phase: "remote-notification private ping fast-path"
- )
- _ = await fetchPushPingsDirect(
- scope: .shared,
- phase: "remote-notification shared ping fast-path"
- )
await refreshSnapshot()
return
}
@@ -1528,18 +1440,8 @@ final class AppServices {
}
if isGameListVisible {
- async let pingFastPath: Int = fetchPushPingsDirect(
- scope: scope,
- phase: "remote-notification ping fast-path"
- )
- async let gameListFreshen: Void = freshenGameList(
- scope: scope,
- reason: .remote
- )
- _ = await (pingFastPath, gameListFreshen)
- await syncMonitor.run("remote-notification fetch") {
- try await self.syncEngine.fetchChanges(source: "push")
- }
+ syncMonitor.note("remote notification: game list visible, refreshing list only")
+ await freshenGameList(scope: scope, reason: .remote)
scheduleBackgroundPushCatchUp(scope: scope)
await refreshSnapshot()
return
@@ -1550,35 +1452,21 @@ final class AppServices {
// Grid surface owns the direct Game/Moves/Player fetch so push
// handling and open-puzzle polling coalesce instead of duplicating
// the same active-zone query.
- async let activeFetch: Void = freshenPuzzleGrid(
+ syncMonitor.note("remote notification: active puzzle visible, refreshing game only")
+ await freshenPuzzleGrid(
gameID: activeGameID,
scope: scope,
reason: .remote
)
- async let pingFastPath: Int = fetchPushPingsDirect(
- scope: scope,
- phase: "remote-notification ping fast-path"
- )
- _ = await (activeFetch, pingFastPath)
} else {
// Cold path: no puzzle open. Discover any zones this device
// hasn't seen yet (e.g. a freshly-accepted share or a game
- // started on another device of the same iCloud user) so the
- // ping fast-path can resolve pings whose zones are brand new.
+ // started on another device of the same iCloud user). The broader
+ // game/moves catch-up is delayed below so a cold push doesn't fan
+ // out multiple immediate CloudKit read paths.
await syncMonitor.run("remote-notification zone discovery") {
_ = try await self.syncEngine.discoverNewZonesDirect(scope: scope)
}
- async let pingFastPath: Int = fetchPushPingsDirect(
- scope: scope,
- phase: "remote-notification ping fast-path"
- )
- async let gameMovesCatchUp: Void = syncMonitor.run("remote-notification game/moves catch-up") {
- _ = try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope)
- }
- _ = await (pingFastPath, gameMovesCatchUp)
- await syncMonitor.run("remote-notification fetch") {
- try await self.syncEngine.fetchChanges(source: "push")
- }
scheduleBackgroundPushCatchUp(scope: scope)
}
@@ -2096,14 +1984,12 @@ final class AppServices {
let pings = await consumeStaleInvites(claimed)
guard !pings.isEmpty else { return }
applyInvitePings(pings)
- // `.friend` is the friendship-bootstrap handshake. `.hail` is a
- // retired engagement-bootstrap kind (live rooms now rendezvous on the
- // Game record's `engagement` creds); any legacy `.hail` record is
- // ignored here rather than surfaced as an alert. Both are system-only:
- // no alert, no notification authorization dependency. Everything else
- // goes through the alert path below.
+ // `.friend` is the friendship-bootstrap handshake. `.join` and `.hail`
+ // are legacy live-notification/bootstrap kinds; APNs and Game-record
+ // engagement creds own those jobs now. System pings do not require
+ // notification authorization.
let (systemPings, playerFacingPings) = pings.partitioned {
- $0.kind == .friend || $0.kind == .hail
+ $0.kind == .friend || $0.kind == .join || $0.kind == .hail
}
for ping in systemPings where ping.kind == .friend {
await friendController.applyFriendPing(ping)
@@ -2201,14 +2087,12 @@ final class AppServices {
}
nonisolated static func bodyText(for ping: Ping) -> String {
- let player = ping.playerName.isEmpty ? "A player" : ping.playerName
let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'"
switch ping.kind {
- case .join:
- return "\(player) joined \(puzzleSuffix)"
case .invite:
+ let player = ping.playerName.isEmpty ? "A player" : ping.playerName
return "\(player) invited you to \(puzzleSuffix)"
- case .friend, .hail:
+ case .friend, .join, .hail:
// System-only kinds handled by the friendship-bootstrap /
// engagement paths; never presented as a notification. If this
// text surfaces in a log or alert, `presentPings` dispatch has
diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift
@@ -75,9 +75,9 @@ final class CloudService {
op.acceptSharesResultBlock = { result in cont.resume(with: result) }
ckContainer.add(op)
}
- syncMonitor.note("Share accepted — fetching shared zone")
- await syncMonitor.run("share-accept fetch") {
- try await syncEngine.fetchChanges()
+ syncMonitor.note("Share accepted — discovering shared zone")
+ await syncMonitor.run("share-accept shared discovery") {
+ _ = try await syncEngine.discoverNewZonesDirect(scope: .shared)
}
let joinedGameID = store.joinedSharedGameIDs()
.subtracting(existingJoinedGameIDs)
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -406,6 +406,94 @@ extension SyncEngine {
return zonesWithGame
}
+ /// Foreground/open-puzzle catch-up for a single game zone. This is the
+ /// latency-sensitive collaboration path, so it bypasses CKSyncEngine's
+ /// broader fetch and pulls only the records the active grid needs.
+ ///
+ /// Returns `false` when the game zone is not known locally yet, allowing
+ /// the caller to fall back to CKSyncEngine for the first discovery pass.
+ @discardableResult
+ func fetchGameDirect(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 { 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
+ )
+
+ async let gameResultsTask = database.records(
+ for: [gameRecordID],
+ desiredKeys: ["title", "completedAt", "shareRecordName", "puzzleSource"]
+ )
+ 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, moves, players) = try await (gameResultsTask, movesTask, playersTask)
+
+ var records = moves + players
+ 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() {
+ setLiveQueryCheckpoint(
+ latestModification,
+ scopeValue: scopeValue,
+ gameID: gameID
+ )
+ }
+
+ await applyDirectRecordZoneChanges(
+ records: records,
+ deletions: [],
+ scopeValue: scopeValue
+ )
+ await trace(
+ "\(label) game catch-up: \(gameID.uuidString.prefix(8)), " +
+ "game=\(gameCount), moves=\(moves.count), players=\(players.count)"
+ )
+ return true
+ }
+
/// Background-push catch-up for library freshness. Intentionally skips
/// Player records because the immediate background session scan already
/// covers presence.
diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift
@@ -2,13 +2,12 @@ import CloudKit
import Foundation
/// What a Ping record represents. Stored as a string in the CKRecord's
-/// `kind` field. Pings now only cover bootstrap/side-channel events that
-/// don't need APN reliability — the user-facing play events (win/resign/
-/// check/reveal/session-start/session-end) all ride on the push worker.
+/// `kind` field. Pings now cover durable bootstrap/side-channel events that
+/// do not need live APN timing. User-facing play events ride on the push
+/// worker, and simultaneous co-solving rides on engagement state.
enum PingKind: String, Sendable {
- /// Collaborator just accepted a share invite. Written into the joining
- /// device's view of the game zone after a successful CKShare accept.
- /// Surfaces as a "joined" announcement on the inviter's device.
+ /// Legacy collaborator-joined notification. New clients no longer write
+ /// or alert on this kind; it remains parseable for old records.
case join
/// Friendship bootstrap. Written into a shared *game* zone; carries the
/// friend-zone share URL in `payload`. System-only — never user-facing.
@@ -16,9 +15,8 @@ enum PingKind: String, Sendable {
/// Re-invite to a game. Written into a *friend* zone; carries the game's
/// share URL in `payload`. Surfaces in the "Invited" section.
case invite
- /// Engagement room bootstrap. Written into a shared game zone; carries
- /// `{"ver":2,"roomID":"…","secret":"…","createdAt":"…","expiresAt":"…"}`
- /// in `payload` and targets a specific author/device via `addressee`.
+ /// Legacy engagement room bootstrap. Live rooms now rendezvous through
+ /// Game-record engagement credentials; this remains parseable for cleanup.
case hail
}
@@ -31,12 +29,10 @@ struct Ping: Sendable {
let puzzleTitle: String
let kind: PingKind
/// Kind-specific JSON. `.friend`: `{friendShareURL,pairKey,ownerAuthorID}`;
- /// `.invite`: `{gameShareURL}`; `.hail` carries engagement room bootstrap;
- /// nil for `.join`.
+ /// `.invite`: `{gameShareURL}`; legacy `.hail` carried engagement room
+ /// bootstrap; nil for legacy `.join`.
let payload: String?
- /// Recipient authorID for a directed ping. For `.hail` this is
- /// `authorID:deviceID` so only one of an author's devices acts on the
- /// room bootstrap. nil ⇒ broadcast — every recipient acts on it.
+ /// Recipient authorID for a directed ping. nil means broadcast.
let addressee: String?
static func parseRecord(_ record: CKRecord) -> Ping? {
diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift
@@ -15,8 +15,8 @@ struct PuzzleNotificationTextTests {
#expect(title.contains("2001"))
}
- @Test("Join notification body quotes puzzle title")
- func bodyQuotesPuzzleTitle() {
+ @Test("Legacy join ping is system-only")
+ func legacyJoinPingIsSystemOnly() {
let ping = Ping(
recordName: "ping-test-1",
gameID: UUID(),
@@ -29,7 +29,7 @@ struct PuzzleNotificationTextTests {
addressee: nil
)
- #expect(AppServices.bodyText(for: ping) == "Alice joined the puzzle 'Saturday Puzzle – 1 January 2001'")
+ #expect(AppServices.bodyText(for: ping) == "system-only ping should not be presented")
}
@Test("pauseBody combines added and cleared counts when both are non-zero")