crossmate

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

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:
MCrossmate/Services/AppServices.swift | 1-
MCrossmate/Sync/MoveBuffer.swift | 53++++++++++++++++++++++++++++++++++++++++++++++++++---
MTests/Unit/MoveBufferTests.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 + ) + } + } + } }