crossmate

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

commit c9bdc969b929a91466e0b8753dedb389a68c74a3
parent 8dc74c94971c319b40dd6a85562e6a865b988611
Author: Michael Camilleri <[email protected]>
Date:   Wed, 10 Jun 2026 04:09:52 +0900

Stop draining CKSyncEngine on every live Moves edit

enqueueMoves fired sendChangesDetached after each state.add to beat the
framework scheduler's latency -- necessary back when the Moves record was
the only path a peer's letters travelled. The engagement socket now
carries live cellEdit/cellEditBatch updates, so CloudKit is the durable
backstop ('appears on sync'), not the live path. During active typing no
peer watches the durable write in real time, so the per-keystroke drain
buys nothing: it only floods CloudKit with round-trips and lets two
concurrent drains race over the same CKRecord.ID, producing self-induced
oplock (serverRecordChanged) conflicts on byte-identical content.

This commit threads a drain flag through the MovesUpdater sink so the
trailing-edge debounce (live typing) enqueues without forcing a send,
leaving the durable writes to CKSyncEngine's own scheduler to coalesce.
The explicit flush() on leave/background still drains: the solver's final
letters must reach CloudKit promptly even with no peer on the socket,
which is the case 60c2bb2 deliberately preserved. enqueueUnconfirmedMoves
keeps the draining default for crash/relaunch recovery.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 4++--
MCrossmate/Sync/MovesUpdater.swift | 18++++++++++++------
MCrossmate/Sync/SyncEngine.swift | 33++++++++++++++++++++-------------
MTests/Support/TestHelpers.swift | 2+-
MTests/Unit/GameMutatorTests.swift | 2+-
MTests/Unit/MovesUpdaterTests.swift | 41++++++++++++++++++++++++++++++++++-------
6 files changed, 70 insertions(+), 30 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -216,7 +216,7 @@ final class AppServices { debounceInterval: .milliseconds(500), persistence: persistence, writerAuthorIDProvider: { await MainActor.run { identity.currentID } }, - sink: { [persistence] gameIDs in + sink: { [persistence] gameIDs, drain in // MovesUpdater bumps game.updatedAt on a background context. // viewContext.automaticallyMergesChangesFromParent applies that // change in-memory but doesn't reliably fire the ObjectsDidChange @@ -241,7 +241,7 @@ final class AppServices { } let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } guard isEnabled else { return } - await syncEngine.enqueueMoves(gameIDs: gameIDs) + await syncEngine.enqueueMoves(gameIDs: gameIDs, drain: drain) } ) self.movesUpdater = movesUpdater diff --git a/Crossmate/Sync/MovesUpdater.swift b/Crossmate/Sync/MovesUpdater.swift @@ -34,7 +34,13 @@ actor MovesUpdater { private let debounceInterval: Duration private let persistence: PersistenceController private let writerAuthorIDProvider: @Sendable () async -> String? - private let sink: @Sendable (Set<UUID>) async -> Void + /// `drain` tells the sink whether to force a CKSyncEngine send: `true` for + /// an explicit `flush()` (leave/background — the solver's final letters + /// must reach CloudKit promptly even with no peer on the socket), `false` + /// for the trailing-edge debounce (live typing, where the engagement + /// socket already carries the letters and the framework's own scheduler + /// can coalesce the durable writes). + private let sink: @Sendable (_ gameIDs: Set<UUID>, _ drain: Bool) async -> Void /// Sleep primitive used by the debounce timer. Injected so tests can /// drive flushes deterministically instead of racing against wall-clock /// `Task.sleep` from the actor's own task queue. @@ -47,7 +53,7 @@ actor MovesUpdater { debounceInterval: Duration = .milliseconds(500), persistence: PersistenceController, writerAuthorIDProvider: @escaping @Sendable () async -> String?, - sink: @escaping @Sendable (Set<UUID>) async -> Void, + sink: @escaping @Sendable (_ gameIDs: Set<UUID>, _ drain: Bool) async -> Void, sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } ) { self.debounceInterval = debounceInterval @@ -86,7 +92,7 @@ actor MovesUpdater { func flush() async { debounceTask?.cancel() debounceTask = nil - await performFlush() + await performFlush(drain: true) } private func scheduleDebounce() { @@ -102,10 +108,10 @@ actor MovesUpdater { private func debouncedFlush() async { debounceTask = nil - await performFlush() + await performFlush(drain: false) } - private func performFlush() async { + private func performFlush(drain: Bool) async { guard !buffer.isEmpty else { return } // The parent record name embeds the writer's authorID; without it we // can't address the row yet. Keep the buffer intact and retry rather @@ -129,7 +135,7 @@ actor MovesUpdater { return } guard !affected.isEmpty else { return } - await sink(affected) + await sink(affected, drain) } /// Merges buffered edits into the local device's `MovesEntity` row per diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -400,17 +400,24 @@ actor SyncEngine { // MARK: - Outbound - /// Registers each game's local-device Moves record as a pending save and - /// kicks the affected engines to send immediately. Called by the - /// `MovesUpdater` sink after the device's `MovesEntity` row has been - /// merged and persisted; the sink already debounces edits, so this fires - /// at most once per typing burst per game. Routes per-game to the correct - /// engine. Going through `sendChanges()` rather than relying on the - /// framework's own scheduler matters because every keystroke targets the - /// same `CKRecord.ID` — the scheduler treats repeated `state.add` calls - /// as the same already-queued intent and may sit on the change for a - /// while before deciding to ship it. - func enqueueMoves(gameIDs: Set<UUID>) { + /// Registers each game's local-device Moves record as a pending save, + /// routed per-game to the correct engine. Called by the `MovesUpdater` + /// sink after the device's `MovesEntity` row has been merged and persisted. + /// + /// `drain` controls whether to force an eager `sendChanges()`: + /// + /// - `false` (live typing debounce): the engagement socket already carries + /// the letters via `cellEdit`/`cellEditBatch`, so CloudKit is the durable + /// backstop ("appears on sync"), not the live path. With no peer watching + /// the durable write in real time, leaving the drain to CKSyncEngine's + /// automatic scheduler lets it coalesce a typing burst into far fewer + /// round-trips and avoids the self-induced oplock races two concurrent + /// drains produced over the same `CKRecord.ID`. + /// - `true` (explicit `flush()` on leave/background, and recovery via + /// `enqueueUnconfirmedMoves`): the solver's final letters must reach + /// CloudKit promptly even when no peer is on the socket, so force the + /// send rather than waiting for the next foreground. + func enqueueMoves(gameIDs: Set<UUID>, drain: Bool = true) { guard !gameIDs.isEmpty else { return } let ctx = persistence.container.newBackgroundContext() let (privateRecordIDs, sharedRecordIDs): ([CKRecord.ID], [CKRecord.ID]) = ctx.performAndWait { @@ -442,13 +449,13 @@ actor SyncEngine { engine.state.add( pendingRecordZoneChanges: privateRecordIDs.map { .saveRecord($0) } ) - sendChangesDetached(on: engine) + if drain { sendChangesDetached(on: engine) } } if !sharedRecordIDs.isEmpty, let engine = sharedEngine { engine.state.add( pendingRecordZoneChanges: sharedRecordIDs.map { .saveRecord($0) } ) - sendChangesDetached(on: engine) + if drain { sendChangesDetached(on: engine) } } } diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift @@ -23,7 +23,7 @@ func makeTestStore( let updater = movesUpdater ?? MovesUpdater( persistence: persistence, writerAuthorIDProvider: { nil }, - sink: { _ in } + sink: { _, _ in } ) return GameStore( persistence: persistence, diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift @@ -350,7 +350,7 @@ struct GameMutatorTests { debounceInterval: .seconds(10), persistence: persistence, writerAuthorIDProvider: { actingAuthorID }, - sink: { await collector.append($0) } + sink: { ids, _ in await collector.append(ids) } ) let gameID = entity.id ?? UUID() return (game, UpdaterHarness(collector: collector, gameID: gameID), updater, persistence) diff --git a/Tests/Unit/MovesUpdaterTests.swift b/Tests/Unit/MovesUpdaterTests.swift @@ -8,12 +8,15 @@ import Testing @MainActor struct MovesUpdaterTests { - /// Thread-safe collector for sink fan-outs (Set<UUID> per flush). + /// Thread-safe collector for sink fan-outs (affected game IDs plus the + /// drain flag, per flush). actor Capture { - private(set) var flushes: [Set<UUID>] = [] - var allGameIDs: Set<UUID> { flushes.reduce(into: Set()) { $0.formUnion($1) } } - var flushCount: Int { flushes.count } - func append(_ ids: Set<UUID>) { flushes.append(ids) } + private(set) var events: [(ids: Set<UUID>, drain: Bool)] = [] + var flushes: [Set<UUID>] { events.map(\.ids) } + var allGameIDs: Set<UUID> { events.reduce(into: Set()) { $0.formUnion($1.ids) } } + var flushCount: Int { events.count } + var drains: [Bool] { events.map(\.drain) } + func append(_ ids: Set<UUID>, drain: Bool) { events.append((ids, drain)) } } actor MutableAuthor { @@ -51,7 +54,7 @@ struct MovesUpdaterTests { debounceInterval: debounce, persistence: persistence, writerAuthorIDProvider: { writerAuthorID }, - sink: { await capture.append($0) }, + sink: { ids, drain in await capture.append(ids, drain: drain) }, sleep: sleep ) } @@ -271,7 +274,7 @@ struct MovesUpdaterTests { debounceInterval: .seconds(10), persistence: persistence, writerAuthorIDProvider: { await author.current() }, - sink: { await capture.append($0) } + sink: { ids, drain in await capture.append(ids, drain: drain) } ) await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: nil) @@ -287,4 +290,28 @@ struct MovesUpdaterTests { let cells = try decodedCells(gameID: gameID, persistence: persistence) #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "A") } + + @Test("Explicit flush drains; debounce does not") + func flushDrainsDebounceDoesNot() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + // A short debounce so the trailing-edge flush fires on its own. + let updater = makeUpdater( + persistence: persistence, + capture: capture, + debounce: .milliseconds(1) + ) + + // Debounced flush (live typing): the engagement socket carries the + // letters, so the durable write rides CKSyncEngine's own scheduler. + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: nil) + try await Task.sleep(for: .milliseconds(50)) + #expect(await capture.drains == [false]) + + // Explicit flush (leave/background): the solver's final letters must + // reach CloudKit promptly even with no peer on the socket. + await updater.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", mark: .none, authorID: nil) + await updater.flush() + #expect(await capture.drains == [false, true]) + } }