Post

BGTaskScheduler Code=3 是假警報:排 Core Data CloudKit 同步問題的正確診斷姿勢

BGTaskScheduler Code=3 是假警報:排 Core Data CloudKit 同步問題的正確診斷姿勢

NSPersistentCloudKitContainer 同步問題時,Xcode Console 最吵的那行 log 通常不是根因。這篇記幾個我初期被誤導、後來找出真正該看哪裡的經驗。

先講結論

排 Core Data + CloudKit 不同步時,先確認這三件事,再看其他 log

  1. CKContainer.accountStatus() 回傳 .available
  2. CKContainer.userRecordID() 能拿到 ID 不 throw
  3. 自己寫 listener 監聽 NSPersistentCloudKitContainer.eventChangedNotification,有沒有 setup began / setup ended 事件

如果 1、2 OK 但 3 完全沒動靜,就是 mirror delegate 沒 init(最常見是 dual-store 沒綁 named configuration)。

常見的假警報

假警報 #1:BGSystemTaskSchedulerErrorDomain Code=3

1
2
3
updateTaskRequest failed for com.apple.coredata.cloudkit.activity.export.<UUID>
Error updating background task request:
  Error Domain=BGSystemTaskSchedulerErrorDomain Code=3 "(null)"

這段在 Xcode Debug attach 時極其常見,尤其是剛啟動或 save 大量 record 時。Code=3 是 BGTaskSchedulerErrorCodeUnavailable——在有 debugger attach 的前景 app 裡,BGTaskScheduler 本來就會回這個錯。

不代表 mirror 出問題NSPersistentCloudKitContainer 除了 BGTask 以外還有很多別的方式觸發 export(foreground activity、network change、remote notification),BGTask 只是眾多 trigger 之一。

我初期花了很多時間在這上面:改 aps-environment、查 Background App Refresh、懷疑 provisioning profile 不 match⋯⋯全是白忙。

假警報 #2:updateTaskRequest called for an already running/updated task

1
2
updateTaskRequest called for an already running/updated task
com.apple.coredata.cloudkit.activity.export.<UUID>

這也是 noise——Core Data 自己的 task tracking 在 heartbeat 同一個 task,log 是「同一個 task 已存在」的資訊性通知,不是 error。

假警報 #3:Failed to send CA Event for app launch measurements

1
2
Failed to send CA Event for app launch measurements for
ca_event_type: 0 event_name: com.apple.app_launch_measurement.FirstFramePresentationMetric

跟 CloudKit 完全無關,是 iOS 自己的 analytics 通道在這個 build 沒註冊成功。忽略。

假警報 #4:LaunchServices 錯誤

1
2
3
(501) personaAttributesForPersonaType for type:0 failed with error
LaunchServices: store (null) or url (null) was nil
Attempt to map database failed: permission was denied.

這是 iOS 的 file picker / launch services 內部雜訊,Debug build 很常噴。跟 CloudKit 一毛錢關係都沒有。

真的該看的訊號

訊號 #1:eventChangedNotification 有沒有發

NSPersistentCloudKitContainer.eventChangedNotification 是最可靠的 sync 狀態來源。Mirror delegate 真的有在做事,就會發這個 notification;沒發 = 沒做事。

推薦寫一個 CloudSyncMonitor每個 event 都 print(不只 error):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@MainActor
final class CloudSyncMonitor: ObservableObject {
    @Published var isSyncing = false
    @Published var lastError: String?
    private var observer: Any?
    private var activeEvents: Set<UUID> = []

    init() {
        observer = NotificationCenter.default.addObserver(
            forName: NSPersistentCloudKitContainer.eventChangedNotification,
            object: nil, queue: .main
        ) { [weak self] notification in
            guard let event = notification.userInfo?[
                NSPersistentCloudKitContainer.eventNotificationUserInfoKey
            ] as? NSPersistentCloudKitContainer.Event else { return }

            let snapshot = (
                identifier: event.identifier,
                type: event.type,
                endDate: event.endDate,
                error: event.error
            )

            Task { @MainActor in
                guard let self else { return }
                if snapshot.endDate == nil {
                    self.activeEvents.insert(snapshot.identifier)
                } else {
                    self.activeEvents.remove(snapshot.identifier)
                }
                self.isSyncing = !self.activeEvents.isEmpty

                let typeName: String
                switch snapshot.type {
                case .setup: typeName = "setup"
                case .import: typeName = "import"
                case .export: typeName = "export"
                @unknown default: typeName = "unknown"
                }
                #if DEBUG
                let phase = snapshot.endDate == nil ? "began" : "ended"
                print("[CloudSyncMonitor] \(typeName) \(phase)")
                #endif

                if let err = snapshot.error as NSError? {
                    self.lastError = "[\(typeName)] \(err.domain) #\(err.code)\(err.localizedDescription)"
                }
            }
        }
    }
}

健康的 app 啟動時應該看到:

1
2
3
4
5
6
[CloudSyncMonitor] setup began
[CloudSyncMonitor] setup ended
[CloudSyncMonitor] setup began      ← dual-store 會 setup 兩次
[CloudSyncMonitor] setup ended
[CloudSyncMonitor] import began
[CloudSyncMonitor] import ended

匯入資料後:

1
2
[CloudSyncMonitor] export began
[CloudSyncMonitor] export ended

如果連 setup began 都沒有——mirror delegate 根本沒初始化。檢查:

  • xcdatamodel 的 configuration 設定
  • NSPersistentStoreDescription.cloudKitContainerOptions 有沒有設
  • NSPersistentHistoryTrackingKey + NSPersistentStoreRemoteChangeNotificationPostOptionKey 有沒有 enable

訊號 #2:accountStatususerRecordID

啟動時跑個 diagnostic,直接問 CloudKit「能用嗎」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private func dumpCloudKitDiagnostics() async {
    let container = CKContainer(identifier: Config.containerIdentifier)
    do {
        let status = try await container.accountStatus()
        let statusStr: String
        switch status {
        case .couldNotDetermine: statusStr = "couldNotDetermine"
        case .available: statusStr = "available"
        case .restricted: statusStr = "restricted"
        case .noAccount: statusStr = "noAccount"
        case .temporarilyUnavailable: statusStr = "temporarilyUnavailable"
        @unknown default: statusStr = "unknown"
        }
        print("[CloudKit-Diag] accountStatus: \(statusStr)")
    } catch {
        print("[CloudKit-Diag] accountStatus error: \(error)")
    }

    do {
        let recordID = try await container.userRecordID()
        print("[CloudKit-Diag] userRecordID: \(recordID.recordName)")
    } catch {
        print("[CloudKit-Diag] userRecordID error: \(error)")
    }
}

放在 @main App.task 裡,包 #if DEBUG

各種 accountStatus 的意思:

Status意義怎麼處理
available可以用沒事
noAccount裝置沒登 iCloud提示使用者登入
restricted家長管控等限制功能 disable
couldNotDetermine通常是網路問題retry
temporarilyUnavailable帳號有問題(例如要重新同意條款)提示使用者去 Settings

訊號 #3:iCloud Console → Logs

icloud.developer.apple.com → 選 container → 切 Development → Logs。

一定要 filter Database = PRIVATE,不然 Console Web UI 自己的 request 會把列表淹掉。

健康的 app 在本地匯入 N 筆資料之後,Logs 應該看到:

  • RecordSave 一大堆(N 筆每筆一個事件,可能會 batch)
  • 偶爾 ZoneSave(第一次建主 zone 時)
  • SubscriptionSave(建 CKShare 時)

零事件 = 資料沒上雲。再怎麼狂刷 updateTaskRequest 都沒用,看這裡就對了。

自建 UI 顯示 lastError

TestFlight build 沒 Xcode console。一個實用做法是在 Settings 頁底部掛 syncMonitor.lastError

1
2
3
4
5
6
7
8
9
10
11
if let lastError = syncMonitor.lastError {
    VStack(alignment: .leading, spacing: 4) {
        Text("CloudKit 最近錯誤")
            .font(.caption.bold())
            .foregroundStyle(.red)
        Text(lastError)
            .font(.caption2)
            .foregroundStyle(.secondary)
            .textSelection(.enabled)
    }
}

使用者不會主動打開這個區塊(沒 error 時根本不顯示),但 debug 期間你可以請他截圖給你。

排查流程建議

遇到 Core Data + CloudKit 不同步時,按這個順序:

  1. CloudSyncMonitor,看有沒有 setup began
    • 沒有 → dual-store configuration 缺、cloudKitContainerOptions 沒設、HistoryTracking 沒 enable
    • 有但沒 export began → 存檔沒 trigger,看你的 save 流程
    • export began 但 iCloud Console 看不到 → 網路、帳號、schema 問題(看下一步)
  2. accountStatususerRecordID
    • 不是 .available → 帳號問題
  3. iCloud Console 看 Logs
    • 有 error → 直接根據 error code 查
    • 零事件 → 回頭檢查 step 1
  4. 忽略 BGSystemTaskSchedulerErrorDomainupdateTaskRequest failed、LaunchServices 雜訊

心得

  1. Apple CloudKit debug log 不友善-com.apple.CoreData.CloudKitDebug 1 launch argument 新版 iOS 常不生效。別太依賴它。
  2. 自己寫 eventChangedNotification listener 是值得的投資,比任何官方 flag 可靠。
  3. BGSystemTaskSchedulerErrorDomain 就當沒看到。Xcode Debug 下它永遠會噴,不代表壞掉。
  4. 永遠要 filter Database = PRIVATE 看 iCloud Console Logs,預設會混入 Web 自己的 PUBLIC request。
  5. 最該相信的訊號是 eventChangedNotification + iCloud Console Logs,其他都是 distraction。

排 CloudKit sync 問題幾乎都是「在一堆雜訊裡找真訊號」的練習。有了上面這幾個工具,真訊號會浮起來。

This post is licensed under CC BY 4.0 by the author.