commit e906a4d8af0dd987864faad69764bcc58016adad
parent 817e0fda448ddfdd3d06e49f84f3a6e059a4ba00
Author: Michael Camilleri <[email protected]>
Date: Sat, 2 May 2026 07:48:18 +0900
Defer CloudKit move enqueues while typing
Visible lag continues to be observed when entering letters in a puzzle on
device. This commit avoids enqueueing CloudKit changes between every typed
cell. Cell-change flushes still persist moves locally so Lamport ordering is
preserved, but the CloudKit enqueue is deferred until the trailing or explicit
flush. This commit also recovers persisted moves that have not yet been
confirmed by CloudKit when the app starts.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
5 files changed, 140 insertions(+), 17 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -163,6 +163,10 @@ final class AppServices {
)
await syncEngine.start()
+ let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves()
+ if recoveredMoveCount > 0 {
+ syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue")
+ }
isReadyForShareAcceptance = true
await processPendingShareAcceptances()
diff --git a/Crossmate/Sync/MoveBuffer.swift b/Crossmate/Sync/MoveBuffer.swift
@@ -43,6 +43,10 @@ actor MoveBuffer {
/// different cell flushes first; subsequent enqueues for the same cell
/// replace the pending value without flushing.
private var lastCell: Key?
+ /// Moves already persisted locally but not yet handed to CloudKit. Cell
+ /// changes flush for Lamport ordering; CloudKit enqueue waits for a
+ /// trailing/explicit flush so typing does not poke CKSyncEngine per cell.
+ private var pendingSinkMoves: [Move] = []
private var debounceTask: Task<Void, Never>?
/// Per-game timestamp of the last SessionPing fired. The first
/// `enqueue` for a game with no entry — or one stale beyond
@@ -148,18 +152,27 @@ actor MoveBuffer {
}
private func performFlush(runAfterFlush: Bool) async {
- guard !buffer.isEmpty else { return }
+ guard !buffer.isEmpty || (runAfterFlush && !pendingSinkMoves.isEmpty) else { return }
+
+ if !buffer.isEmpty {
+ let snapshot = buffer
+ let snapshotOrder = order
+ buffer.removeAll(keepingCapacity: true)
+ order.removeAll(keepingCapacity: true)
+ lastCell = nil
+
+ pendingSinkMoves.append(contentsOf: persistAndAssignLamports(
+ snapshot: snapshot,
+ order: snapshotOrder
+ ))
+ }
- let snapshot = buffer
- let snapshotOrder = order
- buffer.removeAll(keepingCapacity: true)
- order.removeAll(keepingCapacity: true)
- lastCell = nil
+ guard runAfterFlush, !pendingSinkMoves.isEmpty else { return }
+ let moves = pendingSinkMoves
+ pendingSinkMoves.removeAll(keepingCapacity: true)
- let moves = persistAndAssignLamports(snapshot: snapshot, order: snapshotOrder)
- guard !moves.isEmpty else { return }
await sink(moves)
- if runAfterFlush, let afterFlush {
+ if let afterFlush {
await afterFlush(Set(moves.map { $0.gameID }))
}
}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -146,11 +146,49 @@ actor SyncEngine {
guard let info = zoneInfo(forGameID: gameID, in: ctx) else { continue }
let engine = info.scope == 1 ? sharedEngine : privateEngine
guard let engine else { continue }
- engine.state.add(pendingRecordZoneChanges: gameMoves.map { move in
+ let existingPendingNames = Set(engine.state.pendingRecordZoneChanges.compactMap {
+ if case .saveRecord(let id) = $0 { return id.recordName }
+ return nil
+ })
+ let changes: [CKSyncEngine.PendingRecordZoneChange] = gameMoves.compactMap { move in
let name = RecordSerializer.recordName(forMoveInGame: move.gameID, lamport: move.lamport)
+ guard !existingPendingNames.contains(name) else { return nil }
return .saveRecord(CKRecord.ID(recordName: name, zoneID: info.zoneID))
- })
+ }
+ if !changes.isEmpty {
+ engine.state.add(pendingRecordZoneChanges: changes)
+ }
+ }
+ }
+
+ /// Re-enqueues locally persisted moves that do not yet have CloudKit
+ /// system fields. This closes the small crash window introduced by
+ /// deferring CKSyncEngine enqueue until the trailing edit flush.
+ @discardableResult
+ func enqueueUnconfirmedMoves() -> Int {
+ let ctx = persistence.container.newBackgroundContext()
+ let moves: [Move] = ctx.performAndWait {
+ let req = NSFetchRequest<MoveEntity>(entityName: "MoveEntity")
+ req.predicate = NSPredicate(format: "ckSystemFields == nil")
+ req.sortDescriptors = [NSSortDescriptor(key: "lamport", ascending: true)]
+ let entities = (try? ctx.fetch(req)) ?? []
+ return entities.compactMap { entity in
+ guard let gameID = entity.game?.id else { return nil }
+ return Move(
+ gameID: gameID,
+ lamport: entity.lamport,
+ row: Int(entity.row),
+ col: Int(entity.col),
+ letter: entity.letter ?? "",
+ markKind: entity.markKind,
+ checkedWrong: entity.checkedWrong,
+ authorID: entity.authorID,
+ createdAt: entity.createdAt ?? Date()
+ )
+ }
}
+ enqueueMoves(moves)
+ return moves.count
}
/// Registers record deletions as pending sends. Extracts the game UUID
diff --git a/Tests/Unit/MoveBufferTests.swift b/Tests/Unit/MoveBufferTests.swift
@@ -57,7 +57,7 @@ struct MoveBufferTests {
#expect(moves.first?.lamport == 1)
}
- @Test("Enqueuing a different cell flushes the previous cell first")
+ @Test("Enqueuing a different cell persists the previous cell before CloudKit enqueue")
func cellChangeFlushesPrevious() async throws {
// A long debounce makes this test insensitive to timer jitter:
// only the cell-change trigger (and the final explicit flush) can
@@ -72,14 +72,22 @@ struct MoveBufferTests {
await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil)
await buffer.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", markKind: 0, checkedWrong: false, authorID: nil)
+
+ // The cell-change flush allocates Lamport 1 and writes the prior
+ // cell durably, but CloudKit enqueue is deferred until the explicit
+ // or trailing flush.
+ #expect(await capture.flushCount == 0)
+ let persistedBeforeFinalFlush = fetchMoveValues(gameID: gameID, persistence: persistence)
+ #expect(persistedBeforeFinalFlush.count == 1)
+ #expect(persistedBeforeFinalFlush.first?.letter == "A")
+ #expect(persistedBeforeFinalFlush.first?.lamport == 1)
+
await buffer.flush()
let flushes = await capture.flushes
- #expect(flushes.count == 2)
- #expect(flushes.first?.first?.letter == "A")
- #expect(flushes.first?.first?.lamport == 1)
- #expect(flushes.last?.first?.letter == "B")
- #expect(flushes.last?.first?.lamport == 2)
+ #expect(flushes.count == 1)
+ #expect(flushes.first?.map(\.letter) == ["A", "B"])
+ #expect(flushes.first?.map(\.lamport) == [1, 2])
}
@Test("Lamports are allocated from GameEntity.lamportHighWater and bump it")
diff --git a/Tests/Unit/Sync/ShareRoutingTests.swift b/Tests/Unit/Sync/ShareRoutingTests.swift
@@ -63,6 +63,41 @@ struct ShareRoutingTests {
)
}
+ private func makeEngineWithPersistedMove() async throws -> (SyncEngine, UUID) {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+
+ let gameID = UUID()
+ let game = GameEntity(context: ctx)
+ game.id = gameID
+ game.title = "Private"
+ game.puzzleSource = ""
+ game.createdAt = Date()
+ game.updatedAt = Date()
+ game.ckRecordName = "game-\(gameID.uuidString)"
+ game.ckZoneName = "game-\(gameID.uuidString)"
+ game.databaseScope = 0
+
+ let move = MoveEntity(context: ctx)
+ move.game = game
+ move.lamport = 1
+ move.row = 0
+ move.col = 0
+ move.letter = "A"
+ move.markKind = 0
+ move.checkedWrong = false
+ move.createdAt = Date()
+ move.ckRecordName = RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1)
+
+ try ctx.save()
+
+ let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2")
+ let engine = SyncEngine(container: container, persistence: persistence)
+ await engine.start()
+
+ return (engine, gameID)
+ }
+
@Test("Private-game moves land on the private engine only")
func privateMoveEnqueue() async throws {
let (engine, privateID, _) = try await makeEngineWithGames()
@@ -116,4 +151,29 @@ struct ShareRoutingTests {
#expect(privateNames.isEmpty)
#expect(sharedNames.isEmpty)
}
+
+ @Test("Duplicate move enqueues keep one pending save")
+ func duplicateMoveEnqueueIsDeduped() async throws {
+ let (engine, privateID, _) = try await makeEngineWithGames()
+ let move = move(in: privateID, lamport: 1, letter: "A")
+
+ await engine.enqueueMoves([move])
+ await engine.enqueueMoves([move])
+
+ let recordName = RecordSerializer.recordName(forMoveInGame: privateID, lamport: 1)
+ let privateNames = await engine.pendingSaveRecordNames(scope: .private)
+ #expect(privateNames.filter { $0 == recordName }.count == 1)
+ }
+
+ @Test("Unconfirmed persisted moves are re-enqueued")
+ func unconfirmedPersistedMovesAreReEnqueued() async throws {
+ let (engine, gameID) = try await makeEngineWithPersistedMove()
+
+ let count = await engine.enqueueUnconfirmedMoves()
+
+ let recordName = RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1)
+ let privateNames = await engine.pendingSaveRecordNames(scope: .private)
+ #expect(count == 1)
+ #expect(privateNames.contains(recordName))
+ }
}