工程實務

雲端 Mac CI 的 Core Data 併發除錯實戰

雲端 Mac CI 的 Core Data 併發除錯實戰

列表頁偶爾會讀取到已失效的 NSManagedObject。在本機連續執行數十次都一切正常,CI 卻可能在儲存、合併或重新整理介面時隨機崩潰。這類問題通常不是資料庫檔案損毀,而是物件跨出了所屬 NSManagedObjectContext 的佇列。與其增加重試,不如在雲端 Mac 啟用 Core Data 併發除錯,讓違規存取在最接近源頭的位置立即失敗。

先確認問題邊界

Core Data 的基本規則很明確:私有佇列 context 只能在自己的 perform 或 performAndWait 中使用;主佇列 context 只能由主佇列存取。NSManagedObject 也不能直接傳給另一條佇列。需要跨佇列傳遞時,應傳送穩定的 NSManagedObjectID,再由目標 context 重新取得物件。

先檢查以下高風險入口:

不要把 perform 當成一般的非同步包裝器。它保護的是 context 及其物件圖,而不只是某一條儲存陳述式。

開啟併發除錯開關

在專用測試 Scheme 的啟動引數中加入兩項:

-com.apple.CoreData.ConcurrencyDebug
1

對於 UI 測試,建議由測試程式碼明確傳入,避免開發者本機 Scheme 與 CI Scheme 的設定不一致:

let app = XCUIApplication()
app.launchArguments += [
    "-com.apple.CoreData.ConcurrencyDebug",
    "1"
]
app.launch()

一般單元測試若依賴宿主應用程式,可在共用 Scheme 中設定相同引數,並將 .xcscheme 提交至儲存庫。不要把這個引數加入發佈設定;偵測到違規時,除錯機制會主動終止程序,這正是測試環境需要的行為。

啟用後,先執行最小測試集合。若一開始就執行完整套件,大量併發工作會掩蓋第一個有效的呼叫堆疊,也會提高日誌分析成本。

將物件傳遞改為 ID 傳遞

典型錯誤是在背景 context 建立物件後,直接把物件交給主佇列。正確做法是先儲存,取得永久 object ID,再於目標 context 中取得物件:

func importItem(
    payload: Payload,
    backgroundContext: NSManagedObjectContext,
    viewContext: NSManagedObjectContext
) async throws {
    let objectID = try await backgroundContext.perform {
        let item = Item(context: backgroundContext)
        item.title = payload.title
        try backgroundContext.obtainPermanentIDs(for: [item])
        try backgroundContext.save()
        return item.objectID
    }

    try await viewContext.perform {
        let item = try viewContext.existingObject(with: objectID) as! Item
        item.lastViewedAt = Date()
        try viewContext.save()
    }
}

這裡有三個檢查點:回傳前已取得永久 ID;目標 context 使用 existingObject(with:) 重新解析物件;所有屬性讀寫都位於對應 context 的閉包內。若只傳送暫時 ID,目標 context 可能無法解析;若在閉包外讀取屬性,即使只讀一行字串,仍然違反佇列限制。

設計可穩定重現的測試

測試重點不是堆疊隨機併發,而是固定衝突路徑。可以把情境拆成四個階段:背景插入、背景儲存、主 context 合併,以及主佇列讀取。每次都使用獨立的記憶體內持久化儲存區,並在測試結束時等待所有工作完成。

檢查項目 失敗訊號 修正方向
背景寫入 在閉包外存取 context 移回 perform 內
物件交接 閉包捕捉託管物件 改傳 object ID
合併通知 來源 context 不明確 限定儲存通知來源
測試清理 後續測試案例仍收到通知 移除觀察者並銷毀容器

不要用固定的 sleep 等待儲存完成。應透過 await、expectation 或持久化容器回呼建立真實的完成條件。固定等待只會把競爭時間窗移到別處。

對於偶發路徑,可以讓同一個小型測試重複執行 20 至 50 次,但應設定總逾時。重複次數是為了擴大時序涵蓋範圍,不應取代確定性的狀態同步。

在 CI 中保留第一現場

雲端 Mac 工作應固定 Xcode 選擇、模擬器目標與結果目錄。以下指令碼會在失敗時保留完整日誌與結果套件:

#!/bin/zsh
set -euo pipefail

mkdir -p artifacts logs
rm -rf artifacts/CoreDataTests.xcresult

if ! xcodebuild test \
  -workspace App.xcworkspace \
  -scheme AppConcurrencyTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -resultBundlePath artifacts/CoreDataTests.xcresult \
  > logs/coredata-tests.log 2>&1; then
  grep -nE 'CoreData|Concurrency|NSManagedObjectContext' \
    logs/coredata-tests.log || true
  exit 1
fi

目標裝置名稱應取自目前 Xcode 實際提供的模擬器,不要假設所有映像檔都完全相同。提交日誌前,還必須移除業務資料、存取權杖,以及使用者目錄中的敏感片段。結果套件、完整日誌、失敗測試名稱、提交編號與啟動引數,應作為同一組證據封存。

如果工作需要平行測試,先讓併發除錯套件獨立循序執行。它的目標是找出第一個越界存取,而不是追求最高吞吐量。確認穩定後,再評估是否適合併入一般測試分片。

建立低雜訊的合併門禁

建議將門禁分成兩層。第一層只執行資料層單元測試與少量關鍵 UI 流程,並始終啟用併發除錯;第二層執行完整迴歸測試,但不要依賴它來發現基礎佇列錯誤。第一層失敗時應禁止自動重試,因為重試通過會把真實缺陷偽裝成基礎設施抖動。

合併前再次核對以下項目:

當所有跨佇列物件都改用 object ID 交接、非同步完成條件明確,而且結果證據可追溯後,這類「只能在線上偶遇」的崩潰,就會變成一般的程式碼審查問題。

常見問題

Core Data 併發除錯參數適合在正式版本持續啟用嗎?

不適合。偵測到違規時會主動終止程序,因此只應用於開發或 CI 專用測試設定,不應放入正式發佈組態。

為什麼一般測試通過後仍可能存在跨佇列問題?

一般測試未必產生需要的競爭順序。應在同一情境反覆執行背景寫入、主佇列讀取、objectID 傳遞與 context 合併。

CI 失敗時應優先保留哪些證據?

至少保留完整 xcodebuild 日誌、xcresult、失敗測試名稱、提交識別碼與啟動參數。若有崩潰報告,移除敏感資料後一併保存。

ArmCLI 雲端 Mac

為下一次建置準備獨享實體 Mac

依任務選擇機型、機房與租用期間。節點全年正常運作,實際可用狀態以控制台即時回傳為準。