crossmate

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

GameArchiver.swift (12306B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 
      5 /// Writes the private-zone archive of a finished shared game, and promotes it to
      6 /// a normal completed game when the original is revoked.
      7 ///
      8 /// See `Archive` for the why. This type owns the side effects: it
      9 /// creates the `archive-<gameID>` zone in the participant's *private* database
     10 /// and saves the snapshot record there with a raw `CKModifyRecordsOperation`
     11 /// (mirroring `FriendController`'s raw private-DB writes), rather than routing
     12 /// through `CKSyncEngine`'s entity-driven push path — the archive is not backed
     13 /// by a normal sync entity. Every device (including the author) then receives
     14 /// the record back through the private engine's `fetchedRecordZoneChanges` and
     15 /// applies it (`SyncEngine` `Archive` case): inert where the live original
     16 /// still exists, hydrated into a completed owned game where it doesn't.
     17 @MainActor
     18 final class GameArchiver {
     19     nonisolated static let archiveRetryWindow: TimeInterval = 14 * 24 * 60 * 60
     20 
     21     private let container: CKContainer
     22     private let persistence: PersistenceController
     23     private let syncEngine: SyncEngine
     24     private let syncMonitor: SyncMonitor?
     25     private let eventLog: EventLog?
     26 
     27     init(
     28         container: CKContainer,
     29         persistence: PersistenceController,
     30         syncEngine: SyncEngine,
     31         syncMonitor: SyncMonitor? = nil,
     32         eventLog: EventLog? = nil
     33     ) {
     34         self.container = container
     35         self.persistence = persistence
     36         self.syncEngine = syncEngine
     37         self.syncMonitor = syncMonitor
     38         self.eventLog = eventLog
     39     }
     40 
     41     // MARK: - Write
     42 
     43     /// Archives a just-finished participant game, refreshing the snapshot until
     44     /// every contributing device's journal is captured. A no-op for owned games
     45     /// (already durable in the owner's own private DB) and for games already
     46     /// marked complete (`archivedAt != nil`).
     47     ///
     48     /// Convergence: at completion the peers' journals almost never exist yet —
     49     /// they upload at *their* own completion — so the first pass usually captures
     50     /// only this device's log. Each later call (driven by the reconcile sweep)
     51     /// re-fetches the shared zone, folds in whatever has since uploaded, and
     52     /// force-overwrites the archive. `archivedAt` is set once the log is
     53     /// complete (one journal per device that wrote grid state), or once the
     54     /// 14-day retry window has elapsed and we deliberately settle for the best
     55     /// available snapshot.
     56     func archiveIfNeeded(gameID: UUID) async {
     57         let ctx = persistence.container.newBackgroundContext()
     58         let local: Archive.Snapshot? = ctx.performAndWait {
     59             guard shouldArchive(gameID: gameID, in: ctx) else { return nil }
     60             return Archive.snapshot(forGameID: gameID, in: ctx)
     61         }
     62         guard let local else { return }
     63 
     64         // Gather every available copy of each device's log, newest-wins:
     65         // this device's own local log (authoritative) over a freshly-fetched
     66         // peer log over whatever a sibling device already folded into the cloud
     67         // archive.
     68         let fetch = try? await syncEngine.fetchReplay(forGameID: local.originalGameID)
     69         let existing = await fetchArchivePayload(originalGameID: local.originalGameID)
     70         var snapshot = local
     71         if let existing { snapshot = Archive.merging(snapshot, peerJournals: existing.journal) }
     72         if let fetch { snapshot = Archive.merging(snapshot, peerJournals: fetch.journals) }
     73 
     74         // Complete = a journal for every device that wrote grid state. Unknown
     75         // when the shared zone is unreachable (no fetch), so treat as incomplete
     76         // and let a later sweep settle it.
     77         let present = Set(snapshot.journal.map(\.key))
     78         let isComplete = fetch.map { $0.expectedDevices.subtracting(present).isEmpty } ?? false
     79         let retryExpired = Self.hasArchiveRetryExpired(completedAt: local.completedAt)
     80         let shouldFinalize = isComplete || retryExpired
     81         if retryExpired && !isComplete {
     82             syncMonitor?.note(
     83                 "archive \(local.originalGameID.uuidString.prefix(8)): " +
     84                 "retry window expired; finalizing incomplete archive"
     85             )
     86         }
     87 
     88         // Skip a redundant overwrite (and the push churn it causes) when the
     89         // cloud archive already holds every device we'd write — only the
     90         // completeness marker might still need flipping.
     91         if let existing, present.isSubset(of: Set(existing.journal.map(\.key))) {
     92             if shouldFinalize { markArchived(originalGameID: local.originalGameID) }
     93             return
     94         }
     95         await write(snapshot, markComplete: shouldFinalize)
     96     }
     97 
     98     /// Re-attempts (and converges) the archive for any completed participant game
     99     /// not yet marked complete — covering a completion that happened while
    100     /// offline, one whose peers hadn't uploaded their journals yet, or a game
    101     /// completed before this feature shipped. Driven by the cold-launch freshen
    102     /// sweep; after 14 days from completion, `archiveIfNeeded` finalizes the
    103     /// best available archive so legacy/incomplete games stop retrying forever.
    104     func reconcileUnarchived() async {
    105         let ctx = persistence.container.newBackgroundContext()
    106         let ids: [UUID] = ctx.performAndWait {
    107             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    108             req.predicate = NSPredicate(
    109                 format: "databaseScope == 1 AND completedAt != nil AND archivedAt == nil AND isAccessRevoked == NO"
    110             )
    111             return ((try? ctx.fetch(req)) ?? []).compactMap(\.id)
    112         }
    113         for id in ids {
    114             await archiveIfNeeded(gameID: id)
    115         }
    116     }
    117 
    118     nonisolated static func hasArchiveRetryExpired(
    119         completedAt: Date,
    120         now: Date = Date()
    121     ) -> Bool {
    122         now.timeIntervalSince(completedAt) >= archiveRetryWindow
    123     }
    124 
    125     private nonisolated func shouldArchive(gameID: UUID, in ctx: NSManagedObjectContext) -> Bool {
    126         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    127         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    128         req.fetchLimit = 1
    129         guard let entity = try? ctx.fetch(req).first else { return false }
    130         return entity.databaseScope == 1
    131             && entity.completedAt != nil
    132             && entity.archivedAt == nil
    133             && !entity.isAccessRevoked
    134     }
    135 
    136     /// Force-overwrites the archive record with `snapshot`. `markComplete` flips
    137     /// the `archivedAt` marker that stops the reconcile sweep — set only when the
    138     /// caller knows every contributing device is captured.
    139     private func write(_ snapshot: Archive.Snapshot, markComplete: Bool) async {
    140         let zoneID = Archive.zoneID(forOriginalGameID: snapshot.originalGameID)
    141         do {
    142             try await ensureZone(zoneID)
    143             let record = try Archive.record(from: snapshot)
    144             try await save(record)
    145             if markComplete { markArchived(originalGameID: snapshot.originalGameID) }
    146         } catch {
    147             syncMonitor?.recordError("archive game", error)
    148             eventLog?.note(
    149                 "GameArchiver: write failed for \(snapshot.originalGameID.uuidString) — \(error)",
    150                 level: "error"
    151             )
    152         }
    153     }
    154 
    155     /// Reads back the archive record from this user's private database — the
    156     /// accumulated, possibly cross-device-converged copy. `nil` when it doesn't
    157     /// exist yet or the database is unreachable.
    158     private func fetchArchivePayload(
    159         originalGameID: UUID
    160     ) async -> Archive.Payload? {
    161         let recordID = CKRecord.ID(
    162             recordName: Archive.recordName(forOriginalGameID: originalGameID),
    163             zoneID: Archive.zoneID(forOriginalGameID: originalGameID)
    164         )
    165         guard let record = try? await container.privateCloudDatabase.record(for: recordID) else {
    166             return nil
    167         }
    168         return Archive.payload(from: record)
    169     }
    170 
    171     private func markArchived(originalGameID: UUID) {
    172         let ctx = persistence.container.newBackgroundContext()
    173         ctx.performAndWait {
    174             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    175             req.predicate = NSPredicate(format: "id == %@", originalGameID as CVarArg)
    176             req.fetchLimit = 1
    177             guard let entity = try? ctx.fetch(req).first else { return }
    178             entity.archivedAt = Date()
    179             entity.archiveGameID = Archive.archiveGameID(for: originalGameID)
    180             try? ctx.save()
    181         }
    182     }
    183 
    184     // MARK: - Promote on revocation
    185 
    186     /// Turns a revoked shared game into a durable, owned completed game, then
    187     /// deletes the revoked original so the library shows a single completed game
    188     /// rather than a dead tombstone.
    189     ///
    190     /// Prefers the cloud archive as the source: by the time an owner deletes,
    191     /// the reconcile sweep has usually converged it to the *full* multi-author
    192     /// log, whereas this device's local `JournalEntity` rows only hold its own
    193     /// moves plus any peers it happened to cache for replay. Falls back to the
    194     /// local data (and seeds a cloud copy from it) when the archive can't be
    195     /// reached — e.g. a game that completed and was revoked entirely offline.
    196     func promoteRevoked(gameID: UUID) async {
    197         let ctx = persistence.container.newBackgroundContext()
    198         let local: Archive.Snapshot? = ctx.performAndWait {
    199             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    200             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    201             req.fetchLimit = 1
    202             // Only finished games are archivable; an owner deleting an
    203             // in-progress shared game leaves the revoked tombstone as-is.
    204             guard (try? ctx.fetch(req).first)?.completedAt != nil else { return nil }
    205             return Archive.snapshot(forGameID: gameID, in: ctx)
    206         }
    207         guard let local else { return }
    208 
    209         let payload: Archive.Payload
    210         if let cloud = await fetchArchivePayload(originalGameID: gameID) {
    211             payload = cloud
    212         } else {
    213             // No reachable cloud copy — back up the local data now (the private
    214             // DB is still writable) and promote from it.
    215             await write(local, markComplete: true)
    216             payload = Archive.payload(from: local)
    217         }
    218 
    219         let promoteCtx = persistence.container.newBackgroundContext()
    220         promoteCtx.performAndWait {
    221             _ = Archive.materialize(payload, in: promoteCtx)
    222             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    223             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    224             req.fetchLimit = 1
    225             if let original = try? promoteCtx.fetch(req).first {
    226                 promoteCtx.delete(original)
    227             }
    228             if promoteCtx.hasChanges { try? promoteCtx.save() }
    229         }
    230     }
    231 
    232     // MARK: - CloudKit helpers
    233 
    234     private func ensureZone(_ zoneID: CKRecordZone.ID) async throws {
    235         try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    236             let op = CKModifyRecordZonesOperation(
    237                 recordZonesToSave: [CKRecordZone(zoneID: zoneID)],
    238                 recordZoneIDsToDelete: nil
    239             )
    240             op.qualityOfService = .utility
    241             op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) }
    242             container.privateCloudDatabase.add(op)
    243         }
    244     }
    245 
    246     private func save(_ record: CKRecord) async throws {
    247         try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    248             let op = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil)
    249             // Force-overwrite: each convergence pass writes a fresh record (no
    250             // change tag), and the archive is a frozen game, so last-writer-wins
    251             // across this user's own devices is exactly right.
    252             op.savePolicy = .allKeys
    253             op.qualityOfService = .utility
    254             op.modifyRecordsResultBlock = { result in cont.resume(with: result) }
    255             container.privateCloudDatabase.add(op)
    256         }
    257     }
    258 }