commit 7434e62513449d783bce6112fbaee72a21a5f4a6
parent f1b12ffae6f3ee74572bf538ceb23149b3b333cc
Author: Michael Camilleri <[email protected]>
Date: Wed, 13 May 2026 04:48:46 +0900
Parallelise discoverNewZonesDirect across zones and record types
The zone-discovery path was doubly serial: candidate zones were processed one
at a time, and within each zone the Game / Moves / Player queries ran
back-to-back. For a device first encountering ten unseen shared games that
worked out to thirty sequential CKDatabase round-trips before the library
populated — the worst case being a fresh install that just accepted a few
shares.
Two layers of concurrency now apply. The per-zone work is dispatched through a
TaskGroup so the zones fan out instead of queueing. Within each zone the three
record-type queries are kicked off with async let; the Game query still gates
whether the zone is treated as hosting a Crossmate puzzle, but Moves and Player
against a non-puzzle zone return empty, so firing all three in parallel and
discarding when Game is empty is cheaper than waiting on Game first. Per-zone
errors are caught and traced rather than propagated, matching the pattern used
by fetchKnownZoneUpdatesDirect and fetchPushPingsDirect, so one bad zone
doesn't abort the batch.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
1 file changed, 62 insertions(+), 33 deletions(-)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -698,40 +698,69 @@ actor SyncEngine {
return 0
}
- var collected: [CKRecord] = []
- var zonesWithGame = 0
- for zoneID in candidates {
- // Query rather than guess the gameID from the zone name so this
- // doesn't depend on the "game-<UUID>" zone-naming convention.
- // One Game per zone, but a query keeps the contract symmetric
- // with Moves/Player below.
- let games = try await queryLiveRecords(
- type: "Game",
- database: database,
- zoneID: zoneID,
- since: nil,
- desiredKeys: ["title", "completedAt", "shareRecordName", "puzzleSource"]
- )
- guard !games.isEmpty else { continue }
- zonesWithGame += 1
- let moves = try await queryLiveRecords(
- type: "Moves",
- database: database,
- zoneID: zoneID,
- since: nil,
- desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"]
- )
- let players = try await queryLiveRecords(
- type: "Player",
- database: database,
- zoneID: zoneID,
- since: nil,
- desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir"]
- )
- collected.append(contentsOf: games)
- collected.append(contentsOf: moves)
- collected.append(contentsOf: players)
+ // 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.
+ struct PerZoneResult: Sendable {
+ let records: [CKRecord]
+ let hasGame: Bool
+ }
+ let perZoneResults = await withTaskGroup(of: PerZoneResult.self) { group in
+ for zoneID in candidates {
+ group.addTask { [weak self] in
+ guard let self else {
+ return PerZoneResult(records: [], hasGame: false)
+ }
+ do {
+ async let games = try await self.queryLiveRecords(
+ type: "Game",
+ database: database,
+ zoneID: zoneID,
+ since: nil,
+ desiredKeys: ["title", "completedAt", "shareRecordName", "puzzleSource"]
+ )
+ async let moves = try await self.queryLiveRecords(
+ type: "Moves",
+ database: database,
+ zoneID: zoneID,
+ since: nil,
+ desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"]
+ )
+ async let players = try await self.queryLiveRecords(
+ type: "Player",
+ database: database,
+ zoneID: zoneID,
+ since: nil,
+ desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir"]
+ )
+ 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)
+ } catch {
+ await self.trace(
+ "\(label) zone discovery: zone \(zoneID.zoneName) failed: " +
+ "\(error.localizedDescription)"
+ )
+ return PerZoneResult(records: [], hasGame: false)
+ }
+ }
+ }
+ var all: [PerZoneResult] = []
+ for await result in group {
+ all.append(result)
+ }
+ return all
}
+ let collected: [CKRecord] = perZoneResults.flatMap(\.records)
+ let zonesWithGame = perZoneResults.reduce(into: 0) { $0 += $1.hasGame ? 1 : 0 }
await applyDirectRecordZoneChanges(
records: collected,