commit 08aa4e932cefe7aa40746e87568e4780611d9a46
parent 20ddc83686f6d7df296395263787f470652680bc
Author: Michael Camilleri <[email protected]>
Date: Sat, 2 May 2026 08:22:44 +0900
Reduce workload during flushes
This commit seeks to reduce the workload during flushes as a way to
improve responsiveness during puzzle solving.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 122 insertions(+), 4 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -65,7 +65,6 @@ final class AppServices {
)
},
afterFlush: { gameIDs in
- await store.replayCellCaches(for: gameIDs)
let result = await store.createSnapshotsIfNeeded(for: gameIDs)
for name in result.snapshotNames {
await syncEngine.enqueueSnapshot(ckRecordName: name)
diff --git a/Crossmate/Sync/MoveBuffer.swift b/Crossmate/Sync/MoveBuffer.swift
@@ -3,9 +3,10 @@ import Foundation
/// In-memory staging area for cell edits. Collapses rapid same-cell edits
/// down to one move, assigns lamports at flush time from the game's
-/// `lamportHighWater`, and writes `MoveEntity` rows in a single background
-/// transaction. Flushed moves are handed to an injected sink — wired to
-/// `CKSyncEngine` in production, stubbed in tests.
+/// `lamportHighWater`, writes `MoveEntity` rows, and updates the local
+/// `CellEntity` cache in a single background transaction. Flushed moves are
+/// handed to an injected sink — wired to `CKSyncEngine` in production,
+/// stubbed in tests.
///
/// Flush triggers:
/// - trailing-edge debounce (the user has stopped typing);
@@ -227,6 +228,7 @@ actor MoveBuffer {
return context.performAndWait {
var moves: [Move] = []
var gamesByID: [UUID: GameEntity] = [:]
+ var cellsByGameID: [UUID: [GridPosition: CellEntity]] = [:]
for key in order {
guard let pending = snapshot[key] else { continue }
@@ -242,6 +244,9 @@ actor MoveBuffer {
gamesByID[key.gameID] = found
game = found
}
+ if cellsByGameID[key.gameID] == nil {
+ cellsByGameID[key.gameID] = Self.cellCacheMap(for: game)
+ }
let lamport = game.lamportHighWater + 1
game.lamportHighWater = lamport
@@ -264,6 +269,14 @@ actor MoveBuffer {
lamport: lamport
)
+ Self.updateCellCache(
+ for: game,
+ key: key,
+ pending: pending,
+ cells: &cellsByGameID[key.gameID, default: [:]],
+ in: context
+ )
+
moves.append(Move(
gameID: key.gameID,
lamport: lamport,
@@ -287,4 +300,38 @@ actor MoveBuffer {
return moves
}
}
+
+ private nonisolated static func cellCacheMap(for game: GameEntity) -> [GridPosition: CellEntity] {
+ let cellEntities = (game.cells as? Set<CellEntity>) ?? []
+ var cells: [GridPosition: CellEntity] = [:]
+ cells.reserveCapacity(cellEntities.count)
+ for cell in cellEntities {
+ cells[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell
+ }
+ return cells
+ }
+
+ private nonisolated static func updateCellCache(
+ for game: GameEntity,
+ key: Key,
+ pending: Pending,
+ cells: inout [GridPosition: CellEntity],
+ in context: NSManagedObjectContext
+ ) {
+ let position = GridPosition(row: key.row, col: key.col)
+ let cell: CellEntity
+ if let existing = cells[position] {
+ cell = existing
+ } else {
+ cell = CellEntity(context: context)
+ cell.game = game
+ cell.row = Int16(key.row)
+ cell.col = Int16(key.col)
+ cells[position] = cell
+ }
+ cell.letter = pending.letter
+ cell.markKind = pending.markKind
+ cell.checkedWrong = pending.checkedWrong
+ cell.letterAuthorID = pending.authorID
+ }
}
diff --git a/Tests/Unit/MoveBufferTests.swift b/Tests/Unit/MoveBufferTests.swift
@@ -219,6 +219,46 @@ struct MoveBufferTests {
#expect(m?.ckRecordName == "move-\(gameID.uuidString)-1-\(RecordSerializer.localDeviceID)")
}
+ @Test("Flush updates cell cache with the latest local cell values")
+ func flushUpdatesCellCache() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let buffer = MoveBuffer(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { _ in }
+ )
+
+ await buffer.enqueue(
+ gameID: gameID,
+ row: 2,
+ col: 3,
+ letter: "Q",
+ markKind: 1,
+ checkedWrong: true,
+ authorID: "alice"
+ )
+ await buffer.enqueue(
+ gameID: gameID,
+ row: 2,
+ col: 3,
+ letter: "R",
+ markKind: 2,
+ checkedWrong: false,
+ authorID: "bob"
+ )
+ await buffer.flush()
+
+ let cells = fetchCellValues(gameID: gameID, persistence: persistence)
+ #expect(cells.count == 1)
+ let cell = cells.first
+ #expect(cell?.row == 2)
+ #expect(cell?.col == 3)
+ #expect(cell?.letter == "R")
+ #expect(cell?.markKind == 2)
+ #expect(cell?.checkedWrong == false)
+ #expect(cell?.authorID == "bob")
+ }
+
// MARK: - Helpers
/// Reads the game's lamport high-water from a fresh background context.
@@ -255,6 +295,15 @@ struct MoveBufferTests {
let ckRecordName: String?
}
+ struct CellValues {
+ let letter: String
+ let row: Int16
+ let col: Int16
+ let markKind: Int16
+ let checkedWrong: Bool
+ let authorID: String?
+ }
+
private func fetchMoveValues(gameID: UUID, persistence: PersistenceController) -> [MoveValues] {
let context = persistence.container.newBackgroundContext()
return context.performAndWait {
@@ -276,4 +325,27 @@ struct MoveBufferTests {
}
}
}
+
+ private func fetchCellValues(gameID: UUID, persistence: PersistenceController) -> [CellValues] {
+ let context = persistence.container.newBackgroundContext()
+ return context.performAndWait {
+ let request = NSFetchRequest<CellEntity>(entityName: "CellEntity")
+ request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
+ request.sortDescriptors = [
+ NSSortDescriptor(key: "row", ascending: true),
+ NSSortDescriptor(key: "col", ascending: true)
+ ]
+ guard let entities = try? context.fetch(request) else { return [] }
+ return entities.map {
+ CellValues(
+ letter: $0.letter ?? "",
+ row: $0.row,
+ col: $0.col,
+ markKind: $0.markKind,
+ checkedWrong: $0.checkedWrong,
+ authorID: $0.letterAuthorID
+ )
+ }
+ }
+ }
}