commit 4fc8e83ff8147e968aa84826074bb072945a092a
parent c32b4f1b00aee2a86fcf00abd2419f51d1663b29
Author: Michael Camilleri <[email protected]>
Date: Tue, 12 May 2026 19:11:09 +0900
Parallelise the library refresh and skip completed games
The pull-to-refresh action was running serially: for each of the user's known
games it issued three sequential CKDatabase round-trips (Game by ID, Moves
query, Player query), and the per-game blocks ran one after the other. A
library with sixteen active games took roughly sixteen seconds end-to-end, even
when nothing had changed on the server — most of which was spent waiting for
round-trips that could have run at the same time.
Two layers of concurrency address this. Inside fetchPushChangesDirect, the Game
fetch and the Moves/Player queries are now kicked off with `async let` and
joined together, since they're independent and target the same zone. Inside
fetchKnownZoneUpdatesDirect, the per-game calls are now dispatched through a
TaskGroup so all known games fan out concurrently; per-game errors are still
caught and traced so one bad zone doesn't abort the batch. The actor's await
points release isolation between round-trips, so the network operations
actually overlap.
knownGameIDs now also filters out games with a non-nil completedAt. Once a
game is complete no further Moves or Player records can arrive, so refreshing
those zones is wasted work. Completed games are still visible in the library;
they're just skipped during the refresh fan-out.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
1 file changed, 56 insertions(+), 27 deletions(-)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -491,33 +491,39 @@ actor SyncEngine {
recordName: RecordSerializer.recordName(forGameID: gameID),
zoneID: info.zoneID
)
- var records: [CKRecord] = []
- let gameResults = try await database.records(
+ // 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"]
)
- let fetchedGameRecord: Bool
- if case .success(let record)? = gameResults[gameRecordID] {
- records.append(record)
- fetchedGameRecord = true
- } else {
- fetchedGameRecord = false
- }
-
- let moves = try await queryLiveRecords(
+ async let movesTask = queryLiveRecords(
type: "Moves",
database: database,
zoneID: info.zoneID,
since: since,
desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"]
)
- let players = try await queryLiveRecords(
+ async let playersTask = queryLiveRecords(
type: "Player",
database: database,
zoneID: info.zoneID,
since: since,
desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir"]
)
+ 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)
@@ -751,18 +757,36 @@ actor SyncEngine {
return 0
}
- var handled = 0
- for gameID in gameIDs {
- do {
- if try await fetchPushChangesDirect(scope: scope, gameID: gameID) {
- handled += 1
+ // Fan the per-game fetches out concurrently. Each fetchPushChangesDirect
+ // 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.fetchPushChangesDirect(
+ scope: scope,
+ gameID: gameID
+ )
+ } catch {
+ await self.trace(
+ "\(label) known-zone refresh: game " +
+ "\(gameID.uuidString.prefix(8)) failed: " +
+ "\(error.localizedDescription)"
+ )
+ return false
+ }
}
- } catch {
- await trace(
- "\(label) known-zone refresh: game \(gameID.uuidString.prefix(8)) " +
- "failed: \(error.localizedDescription)"
- )
}
+ 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)"
@@ -1125,17 +1149,22 @@ actor SyncEngine {
}
}
- /// Game UUIDs for every locally-known game in the given database scope.
- /// Used by the known-zone refresh path so each game can be routed
- /// through `fetchPushChangesDirect`, which is the existing per-game
- /// direct fetch.
+ /// 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 `fetchPushChangesDirect`. 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.
private nonisolated func knownGameIDs(
forScope scope: Int16,
in ctx: NSManagedObjectContext
) -> [UUID] {
ctx.performAndWait {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(format: "databaseScope == %d", scope)
+ req.predicate = NSPredicate(
+ format: "databaseScope == %d AND completedAt == nil",
+ scope
+ )
guard let entities = try? ctx.fetch(req) else { return [] }
return entities.compactMap(\.id)
}