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:
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])
+ }
}