Engineering Practice

Debug Core Data Concurrency in Cloud Mac CI

Debug Core Data Concurrency in Cloud Mac CI

A list screen occasionally reads an invalidated NSManagedObject. It can run successfully dozens of times locally, yet CI crashes at random while saving, merging, or refreshing the UI. This usually does not indicate a damaged database file. Instead, an object has crossed the queue boundary of its NSManagedObjectContext. Rather than adding retries, enable Core Data concurrency debugging on a cloud Mac so invalid access fails immediately, as close to its source as possible.

Define the problem boundary first

Core Data’s basic rule is straightforward: a private-queue context may only be used inside its own perform or performAndWait block, while a main-queue context may only be accessed from the main queue. An NSManagedObject also cannot be passed directly to another queue. To hand data across queues, pass a stable NSManagedObjectID, then fetch the object again in the destination context.

Start by checking these high-risk entry points:

Do not treat perform as a generic asynchronous wrapper. It protects the context and its object graph, not merely a particular save statement.

Enable concurrency debugging

Add these two launch arguments to a dedicated test Scheme:

-com.apple.CoreData.ConcurrencyDebug
1

For UI tests, pass them explicitly from the test code to prevent differences between developers’ local Schemes and the CI Scheme:

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

If regular unit tests depend on a host application, configure the same argument in the shared Scheme and commit the .xcscheme file to the repository. Do not add this argument to release configurations. When a violation is detected, the debugging mechanism intentionally terminates the process, which is exactly the behavior required in a test environment.

Once enabled, run the smallest test set first. Starting with the full suite creates enough concurrent work to obscure the first useful call stack and increases the cost of analyzing the logs.

Pass object IDs instead of objects

A common mistake is to create an object in a background context and then pass that object directly to the main queue. The correct approach is to save it, obtain a permanent object ID, and fetch it again in the destination 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()
    }
}

There are three checks here: a permanent ID is obtained before returning; the destination context resolves the object again with existingObject(with:); and every property read and write occurs inside the corresponding context’s closure. If only a temporary ID is passed, the destination context may be unable to resolve it. Reading a property outside the closure—even a single string—still violates the queue constraint.

Build a reliably reproducible test

The goal is not to pile up random concurrent operations, but to fix the conflicting path in place. Break the scenario into four stages: background insertion, background save, merge into the main context, and read on the main queue. Use a separate in-memory persistent store for every test and wait for all tasks to finish before the test ends.

Check Failure signal Fix
Background write Context accessed outside the closure Move it back inside perform
Object handoff Closure captures a managed object Pass an object ID instead
Merge notification Source context is unclear Restrict the source of save notifications
Test cleanup Later tests still receive notifications Remove observers and destroy the container

Do not use a fixed sleep to wait for a save. Establish a real completion condition with await, an expectation, or a persistent-container callback. A fixed delay only moves the race window somewhere else.

For an intermittent path, repeat the same small test 20 to 50 times, but set an overall timeout. Repetition broadens timing coverage; it must not replace deterministic state synchronization.

Preserve first-failure evidence in CI

The cloud Mac job should pin the Xcode selection, simulator destination, and results directory. The following script preserves the complete log and result bundle when the test fails:

#!/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

Choose the destination device name from the simulators actually available in the current Xcode installation; do not assume every image provides exactly the same set. Before uploading logs, remove business data, access tokens, and sensitive fragments from user directories. Archive the result bundle, complete log, failed test name, commit identifier, and launch arguments together as a single evidence set.

If the job requires parallel testing, first run the concurrency-debugging suite separately and serially. Its purpose is to identify the first out-of-queue access, not to maximize throughput. Once it is stable, evaluate whether it should be incorporated into the regular test shards.

Create a low-noise merge gate

A two-layer gate is recommended. The first layer runs only data-layer unit tests and a small number of critical UI flows, always with concurrency debugging enabled. The second runs the complete regression suite, but should not be relied on to detect basic queue violations. Disable automatic retries when the first layer fails, because a successful retry disguises a real defect as infrastructure noise.

Before merging, verify the following:

Once every cross-queue object handoff uses an object ID, asynchronous completion conditions are explicit, and result evidence is traceable, crashes that previously appeared “only in production” become ordinary code-review issues.

Frequently asked questions

Should Core Data concurrency diagnostics stay enabled in production?

No. The diagnostic intentionally terminates the process when it detects a violation. Enable it only in a dedicated development or CI test configuration, not in release builds.

Why can normal tests pass while a queue violation still exists?

Ordinary tests may not produce the required interleaving. Repeatedly exercise background writes, main-queue reads, object ID handoff, and context merging in the same focused scenario.

What evidence should a failed CI job retain?

Keep the complete xcodebuild log, xcresult bundle, failing test name, commit identifier, and launch arguments. Retain any crash report after removing sensitive paths and application data.

ArmCLI Cloud Mac

Prepare a dedicated physical Mac for your next build

Choose the model, data center, and rental term for your task. Nodes run normally year-round; the live availability status is shown in the console.