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 }