Eine Listenansicht greift gelegentlich auf ein bereits ungültiges NSManagedObject zu. Lokal laufen Dutzende Durchläufe fehlerfrei, doch in der CI stürzt die App beim Speichern, Zusammenführen oder Aktualisieren der Oberfläche sporadisch ab. Meist ist nicht die Datenbankdatei beschädigt. Stattdessen wurde ein Objekt außerhalb der Queue seines NSManagedObjectContext verwendet. Anstatt weitere Wiederholungsversuche einzubauen, sollte das Core-Data-Concurrency-Debugging auf einem Cloud-Mac aktiviert werden. Dadurch schlagen unzulässige Zugriffe möglichst nah an ihrer Ursache sofort fehl.
Zuerst die Problemgrenzen bestimmen
Die Grundregeln von Core Data sind eindeutig: Ein Private-Queue-Context darf nur innerhalb seines eigenen perform- oder performAndWait-Blocks verwendet werden. Auf einen Main-Queue-Context darf ausschließlich von der Main Queue aus zugegriffen werden. Auch ein NSManagedObject darf nicht direkt an eine andere Queue übergeben werden. Queue-übergreifend sollte stattdessen eine stabile NSManagedObjectID übergeben und das Objekt im Ziel-Context erneut geladen werden.
Zuerst sollten besonders riskante Übergabepunkte geprüft werden:
- Ein Netzwerk-Callback übergibt ein Managed Object direkt an den Hauptthread, um die Oberfläche zu aktualisieren.
Task.detachederfasst einen Context oder ein Managed Object.- Nach einem Speichervorgang im Hintergrund wird ein Objekt-Array über eine Benachrichtigung oder einen Closure weitergereicht.
- Tests verwenden denselben Persistent Container gemeinsam, ohne das Ende des vorherigen asynchronen Schreibvorgangs abzuwarten.
- Während Contexts zusammengeführt werden, hält die Oberfläche weiterhin Objekte, die bereits gelöscht oder zurückgesetzt wurden.
performist kein gewöhnlicher asynchroner Wrapper. Es schützt den Context und seinen Objektgraphen, nicht nur eine einzelne Speicheranweisung.
Concurrency-Debugging aktivieren
Fügen Sie den Startargumenten eines eigens dafür vorgesehenen Test-Schemes zwei Einträge hinzu:
-com.apple.CoreData.ConcurrencyDebug
1
Bei UI-Tests empfiehlt es sich, die Argumente ausdrücklich im Testcode zu setzen. So entstehen keine Abweichungen zwischen dem lokalen Scheme der Entwickler und dem CI-Scheme:
let app = XCUIApplication()
app.launchArguments += [
"-com.apple.CoreData.ConcurrencyDebug",
"1"
]
app.launch()
Wenn reguläre Unit-Tests von einer Host-App abhängen, kann derselbe Parameter im gemeinsam genutzten Scheme konfiguriert und die .xcscheme-Datei in das Repository eingecheckt werden. Der Parameter darf nicht in die Release-Konfiguration übernommen werden. Bei einem Verstoß beendet der Debug-Mechanismus den Prozess absichtlich – genau dieses Verhalten ist in der Testumgebung erwünscht.
Nach der Aktivierung sollte zunächst nur die kleinstmögliche Testmenge ausgeführt werden. Wird sofort die vollständige Suite gestartet, können zahlreiche nebenläufige Tasks den ersten aussagekräftigen Aufruf-Stack verdecken und die Logauswertung unnötig erschweren.
Objekte per ID statt direkt übergeben
Ein typischer Fehler besteht darin, ein in einem Hintergrund-Context erstelltes Objekt direkt an die Main Queue weiterzugeben. Korrekt ist es, das Objekt zuerst zu speichern, eine permanente Object ID abzurufen und es anschließend im Ziel-Context neu zu laden:
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()
}
}
Dabei sind drei Punkte zu prüfen: Vor der Rückgabe wurde bereits eine permanente ID erzeugt; der Ziel-Context löst das Objekt mit existingObject(with:) erneut auf; sämtliche Lese- und Schreibzugriffe auf Eigenschaften finden innerhalb des Blocks des jeweiligen Contexts statt. Wird nur eine temporäre ID weitergegeben, kann der Ziel-Context sie möglicherweise nicht auflösen. Wird eine Eigenschaft außerhalb des Blocks gelesen, verletzt selbst das bloße Lesen einer einzelnen Zeichenfolge weiterhin die Queue-Regeln.
Einen zuverlässig reproduzierbaren Test entwerfen
Im Mittelpunkt des Tests sollte kein wahllos erzeugter Parallelismus stehen, sondern ein fest definierter Konfliktpfad. Das Szenario lässt sich in vier Phasen unterteilen: Einfügen im Hintergrund, Speichern im Hintergrund, Zusammenführen in den Main-Context und Lesen auf der Main Queue. Jeder Testlauf sollte einen eigenen In-Memory-Persistent-Store verwenden und am Testende auf den Abschluss aller Tasks warten.
| Prüfpunkt | Fehlersignal | Korrektur |
|---|---|---|
| Schreiben im Hintergrund | Zugriff auf den Context außerhalb des Blocks | Zugriff in perform verlagern |
| Objektübergabe | Ein Closure erfasst ein Managed Object | Stattdessen die Object ID übergeben |
| Merge-Benachrichtigung | Herkunfts-Context ist nicht eindeutig | Quelle der Speicherbenachrichtigung einschränken |
| Testbereinigung | Nachfolgende Tests erhalten weiterhin Benachrichtigungen | Observer entfernen und Container freigeben |
Speichervorgänge dürfen nicht mit einem festen sleep abgewartet werden. Stattdessen sollte mit await, einer Expectation oder einem Callback des Persistent Containers eine echte Abschlussbedingung hergestellt werden. Eine feste Wartezeit verschiebt das Race Window lediglich an eine andere Stelle.
Bei sporadisch auftretenden Pfaden kann derselbe kleine Test 20 bis 50 Mal wiederholt werden, allerdings mit einem Gesamt-Timeout. Die Wiederholungen sollen eine größere Bandbreite möglicher zeitlicher Abläufe abdecken. Sie sind kein Ersatz für eine deterministische Zustandssynchronisierung.
In der CI den ursprünglichen Fehlerzustand sichern
Für Cloud-Mac-Jobs sollten die Xcode-Auswahl, das Simulatorziel und das Ergebnisverzeichnis fest vorgegeben werden. Das folgende Skript bewahrt bei einem Fehler das vollständige Log und das Ergebnis-Bundle auf:
#!/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
Der Name des Zielgeräts muss einem Simulator entsprechen, den die aktuell verwendete Xcode-Version tatsächlich bereitstellt. Es darf nicht vorausgesetzt werden, dass alle Images exakt dieselben Simulatoren enthalten. Vor dem Speichern oder Übermitteln von Logs müssen außerdem Geschäftsdaten, Zugriffstoken und vertrauliche Ausschnitte aus Benutzerverzeichnissen entfernt werden. Ergebnis-Bundle, vollständiges Log, Name des fehlgeschlagenen Tests, Commit-ID und Startargumente sollten gemeinsam als ein zusammengehöriger Beweissatz archiviert werden.
Falls ein Job parallele Tests benötigt, sollte die Suite mit aktiviertem Concurrency-Debugging zunächst separat und seriell ausgeführt werden. Ihr Zweck besteht darin, den ersten Zugriff außerhalb der zulässigen Queue zu finden, nicht den maximalen Durchsatz zu erzielen. Erst wenn die Suite stabil läuft, sollte geprüft werden, ob sie sich für die reguläre Testaufteilung eignet.
Ein Merge-Gate mit wenig Fehlrauschen einrichten
Es empfiehlt sich, das Merge-Gate in zwei Stufen aufzuteilen. Die erste Stufe führt ausschließlich Unit-Tests der Datenschicht sowie einige wenige zentrale UI-Abläufe aus und aktiviert dabei stets das Concurrency-Debugging. Die zweite Stufe übernimmt die vollständige Regression, sollte aber nicht dafür zuständig sein, grundlegende Queue-Fehler zu erkennen. Schlägt die erste Stufe fehl, dürfen keine automatischen Wiederholungsversuche stattfinden. Ein erfolgreicher Wiederholungslauf würde einen echten Defekt als Schwankung der Infrastruktur erscheinen lassen.
Vor dem Merge sollten außerdem folgende Punkte geprüft werden:
- Das Scheme ist freigegeben und wird in der Versionsverwaltung geführt.
- Die Tests verwenden einen eigenen Store und greifen nicht auf die Entwicklungsdatenbank zurück.
- Contexts, Objekte und Fetched Results Controller werden nicht Queue-übergreifend weitergegeben.
- Benachrichtigungs-Observer werden am Testende ordnungsgemäß entfernt.
- Bei einem CI-Fehler werden Logs und xcresult weiterhin hochgeladen.
- Der korrigierende Commit enthält einen Regressionstest, der mit dem alten Code fehlschlägt.
Sobald Queue-übergreifend nur noch Object IDs übergeben werden, asynchrone Abschlussbedingungen eindeutig definiert sind und sich alle Ergebnisartefakte nachverfolgen lassen, werden solche „nur in Produktion gelegentlich auftretenden“ Abstürze zu gewöhnlichen Problemen, die sich bereits im Code-Review erkennen lassen.
Häufig gestellte Fragen
Sollte die Core-Data-Diagnose in produktiven Builds aktiv bleiben?
Nein. Bei einem Verstoß beendet die Diagnose den Prozess absichtlich. Sie gehört ausschließlich in eine Entwicklungs- oder dedizierte CI-Testkonfiguration.
Warum können normale Tests trotz eines Queue-Verstoßes erfolgreich sein?
Sie erzeugen nicht immer die problematische Reihenfolge. Hintergrundschreibvorgänge, Lesen in der Main Queue, objectID-Übergabe und Context-Merges sollten gemeinsam wiederholt werden.
Welche Nachweise sollte ein fehlgeschlagener CI-Lauf aufbewahren?
Bewahren Sie das vollständige xcodebuild-Protokoll, das xcresult-Paket, den Testnamen, den Commit und die Startargumente auf. Absturzberichte werden nach Bereinigung sensibler Daten ergänzt.
Einen exklusiven physischen Mac für den nächsten Build vorbereiten
Modell, Rechenzentrum und Mietdauer passend zur Aufgabe auswählen. Die Knoten laufen ganzjährig stabil; maßgeblich ist der in der Konsole angezeigte Status in Echtzeit.