ファンアウトを極める:耐久性と冪等性を備えたワークフローの構築
SchemaBridge Team · 2025-12-15 · Scalability, Idempotency, Orchestration
作業の行方を見失わずに大規模なファンアウトを扱う。Spawner vertex、アイテム単位の冪等性キー、at-least-once 配信を詳しく解説します。
大規模ファンアウトの課題:ループが行き詰まる場所
ループを書いたことのない開発者はいません。Java の for ループでも、JavaScript の .map() でも、Python の list comprehension でも、ロジックは同じです。アイテムのリストを受け取り、それぞれに対して何かを行う。これはデータ処理の最も単純な形です。数件の規模なら取るに足りません。数百件なら管理できます。しかし何千件、何万件という閾値を越えると、この素朴なループはアプリケーションの信頼性とスケーラビリティにとって深刻な弱点になります。
分散システムの世界では、ローカルループは障害点です。数件のアイテムから何千件へと移行すると、複雑さは単に線形に増えるのではなく、複雑さの壁にぶつかります。この壁は、メモリ管理、ネットワークレイテンシ、そしてコードを実行するマシンが必ずいつか故障するという、冷徹な現実でできています。「ロジック」について考えるのをやめ、「物理」と戦い始めることになるのです。
ローカルループの限界:なぜ Promise.all はスケール以前のパターンなのか
素朴な実装では、巨大な JSON ペイロード(たとえば大きな日次の注文 CSV や CRM からのバッチエクスポート)を受け取り、下流サービスへの API 呼び出しをループで包むかもしれません。モダンな JavaScript 開発者なら、Promise.all() を使ってすべてを並列に発行するでしょう。これがスケールを考えていない開発者の最初の過ちです。
スケールした状況では、これは 3 つの重大な理由から惨事になります。
1. メモリ枯渇:静かなクラッシャー
非常に多数の複雑なオブジェクトをメモリに読み込むと、ワーカーは簡単にクラッシュします。オブジェクトあたりのサイズが控えめでもすぐに積み重なり、メモリを多く消費するランタイムでは生データがメモリ上でさらに膨らむこともあります。その結果、最初のアイテムが処理される前にプロセスを強制終了させる「Out of Memory」(OOM)エラーが即座に発生します。
2. 実行タイムアウト:時間は刻々と過ぎる
ほとんどのプラットフォームには厳しい制限があります。ループが何千もの API 呼び出しを行い、各呼び出しがわずか 100ms だとしても、スクリプトは何分も実行され続ける可能性があります。並列化したとしても、その単一コンテナのリソース制限とオーバーヘッドには縛られたままです。最後のアイテムが処理される前にプラットフォームによって終了させられ、システムは不確定な状態のまま残されます。
3. 進捗の記録がない
プロセスが死んだとき、何が行われたかの唯一の記録は宛先システムにしかありません。フォレンジック的な突き合わせなしには、どのアイテムが送信され、どれが成功し、どれが一度も試行されなかったのかを誰も答えられません。
Spawner の登場:耐久性のある子実行としてのファンアウト
SchemaBridge では、ファンアウトは専用の Spawner Vertex です。Spawner は単なるループではありません。各アイテムを、エンジンが個別に追跡・報告し、個別にリトライもできる第一級の作業単位に変換します。
Spawner の実際の動作:並列分割
Spawner はリストとアイテムの実行を分離します。各アイテムは 1 つのプロセス内のイテレーションではなくなり、それ自体が独立した実行になります。
1. アイテムごとに 1 つの子実行:Spawner は各アイテムに対して、対象ワークフローの子実行を開始します。各子実行は独自の ID、独自のステータス、独自のログとともに PostgreSQL に永続化され、親の実行までの系譜を保持します。
2. アイテムごとの決定的キー:すべての子リクエストは、親実行、Spawner vertex、アイテムの位置から導出される冪等性キーを持ちます。Spawner ステップ自体が再配信された場合(たとえばファンアウトの途中でワーカー Pod が再起動され、別のワーカーがそのステップを再取得した場合)、ステップはリストを最初から再投入しますが、すでに開始済みの子実行はキーによって識別され、二重に作成されることはありません。
3. ライフサイクルの独立性:501 番目のアイテムが失敗しても、502 番目のディスパッチや成功は妨げられません。1 つの巨大で脆いバッチではなく、多数の個別実行という粒度が得られ、失敗した子実行は単独でリトライできます。
保証については冷静に理解しておいてください。このキーによる重複排除は、子実行を作成する前の検索であり、データベースの一意性制約ではありません。したがって重複の発生しうる時間窓を閉じるのではなく、狭めるものです。エンジンは at-least-once であり、子実行が外部に対して行うことはすべて宛先側で冪等であるべきです。
並列処理の経済性
重いシングルスレッドのワーカーを 15 分間動かすのは高コストです。大容量メモリのインスタンス代だけでなく、コードが API レスポンスを待っている間の「アイドル時間」にも支払っています。課金対象の CPU サイクルを、ネットワーク I/O の待ち時間に浪費しているのです。
アイテムごとの子実行によって、作業の形が変わります。
- 並列実行:子実行は、1 つの長寿命プロセスの中ですべて実行されるのではなく、空き容量のあるワーカーに順次引き取られます。
- 影響範囲の縮小:1 つの子実行の失敗は他に影響しません。従来のループでは、1 つのアイテムでのメモリリークや未処理の例外ひとつで、実行全体が落ちることがあります。
これは、ほとんどの時間を I/O 待ちに費やす長時間実行のバッチスクリプトよりも計算資源を効率的に使うと期待できますが、より大きな利点は請求額ではなく、信頼性と可観測性です。
このモデルには限界があり、それを知っておくべきです。各アイテムが 1 つの実行であるため、ファンアウトは子実行を 1 つでも開始する前に、プランの実行クォータと照合されます。Spawner はアイテムリストをメモリに保持し、単一のステップから子実行を 1 つずつ投入するため、無限に太いパイプではありません。また、プラットフォームのペイロードストアに負荷がかかっているときは、逼迫したストアにさらにデータを押し込むのではなく、Spawner はファンアウト全体を延期します。つまりステップを待機させ、後で耐久性のあるタイマーから再駆動します。
分散ファンアウトにおける冪等性:ガード付きの at-least-once
ファンアウトパターンにおける最大の障壁は冪等性です。Spawner ステップが中断されて再実行された場合、同じ子実行を二度開始しないようにするにはどうすればよいでしょうか。
SchemaBridge は複数のガードを重ねています。
- アイテムごとのキー:上で説明した決定的キーにより、再実行された Spawner は、新しい子実行を作成する代わりに、すでに存在する子実行を見つけることができます。この検索はステータスに関係なく既存の子実行を返すため、すでに失敗した子実行は再実行されず、再び紐づけられるだけです。
- ステップレベルのガード:エンジン内部では、すでに完了したステップは再実行されません。また、実行のためにステップを確保する操作はアトミックな状態遷移なので、同じステップに対する重複メッセージは二度実行されるのではなく破棄されます。
これによって得られないのが exactly-once 実行です。副作用が発生した後、その完了が記録される前にワーカーがクラッシュすることはあり得て、その場合ステップは再度実行されます。だからこそ私たちはこのプラットフォームを強力なガードを備えた at-least-once と説明しており、冪等でない呼び出しの宛先には独自の保護が必要なのです。
説明用シナリオ:大規模マイグレーションの失敗
あるチームが、非常に大きな台帳エントリのバッチを移行する重要なマイグレーションを行っていると想像してください。彼らは従来型の Python スクリプトを使うことにしました。長時間ジョブの途中で VPN が切断され、スクリプトはクラッシュします。
復旧(高くつくやり方)
復旧は苦痛です。エンジニアは何日もかけて宛先データベースを調べ、手作業でレコードを突き合わせて、何が書き込まれ何が書き込まれなかったのかを正確に割り出します。
復旧(SchemaBridge のやり方)
次に、同じマイグレーションを台帳エントリごとに 1 つの子実行をファンアウトする Spawner としてモデル化し、同じ接続断が発生した場合を想像してください。
1. 失敗はアイテム単位にとどまる:すでに実行中だった子実行はそのまま続行し、宛先に到達できなかった子実行はそれぞれ固有のエラーとともに個別に失敗します。ファンアウトの残りには影響しません。Spawner 自体が子実行を投入できない場合は、待機しているふりをするのではなく、Spawner ステップが目に見える形で失敗します。
2. 進捗の可視化:実行ビューには成功した子実行と失敗した子実行の数が表示されるため、チームは宛先データベースから進捗を逆算する必要がなくなるでしょう。
3. 的を絞った復旧:接続が回復したら、チームはマイグレーション全体を再実行するのではなく、失敗した子実行を個別にリトライします。失敗した Spawner ステップをリトライしても、それらは再実行されません。アイテムごとのキーは、失敗したものも含めてすでに存在するすべての子実行を再び紐づけるため、Spawner のリトライで作成されるのは一度も開始されなかった子実行だけです。復旧はフォレンジック的なプロジェクトではなく、クリック数で測れるオペレーター操作になるでしょう。ただし台帳への書き込み自体は依然として冪等である必要があります。リトライされた子実行が、すでに反映済みの書き込みを繰り返す可能性があるためです。
詳細比較:実環境におけるスケーリングモデル
| 機能 | 素朴なループ(forEach) | SQS/Lambda(自作) | SchemaBridge Spawner |
| :--- | :--- | :--- | :--- |
| 状態管理 | ローカル(揮発性) | 手動(DB/キュー) | ネイティブ(耐久性あり、子実行ごと) |
| エラー処理 | 単一の try/catch | 手動のリトライ/DLQ | アイテムごとの障害分離 + オペレーターによるリトライ |
| 可視性 | ログファイルの断片 | 不透明なキュー深度 | 実行ビューでの子実行ごとのステータス |
| 結合ロジック | 困難(シングルスレッド) | 非常に困難(カウンター) | ネイティブの Merge Vertex |
| 冪等 ID | なし | 手動生成 | アイテムごとの決定的キー |
大量オーケストレーションのためのエキスパート向けチェックリスト
大量のファンアウトを設計する場合は、次の経験則に従ってください。
1. 宛先側で並行度を制限する:下流システムを並行度制限で保護します。SchemaBridge では、これは Spawner 自体のつまみではなく、宛先ごとの並行度制限(Rate Limits ページで設定)であり、その宛先を呼び出すすべてのステップに適用されます。控えめな値から始め、下流の健全性を観察しながら広げていきましょう。
2. アイテムの失敗は普通のことだと想定する:各アイテムを独立してリトライできるようにし、1 つの子実行の失敗でファンアウト全体を失敗させるのか、許容するのかを事前に決めておきます。
3. 「ロングテール」のレイテンシを監視する:他よりはるかに時間のかかるごく一部のアイテムに注目してください。それらはたいてい、最も複雑なエッジケースか、データベースロックの対象です。
4. 決定的 ID を活用する:再起動から保護するためにエンジンのアイテムごとのキーを使い、宛先側にも安定したビジネスキーを持たせてください。高並行のファンアウトでは、一意性をタイムスタンプに頼ってはいけません。
{
"type": "MERGE",
"config": {
"tolerateChildFailures": ["FAILED"],
"failureTolerance": { "maxFailureRatio": 0.05 },
"failFast": true
}
}
部分的な失敗の許容はオプトインであり、ファンアウトのコールバック merge で設定します。デフォルトでは、失敗した子実行が 1 つでもあれば結合は失敗します。これはすべてのアイテムが重要な場合の安全な選択です。上の例では、失敗した子実行をファンアウトの 5% まで許容し、failFast によって、その許容枠を超えることが確定した時点で、遅れている子実行を待たずに結合を確定させます。
結論:スケーリングはインフラの問題である
ファンアウトを極めるとは、より良いループを書くことではなく、分散を前提に設計することです。イテレーション、アイテムごとの状態、重複の抑止をエンジンに移すことで、部分的な失敗は不明な状態ではなく、具体的な失敗アイテムのリストになります。そしてそれこそが、リトライで済むか、突き合わせプロジェクトになるかの違いです。
スケーリングには依然として限界があります。クォータ、バックプレッシャー、そして分散配信の at-least-once という性質です。しかしそれらの限界は、午前 3 時に本番環境で発見されるものではなく、目に見えて範囲が明確なものです。
関連記事:キーと宛先側の冪等性がどのように組み合わさるかについては 冪等性エンジン:トランザクションの整合性を確保する を、ファンアウトの結果を集約する方法については Merge を極める をご覧ください。