crossmate

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

commit bc26a85e9ed8b8d2eb3605a9640e7c42427e5803
parent f9583647242fa49b1c146c73dd887d3e0f354db4
Author: Michael Camilleri <[email protected]>
Date:   Thu, 14 May 2026 13:41:46 +0900

Cut redundant CloudKit work

A device-pair log dump showed three overlapping sources of churn on cold launch
and during a typing burst. None affected correctness, but they inflated
CloudKit reads, APN traffic and per-keystroke pushes.

services.start() and the scene-active syncOnForeground both called
ensureICloudSyncStarted on cold launch; with two await suspensions between the
!syncStarted guard and the syncStarted = true assignment, both callers slipped
through and each invoked SyncEngine.start(), recreating engines and double-
firing ensureDatabaseSubscriptions (visible as paired "subscription already
present" log lines). The two paths also ran initial fetch+push and foreground
fetch+push concurrently against the same fresh engine. Now (a)
ensureICloudSyncStarted caches an in-flight Task so concurrent callers converge
on one run, (b) SyncEngine.start() short-circuits when the engines already
exist and (c) services.start() no longer fires its own fetch+push —
scene-active's syncOnForeground handles that for both cold launch and resume.

Separately, PuzzleDisplayView polled the open puzzle every 5s while a session
was active, even when nothing had arrived for minutes. The poll exists to catch
the rare case where CKSyncEngine misses a push, so it can back off while idle.
AppServices now records the timestamp of each inbound silent push and exposes
hadRecentRemoteNotification(within:); the poll runs every 5s while a push has
landed in the last 30s and every 60s otherwise.

Finally, MovesUpdater flushed its buffer every time the focused cell changed,
which turned the 500ms debounce into "debounce within a single cell" — typing
across a word produced one CloudKit save per cell. The visible grid is driven
by the in-memory Puzzle via GameMutator, not by the MovesUpdater flush, so
coalescing across cells is safe. The cell-change flush is removed; the buffer
keys by cell and merges with per-cell enqueuedAt timestamps, so cross-cell
ordering is preserved by per-cell LWW at merge time.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 17+++++++++++++++--
MCrossmate/Services/AppServices.swift | 57+++++++++++++++++++++++++++++++++++++++------------------
MCrossmate/Sync/MovesUpdater.swift | 23+++++++++--------------
MCrossmate/Sync/SyncEngine.swift | 6+++++-
MTests/Unit/MovesUpdaterTests.swift | 21++++++++++-----------
5 files changed, 78 insertions(+), 46 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -305,7 +305,17 @@ private extension UIApplication { /// Loads a game when navigated to. private struct PuzzleDisplayView: View { - private static let syncedPuzzlePollingInterval: Duration = .seconds(5) + /// Tight cadence used while a collaborator burst is in progress (a silent + /// push has arrived recently). Most updates ride the push path directly; + /// this poll just covers the rare cases where CKSyncEngine drops one. + private static let activePollingInterval: Duration = .seconds(5) + /// Long safety-belt cadence used when no pushes have arrived for a while. + /// Trades latency-on-missed-push for far fewer CloudKit reads at idle. + private static let idlePollingInterval: Duration = .seconds(60) + /// How recent a push must be to count as "active". Slightly longer than + /// the active interval so a burst with brief pauses keeps tight polling. + private static let activityWindow: TimeInterval = 30 + private var syncedID: UUID? { preferences.isICloudSyncEnabled && session != nil ? gameID : nil } @@ -503,8 +513,11 @@ private struct PuzzleDisplayView: View { guard let scope = syncedScope else { return } await services.syncOpenPuzzle(gameID: gameID, scope: scope) while !Task.isCancelled { + let interval = services.hadRecentRemoteNotification(within: Self.activityWindow) + ? Self.activePollingInterval + : Self.idlePollingInterval do { - try await Task.sleep(for: Self.syncedPuzzlePollingInterval) + try await Task.sleep(for: interval) } catch { break } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -25,10 +25,19 @@ final class AppServices { private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") private var started = false private var syncStarted = false + /// In-flight `ensureICloudSyncStarted()` work, shared by concurrent + /// callers so the cold-launch race between `services.start()` and a + /// near-simultaneous `syncOnForeground()` doesn't admit two parallel + /// `SyncEngine.start()` runs. + private var syncStartTask: Task<Bool, Never>? private(set) var playerNamePublisher: PlayerNamePublisher? private var isReadyForShareAcceptance = false private var isProcessingShareAcceptanceQueue = false private var pendingShareMetadatas: [CKShare.Metadata] = [] + /// Wall-clock timestamp of the most recent inbound silent push. Lets the + /// open-puzzle live-fetch loop poll quickly during a collaborator burst + /// and back off when nothing has arrived for a while. + private var lastRemoteNotificationAt: Date? init() { let preferences = PlayerPreferences() @@ -200,17 +209,12 @@ final class AppServices { ) guard await ensureICloudSyncStarted() else { - syncMonitor.note("iCloud sync disabled — initial fetch/push skipped") + syncMonitor.note("iCloud sync disabled — engine startup skipped") return } - - await syncMonitor.run("initial fetch") { - try await syncEngine.fetchChanges(source: "initial") - } - await syncMonitor.run("initial push") { - try await syncEngine.pushChanges() - } - await refreshSnapshot() + // The scene-active phase that fires alongside cold launch runs the + // first fetch + push via `syncOnForeground`. Doing it here as well + // would mean two concurrent CKSyncEngine fetches on a fresh engine. } func enqueueShareAcceptance(_ metadata: CKShare.Metadata) async { @@ -281,6 +285,15 @@ final class AppServices { } } + /// True if a silent push has arrived within `window` seconds. Drives the + /// open-puzzle poll cadence — a recent push suggests a collaborator + /// burst, so polling stays tight; otherwise it backs off to a long + /// safety-belt interval. + func hadRecentRemoteNotification(within window: TimeInterval) -> Bool { + guard let last = lastRemoteNotificationAt else { return false } + return Date().timeIntervalSince(last) <= window + } + func syncOpenPuzzle(gameID: UUID, scope: CKDatabase.Scope) async { await movesUpdater.flush() guard await ensureICloudSyncStarted() else { return } @@ -318,6 +331,7 @@ final class AppServices { return } guard await ensureICloudSyncStarted() else { return } + lastRemoteNotificationAt = Date() syncMonitor.note("remote notification: \(summary)") guard let scope, scope != .public else { @@ -401,18 +415,25 @@ final class AppServices { private func ensureICloudSyncStarted() async -> Bool { guard preferences.isICloudSyncEnabled else { return false } guard !syncStarted else { return true } + if let inFlight = syncStartTask { return await inFlight.value } - await identity.refresh(using: ckContainer) - await syncEngine.start() - syncStarted = true + let task = Task { @MainActor in + await identity.refresh(using: ckContainer) + await syncEngine.start() + syncStarted = true - let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves() - if recoveredMoveCount > 0 { - syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue") + let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves() + if recoveredMoveCount > 0 { + syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue") + } + isReadyForShareAcceptance = true + await processPendingShareAcceptances() + return true } - isReadyForShareAcceptance = true - await processPendingShareAcceptances() - return true + syncStartTask = task + let result = await task.value + syncStartTask = nil + return result } private func presentPings(_ pings: [Ping]) async { diff --git a/Crossmate/Sync/MovesUpdater.swift b/Crossmate/Sync/MovesUpdater.swift @@ -1,16 +1,22 @@ import CoreData import Foundation -/// In-memory staging area for cell edits. Coalesces rapid same-cell edits down -/// to one per-cell entry, on flush merges them into the local device's +/// In-memory staging area for cell edits. Coalesces rapid edits — same-cell +/// rewrites collapse to the latest value, cross-cell edits accumulate as +/// distinct entries — and on flush merges them into the local device's /// `MovesEntity` row (per-cell wall-clock LWW), updates the local `CellEntity` /// cache, and hands the affected `gameIDs` to the injected sink so SyncEngine /// can enqueue Moves records for upload. /// /// Flush triggers: /// - trailing-edge debounce (the user has stopped typing); -/// - cell change (focus moves to a different cell); /// - explicit `flush()` (view exit, app background, game completion, tests). +/// +/// The UI does not depend on a flush for typed letters to appear — those go +/// straight into the in-memory `Puzzle` via `GameMutator`. This staging area +/// is purely for durable persistence + CloudKit handoff, so coalescing a full +/// across-or-down word into one flush is a pure win: fewer Core Data writes +/// and one CloudKit push per typing burst instead of one per cell. actor MovesUpdater { private struct Key: Hashable { let gameID: UUID @@ -36,10 +42,6 @@ actor MovesUpdater { private let sleep: @Sendable (Duration) async throws -> Void private var buffer: [Key: Pending] = [:] - /// Cell most recently enqueued. A subsequent enqueue targeting a different - /// cell flushes first so `updatedAt` ordering in the merged grid matches - /// editing order for cells whose wall clocks are within the same tick. - private var lastCell: Key? private var debounceTask: Task<Void, Never>? init( @@ -71,11 +73,6 @@ actor MovesUpdater { actingAuthorID: String? = nil ) async { let key = Key(gameID: gameID, row: row, col: col) - - if let lastCell, lastCell != key { - await performFlush() - } - buffer[key] = Pending( letter: letter, markKind: markKind, @@ -83,7 +80,6 @@ actor MovesUpdater { authorID: authorID, enqueuedAt: Date() ) - lastCell = key scheduleDebounce() } @@ -125,7 +121,6 @@ actor MovesUpdater { let snapshot = buffer buffer.removeAll(keepingCapacity: true) - lastCell = nil guard let affected = persistAndMerge( snapshot: snapshot, diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -155,8 +155,12 @@ actor SyncEngine { /// Creates both `CKSyncEngine` instances, restoring previously-saved state /// so pending changes and change tokens survive restarts. Call once after - /// wiring callbacks. + /// wiring callbacks. Idempotent — extra calls are no-ops so a race between + /// `services.start()` and a scene-active foreground sync can't double- + /// initialise the engines or re-fire subscription setup. func start() { + guard privateEngine == nil, sharedEngine == nil else { return } + let bgCtx = persistence.container.newBackgroundContext() let privateState: CKSyncEngine.State.Serialization? = bgCtx.performAndWait { diff --git a/Tests/Unit/MovesUpdaterTests.swift b/Tests/Unit/MovesUpdaterTests.swift @@ -139,8 +139,8 @@ struct MovesUpdaterTests { #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "C") } - @Test("Enqueuing a different cell flushes the previous cell first") - func cellChangeFlushesPrevious() async throws { + @Test("Cross-cell enqueues coalesce into a single flush") + func crossCellEnqueuesCoalesce() async throws { let (persistence, gameID) = try makePersistenceWithGame() let capture = Capture() let updater = makeUpdater(persistence: persistence, capture: capture) @@ -148,18 +148,17 @@ struct MovesUpdaterTests { await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") await updater.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice") - // Cell-change triggered the first flush. - #expect(await capture.flushCount == 1) - let cellsAfterFirst = try decodedCells(gameID: gameID, persistence: persistence) - #expect(cellsAfterFirst[GridPosition(row: 0, col: 0)]?.letter == "A") - #expect(cellsAfterFirst[GridPosition(row: 0, col: 1)] == nil) + // The cell change should NOT have triggered a flush — both edits are + // still buffered until the debounce (or explicit flush) fires. + #expect(await capture.flushCount == 0) await updater.flush() - let cellsAfterFinal = try decodedCells(gameID: gameID, persistence: persistence) - #expect(cellsAfterFinal.count == 2) - #expect(cellsAfterFinal[GridPosition(row: 0, col: 1)]?.letter == "B") - #expect(await capture.flushCount == 2) + let cells = try decodedCells(gameID: gameID, persistence: persistence) + #expect(cells.count == 2) + #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "A") + #expect(cells[GridPosition(row: 0, col: 1)]?.letter == "B") + #expect(await capture.flushCount == 1) } @Test("Debounce coalesces rapid same-cell enqueues into one flush")