crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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:
MCrossmate/Sync/SyncEngine.swift | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
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) }