Listless/Sync/CloudKitSyncMonitor.swift (6144B)
1import CoreData
2import Foundation
3import os
4
5struct SyncDiagnosticEntry: Identifiable {
6 let id = UUID()
7 let timestamp: Date
8 let level: String
9 let message: String
10}
11
12@MainActor
13final class CloudKitSyncMonitor: ObservableObject {
14 @Published private(set) var transientErrorMessage: String?
15 @Published private(set) var lastSuccessfulSyncDate: Date?
16 @Published private(set) var lastCloudKitErrorDomain: String?
17 @Published private(set) var lastCloudKitErrorCode: Int?
18 @Published private(set) var lastCloudKitErrorDescription: String?
19 @Published private(set) var recentDiagnostics: [SyncDiagnosticEntry] = []
20
21 private var monitoringTask: Task<Void, Never>?
22 private var deferredTask: Task<Void, Never>?
23 private let logger = Logger(subsystem: "net.inqk.listless", category: "CloudKitSync")
24 private let maxDiagnosticEntries = 80
25
26 var hasDiagnosticsIssue: Bool {
27 transientErrorMessage != nil || lastCloudKitErrorDescription != nil
28 }
29
30 deinit {
31 monitoringTask?.cancel()
32 deferredTask?.cancel()
33 }
34
35 func startMonitoring(container: NSPersistentCloudKitContainer) {
36 guard monitoringTask == nil else { return }
37 logger.info("Starting CloudKit event monitoring")
38 appendDiagnostic(level: "info", "Starting CloudKit event monitoring")
39
40 monitoringTask = Task { [weak self] in
41 guard let self else { return }
42
43 for await notification in NotificationCenter.default.notifications(
44 named: NSPersistentCloudKitContainer.eventChangedNotification,
45 object: container
46 ) {
47 guard
48 let event =
49 notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
50 as? NSPersistentCloudKitContainer.Event
51 else {
52 self.logger.error("Received CloudKit eventChangedNotification without event payload")
53 self.appendDiagnostic(
54 level: "error",
55 "Received eventChangedNotification without event payload"
56 )
57 continue
58 }
59
60 let eventDescription =
61 "CloudKit event: type=\(self.eventTypeName(event.type)) endDatePresent=\(event.endDate != nil)"
62 self.logger.debug("\(eventDescription, privacy: .public)")
63 self.appendDiagnostic(level: "debug", eventDescription)
64
65 if let error = event.error {
66 self.logCloudKitError(error)
67 self.handle(issue: CloudKitErrorClassifier.classify(error))
68 } else if self.isSuccessfulSyncCompletion(event) {
69 self.deferredTask?.cancel()
70 self.deferredTask = nil
71 self.transientErrorMessage = nil
72 self.lastSuccessfulSyncDate = event.endDate ?? Date()
73 let successDescription = "CloudKit sync succeeded: type=\(self.eventTypeName(event.type))"
74 self.logger.info("\(successDescription, privacy: .public)")
75 self.appendDiagnostic(level: "info", successDescription)
76 }
77 }
78 }
79 }
80
81 func ingest(error: Error) {
82 handle(issue: CloudKitErrorClassifier.classify(error))
83 }
84
85 private func handle(issue: SyncIssue) {
86 switch issue {
87 case .transient(let message):
88 logger.warning("Transient sync issue: \(message, privacy: .public)")
89 appendDiagnostic(level: "warning", "Transient sync issue: \(message)")
90 showTransient(message)
91
92 case .deferred(let message):
93 logger.warning("Deferred sync issue scheduled: \(message, privacy: .public)")
94 appendDiagnostic(level: "warning", "Deferred sync issue scheduled: \(message)")
95 guard deferredTask == nil else { return }
96 deferredTask = Task {
97 try? await Task.sleep(for: .seconds(60))
98 guard !Task.isCancelled else { return }
99 showTransient(message)
100 self.logger.warning("Deferred sync issue surfaced: \(message, privacy: .public)")
101 self.appendDiagnostic(level: "warning", "Deferred sync issue surfaced: \(message)")
102 }
103 }
104 }
105
106 private func showTransient(_ message: String) {
107 transientErrorMessage = message
108 }
109
110 private func isSuccessfulSyncCompletion(_ event: NSPersistentCloudKitContainer.Event) -> Bool {
111 guard event.endDate != nil else { return false }
112
113 switch event.type {
114 case .import, .export:
115 return true
116 case .setup:
117 return false
118 @unknown default:
119 return false
120 }
121 }
122
123 private func eventTypeName(_ type: NSPersistentCloudKitContainer.EventType) -> String {
124 switch type {
125 case .setup:
126 return "setup"
127 case .import:
128 return "import"
129 case .export:
130 return "export"
131 @unknown default:
132 return "unknown"
133 }
134 }
135
136 private func logCloudKitError(_ error: Error) {
137 let nsError = error as NSError
138 lastCloudKitErrorDomain = nsError.domain
139 lastCloudKitErrorCode = nsError.code
140 lastCloudKitErrorDescription = nsError.localizedDescription
141 let message =
142 "CloudKit event error: domain=\(nsError.domain) code=\(nsError.code) description=\(nsError.localizedDescription) userInfo=\(String(describing: nsError.userInfo))"
143 logger.error(
144 """
145 \(message, privacy: .public)
146 """
147 )
148 appendDiagnostic(level: "error", message)
149 }
150
151 private func appendDiagnostic(level: String, _ message: String) {
152 recentDiagnostics.append(SyncDiagnosticEntry(timestamp: Date(), level: level, message: message))
153 if recentDiagnostics.count > maxDiagnosticEntries {
154 recentDiagnostics.removeFirst(recentDiagnostics.count - maxDiagnosticEntries)
155 }
156 }
157}