Практика разработки

Диагностика нарушений конкурентности Core Data в Mac CI

Диагностика нарушений конкурентности Core Data в Mac CI

На экране списка время от времени считывается уже недействительный NSManagedObject. Локально приложение без проблем выдерживает десятки последовательных запусков, но в CI случайным образом завершается с ошибкой при сохранении, слиянии изменений или обновлении интерфейса. Обычно причина не в повреждении файла базы данных, а в том, что объект покинул очередь своего NSManagedObjectContext. Вместо добавления повторных попыток лучше включить отладку конкурентности Core Data на облачном Mac, чтобы недопустимый доступ немедленно приводил к сбою как можно ближе к его источнику.

Сначала определите границы проблемы

Основное правило Core Data однозначно: контекст с приватной очередью можно использовать только внутри его perform или performAndWait, а к контексту главной очереди разрешено обращаться только из главной очереди. NSManagedObject также нельзя напрямую передавать в другую очередь. Между очередями следует передавать стабильный NSManagedObjectID, а затем повторно получать объект в целевом контексте.

Сначала проверьте наиболее рискованные точки:

Не воспринимайте perform как обычную асинхронную обёртку. Он защищает контекст и весь его граф объектов, а не только отдельную операцию сохранения.

Включите отладку конкурентности

Добавьте в аргументы запуска отдельной тестовой Scheme две записи:

-com.apple.CoreData.ConcurrencyDebug
1

Для UI-тестов рекомендуется явно передавать их из тестового кода, чтобы конфигурации локальной Scheme разработчика и Scheme в CI не различались:

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

Если обычные модульные тесты зависят от host-приложения, задайте тот же аргумент в общей Scheme и добавьте .xcscheme в репозиторий. Не включайте этот аргумент в релизную конфигурацию: при обнаружении нарушения механизм отладки намеренно завершает процесс, и именно такое поведение требуется в тестовой среде.

После включения сначала запустите минимальный набор тестов. Если сразу выполнить весь комплект, множество параллельных задач скроет первый полезный стек вызовов и усложнит анализ журналов.

Передавайте ID вместо объектов

Типичная ошибка — создать объект в фоновом контексте и напрямую передать его в главную очередь. Правильный подход: сначала сохранить объект, получить постоянный object ID, а затем загрузить его в целевом контексте:

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; целевой контекст повторно разрешает объект через existingObject(with:); все операции чтения и записи свойств выполняются внутри замыкания соответствующего контекста. Если передать только временный ID, целевой контекст может не разрешить его. Если прочитать свойство за пределами замыкания, даже единственную строку, это всё равно нарушит ограничения очереди.

Создайте стабильно воспроизводящий тест

Задача теста — не наращивать случайный параллелизм, а зафиксировать конфликтующий путь выполнения. Сценарий можно разделить на четыре этапа: фоновая вставка, фоновое сохранение, слияние в главном контексте и чтение в главной очереди. Для каждого запуска используйте отдельное in-memory persistent store и в конце теста дожидайтесь завершения всех задач.

Проверка Признак сбоя Направление исправления
Фоновая запись Доступ к контексту за пределами замыкания Перенести внутрь perform
Передача объекта Замыкание захватывает управляемый объект Передавать object ID
Уведомление о слиянии Исходный контекст не определён Ограничить источник уведомлений о сохранении
Очистка после теста Последующие тесты продолжают получать уведомления Удалить наблюдателей и уничтожить контейнер

Не используйте фиксированный sleep для ожидания сохранения. Формируйте реальное условие завершения через await, expectation или callback persistent container. Фиксированная задержка лишь перемещает окно гонки в другое место.

Для нестабильного пути один и тот же небольшой тест можно повторить от 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. Не следует предполагать, что оно одинаково во всех образах. Перед публикацией журналов также удалите бизнес-данные, токены доступа и конфиденциальные фрагменты из пользовательских каталогов. Пакет результатов, полный журнал, название упавшего теста, номер коммита и аргументы запуска необходимо архивировать как единый набор диагностических данных.

Если задание требует параллельного тестирования, сначала запускайте комплект с отладкой конкурентности отдельно и последовательно. Его цель — найти первое обращение за пределами допустимой очереди, а не обеспечить максимальную пропускную способность. После стабилизации можно оценить, подходит ли он для включения в обычные тестовые шарды.

Настройте merge gate с низким уровнем шума

Рекомендуется разделить проверку перед слиянием на два уровня. На первом запускаются только модульные тесты слоя данных и несколько критически важных UI-сценариев с постоянно включённой отладкой конкурентности. На втором выполняется полная регрессия, но полагаться на неё при выявлении базовых ошибок очередей не следует. При сбое первого уровня автоматические повторные попытки должны быть запрещены: успешный повтор замаскирует реальный дефект под нестабильность инфраструктуры.

Перед слиянием ещё раз проверьте следующее:

Когда все объекты между очередями передаются через object ID, условия завершения асинхронных операций однозначны, а диагностические данные можно проследить до источника, сбои, которые раньше «случайно встречались только в продакшене», превращаются в обычные замечания при ревью кода.

Часто задаваемые вопросы

Нужно ли включать диагностику конкурентности в рабочей сборке?

Нет. При обнаружении нарушения процесс намеренно завершается. Параметр следует использовать только в отдельной конфигурации разработки или в задании CI.

Почему обычные тесты могут не обнаружить нарушение очереди?

Они не всегда создают нужный порядок операций. Повторяйте фоновые записи, чтение в главной очереди, передачу objectID и слияние контекстов в одном сценарии.

Какие материалы сохранять после сбоя CI?

Сохраните полный журнал xcodebuild, пакет xcresult, имя теста, идентификатор коммита и параметры запуска. Отчёт о сбое добавляйте после удаления конфиденциальных данных.

Облачный Mac ArmCLI

Подготовьте выделенный физический Mac для следующей сборки

Выбирайте модель, дата-центр и срок аренды под задачу. Узлы стабильно работают 365 дней в году; актуальный статус доступности отображается в консоли в реальном времени.