Fehler und Abbruch bei OperationQueue gezielt testen

Fehler und Abbruch bei OperationQueue gezielt testen

Eine Bildverarbeitung lädt zunächst Daten herunter, konvertiert sie und schreibt sie anschließend in ein Verzeichnis. Doch obwohl der Download fehlschlägt, wird die Konvertierung trotzdem eingeplant; und obwohl der Nutzer auf Abbrechen klickt, werden weiter Dateien geschrieben. Meist ist daran nicht die Warteschlange schuld. Vielmehr werden „Abhängigkeit abgeschlossen“, „fachlich erfolgreich“ und „Abbruch angefordert“ miteinander verwechselt. Dieser Artikel stellt die Grenzfälle mit einem eigenständigen Swift-Programm auf einem Cloud-Mac nach und überträgt die Prüfungen anschließend in ein iOS-Testtarget. Dafür sind weder Netzwerkzugriff noch Signierungszugangsdaten erforderlich.

Wofür die Warteschlange nicht zuständig ist

Abhängigkeiten in OperationQueue verlangen lediglich, dass die vorausgehende Operation den Zustand „abgeschlossen“ erreicht. Fachliche Ergebnisse wie einen fehlgeschlagenen Download oder eine gescheiterte Konvertierung wertet die Warteschlange nicht aus. Auch cancel() setzt nur eine Abbruchmarkierung und beendet keine bereits laufende Closure zwangsweise.

Definieren Sie für jeden fachlichen Schritt vier Endzustände: erfolgreich, fehlgeschlagen, übersprungen und abgebrochen. Schlägt eine Abhängigkeit fehl, vermerken Sie den Schritt als übersprungen. Erhält ein bereits laufender Schritt eine Abbruchanforderung, beendet er sich an einer sicheren Stelle. Verwenden Sie nicht denselben booleschen Wert für „nicht ausgeführt“ und „Ausführung fehlgeschlagen“.

isFinished bedeutet, dass der Lebenszyklus der Operation beendet ist, nicht dass sie fachlich erfolgreich war. isCancelled bedeutet, dass ein Abbruch angefordert wurde, nicht dass die Arbeit bereits eingestellt wurde.

Dieses Beispiel verwendet ausschließlich synchrone BlockOperation-Instanzen. Startet eine Closure eine asynchrone Anfrage und kehrt sofort zurück, betrachtet die Warteschlange die Closure als abgeschlossen. Mit den hier gezeigten Abhängigkeiten lässt sich dann nicht auf den Callback der Anfrage warten. Asynchrone Operationen benötigen eine eigene Implementierung mit korrekter Verwaltung der Lebenszykluszustände.

Mit expliziten Ergebnissen die Fortsetzung nach Fehlern verhindern

Speichern Sie den folgenden Inhalt als QueueContract.swift. Eine Sperre schützt die Lese- und Schreibzugriffe auf die Ergebnisse, damit beim späteren Ergänzen paralleler Schritte keine Datenrennen entstehen. Das Beispiel verlässt sich nicht auf eine zufällige Ausführungsreihenfolge.

import Foundation

enum Outcome: Equatable {
    case pending
    case succeeded
    case failed
    case skipped
}

final class State: @unchecked Sendable {
    private let lock = NSLock()
    private var values: [String: Outcome] = [:]

    func set(_ key: String, _ value: Outcome) {
        lock.lock()
        defer { lock.unlock() }
        values[key] = value
    }

    func get(_ key: String) -> Outcome {
        lock.lock()
        defer { lock.unlock() }
        return values[key] ?? .pending
    }
}

let state = State()
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 2

let download = BlockOperation {
    state.set("download", .failed)
}

let convert = BlockOperation {
    guard state.get("download") == .succeeded else {
        state.set("convert", .skipped)
        return
    }
    state.set("convert", .succeeded)
}

convert.addDependency(download)
queue.addOperations([convert, download], waitUntilFinished: true)

precondition(state.get("download") == .failed)
precondition(state.get("convert") == .skipped)
precondition(download.isFinished && convert.isFinished)

Kompilieren und starten Sie das Programm im Terminal:

xcrun swiftc QueueContract.swift -o QueueContract
./QueueContract

Der Exitcode null zeigt an, dass die Zusicherungen erfüllt sind; eine Erfolgsmeldung muss nicht ausgegeben werden. Ergänzen Sie auch einen Fall mit erfolgreichem Download und prüfen Sie darin entsprechend die erfolgreiche Konvertierung. So decken Sie sowohl den Erfolgs- als auch den Fehlerfall ab. @unchecked Sendable ist lediglich eine manuelle Zusicherung der Threadsicherheit. Sie gilt nur, wenn sämtliche Zugriffe auf den gemeinsamen Zustand über die Sperre erfolgen und das interne Dictionary nicht nach außen zugänglich ist.

Abbruch vor und während der Ausführung getrennt testen

Prüfen Sie zunächst eine Aufgabe, die noch nicht ausgeführt wurde. Hängen Sie den folgenden Abschnitt an dieselbe Datei an und kompilieren Sie erneut. Eine separate Warteschlange verhindert Wechselwirkungen mit dem vorherigen Testfall.

let heldQueue = OperationQueue()
heldQueue.isSuspended = true

let cancelledState = State()
let waiting = BlockOperation {
    cancelledState.set("body", .succeeded)
}

heldQueue.addOperation(waiting)
waiting.cancel()
heldQueue.isSuspended = false
heldQueue.waitUntilAllOperationsAreFinished()

precondition(waiting.isCancelled)
precondition(waiting.isFinished)
precondition(cancelledState.get("body") == .pending)

Durch das Anhalten der Warteschlange ist sichergestellt, dass der Abbruch vor der Ausführung erfolgt. Ein Zeitintervall in Millisekunden muss nicht erraten werden. Die Zusicherungen prüfen zugleich die Abbruchmarkierung, den endgültigen Abschluss und dass die Closure nicht ausgeführt wurde. Fehlt eine dieser Prüfungen, bleibt eine Lücke.

Für eine laufende Aufgabe ist ein eigener Testfall nötig: Sobald die Aufgabe in ihre Verarbeitungsschleife eintritt, sendet sie ein Startsignal. Erst nach Empfang dieses Signals fordert der Test den Abbruch an und gibt anschließend die nächste Iteration frei. Prüfen Sie in jeder Iteration isCancelled, bevor Daten gelesen und bevor Ergebnisse verbindlich übernommen werden. Warten Sie auf Signale nur mit einem Zeitlimit. Wird es überschritten, muss der Test fehlschlagen, statt unbegrenzt hängen zu bleiben.

Eine Closure, die vor dem Abbruch noch nicht zur Ausführung gekommen ist, trägt keinen fachlichen Endzustand für Sie ein. Deshalb muss die Orchestrierungsschicht den Abbruch vermerken. Erwarten Sie nicht von einer Closure, die nie gelaufen ist, dass sie Aufräumarbeiten übernimmt.

Beim Schreiben eine klare Commit-Grenze einhalten

Kooperativer Abbruch wird nur an Prüfpunkten wirksam. Dauert eine einzelne Konvertierung lange, kann sich die Reaktion auf den Abbruch mindestens bis zum Ende des laufenden Konvertierungsschritts verzögern. Zeitaufwendige Arbeit in sinnvoll große Teilmengen aufzuteilen, ist hilfreicher, als die Markierung in einer leeren Schleife ständig abzufragen.

Schreiben Sie Dateien zunächst an einen aufgabenspezifischen temporären Pfad. Schließen Sie die Dateihandles, bevor Sie die Dateien verbindlich übernehmen. Bei Fehlern oder einem Abbruch werden nur die eigenen temporären Dateien entfernt, nicht das gemeinsam genutzte Verzeichnis. Trifft nach einem erfolgreichen Commit eine Abbruchanforderung ein, bleibt der Endzustand „erfolgreich“ bestehen. Ändern Sie ihn nicht nachträglich in „abgebrochen“, da der Aufrufer sonst fälschlich annehmen könnte, es sei kein Ergebnis entstanden.

Abbruch und Commit können gleichzeitig auftreten. Verlangt die Fachlogik einen strikten gegenseitigen Ausschluss, müssen beide Entscheidungen dieselbe Sperre oder einen seriellen Zustandsautomaten durchlaufen. Eine einzelne Prüfung der Markierung vor dem Schreiben beseitigt nicht das danach mögliche Race Condition.

In Regressionstests den vollständigen Endzustand prüfen

Ersetzen Sie beim Übertragen der Beispielprüfungen nach XCTest die Aufrufe von precondition durch Test-Assertions. In einem Kommandozeilenjob auf einem Cloud-Mac ist es unproblematisch, auf den Abschluss der Warteschlange zu warten. Im Hauptthread einer Anwendung sollten Sie dagegen nicht synchron warten – insbesondere dann nicht, wenn die Aufgaben ihrerseits auf einen Callback im Hauptthread angewiesen sind.

Decken Sie in jedem Durchlauf mindestens diese Fälle ab: erfolgreiche vorausgehende Operation, fehlgeschlagene vorausgehende Operation, Abbruch vor der Ausführung, Abbruch während der Ausführung und Abbruch nach dem Commit. Prüfen Sie in jedem Fall den fachlichen Endzustand, das Vorhandensein der erzeugten Dateien und die Anzahl der Abschlussbenachrichtigungen. Eine Abschlussbenachrichtigung darf nur einmal gesendet werden. Sowohl ein fehlender als auch ein mehrfacher Callback muss den Test fehlschlagen lassen.

Legen Sie die Reihenfolge mit kontrollierbaren Signalen fest, statt mit sleep vermeintlich stabile Zeitpunkte zu erzeugen. Das Ergebnis ist ein reproduzierbarer Vertrag: Nach dem Fehlschlagen einer Abhängigkeit wird nichts irrtümlich übernommen, nach einem Abbruch kann die Ausführung enden, und erst nach dem Ende wird aufgeräumt. Sind diese Bedingungen abgesichert, lässt sich der Parallelitätsgrad auf einer verlässlichen Grundlage erhöhen.

Häufig gestellte Fragen

Wird eine abhängige Operation nach einem Fehler automatisch übersprungen?

Nein. Eine Abhängigkeit legt nur die Reihenfolge fest. Übergeben Sie einen threadsicher gespeicherten Ergebniszustand und lassen Sie den Folgeschritt bei einem Fehler ausdrücklich aussetzen.

Dürfen Dateien direkt nach cancelAllOperations gelöscht werden?

Nein. Laufende Operationen können noch darauf zugreifen. Fordern Sie den Abbruch an, warten Sie außerhalb des Hauptthreads auf das Ende und räumen Sie erst danach auf.

Bereit für Ihren nächsten Build mit einem Cloud-Mac

Wählen Sie Modell und Laufzeit passend zu Ihrer Arbeitslast. Dedizierte physische Apple-Silicon-Knoten statt virtueller Maschinen.

Cloud-Mac mieten

Vom Lesen zum ersten Build

Stellen Sie Ihre Arbeitslast zusammen und wählen Sie den passenden dedizierten physischen Knoten.