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 }