listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

CloudKitSyncMonitor.swift (6144B)


      1 import CoreData
      2 import Foundation
      3 import os
      4 
      5 struct SyncDiagnosticEntry: Identifiable {
      6     let id = UUID()
      7     let timestamp: Date
      8     let level: String
      9     let message: String
     10 }
     11 
     12 @MainActor
     13 final 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 }