CloudDiagnostics.swift (21075B)
1 import CloudKit 2 import Foundation 3 4 extension SyncEngine { 5 private struct StorageAuditAsset { 6 let containerLabel: String 7 let recordType: String 8 let field: String 9 let recordName: String 10 let zoneName: String 11 let bytes: Int64 12 } 13 14 private struct StorageAuditFieldTotal { 15 var count = 0 16 var bytes: Int64 = 0 17 var unavailableCount = 0 18 } 19 20 private struct StorageAuditTotals { 21 var zoneCount = 0 22 var recordCount = 0 23 var title: String? 24 var recordCounts: [String: Int] = [:] 25 var assetFields: [String: StorageAuditFieldTotal] = [:] 26 var inlineBytes: Int64 = 0 27 var unavailableAssetCount = 0 28 var recordErrorCount = 0 29 var assets: [StorageAuditAsset] = [] 30 31 var assetBytes: Int64 { 32 assetFields.values.reduce(0) { $0 + $1.bytes } 33 } 34 35 var assetCount: Int { 36 assetFields.values.reduce(0) { $0 + $1.count } 37 } 38 39 mutating func merge(_ other: StorageAuditTotals) { 40 zoneCount += other.zoneCount 41 recordCount += other.recordCount 42 inlineBytes += other.inlineBytes 43 unavailableAssetCount += other.unavailableAssetCount 44 recordErrorCount += other.recordErrorCount 45 assets.append(contentsOf: other.assets) 46 for (type, count) in other.recordCounts { 47 recordCounts[type, default: 0] += count 48 } 49 for (field, value) in other.assetFields { 50 assetFields[field, default: StorageAuditFieldTotal()].count += value.count 51 assetFields[field, default: StorageAuditFieldTotal()].bytes += value.bytes 52 assetFields[field, default: StorageAuditFieldTotal()].unavailableCount += 53 value.unavailableCount 54 } 55 } 56 } 57 58 private struct DocumentsAuditFile: Sendable { 59 let path: String 60 let bytes: Int64 61 } 62 63 private struct DocumentsAuditTotals: Sendable { 64 var fileCount = 0 65 var bytes: Int64 = 0 66 var unavailableCount = 0 67 var files: [DocumentsAuditFile] = [] 68 } 69 70 struct DiagnosticSnapshot: Sendable { 71 let accountStatus: CKAccountStatus 72 let engineRunning: Bool 73 let pendingChangesCount: Int 74 let privatePendingCount: Int 75 let sharedPendingCount: Int 76 let pendingInvitationCount: Int 77 } 78 79 /// Record names of pending `.saveRecord` changes queued on the given 80 /// scope's engine. Used by tests to verify that outbound enqueues route 81 /// to the correct database. 82 func pendingSaveRecordNames(scope: CKDatabase.Scope) -> [String] { 83 let engine = scope == .shared ? sharedEngine : privateEngine 84 guard let engine else { return [] } 85 return engine.state.pendingRecordZoneChanges.compactMap { 86 if case .saveRecord(let id) = $0 { return id.recordName } 87 return nil 88 } 89 } 90 91 /// Zone names queued for deletion on the given scope's engine. Used by 92 /// tests to verify delete routing after the local GameEntity is gone. 93 func pendingDeletedZoneNames(scope: CKDatabase.Scope) -> [String] { 94 let engine = scope == .shared ? sharedEngine : privateEngine 95 guard let engine else { return [] } 96 return engine.state.pendingDatabaseChanges.compactMap { 97 if case .deleteZone(let id) = $0 { return id.zoneName } 98 return nil 99 } 100 } 101 102 func diagnosticSnapshot() async -> DiagnosticSnapshot { 103 let status: CKAccountStatus 104 do { status = try await container.accountStatus() } 105 catch { status = .couldNotDetermine } 106 let running = privateEngine != nil 107 let privateCount = privateEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0 108 let sharedCount = sharedEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0 109 return DiagnosticSnapshot( 110 accountStatus: status, 111 engineRunning: running, 112 pendingChangesCount: privateCount + sharedCount, 113 privatePendingCount: privateCount, 114 sharedPendingCount: sharedCount, 115 pendingInvitationCount: pendingInvitationPingCount() 116 ) 117 } 118 119 /// Runs a series of lightweight CloudKit probes and returns human-readable 120 /// (name, result) pairs for display in the diagnostics view. 121 func probeContainer() async -> [(name: String, result: String)] { 122 var results: [(String, String)] = [] 123 results.append(("containerIdentifier", container.containerIdentifier ?? "nil")) 124 do { 125 let s = try await container.accountStatus() 126 results.append(("accountStatus", describeStatus(s))) 127 } catch { 128 results.append(("accountStatus", describe(error))) 129 } 130 do { 131 let id = try await container.userRecordID() 132 results.append(("userRecordID", id.recordName)) 133 } catch { 134 results.append(("userRecordID", describe(error))) 135 } 136 do { 137 let zones = try await container.privateCloudDatabase.allRecordZones() 138 let names = zones.map(\.zoneID.zoneName).joined(separator: ", ") 139 results.append(("privateZones", "\(zones.count) zone(s): [\(names)]")) 140 } catch { 141 results.append(("privateZones", describe(error))) 142 } 143 do { 144 let zones = try await container.sharedCloudDatabase.allRecordZones() 145 let names = zones.map(\.zoneID.zoneName).joined(separator: ", ") 146 results.append(("sharedZones", "\(zones.count) zone(s): [\(names)]")) 147 } catch { 148 results.append(("sharedZones", describe(error))) 149 } 150 // CKSyncEngine creates a CKDatabaseSubscription per scope on first 151 // start. If subscription creation silently failed, no push will ever 152 // fire for that scope — surface what's actually present so a missing 153 // entry is visible from the diagnostics view rather than diagnosed 154 // by elimination. 155 results.append(await probeSubscriptions(database: container.privateCloudDatabase, label: "privateSubs")) 156 results.append(await probeSubscriptions(database: container.sharedCloudDatabase, label: "sharedSubs")) 157 return results 158 } 159 160 /// Downloads the current records in every custom zone owned by this iCloud 161 /// account across every production container in the app's current 162 /// entitlements, then inventories the separate iCloud Documents container. 163 /// This is an account-scoped alternative to CloudKit Console's Act As iCloud 164 /// workflow for a TestFlight build. 165 /// 166 /// The byte count is diagnostic rather than billing-exact: CKAsset file 167 /// lengths and inline String/Data payloads are measurable, but CloudKit does 168 /// not expose its record, share, zone, encryption, or retained-server-state 169 /// overhead. Nothing is saved, changed, or deleted. 170 func auditPrivateCloudStorage( 171 progress: @MainActor @Sendable (String) -> Void 172 ) async { 173 let containers: [(label: String, container: CKContainer)] = [ 174 ("v4", container), 175 ("v3", CloudContainer.legacyContainer), 176 ("original", CloudContainer.originalContainer), 177 ] 178 await progress( 179 "storage audit: starting accessible production containers " + 180 "[v4, v3, original]; v2 is not entitled and will not be queried" 181 ) 182 183 var grandTotals = StorageAuditTotals() 184 for source in containers { 185 if let totals = await auditPrivateCloudStorage( 186 label: source.label, 187 container: source.container, 188 progress: progress 189 ) { 190 grandTotals.merge(totals) 191 } 192 } 193 194 await logStorageTotals( 195 grandTotals, 196 prefix: "storage audit accessible CloudKit grand totals", 197 includeLargest: true, 198 progress: progress 199 ) 200 await auditICloudDocuments(progress: progress) 201 await progress( 202 "storage audit complete: measured bytes exclude CloudKit metadata, shares, " + 203 "zone overhead, encryption overhead, server-retained state, and v2" 204 ) 205 } 206 207 private func auditPrivateCloudStorage( 208 label: String, 209 container: CKContainer, 210 progress: @MainActor @Sendable (String) -> Void 211 ) async -> StorageAuditTotals? { 212 let identifier = container.containerIdentifier ?? "unknown" 213 let database = container.privateCloudDatabase 214 await progress("storage audit container \(label) [\(identifier)]: starting private database") 215 216 let zones: [CKRecordZone] 217 do { 218 zones = try await database.allRecordZones() 219 } catch { 220 await progress( 221 "storage audit container \(label): couldn't list private zones — \(describe(error))" 222 ) 223 return nil 224 } 225 226 let defaultZoneID = CKRecordZone.default().zoneID 227 let customZones = zones 228 .map(\.zoneID) 229 .filter { $0 != defaultZoneID } 230 .sorted { $0.zoneName < $1.zoneName } 231 await progress( 232 "storage audit container \(label): found \(customZones.count) custom zone(s); " + 233 "asset contents will be downloaded" 234 ) 235 236 var totals = StorageAuditTotals() 237 for (index, zoneID) in customZones.enumerated() { 238 do { 239 let zone = try await auditStorageZone(zoneID, containerLabel: label, in: database) 240 var completedZone = zone 241 completedZone.zoneCount = 1 242 totals.merge(completedZone) 243 244 let types = formattedCounts(zone.recordCounts) 245 let title = zone.title.map { " title=[\(singleLine($0))]" } ?? "" 246 await progress( 247 "storage audit container \(label) zone \(index + 1)/\(customZones.count) " + 248 "[\(zoneID.zoneName)]:\(title) records=\(zone.recordCount) " + 249 "assets=\(zone.assetCount) assetBytes=\(formatBytes(zone.assetBytes)) " + 250 "inline=\(formatBytes(zone.inlineBytes)) " + 251 "types=[\(types)]" 252 ) 253 } catch { 254 await progress( 255 "storage audit container \(label) zone \(index + 1)/\(customZones.count) " + 256 "[\(zoneID.zoneName)] FAILED — \(describe(error))" 257 ) 258 } 259 } 260 261 await logStorageTotals( 262 totals, 263 prefix: "storage audit container \(label) totals", 264 includeLargest: false, 265 progress: progress 266 ) 267 return totals 268 } 269 270 private func logStorageTotals( 271 _ totals: StorageAuditTotals, 272 prefix: String, 273 includeLargest: Bool, 274 progress: @MainActor @Sendable (String) -> Void 275 ) async { 276 await progress( 277 "\(prefix): zones=\(totals.zoneCount) " + 278 "records=\(totals.recordCount) " + 279 "assets=\(totals.assetCount) assetBytes=\(formatBytes(totals.assetBytes)) " + 280 "inline=\(formatBytes(totals.inlineBytes)) " + 281 "measured=\(formatBytes(totals.assetBytes + totals.inlineBytes))" 282 ) 283 await progress("\(prefix) record types: [\(formattedCounts(totals.recordCounts))]") 284 285 for field in totals.assetFields.keys.sorted() { 286 guard let value = totals.assetFields[field] else { continue } 287 await progress( 288 "\(prefix) asset field \(field): count=\(value.count) " + 289 "bytes=\(formatBytes(value.bytes)) unavailable=\(value.unavailableCount)" 290 ) 291 } 292 293 guard includeLargest else { 294 if totals.unavailableAssetCount > 0 || totals.recordErrorCount > 0 { 295 await logStorageWarnings(totals, prefix: prefix, progress: progress) 296 } 297 return 298 } 299 300 let largest = totals.assets 301 .sorted { $0.bytes > $1.bytes } 302 .prefix(20) 303 for (index, asset) in largest.enumerated() { 304 await progress( 305 "\(prefix) largest #\(index + 1): \(asset.recordType).\(asset.field) " + 306 "bytes=\(formatBytes(asset.bytes)) container=\(asset.containerLabel) " + 307 "zone=\(asset.zoneName) " + 308 "record=\(asset.recordName)" 309 ) 310 } 311 312 if totals.unavailableAssetCount > 0 || totals.recordErrorCount > 0 { 313 await logStorageWarnings(totals, prefix: prefix, progress: progress) 314 } 315 } 316 317 private func logStorageWarnings( 318 _ totals: StorageAuditTotals, 319 prefix: String, 320 progress: @MainActor @Sendable (String) -> Void 321 ) async { 322 await progress( 323 "\(prefix) warnings: unavailableAssets=\(totals.unavailableAssetCount) " + 324 "recordErrors=\(totals.recordErrorCount)" 325 ) 326 } 327 328 private func auditICloudDocuments( 329 progress: @MainActor @Sendable (String) -> Void 330 ) async { 331 guard let root = FileManager.default.url( 332 forUbiquityContainerIdentifier: CloudContainer.originalIdentifier 333 ) else { 334 await progress("storage audit iCloud Documents: container unavailable") 335 return 336 } 337 let documents = root.appendingPathComponent("Documents", isDirectory: true) 338 let totals = Self.scanICloudDocuments(at: documents) 339 340 await progress( 341 "storage audit iCloud Documents totals: files=\(totals.fileCount) " + 342 "bytes=\(formatBytes(totals.bytes)) unavailable=\(totals.unavailableCount)" 343 ) 344 for (index, file) in totals.files.sorted(by: { $0.bytes > $1.bytes }).prefix(20).enumerated() { 345 await progress( 346 "storage audit iCloud Documents largest #\(index + 1): " + 347 "bytes=\(formatBytes(file.bytes)) path=[\(singleLine(file.path))]" 348 ) 349 } 350 } 351 352 private nonisolated static func scanICloudDocuments( 353 at documents: URL 354 ) -> DocumentsAuditTotals { 355 let keys: [URLResourceKey] = [.isRegularFileKey, .fileSizeKey] 356 guard let enumerator = FileManager.default.enumerator( 357 at: documents, 358 includingPropertiesForKeys: keys, 359 options: [.skipsHiddenFiles, .skipsPackageDescendants] 360 ) else { 361 return DocumentsAuditTotals(unavailableCount: 1) 362 } 363 364 var totals = DocumentsAuditTotals() 365 for case let url as URL in enumerator { 366 guard let values = try? url.resourceValues(forKeys: Set(keys)), 367 values.isRegularFile == true 368 else { continue } 369 totals.fileCount += 1 370 if let size = values.fileSize { 371 let bytes = Int64(size) 372 totals.bytes += bytes 373 totals.files.append( 374 DocumentsAuditFile( 375 path: url.path.replacingOccurrences(of: documents.path + "/", with: ""), 376 bytes: bytes 377 ) 378 ) 379 } else { 380 totals.unavailableCount += 1 381 } 382 } 383 384 return totals 385 } 386 387 private func auditStorageZone( 388 _ zoneID: CKRecordZone.ID, 389 containerLabel: String, 390 in database: CKDatabase 391 ) async throws -> StorageAuditTotals { 392 var totals = StorageAuditTotals() 393 var token: CKServerChangeToken? 394 var moreComing = true 395 396 while moreComing { 397 let page = try await database.recordZoneChanges( 398 inZoneWith: zoneID, 399 since: token 400 ) 401 token = page.changeToken 402 moreComing = page.moreComing 403 404 for result in page.modificationResultsByID.values { 405 do { 406 let record = try result.get().record 407 measure(record, containerLabel: containerLabel, into: &totals) 408 } catch { 409 totals.recordErrorCount += 1 410 } 411 } 412 } 413 return totals 414 } 415 416 private func measure( 417 _ record: CKRecord, 418 containerLabel: String, 419 into totals: inout StorageAuditTotals 420 ) { 421 totals.recordCount += 1 422 totals.recordCounts[record.recordType, default: 0] += 1 423 if totals.title == nil, 424 record.recordType == "Game" 425 || record.recordType == Archive.recordType 426 || record.recordType == Archive.legacyRecordType { 427 totals.title = record["title"] as? String 428 } 429 430 for field in record.allKeys() { 431 guard let value = record[field] else { continue } 432 if let asset = value as? CKAsset { 433 let key = "\(record.recordType).\(field)" 434 var fieldTotal = totals.assetFields[key, default: StorageAuditFieldTotal()] 435 fieldTotal.count += 1 436 if let bytes = asset.fileURL.flatMap(assetFileSize) { 437 fieldTotal.bytes += bytes 438 totals.assets.append( 439 StorageAuditAsset( 440 containerLabel: containerLabel, 441 recordType: record.recordType, 442 field: field, 443 recordName: record.recordID.recordName, 444 zoneName: record.recordID.zoneID.zoneName, 445 bytes: bytes 446 ) 447 ) 448 } else { 449 fieldTotal.unavailableCount += 1 450 totals.unavailableAssetCount += 1 451 } 452 totals.assetFields[key] = fieldTotal 453 } else { 454 totals.inlineBytes += approximateInlineSize(value) 455 } 456 } 457 } 458 459 private func assetFileSize(at url: URL) -> Int64? { 460 guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), 461 let size = attributes[.size] as? NSNumber 462 else { return nil } 463 return size.int64Value 464 } 465 466 private func approximateInlineSize(_ value: Any) -> Int64 { 467 switch value { 468 case let data as Data: 469 return Int64(data.count) 470 case let string as String: 471 return Int64(string.utf8.count) 472 case let values as [Any]: 473 return values.reduce(0) { $0 + approximateInlineSize($1) } 474 case is NSNumber, is Date: 475 return 8 476 case let reference as CKRecord.Reference: 477 return Int64(reference.recordID.recordName.utf8.count) 478 default: 479 return 0 480 } 481 } 482 483 private func formattedCounts(_ counts: [String: Int]) -> String { 484 counts.keys.sorted().compactMap { key in 485 counts[key].map { "\(key)=\($0)" } 486 }.joined(separator: ", ") 487 } 488 489 private func formatBytes(_ bytes: Int64) -> String { 490 "\(ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)) (\(bytes) B)" 491 } 492 493 private func singleLine(_ value: String) -> String { 494 String(value.replacing(/\s+/, with: " ").prefix(80)) 495 } 496 497 private func probeSubscriptions( 498 database: CKDatabase, 499 label: String 500 ) async -> (String, String) { 501 do { 502 let subs = try await database.allSubscriptions() 503 if subs.isEmpty { 504 return (label, "0 subscriptions — pushes will not fire") 505 } 506 let descriptions = subs.map { sub -> String in 507 let kind: String 508 switch sub { 509 case is CKDatabaseSubscription: kind = "database" 510 case is CKQuerySubscription: kind = "query" 511 case is CKRecordZoneSubscription: kind = "zone" 512 default: kind = "other(\(type(of: sub)))" 513 } 514 let silent = sub.notificationInfo?.shouldSendContentAvailable == true ? "silent" : "alert-only" 515 return "\(kind):\(sub.subscriptionID)[\(silent)]" 516 } 517 return (label, "\(subs.count): [\(descriptions.joined(separator: ", "))]") 518 } catch { 519 return (label, describe(error)) 520 } 521 } 522 523 nonisolated func describe(_ error: Error) -> String { 524 let nsError = error as NSError 525 return "ERROR domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)" 526 } 527 528 private nonisolated func describeStatus(_ status: CKAccountStatus) -> String { 529 switch status { 530 case .available: return "available" 531 case .noAccount: return "noAccount" 532 case .restricted: return "restricted" 533 case .couldNotDetermine: return "couldNotDetermine" 534 case .temporarilyUnavailable: return "temporarilyUnavailable" 535 @unknown default: return "unknown(\(status.rawValue))" 536 } 537 } 538 }