SnapshotService.swift (7653B)
1 import CoreData 2 import Foundation 3 4 /// Owns the snapshot-creation and move-pruning lifecycle for local 5 /// (single-user) games. Lives outside `GameStore` so that 6 /// `MoveBuffer.afterFlush` can run snapshot work without depending on 7 /// `GameStore`, which lets `GameStore` accept its collaborators as required 8 /// init parameters. 9 @MainActor 10 final class SnapshotService { 11 let persistence: PersistenceController 12 private var context: NSManagedObjectContext { persistence.viewContext } 13 14 init(persistence: PersistenceController) { 15 self.persistence = persistence 16 } 17 18 /// Checks each game in `gameIDs` and writes a `SnapshotEntity` if the 19 /// game is complete (has `completedAt` set) or has 200 or more moves not 20 /// yet covered by an existing snapshot. Moves folded into a local 21 /// compaction snapshot are pruned only after CloudKit has confirmed that 22 /// snapshot save. Returns the `ckRecordName` of each new snapshot for 23 /// enqueueing, plus any move deletions made for previously-durable 24 /// snapshots. 25 func createSnapshotsIfNeeded( 26 for gameIDs: Set<UUID> 27 ) async -> (snapshotNames: [String], prunedMoveNames: [String]) { 28 let bgCtx = persistence.container.newBackgroundContext() 29 bgCtx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 30 return await bgCtx.perform { 31 var snapshotNames: [String] = [] 32 let prunedMoveNames = Self.pruneDurableSnapshots(in: bgCtx) 33 34 for gameID in gameIDs { 35 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 36 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 37 req.fetchLimit = 1 38 guard let entity = try? bgCtx.fetch(req).first else { continue } 39 guard !Self.usesSharedSync(entity) else { continue } 40 41 let allMoves = (entity.moves as? Set<MoveEntity>) ?? [] 42 let allSnapshots = (entity.snapshots as? Set<SnapshotEntity>) ?? [] 43 let latestCoveredLamport = allSnapshots.map(\.upToLamport).max() ?? 0 44 let uncoveredCount = allMoves.filter { $0.lamport > latestCoveredLamport }.count 45 let highWater = entity.lamportHighWater 46 47 let shouldSnapshot = (entity.completedAt != nil || uncoveredCount >= 200) 48 && highWater > latestCoveredLamport 49 guard shouldSnapshot else { continue } 50 51 let snapshots: [Snapshot] = allSnapshots.compactMap { se in 52 guard let data = se.gridState, 53 let grid = try? MoveLog.decodeGridState(data) else { return nil } 54 return Snapshot( 55 gameID: gameID, 56 upToLamport: se.upToLamport, 57 grid: grid, 58 createdAt: se.createdAt ?? Date() 59 ) 60 } 61 let moves: [Move] = allMoves.map { me in 62 Move( 63 gameID: gameID, 64 lamport: me.lamport, 65 row: Int(me.row), 66 col: Int(me.col), 67 letter: me.letter ?? "", 68 markKind: me.markKind, 69 checkedWrong: me.checkedWrong, 70 authorID: me.authorID, 71 createdAt: me.createdAt ?? Date() 72 ) 73 } 74 let grid = MoveLog.replay(snapshot: MoveLog.latestSnapshot(from: snapshots), moves: moves) 75 76 let snapshotEntity = SnapshotEntity(context: bgCtx) 77 snapshotEntity.game = entity 78 snapshotEntity.upToLamport = highWater 79 snapshotEntity.createdAt = Date() 80 let ckName = RecordSerializer.recordName(forSnapshotInGame: gameID, upToLamport: highWater) 81 snapshotEntity.ckRecordName = ckName 82 snapshotEntity.gridState = try? MoveLog.encodeGridState(grid) 83 snapshotEntity.needsPruning = true 84 85 snapshotNames.append(ckName) 86 } 87 if bgCtx.hasChanges { 88 try? bgCtx.save() 89 } 90 return (snapshotNames, prunedMoveNames) 91 } 92 } 93 94 /// Deletes moves covered by local compaction snapshots after there is 95 /// durable evidence that the snapshot exists in CloudKit. Passing names 96 /// is used directly from CKSyncEngine's saved-record callback; omitting 97 /// names performs crash recovery by looking for saved snapshots with 98 /// written-back system fields. 99 func pruneMoves( 100 ckRecordNames: Set<String>? = nil 101 ) -> [String] { 102 let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") 103 if let ckRecordNames { 104 guard !ckRecordNames.isEmpty else { return [] } 105 req.predicate = NSPredicate( 106 format: "needsPruning == YES AND ckRecordName IN %@", 107 Array(ckRecordNames) 108 ) 109 } else { 110 req.predicate = NSPredicate( 111 format: "needsPruning == YES AND ckSystemFields != nil" 112 ) 113 } 114 115 let snapshots = (try? context.fetch(req)) ?? [] 116 var prunedMoveNames: [String] = [] 117 for snapshot in snapshots { 118 guard let game = snapshot.game else { 119 snapshot.needsPruning = false 120 continue 121 } 122 guard !Self.usesSharedSync(game) else { 123 snapshot.needsPruning = false 124 continue 125 } 126 127 let covered = ((game.moves as? Set<MoveEntity>) ?? []) 128 .filter { $0.lamport <= snapshot.upToLamport } 129 for move in covered { 130 if let name = move.ckRecordName { 131 prunedMoveNames.append(name) 132 } 133 context.delete(move) 134 } 135 snapshot.needsPruning = false 136 } 137 138 if context.hasChanges { 139 try? context.save() 140 } 141 return prunedMoveNames 142 } 143 144 /// Background-context equivalent of `pruneMoves(ckRecordNames: nil)` — 145 /// drops moves covered by snapshots whose CloudKit system fields prove 146 /// the snapshot is durable. Caller is responsible for saving `ctx`. 147 fileprivate nonisolated static func pruneDurableSnapshots( 148 in ctx: NSManagedObjectContext 149 ) -> [String] { 150 let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") 151 req.predicate = NSPredicate( 152 format: "needsPruning == YES AND ckSystemFields != nil" 153 ) 154 let snapshots = (try? ctx.fetch(req)) ?? [] 155 var prunedMoveNames: [String] = [] 156 for snapshot in snapshots { 157 guard let game = snapshot.game else { 158 snapshot.needsPruning = false 159 continue 160 } 161 guard !Self.usesSharedSync(game) else { 162 snapshot.needsPruning = false 163 continue 164 } 165 let covered = ((game.moves as? Set<MoveEntity>) ?? []) 166 .filter { $0.lamport <= snapshot.upToLamport } 167 for move in covered { 168 if let name = move.ckRecordName { 169 prunedMoveNames.append(name) 170 } 171 ctx.delete(move) 172 } 173 snapshot.needsPruning = false 174 } 175 return prunedMoveNames 176 } 177 178 fileprivate nonisolated static func usesSharedSync(_ game: GameEntity) -> Bool { 179 game.databaseScope == 1 || game.ckShareRecordName != nil 180 } 181 }