MovesUpdater.swift (11789B)
1 import CoreData 2 import Foundation 3 4 /// In-memory staging area for cell edits. Coalesces rapid edits — same-cell 5 /// rewrites collapse to the latest value, cross-cell edits accumulate as 6 /// distinct entries — and on flush merges them into the local device's 7 /// `MovesEntity` row (per-cell logical revision), updates the local `CellEntity` 8 /// cache, and hands the affected `gameIDs` to the injected sink so SyncEngine 9 /// can enqueue Moves records for upload. 10 /// 11 /// Flush triggers: 12 /// - trailing-edge debounce (the user has stopped typing); 13 /// - explicit `flush()` (view exit, app background, game completion, tests). 14 /// 15 /// The UI does not depend on a flush for typed letters to appear — those go 16 /// straight into the in-memory `Puzzle` via `GameMutator`. This staging area 17 /// is purely for durable persistence + CloudKit handoff, so coalescing a full 18 /// across-or-down word into one flush is a pure win: fewer Core Data writes 19 /// and one CloudKit push per typing burst instead of one per cell. 20 actor MovesUpdater { 21 private struct Key: Hashable { 22 let gameID: UUID 23 let row: Int 24 let col: Int 25 } 26 27 private struct Pending { 28 var letter: String 29 var mark: CellMark 30 var authorID: String? 31 var enqueuedAt: Date 32 var tick: Int64? 33 } 34 35 private let debounceInterval: Duration 36 private let persistence: PersistenceController 37 private let writerAuthorIDProvider: @Sendable () async -> String? 38 /// `drain` tells the sink whether to force a CKSyncEngine send: `true` for 39 /// an explicit `flush()` (leave/background — the solver's final letters 40 /// must reach CloudKit promptly even with no peer on the socket), `false` 41 /// for the trailing-edge debounce (live typing, where the engagement 42 /// socket already carries the letters and the framework's own scheduler 43 /// can coalesce the durable writes). 44 private let sink: @Sendable (_ gameIDs: Set<UUID>, _ drain: Bool) async -> Void 45 /// Sleep primitive used by the debounce timer. Injected so tests can 46 /// drive flushes deterministically instead of racing against wall-clock 47 /// `Task.sleep` from the actor's own task queue. 48 private let sleep: @Sendable (Duration) async throws -> Void 49 50 private var buffer: [Key: Pending] = [:] 51 private var debounceTask: Task<Void, Never>? 52 53 init( 54 debounceInterval: Duration = .milliseconds(500), 55 persistence: PersistenceController, 56 writerAuthorIDProvider: @escaping @Sendable () async -> String?, 57 sink: @escaping @Sendable (_ gameIDs: Set<UUID>, _ drain: Bool) async -> Void, 58 sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } 59 ) { 60 self.debounceInterval = debounceInterval 61 self.persistence = persistence 62 self.writerAuthorIDProvider = writerAuthorIDProvider 63 self.sink = sink 64 self.sleep = sleep 65 } 66 67 /// Registers a cell edit. `authorID` is the cell-effective author that 68 /// gets persisted into the merged grid — it may differ from the writer 69 /// when a same-letter rewrite or a reveal-of-correct preserves the 70 /// original author. 71 func enqueue( 72 gameID: UUID, 73 row: Int, 74 col: Int, 75 letter: String, 76 mark: CellMark, 77 authorID: String?, 78 enqueuedAt: Date = Date(), 79 tick: Int64? = nil 80 ) async { 81 let key = Key(gameID: gameID, row: row, col: col) 82 buffer[key] = Pending( 83 letter: letter, 84 mark: mark, 85 authorID: authorID, 86 enqueuedAt: enqueuedAt, 87 tick: tick 88 ) 89 scheduleDebounce() 90 } 91 92 /// Flushes any pending edits immediately and cancels the debounce. Safe 93 /// to call when the buffer is empty. 94 func flush() async { 95 debounceTask?.cancel() 96 debounceTask = nil 97 await performFlush(drain: true) 98 } 99 100 private func scheduleDebounce() { 101 debounceTask?.cancel() 102 let interval = debounceInterval 103 let sleep = self.sleep 104 debounceTask = Task { [weak self] in 105 try? await sleep(interval) 106 if Task.isCancelled { return } 107 await self?.debouncedFlush() 108 } 109 } 110 111 private func debouncedFlush() async { 112 debounceTask = nil 113 await performFlush(drain: false) 114 } 115 116 private func performFlush(drain: Bool) async { 117 guard !buffer.isEmpty else { return } 118 // The parent record name embeds the writer's authorID; without it we 119 // can't address the row yet. Keep the buffer intact and retry rather 120 // than losing live in-memory edits that have not reached Core Data. 121 guard let writerAuthorID = await writerAuthorIDProvider(), 122 !writerAuthorID.isEmpty 123 else { 124 scheduleDebounce() 125 return 126 } 127 128 let snapshot = buffer 129 buffer.removeAll(keepingCapacity: true) 130 131 guard let affected = await persistAndMerge( 132 snapshot: snapshot, 133 writerAuthorID: writerAuthorID 134 ) else { 135 buffer.merge(snapshot) { current, _ in current } 136 scheduleDebounce() 137 return 138 } 139 guard !affected.isEmpty else { return } 140 await sink(affected, drain) 141 } 142 143 /// Merges buffered edits into the local device's `MovesEntity` row per 144 /// game. Idempotent: existing per-cell entries are kept when their logical 145 /// revision is later than the new edit. 146 private func persistAndMerge( 147 snapshot: [Key: Pending], 148 writerAuthorID: String 149 ) async -> Set<UUID>? { 150 let context = persistence.container.newBackgroundContext() 151 // This flush links MovesEntity/CellEntity to their GameEntity and bumps 152 // game.updatedAt, while the sync engine writes the same rows from its own 153 // context. Without an explicit policy the default NSErrorMergePolicy would 154 // fail the save on an optimistic-lock conflict (Core Data 133020) and lose 155 // the whole flush — the grid-side analogue of the journal-drop bug. Match 156 // the rest of the sync layer with mergeByPropertyObjectTrump, which also 157 // keeps the grid fail-safe to the local typist on a same-cell race. 158 context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 159 let eventLog = persistence.eventLog 160 return await context.perform { 161 var byGame: [UUID: [(Key, Pending)]] = [:] 162 for (key, pending) in snapshot { 163 byGame[key.gameID, default: []].append((key, pending)) 164 } 165 166 var affected = Set<UUID>() 167 for (gameID, edits) in byGame { 168 guard let game = Self.fetchGame(gameID: gameID, in: context) else { continue } 169 170 let movesEntity = Self.ensureMovesEntity( 171 for: gameID, 172 writerAuthorID: writerAuthorID, 173 game: game, 174 in: context 175 ) 176 177 var existing: [GridPosition: TimestampedCell] = [:] 178 if let data = movesEntity.cells, !data.isEmpty { 179 existing = (try? MovesCodec.decode(data)) ?? [:] 180 } 181 182 var maxUpdatedAt = movesEntity.updatedAt ?? .distantPast 183 var cellCacheMap = Self.cellCacheMap(for: game) 184 185 for (key, pending) in edits { 186 let position = GridPosition(row: key.row, col: key.col) 187 let newCell = TimestampedCell( 188 letter: pending.letter, 189 mark: pending.mark, 190 updatedAt: pending.enqueuedAt, 191 authorID: pending.authorID, 192 tick: pending.tick 193 ) 194 if let current = existing[position], 195 current.compareRevision(to: newCell) == .orderedDescending { 196 continue 197 } 198 existing[position] = newCell 199 if newCell.updatedAt > maxUpdatedAt { 200 maxUpdatedAt = newCell.updatedAt 201 } 202 203 Self.updateCellCache( 204 for: game, 205 key: key, 206 pending: pending, 207 cells: &cellCacheMap, 208 in: context 209 ) 210 } 211 212 movesEntity.cells = (try? MovesCodec.encode(existing)) ?? Data() 213 movesEntity.updatedAt = maxUpdatedAt 214 if game.updatedAt.map({ $0 < maxUpdatedAt }) ?? true { 215 game.updatedAt = maxUpdatedAt 216 } 217 affected.insert(gameID) 218 } 219 220 if context.hasChanges { 221 do { 222 try context.save() 223 } catch { 224 let message = "MovesUpdater: failed to save context: \(error)" 225 Task { @MainActor in 226 eventLog?.note(message, level: "error") 227 } 228 return nil 229 } 230 } 231 return affected 232 } 233 } 234 235 private nonisolated static func fetchGame( 236 gameID: UUID, 237 in ctx: NSManagedObjectContext 238 ) -> GameEntity? { 239 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 240 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 241 req.fetchLimit = 1 242 return try? ctx.fetch(req).first 243 } 244 245 private nonisolated static func ensureMovesEntity( 246 for gameID: UUID, 247 writerAuthorID: String, 248 game: GameEntity, 249 in ctx: NSManagedObjectContext 250 ) -> MovesEntity { 251 let deviceID = RecordSerializer.localDeviceID 252 let recordName = RecordSerializer.recordName( 253 forMovesInGame: gameID, 254 authorID: writerAuthorID, 255 deviceID: deviceID 256 ) 257 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 258 req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 259 req.fetchLimit = 1 260 if let existing = try? ctx.fetch(req).first { 261 return existing 262 } 263 let entity = MovesEntity(context: ctx) 264 entity.game = game 265 entity.ckRecordName = recordName 266 entity.authorID = writerAuthorID 267 entity.deviceID = deviceID 268 entity.cells = Data() 269 entity.updatedAt = Date() 270 return entity 271 } 272 273 private nonisolated static func cellCacheMap( 274 for game: GameEntity 275 ) -> [GridPosition: CellEntity] { 276 let cellEntities = (game.cells as? Set<CellEntity>) ?? [] 277 var cells: [GridPosition: CellEntity] = [:] 278 cells.reserveCapacity(cellEntities.count) 279 for cell in cellEntities { 280 cells[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell 281 } 282 return cells 283 } 284 285 private nonisolated static func updateCellCache( 286 for game: GameEntity, 287 key: Key, 288 pending: Pending, 289 cells: inout [GridPosition: CellEntity], 290 in context: NSManagedObjectContext 291 ) { 292 let position = GridPosition(row: key.row, col: key.col) 293 let cell: CellEntity 294 if let existing = cells[position] { 295 cell = existing 296 } else { 297 cell = CellEntity(context: context) 298 cell.game = game 299 cell.row = Int16(key.row) 300 cell.col = Int16(key.col) 301 cells[position] = cell 302 } 303 cell.letter = pending.letter 304 cell.markCode = pending.mark.code 305 cell.letterAuthorID = pending.authorID 306 } 307 }