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:
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()