BGTaskScheduler Code=3 是假警報:排 Core Data CloudKit 同步問題的正確診斷姿勢
排 NSPersistentCloudKitContainer 同步問題時,Xcode Console 最吵的那行 log 通常不是根因。這篇記幾個我初期被誤導、後來找出真正該看哪裡的經驗。
先講結論
排 Core Data + CloudKit 不同步時,先確認這三件事,再看其他 log:
CKContainer.accountStatus()回傳.availableCKContainer.userRecordID()能拿到 ID 不 throw- 自己寫 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:accountStatus 跟 userRecordID
啟動時跑個 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 不同步時,按這個順序:
- 開
CloudSyncMonitor,看有沒有setup began- 沒有 → dual-store configuration 缺、
cloudKitContainerOptions沒設、HistoryTracking 沒 enable - 有但沒
export began→ 存檔沒 trigger,看你的 save 流程 - 有
export began但 iCloud Console 看不到 → 網路、帳號、schema 問題(看下一步)
- 沒有 → dual-store configuration 缺、
- 印
accountStatus跟userRecordID- 不是
.available→ 帳號問題
- 不是
- iCloud Console 看 Logs
- 有 error → 直接根據 error code 查
- 零事件 → 回頭檢查 step 1
- 忽略
BGSystemTaskSchedulerErrorDomain、updateTaskRequest failed、LaunchServices 雜訊
心得
- Apple CloudKit debug log 不友善。
-com.apple.CoreData.CloudKitDebug 1launch argument 新版 iOS 常不生效。別太依賴它。 - 自己寫 eventChangedNotification listener 是值得的投資,比任何官方 flag 可靠。
- BGSystemTaskSchedulerErrorDomain 就當沒看到。Xcode Debug 下它永遠會噴,不代表壞掉。
- 永遠要 filter
Database = PRIVATE看 iCloud Console Logs,預設會混入 Web 自己的 PUBLIC request。 - 最該相信的訊號是
eventChangedNotification+ iCloud Console Logs,其他都是 distraction。
排 CloudKit sync 問題幾乎都是「在一堆雜訊裡找真訊號」的練習。有了上面這幾個工具,真訊號會浮起來。