crossmate

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

commit 5584d392904122d419c77aac0e7985fc5904c957
parent 03a62dda1ebaa53ef026bffe48e30cbb385b2866
Author: Michael Camilleri <[email protected]>
Date:   Sat,  2 May 2026 02:47:38 +0900

Improve responsiveness in puzzle view

There is visible lag when entering letters in a puzzle. This commit
attempts to avoid some blocking behaviour on the main actor that may be
interrupting rendering.

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

Diffstat:
MCrossmate/Persistence/GameStore.swift | 244++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
MCrossmate/Sync/MoveBuffer.swift | 15++++++++++-----
MTests/Unit/GameStoreSnapshotPruningTests.swift | 15+++++++++------
3 files changed, 168 insertions(+), 106 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -140,45 +140,54 @@ final class GameStore { /// Replays the move log for each game ID and updates the `CellEntity` /// cache so that list thumbnails reflect local edits immediately after a - /// `MoveBuffer` flush, without waiting for the next sync cycle. - func replayCellCaches(for gameIDs: Set<UUID>) { - for gameID in gameIDs { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - req.fetchLimit = 1 - guard let entity = try? context.fetch(req).first else { continue } + /// `MoveBuffer` flush, without waiting for the next sync cycle. The work + /// runs on a background context so the main actor isn't pinned while + /// fetching and replaying potentially-long move logs. + func replayCellCaches(for gameIDs: Set<UUID>) async { + let bgCtx = persistence.container.newBackgroundContext() + bgCtx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + await bgCtx.perform { + for gameID in gameIDs { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + guard let entity = try? bgCtx.fetch(req).first else { continue } + + let snapReq = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") + snapReq.predicate = NSPredicate(format: "game == %@", entity) + let snapshots: [Snapshot] = ((try? bgCtx.fetch(snapReq)) ?? []).compactMap { se in + guard let data = se.gridState, + let grid = try? MoveLog.decodeGridState(data) else { return nil } + return Snapshot( + gameID: gameID, + upToLamport: se.upToLamport, + grid: grid, + createdAt: se.createdAt ?? Date() + ) + } - let snapReq = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - snapReq.predicate = NSPredicate(format: "game == %@", entity) - let snapshots: [Snapshot] = ((try? context.fetch(snapReq)) ?? []).compactMap { se in - guard let data = se.gridState, - let grid = try? MoveLog.decodeGridState(data) else { return nil } - return Snapshot( - gameID: gameID, - upToLamport: se.upToLamport, - grid: grid, - createdAt: se.createdAt ?? Date() - ) - } + let moveReq = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") + moveReq.predicate = NSPredicate(format: "game == %@", entity) + let moves: [Move] = ((try? bgCtx.fetch(moveReq)) ?? []).map { me in + Move( + gameID: gameID, + lamport: me.lamport, + row: Int(me.row), + col: Int(me.col), + letter: me.letter ?? "", + markKind: me.markKind, + checkedWrong: me.checkedWrong, + authorID: me.authorID, + createdAt: me.createdAt ?? Date() + ) + } - let moveReq = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") - moveReq.predicate = NSPredicate(format: "game == %@", entity) - let moves: [Move] = ((try? context.fetch(moveReq)) ?? []).map { me in - Move( - gameID: gameID, - lamport: me.lamport, - row: Int(me.row), - col: Int(me.col), - letter: me.letter ?? "", - markKind: me.markKind, - checkedWrong: me.checkedWrong, - authorID: me.authorID, - createdAt: me.createdAt ?? Date() - ) + let grid = MoveLog.replay(snapshot: MoveLog.latestSnapshot(from: snapshots), moves: moves) + Self.applyCellCache(to: entity, from: grid, in: bgCtx) + } + if bgCtx.hasChanges { + try? bgCtx.save() } - - let grid = MoveLog.replay(snapshot: MoveLog.latestSnapshot(from: snapshots), moves: moves) - updateCellCache(for: entity, from: grid) } } @@ -222,66 +231,70 @@ final class GameStore { /// snapshots. func createSnapshotsIfNeeded( for gameIDs: Set<UUID> - ) -> (snapshotNames: [String], prunedMoveNames: [String]) { - var snapshotNames: [String] = [] - let prunedMoveNames = pruneMoves() - - for gameID in gameIDs { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - req.fetchLimit = 1 - guard let entity = try? context.fetch(req).first else { continue } - - let allMoves = (entity.moves as? Set<MoveEntity>) ?? [] - let allSnapshots = (entity.snapshots as? Set<SnapshotEntity>) ?? [] - let latestCoveredLamport = allSnapshots.map(\.upToLamport).max() ?? 0 - let uncoveredCount = allMoves.filter { $0.lamport > latestCoveredLamport }.count - let highWater = entity.lamportHighWater - - let shouldSnapshot = (entity.completedAt != nil || uncoveredCount >= 200) - && highWater > latestCoveredLamport - guard shouldSnapshot else { continue } - - let snapshots: [Snapshot] = allSnapshots.compactMap { se in - guard let data = se.gridState, - let grid = try? MoveLog.decodeGridState(data) else { return nil } - return Snapshot( - gameID: gameID, - upToLamport: se.upToLamport, - grid: grid, - createdAt: se.createdAt ?? Date() - ) - } - let moves: [Move] = allMoves.map { me in - Move( - gameID: gameID, - lamport: me.lamport, - row: Int(me.row), - col: Int(me.col), - letter: me.letter ?? "", - markKind: me.markKind, - checkedWrong: me.checkedWrong, - authorID: me.authorID, - createdAt: me.createdAt ?? Date() - ) + ) async -> (snapshotNames: [String], prunedMoveNames: [String]) { + let bgCtx = persistence.container.newBackgroundContext() + bgCtx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + return await bgCtx.perform { + var snapshotNames: [String] = [] + let prunedMoveNames = Self.pruneDurableSnapshots(in: bgCtx) + + for gameID in gameIDs { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + guard let entity = try? bgCtx.fetch(req).first else { continue } + + let allMoves = (entity.moves as? Set<MoveEntity>) ?? [] + let allSnapshots = (entity.snapshots as? Set<SnapshotEntity>) ?? [] + let latestCoveredLamport = allSnapshots.map(\.upToLamport).max() ?? 0 + let uncoveredCount = allMoves.filter { $0.lamport > latestCoveredLamport }.count + let highWater = entity.lamportHighWater + + let shouldSnapshot = (entity.completedAt != nil || uncoveredCount >= 200) + && highWater > latestCoveredLamport + guard shouldSnapshot else { continue } + + let snapshots: [Snapshot] = allSnapshots.compactMap { se in + guard let data = se.gridState, + let grid = try? MoveLog.decodeGridState(data) else { return nil } + return Snapshot( + gameID: gameID, + upToLamport: se.upToLamport, + grid: grid, + createdAt: se.createdAt ?? Date() + ) + } + let moves: [Move] = allMoves.map { me in + Move( + gameID: gameID, + lamport: me.lamport, + row: Int(me.row), + col: Int(me.col), + letter: me.letter ?? "", + markKind: me.markKind, + checkedWrong: me.checkedWrong, + authorID: me.authorID, + createdAt: me.createdAt ?? Date() + ) + } + let grid = MoveLog.replay(snapshot: MoveLog.latestSnapshot(from: snapshots), moves: moves) + + let snapshotEntity = SnapshotEntity(context: bgCtx) + snapshotEntity.game = entity + snapshotEntity.upToLamport = highWater + snapshotEntity.createdAt = Date() + let ckName = RecordSerializer.recordName(forSnapshotInGame: gameID, upToLamport: highWater) + snapshotEntity.ckRecordName = ckName + snapshotEntity.gridState = try? MoveLog.encodeGridState(grid) + snapshotEntity.needsPruning = true + + snapshotNames.append(ckName) } - let grid = MoveLog.replay(snapshot: MoveLog.latestSnapshot(from: snapshots), moves: moves) - - let snapshotEntity = SnapshotEntity(context: context) - snapshotEntity.game = entity - snapshotEntity.upToLamport = highWater - snapshotEntity.createdAt = Date() - let ckName = RecordSerializer.recordName(forSnapshotInGame: gameID, upToLamport: highWater) - snapshotEntity.ckRecordName = ckName - snapshotEntity.gridState = try? MoveLog.encodeGridState(grid) - snapshotEntity.needsPruning = true - - if context.hasChanges { - try? context.save() + if bgCtx.hasChanges { + try? bgCtx.save() } - snapshotNames.append(ckName) + return (snapshotNames, prunedMoveNames) } - return (snapshotNames, prunedMoveNames) } /// Deletes moves covered by local compaction snapshots after there is @@ -619,6 +632,19 @@ final class GameStore { } private func updateCellCache(for gameEntity: GameEntity, from grid: GridState) { + Self.applyCellCache(to: gameEntity, from: grid, in: context) + try? context.save() + } + + /// Reconciles a `GameEntity`'s `CellEntity` cache against `grid` inside + /// `ctx`. Caller is responsible for saving `ctx`. Used from both the + /// main-context `updateCellCache` and the background-context + /// `replayCellCaches`. + fileprivate nonisolated static func applyCellCache( + to gameEntity: GameEntity, + from grid: GridState, + in ctx: NSManagedObjectContext + ) { let cellEntities = (gameEntity.cells as? Set<CellEntity>) ?? [] var existing: [GridPosition: CellEntity] = [:] for ce in cellEntities { @@ -630,7 +656,7 @@ final class GameStore { if let found = existing[position] { ce = found } else { - ce = CellEntity(context: context) + ce = CellEntity(context: ctx) ce.row = Int16(position.row) ce.col = Int16(position.col) ce.game = gameEntity @@ -647,8 +673,36 @@ final class GameStore { ce.checkedWrong = false ce.letterAuthorID = nil } + } - try? context.save() + /// Background-context equivalent of `pruneMoves(ckRecordNames: nil)` — + /// drops moves covered by snapshots whose CloudKit system fields prove + /// the snapshot is durable. Caller is responsible for saving `ctx`. + fileprivate nonisolated static func pruneDurableSnapshots( + in ctx: NSManagedObjectContext + ) -> [String] { + let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") + req.predicate = NSPredicate( + format: "needsPruning == YES AND ckSystemFields != nil" + ) + let snapshots = (try? ctx.fetch(req)) ?? [] + var prunedMoveNames: [String] = [] + for snapshot in snapshots { + guard let game = snapshot.game else { + snapshot.needsPruning = false + continue + } + let covered = ((game.moves as? Set<MoveEntity>) ?? []) + .filter { $0.lamport <= snapshot.upToLamport } + for move in covered { + if let name = move.ckRecordName { + prunedMoveNames.append(name) + } + ctx.delete(move) + } + snapshot.needsPruning = false + } + return prunedMoveNames } /// Marks the active game read-only when the sync engine sees its shared diff --git a/Crossmate/Sync/MoveBuffer.swift b/Crossmate/Sync/MoveBuffer.swift @@ -80,7 +80,12 @@ actor MoveBuffer { let key = Key(gameID: gameID, row: row, col: col) if let lastCell, lastCell != key { - await performFlush() + // Cell-change flush exists to keep lamport ordering correct. Skip + // the afterFlush callback here so the cache rebuild and snapshot + // creation don't run on the main actor between every keystroke; + // the trailing-edge debounced flush picks them up once typing + // settles. + await performFlush(runAfterFlush: false) } if buffer[key] == nil { @@ -124,7 +129,7 @@ actor MoveBuffer { func flush() async { debounceTask?.cancel() debounceTask = nil - await performFlush() + await performFlush(runAfterFlush: true) } private func scheduleDebounce() { @@ -139,10 +144,10 @@ actor MoveBuffer { private func debouncedFlush() async { debounceTask = nil - await performFlush() + await performFlush(runAfterFlush: true) } - private func performFlush() async { + private func performFlush(runAfterFlush: Bool) async { guard !buffer.isEmpty else { return } let snapshot = buffer @@ -154,7 +159,7 @@ actor MoveBuffer { let moves = persistAndAssignLamports(snapshot: snapshot, order: snapshotOrder) guard !moves.isEmpty else { return } await sink(moves) - if let afterFlush { + if runAfterFlush, let afterFlush { await afterFlush(Set(moves.map { $0.gameID })) } } diff --git a/Tests/Unit/GameStoreSnapshotPruningTests.swift b/Tests/Unit/GameStoreSnapshotPruningTests.swift @@ -8,10 +8,11 @@ import Testing @MainActor struct GameStoreSnapshotPruningTests { @Test("Creating a compaction snapshot keeps covered moves until the snapshot is saved") - func snapshotCreationDoesNotImmediatelyPruneMoves() throws { + func snapshotCreationDoesNotImmediatelyPruneMoves() async throws { let (store, gameID) = try makeStoreWithCompletedGame(moveCount: 2) - let result = store.createSnapshotsIfNeeded(for: [gameID]) + let result = await store.createSnapshotsIfNeeded(for: [gameID]) + store.persistence.viewContext.refreshAllObjects() #expect(result.snapshotNames.count == 1) #expect(result.prunedMoveNames.isEmpty) @@ -20,9 +21,10 @@ struct GameStoreSnapshotPruningTests { } @Test("Saved local compaction snapshots prune their covered moves") - func savedSnapshotPrunesCoveredMoves() throws { + func savedSnapshotPrunesCoveredMoves() async throws { let (store, gameID) = try makeStoreWithCompletedGame(moveCount: 2) - let result = store.createSnapshotsIfNeeded(for: [gameID]) + let result = await store.createSnapshotsIfNeeded(for: [gameID]) + store.persistence.viewContext.refreshAllObjects() let pruned = store.pruneMoves( ckRecordNames: Set(result.snapshotNames) @@ -37,9 +39,10 @@ struct GameStoreSnapshotPruningTests { } @Test("Durable pending snapshots are pruned on a later pass") - func durablePendingSnapshotPrunesOnRecoveryPass() throws { + func durablePendingSnapshotPrunesOnRecoveryPass() async throws { let (store, gameID) = try makeStoreWithCompletedGame(moveCount: 1) - let result = store.createSnapshotsIfNeeded(for: [gameID]) + let result = await store.createSnapshotsIfNeeded(for: [gameID]) + store.persistence.viewContext.refreshAllObjects() try markSnapshotSaved(store: store, ckRecordName: result.snapshotNames[0]) let pruned = store.pruneMoves()