Node.jsで開発していると、最初はエラーハンドリングが非常に簡単に感じられます。ルートをtry-catchで囲み、500ステータスコードを返せば、クライアントが次のアクションを判断してくれます。このモデルはHTTPではうまく機能しますが、バックグラウンドジョブに移った瞬間に崩壊します。キューシステムには、待機しているクライアントはいません。そこにあるのは、ワーカー、ペイロード、そしてRedis、RabbitMQ、あるいはSQSのどこかでカウントアップされているリトライ回数だけです。Webリクエストの失敗と同じ方法で失敗を処理しようとすると、単に一つのトランザクションを落とすだけでは済みません。パイプライン全体を停滞させたり、計算リソースを浪費したり、あるいは同じ「毒メッセージ(poisoned message)」によってワーカーを繰り返しクラッシュさせたりすることになります。

HTTP的な考え方はバックグラウンドジョブでは通用しない

リクエスト・レスポンス・サイクルでは、フィードバックループが即時的です。ユーザーがボタンをクリックし、サーバーがエラーを投げると、ユーザーには失敗画面が表示されます。クリーンアップも簡単です。一方、キューワーカーは隔離された環境で動作します。ジョブを取り出し、数秒または数分間処理を行い、成功を承認(acknowledge)します。処理の途中で何かがうまくいかなくなっても、キューはその理由を知りません。ただ「承認が届かなかった」ことしか分かりません。設定によりますが、キューは(おそらく永遠に)リトライを繰り返します。たった一つの不正なペイロードがワーカー間を何百回も往復し、CPUを浪費し、実際に処理が必要な正当なジョブの背後に隠れてしまう可能性があるのです。

2種類の失敗

弾力性のあるキューを作るための第一原則は、すべてのエラーを同じように扱うのをやめることです。エラーが発生した瞬間に、それらを2つのグループに分類する必要があります。

**再試行可能な失敗(Retryable failures)**は一時的なものです。ネットワークのタイムアウト、サードパーティAPIのレート制限、あるいはプールが一時的に枯渇したことによるデータベース接続のリセットなどを考えてみてください。これらは、負荷がかかっている稼働中のシステムにおける兆候です。2分後の次の試行では成功するかもしれません。

**永続的な失敗(Permanent failures)**はポイズンピル(poison pills)です。これには、不正なペイロード、スキーマ検証エラー、あるいはアップストリームのサービスが契約(contract)を変更したことによる必須フィールドの欠落などが含まれます。これらをリトライするのは純粋な無駄です。100回目の試行でも、全く同じように失敗します。

もしあなたのcatchブロックがこの2つの違いを判別できないのであれば、あなたのキューは目隠しをして飛行しているようなものです。

パターン1:catchブロックでエラーを分類する

ワーカーのcatchブロックは、そのファイル内で最も慎重に設計されたコードであるべきです。エラーが発生したら、すぐにそれを調査してください。エラーコードがECONNRESETやタイムアウトですか?それならリトライ用にキューに入れてください。SyntaxError、Joiのバリデーション拒否、あるいは外部キー制約の欠落ですか?それならすぐにデッドレターキュー(DLQ)に移動させ、リトライ回数にはカウントしないでください。

BullMQやBee Queueを含むほとんどのNode.jsキューライブラリでは、カスタムのバックオフ戦略やエラーフックを定義できます。それらを活用しましょう。永続的なエラーは、デフォルトで「スリープして3回リトライする」といった処理をすべきではありません。他のジョブが流れるように、メインのキューから退避させるべきです。DLQは正確なペイロードとエラーのコンテキストを保持するため、バグを修正したりスキーマを直したりした後に、後でジョブを再実行(replay)することができます。

パターン2:ジッター付き指数バックオフ

即座にリトライするのは攻撃的すぎます。もしダウンストリームのデータベースがすでに負荷で喘いでいる場合、50個のワーカーから2秒ごとに叩き続けると、トドメを刺すことになります。一歩退いて、システムが回復するための猶予を与える必要があります。

指数バックオフ(exponential backoff)を使用してください。最初の失敗では1秒待ちます。2回目は2秒、次は4秒、8秒と、5分といった妥当な上限まで増やしていきます。しかし、タイミングだけでは不十分です。すべての失敗したジョブが全く同じ間隔を使用すると、バックオフが終了したときにすべてが衝突してしまいます。この同期された波は、時に「サンダリングハード(thundering herd)」と呼ばれ、回復中のサービスを圧倒してしまう可能性があります。

ジッター(jitter)を追加してください。計算された遅延時間に、10%から20%程度のランダムな割合で「ゆらぎ」を加えます。4秒が4.2秒や4.7秒になるイメージです。この単純なランダム性がリトライのスパイクを分散させ、インフラが波状攻撃を受けるのを防いでくれます。

パターン3:冪等性を考慮して設計する

ここでキューのインフラストラクチャとビジネスロジックが交差します。決済プロバイダーを通じて顧客に課金するジョブを想像してみてください。ワーカーは課金処理に成功しましたが、データベースに成功を記録したりジョブを承認したりする前に接続が切断されました。キューは失敗と判断します。そしてリトライします。結果として、顧客には二重に課金されてしまいます。

Node.jsでは、すべての副作用を冪等(べきとう)にすることで、これを防ぎます。ジョブIDまたはビジネス固有の識別子から冪等キーを生成してください。決済の作成、メールの送信、または在庫の調整を行う前に、その作業がすでに完了していないかを確認します。そのキーをデータベースや、キーを受け付けるサードパーティAPIまで一貫して渡してください。同じペイロードを10回実行しても、1回実行したときと同じ結果になるようにジョブを構成します。この習慣を身につけるだけで、財務やデータの整合性に関するバグのカテゴリーを丸ごと排除できます。

パターン 4: デッドレターキュー(DLQ)をダッシュボードのように扱う

DLQは、失敗したジョブが忘れ去られるための墓場ではありません。それは運用ツールであり、最も注意深く監視すべき対象の一つであるべきです。

DLQの深さが増加したときに作動するアラートを設定してください。DLQにメッセージが1つあるだけでも、バリデーションロジックの破損、アップストリームのスキーマ変更、あるいはダウンストリームのサービスが認識できない不正なデータを送信していることを意味する場合が多いです。これらは、顧客が不満を漏らし始める前に確実に捉えたいシグナルです。生のペイロード、スタックトレース、タイムスタンプを調査できるダッシュボードを構築してください。また、失敗を調査し、コードを修正し、正しい順序でメッセージをリプレイするためのランブックを用意しておきましょう。キューの実装がサポートしている場合は、絶対数だけでなく増加率についてもアラートを設定してください。不適切なデプロイが一度行われるだけで、数分以内にDLQが溢れかえる可能性があるからです。

パターン 5: 迷ったらプロセスをクラッシュさせる

Node.jsは、V8 isolate内の単一のイベントループ上で動作します。ハンドルされていないPromiseの拒否(unhandled promise rejection)や、あるいは...