クラウド Mac で iOS タスクの依存失敗とキャンセルを検証する

クラウド Mac で iOS タスクの依存失敗とキャンセルを検証する

画像処理では、まずダウンロードし、次に変換し、最後にディレクトリへ書き込みます。ところが、ダウンロードに失敗しても変換タスクが実行対象になったり、ユーザーがキャンセルしてもファイルへの書き込みが続いたりすることがあります。多くの場合、原因はキューの不具合ではなく、「依存先の完了」「業務処理の成功」「キャンセル要求」を同じものとして扱っていることにあります。本記事では、クラウド Mac 上の独立した Swift プログラムでこれらの境界を再現し、その検証を iOS のテストターゲットへ移します。ネットワーク接続や署名用の認証情報は不要です。

まず、キューが保証しないことを確認する

OperationQueue の依存関係が求めるのは、先行する操作が完了状態になることだけです。ダウンロードや変換が業務上成功したかどうかまでは判断しません。また、cancel() はキャンセルフラグを設定するだけで、実行中のクロージャを強制終了するものではありません。

業務処理の各ステップには、成功、失敗、スキップ、キャンセルの四つの終端状態を定義します。依存先が失敗した場合はスキップとして記録し、すでに実行を開始したステップがキャンセル要求を受けた場合は、安全に中断できる境界で終了させます。「実行しなかった」と「実行したが失敗した」を、一つの真偽値で表現してはいけません。

isFinished は操作のライフサイクルが終了したことを示し、業務処理の成功を意味しません。isCancelled はキャンセル要求を受けたことを示し、処理がすでに停止したことを意味しません。

この例では、同期的に処理する BlockOperation だけを使います。クロージャが非同期リクエストを開始してすぐに戻ると、キューはそのクロージャの処理が完了したと見なします。その場合、ここで使う依存関係ではリクエストのコールバックを待てません。非同期操作には、ライフサイクルの状態を正しく管理する実装が別途必要です。

明示的な結果で、失敗後の後続処理を止める

次の内容を QueueContract.swift として保存します。結果の読み書きはロックで保護し、後から並行処理を追加してもデータ競合が起きないようにします。この例は、たまたま成立したタスクの実行順序には依存しません。

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)

ターミナルでコンパイルして実行します。

xcrun swiftc QueueContract.swift -o QueueContract
./QueueContract

終了コードがゼロなら、アサーションはすべて通過しています。成功メッセージを出力する必要はありません。ダウンロード結果を成功に変えた場合は、変換の成功もアサーションで確認し、成功側と失敗側の両方をテストします。@unchecked Sendable は、スレッドセーフであることを実装者が保証する宣言にすぎません。その保証が成り立つのは、共有状態へのすべてのアクセスがロックを経由する場合だけです。内部の辞書を外部に公開してはいけません。

実行待ちのキャンセルと実行中のキャンセルを分けてテストする

まず、まだ実行されていないタスクを検証します。次のコードを同じファイルに追記して、再コンパイルしてください。別のキューを使うことで、前のテストケースへの影響を避けます。

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)

キューを一時停止すれば、実行前にキャンセルされることを保証できます。何ミリ秒待てばよいかを推測する必要はありません。アサーションでは、キャンセルフラグ、最終的な完了、クロージャが実行されなかったことをすべて確認します。どれか一つでも欠けると、検証に抜けが生じます。

実行中のタスクには別のテストケースを用意します。タスクが処理ループに入ったら「開始済み」のシグナルを送り、テスト側はそのシグナルを受け取ってからキャンセルを要求し、続いて次の反復へ進めるようにします。各反復では、データを読み込む前と結果をコミットする前に isCancelled を確認します。シグナルの待機にはタイムアウトを設け、時間切れになったらテストを失敗させます。無期限に待ち続けるテストにしてはいけません。

キャンセル時点でまだ実行対象になっていないクロージャは、業務上の終端状態を記録してくれません。そのため、処理を制御する層でキャンセル状態を補って記録する必要があります。一度も実行されていないクロージャに後始末を任せてはいけません。

結果の書き込みにはコミット境界を設ける

協調的キャンセルが機能するのは、キャンセルを確認するチェックポイントだけです。一回の変換に時間がかかる場合、キャンセルが反映されるまで、少なくとも実行中の変換ステップが終わるだけの時間がかかる可能性があります。空のループで頻繁にポーリングするよりも、時間のかかる処理を適切な単位に分割するほうが有効です。

ファイルはまずタスク専用の一時保存パスに書き込み、ハンドルを閉じてからコミットします。失敗時やキャンセル時に削除するのは、そのタスク自身の一時ファイルだけにし、共有ディレクトリは削除しません。コミットが成功した後にキャンセル要求を受けた場合は、終端状態を「成功」のまま保持します。後からキャンセルに書き換えると、呼び出し元は成果物が存在しないと誤解してしまいます。

キャンセルとコミットは同時に発生する可能性があります。業務要件として両者を厳密に排他制御する必要があるなら、キャンセルの判断とコミットの判断を同じロック、または直列に処理する状態機械で管理します。書き込み前に一度フラグを確認するだけでは、その後に発生する競合を防げません。

回帰テストでは終端状態を漏れなく確認する

この例のアサーションを XCTest に移す際は、precondition をテスト用のアサーションに置き換えます。クラウド Mac のコマンドラインタスクでキューの完了を待つことに問題はありません。一方、アプリのメインスレッドでは同期的に待機しないでください。特に、タスク側がメインスレッド上のコールバックを待つ構造にしてはいけません。

各回のテストでは、少なくとも、先行処理の成功、先行処理の失敗、実行前のキャンセル、実行中のキャンセル、コミット後のキャンセルを網羅します。各ケースで、業務上の終端状態、成果物の有無、完了通知の回数を併せて確認します。完了通知は一度だけ送られる必要があり、コールバックが来ない場合も、重複して呼ばれた場合も失敗にします。

処理の前後関係は、制御可能なシグナルを使って必ず明示的に決めます。sleep で待ち時間を挟んで、安定したタイミングを作れたつもりになってはいけません。最終的に保証すべきなのは、再現可能な契約です。依存先が失敗したら誤ってコミットしないこと、キャンセル後に処理が終了できること、そして終了してから後始末をすること。この条件を守って初めて、並行度を上げるための信頼できる土台ができます。

よくある質問

前段の Operation が失敗すると、依存タスクは自動でスキップされますか?

いいえ。依存関係が保証するのは実行順序だけです。スレッドセーフな結果状態で失敗を伝え、後段が明示的にスキップする必要があります。

cancelAllOperations の直後に作業ファイルを削除してもよいですか?

削除してはいけません。実行中の処理がまだファイルを使う可能性があります。キャンセルを要求し、メインスレッド以外で終了を待ってから削除します。

次のビルドに備えてクラウドMacを準備

ワークロードに合わせて、マシンと利用期間をお選びください。Apple Silicon搭載の専有物理ノードを利用でき、仮想マシンではありません。

クラウドMacをレンタル

記事を読んだら、最初のビルドへ

ワークロードを確認して、用途に合った専有物理ノードをお選びください。