crossmate

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

MoveBuffer.swift (10825B)


      1 import CoreData
      2 import Foundation
      3 
      4 /// In-memory staging area for cell edits. Collapses rapid same-cell edits
      5 /// down to one move, assigns lamports at flush time from the game's
      6 /// `lamportHighWater`, writes `MoveEntity` rows, and updates the local
      7 /// `CellEntity` cache in a single background transaction. Flushed moves are
      8 /// handed to an injected sink — wired to `CKSyncEngine` in production,
      9 /// stubbed in tests.
     10 ///
     11 /// Flush triggers:
     12 /// - trailing-edge debounce (the user has stopped typing);
     13 /// - cell change (focus moves to a different cell, so the previous cell's
     14 ///   final value gets a lamport before new edits start accumulating);
     15 /// - explicit `flush()` (app background, game completion, tests).
     16 actor MoveBuffer {
     17     private struct Key: Hashable {
     18         let gameID: UUID
     19         let row: Int
     20         let col: Int
     21     }
     22 
     23     private struct Pending {
     24         var letter: String
     25         var markKind: Int16
     26         var checkedWrong: Bool
     27         var authorID: String?
     28         var enqueuedAt: Date
     29     }
     30 
     31     private let debounceInterval: Duration
     32     private let sessionPingStaleInterval: TimeInterval
     33     private let persistence: PersistenceController
     34     private let sink: @Sendable ([Move]) async -> Void
     35     private let afterFlush: (@Sendable (Set<UUID>) async -> Void)?
     36     private let sessionPingSink: (@Sendable (UUID, String) async -> Void)?
     37 
     38     private var buffer: [Key: Pending] = [:]
     39     /// Insertion order so that lamports within a single flush are assigned
     40     /// in the order edits were made rather than whatever the dictionary
     41     /// happens to iterate.
     42     private var order: [Key] = []
     43     /// The cell most recently enqueued. A subsequent enqueue targeting a
     44     /// different cell flushes first; subsequent enqueues for the same cell
     45     /// replace the pending value without flushing.
     46     private var lastCell: Key?
     47     private var debounceTask: Task<Void, Never>?
     48     /// Per-game timestamp of the last session ping fired. The first
     49     /// `enqueue` for a game with no entry — or one stale beyond
     50     /// `sessionPingStaleInterval` — counts as a new session and fires a ping.
     51     private var lastSessionPingAt: [UUID: Date] = [:]
     52 
     53     init(
     54         debounceInterval: Duration = .milliseconds(1500),
     55         sessionPingStaleInterval: TimeInterval = 20 * 60,
     56         persistence: PersistenceController,
     57         sink: @escaping @Sendable ([Move]) async -> Void,
     58         afterFlush: (@Sendable (Set<UUID>) async -> Void)? = nil,
     59         sessionPingSink: (@Sendable (UUID, String) async -> Void)? = nil
     60     ) {
     61         self.debounceInterval = debounceInterval
     62         self.sessionPingStaleInterval = sessionPingStaleInterval
     63         self.persistence = persistence
     64         self.sink = sink
     65         self.afterFlush = afterFlush
     66         self.sessionPingSink = sessionPingSink
     67     }
     68 
     69     /// Registers a cell edit. If the edit targets a different cell than the
     70     /// previous enqueue, the previous cell is flushed first so the resulting
     71     /// lamport order matches the user's editing order.
     72     ///
     73     /// `authorID` is the cell-effective author that gets persisted on the
     74     /// `MoveEntity` and `CellEntity`. `actingAuthorID` is the user who
     75     /// performed the action — usually the same value, but a same-letter
     76     /// rewrite or a reveal-of-correct preserves the cell's original author
     77     /// while the acting user is the typist. Session pings fire on the acting
     78     /// user so presence reflects who is actually live in the game.
     79     func enqueue(
     80         gameID: UUID,
     81         row: Int,
     82         col: Int,
     83         letter: String,
     84         markKind: Int16,
     85         checkedWrong: Bool,
     86         authorID: String?,
     87         actingAuthorID: String? = nil
     88     ) async {
     89         let key = Key(gameID: gameID, row: row, col: col)
     90 
     91         if let lastCell, lastCell != key {
     92             await performFlush()
     93         }
     94 
     95         if buffer[key] == nil {
     96             order.append(key)
     97         }
     98         buffer[key] = Pending(
     99             letter: letter,
    100             markKind: markKind,
    101             checkedWrong: checkedWrong,
    102             authorID: authorID,
    103             enqueuedAt: Date()
    104         )
    105         lastCell = key
    106         scheduleDebounce()
    107 
    108         let pingAuthorID = actingAuthorID ?? authorID
    109         if let pingAuthorID, !pingAuthorID.isEmpty {
    110             await maybeFireSessionPing(gameID: gameID, authorID: pingAuthorID)
    111         }
    112     }
    113 
    114     /// Resets session-ping tracking for `gameID`. Called from the puzzle
    115     /// view's teardown so re-entry counts as a fresh session.
    116     func noteSessionEnded(gameID: UUID) {
    117         lastSessionPingAt.removeValue(forKey: gameID)
    118     }
    119 
    120     private func maybeFireSessionPing(gameID: UUID, authorID: String) async {
    121         let now = Date()
    122         if let last = lastSessionPingAt[gameID],
    123            now.timeIntervalSince(last) < sessionPingStaleInterval {
    124             return
    125         }
    126         lastSessionPingAt[gameID] = now
    127         if let sessionPingSink {
    128             await sessionPingSink(gameID, authorID)
    129         }
    130     }
    131 
    132     /// Flushes any pending edits immediately and cancels the debounce. Safe
    133     /// to call when the buffer is empty.
    134     func flush() async {
    135         debounceTask?.cancel()
    136         debounceTask = nil
    137         await performFlush()
    138     }
    139 
    140     private func scheduleDebounce() {
    141         debounceTask?.cancel()
    142         let interval = debounceInterval
    143         debounceTask = Task { [weak self] in
    144             try? await Task.sleep(for: interval)
    145             if Task.isCancelled { return }
    146             await self?.debouncedFlush()
    147         }
    148     }
    149 
    150     private func debouncedFlush() async {
    151         debounceTask = nil
    152         await performFlush()
    153     }
    154 
    155     private func performFlush() async {
    156         guard !buffer.isEmpty else { return }
    157 
    158         let snapshot = buffer
    159         let snapshotOrder = order
    160         buffer.removeAll(keepingCapacity: true)
    161         order.removeAll(keepingCapacity: true)
    162         lastCell = nil
    163 
    164         let moves = persistAndAssignLamports(
    165             snapshot: snapshot,
    166             order: snapshotOrder
    167         )
    168         guard !moves.isEmpty else { return }
    169         await sink(moves)
    170         if let afterFlush {
    171             await afterFlush(Set(moves.map { $0.gameID }))
    172         }
    173     }
    174 
    175     /// Allocates lamports from each game's `lamportHighWater`, writes
    176     /// `MoveEntity` rows, and bumps the high-water — all inside a single
    177     /// background-context transaction so a crash can't leave the high-water
    178     /// out of sync with the written moves.
    179     private func persistAndAssignLamports(
    180         snapshot: [Key: Pending],
    181         order: [Key]
    182     ) -> [Move] {
    183         let context = persistence.container.newBackgroundContext()
    184         return context.performAndWait {
    185             var moves: [Move] = []
    186             var gamesByID: [UUID: GameEntity] = [:]
    187             var cellsByGameID: [UUID: [GridPosition: CellEntity]] = [:]
    188 
    189             for key in order {
    190                 guard let pending = snapshot[key] else { continue }
    191 
    192                 let game: GameEntity
    193                 if let cached = gamesByID[key.gameID] {
    194                     game = cached
    195                 } else {
    196                     let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    197                     request.predicate = NSPredicate(format: "id == %@", key.gameID as CVarArg)
    198                     request.fetchLimit = 1
    199                     guard let found = try? context.fetch(request).first else { continue }
    200                     gamesByID[key.gameID] = found
    201                     game = found
    202                 }
    203                 if cellsByGameID[key.gameID] == nil {
    204                     cellsByGameID[key.gameID] = Self.cellCacheMap(for: game)
    205                 }
    206 
    207                 let lamport = game.lamportHighWater + 1
    208                 game.lamportHighWater = lamport
    209                 if game.updatedAt.map({ $0 < pending.enqueuedAt }) ?? true {
    210                     game.updatedAt = pending.enqueuedAt
    211                 }
    212 
    213                 let entity = MoveEntity(context: context)
    214                 entity.game = game
    215                 entity.lamport = lamport
    216                 entity.row = Int16(key.row)
    217                 entity.col = Int16(key.col)
    218                 entity.letter = pending.letter
    219                 entity.markKind = pending.markKind
    220                 entity.checkedWrong = pending.checkedWrong
    221                 entity.authorID = pending.authorID
    222                 entity.createdAt = pending.enqueuedAt
    223                 entity.ckRecordName = RecordSerializer.recordName(
    224                     forMoveInGame: key.gameID,
    225                     lamport: lamport
    226                 )
    227 
    228                 Self.updateCellCache(
    229                     for: game,
    230                     key: key,
    231                     pending: pending,
    232                     cells: &cellsByGameID[key.gameID, default: [:]],
    233                     in: context
    234                 )
    235 
    236                 moves.append(Move(
    237                     gameID: key.gameID,
    238                     lamport: lamport,
    239                     row: key.row,
    240                     col: key.col,
    241                     letter: pending.letter,
    242                     markKind: pending.markKind,
    243                     checkedWrong: pending.checkedWrong,
    244                     authorID: pending.authorID,
    245                     createdAt: pending.enqueuedAt
    246                 ))
    247             }
    248 
    249             if context.hasChanges {
    250                 do {
    251                     try context.save()
    252                 } catch {
    253                     print("MoveBuffer: failed to save context: \(error)")
    254                 }
    255             }
    256             return moves
    257         }
    258     }
    259 
    260     private nonisolated static func cellCacheMap(for game: GameEntity) -> [GridPosition: CellEntity] {
    261         let cellEntities = (game.cells as? Set<CellEntity>) ?? []
    262         var cells: [GridPosition: CellEntity] = [:]
    263         cells.reserveCapacity(cellEntities.count)
    264         for cell in cellEntities {
    265             cells[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell
    266         }
    267         return cells
    268     }
    269 
    270     private nonisolated static func updateCellCache(
    271         for game: GameEntity,
    272         key: Key,
    273         pending: Pending,
    274         cells: inout [GridPosition: CellEntity],
    275         in context: NSManagedObjectContext
    276     ) {
    277         let position = GridPosition(row: key.row, col: key.col)
    278         let cell: CellEntity
    279         if let existing = cells[position] {
    280             cell = existing
    281         } else {
    282             cell = CellEntity(context: context)
    283             cell.game = game
    284             cell.row = Int16(key.row)
    285             cell.col = Int16(key.col)
    286             cells[position] = cell
    287         }
    288         cell.letter = pending.letter
    289         cell.markKind = pending.markKind
    290         cell.checkedWrong = pending.checkedWrong
    291         cell.letterAuthorID = pending.authorID
    292     }
    293 }