crossmate

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

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 }