crossmate

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

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:
MCrossmate/Sync/SyncEngine.swift | 95+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
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,