Claude Media
Claude CodeでLaravelのキュー失敗を調べる — failed_jobsとHorizon

Claude CodeでLaravelのキュー失敗を調べる — failed_jobsとHorizon

Laravelのキュージョブが失敗したとき、failed_jobsとtries・backoff・retryUntil・timeoutの設定から原因を絞り、Queue::fakeでテストを書くまでをClaude Codeで進める手順です。

Laravelのキュージョブが失敗したら、Claude Codeには failed_jobs の例外メッセージとジョブクラスを一緒に渡し、tries・backoff・retryUntil・timeout のどれが効いているかを先に絞らせます。原因の見当がついてから、Queue::fake を使うテストで再発を止めます。キューの失敗は、例外そのものより「何回試され、何秒待たれ、どこで打ち切られたか」の設定に原因があることが多いためです。

この記事では、失敗ジョブの調べ方、Horizonを使うときの設定の優先順位、調査中に危険な操作を止める権限設定、テストの書き方を順に説明します。Laravel全般の環境づくりはClaude CodeでLaravel開発にあります。

失敗ジョブはどこに残るのか

非同期のジョブが最大試行回数を使い切ると、failed_jobs テーブルに記録されます。同期ディスパッチしたジョブの失敗はこのテーブルに入らず、例外はアプリケーション側でそのまま処理されます。「失敗したはずなのにテーブルが空」という場合は、まずディスパッチが同期になっていないかを疑います。

失敗の一覧は queue:failed で見ます。再実行は queue:retry にジョブIDを渡します。

php artisan queue:failed
php artisan queue:retry ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece

Horizonを使っている場合、失敗ジョブの削除は queue:forget ではなく horizon:forget を使います。公式ドキュメントがその使い分けを明記しています。

試行回数は「例外」以外でも消費される

失敗の読み違えで多いのは、例外が出ていないのに失敗扱いになるケースです。公式ドキュメントによると、1回の試行は次のどれでも消費されます。

  • ジョブが未処理の例外を投げた
  • $this->release() で手動でキューに戻した
  • WithoutOverlapping や RateLimited のミドルウェアがロックを取れず、ジョブを戻した
  • ジョブがタイムアウトした
  • handle が例外なしで完了した

しかも既定の試行回数は1回です。ミドルウェアで戻されるジョブを使うなら、公式ドキュメントは tries を増やすよう求めています。

最大試行回数に達して失敗した場合、failed メソッドに渡される例外は MaxAttemptsExceededException になります。タイムアウトなら TimeoutExceededException です。つまり failed_jobs の例外欄にこの2つが出ていたら、ジョブの中身のバグではなく、試行回数かタイムアウトの設定を見る合図になります。

Claude Codeに渡す材料と頼み方

調査の入口は、公式ワークフローの「バグを直す」と同じです。エラーを共有し、再現コマンドを伝え、断続的か常時かを添えます。キュー失敗ではその材料が次の3点になります。

  1. queue:failed の出力と、対象IDの例外メッセージ
  2. 失敗したジョブクラス(tries・backoff・timeout などのプロパティを含む)
  3. config/queue.php の該当コネクションの retry_after、Horizonなら config/horizon.php のスーパーバイザ設定

プロンプトは、原因の候補を先に絞らせる形にします。

queue:failed の最新1件を読んで、失敗の種類を分けて。
例外が MaxAttemptsExceededException か TimeoutExceededException なら、
ジョブクラスの tries / backoff / timeout と config/queue.php の retry_after、
config/horizon.php の supervisor 設定を突き合わせて、食い違いを表にして。
コードはまだ直さないで。

「まだ直さない」と添えるのが要点です。設定の食い違いは、ジョブのコードを触らずに設定だけで直ることがあります。先にコードを書き換えさせると、効いていない修正が積み上がります。

症状から疑う設定を絞る

失敗の見え方ごとに、疑う設定と確認先を並べます。

症状疑う設定確認する場所
例外欄が MaxAttemptsExceededException疑う設定tries が小さい。ミドルウェアの差し戻しで試行が消費されている確認する場所ジョブの $tries、--tries、Horizonの tries
例外欄が TimeoutExceededException疑う設定timeout が処理時間より短い確認する場所ジョブの $timeout、--timeout
同じジョブが2回処理される疑う設定timeout が retry_after 以上確認する場所config/queue.php の retry_after
失敗後すぐ再試行されて外部APIに当たり続ける疑う設定backoff が未設定確認する場所ジョブの $backoff、Horizonの backoff
再試行しても意味のない失敗を繰り返す疑う設定永続的な例外を再試行している確認する場所FailOnException ミドルウェア
モデルが無くて失敗する疑う設定待機中にモデルが削除された確認する場所deleteWhenMissingModels

設定の優先順位を先に決める

どの値が勝つのかを知らないと、設定を直しても動作が変わりません。公式ドキュメントに書かれた優先順位は次のとおりです。

  • 試行回数: ジョブクラスの $tries が --tries より優先されます。
  • 時間ベースの打ち切り: retryUntil と tries の両方があると、retryUntil が優先されます。
  • タイムアウト: ジョブクラスの $timeout がコマンドラインの --timeout より優先されます。
  • Horizon: tries を設定しなければ1回だけ試します。ジョブクラスに $tries があれば、Horizonの設定よりそちらが優先されます。

Claude Codeには、この4行を調査の前提として渡します。「ジョブクラスのプロパティが残っているのに、Horizonの tries を上げても変わらない」という見当違いの修正を避けられます。

timeoutとretry_afterの関係

二重処理の原因として最も紛らわしいのが、この組み合わせです。retry_after は、処理中のジョブをキューが「戻してよい」と判断するまでの秒数です。--timeout は、ワーカーがジョブを打ち切る秒数です。公式ドキュメントは、timeout を retry_after より数秒短くするよう求めています。長いと、ワーカーが処理している最中にジョブが別のワーカーへ戻り、二重に処理される可能性があります。

Horizonの auto バランシングでは、縮退時に処理中のワーカーをHorizonのタイムアウト後に強制終了します。Horizonの timeout はジョブ側のタイムアウトより大きくし、同時に retry_after より数秒短くします。この2条件を両立できているかを、Claude Codeに数値で照合させるのが有効です。

// config/horizon.php(例)
'production' => [
    'supervisor-1' => [
        'tries' => 3,
        'timeout' => 80,   // ジョブの $timeout より大きく、retry_after(90) より短く
        'backoff' => [1, 5, 10],
    ],
],

backoff に配列を渡すと、1回目の再試行は1秒、2回目は5秒、3回目以降は10秒の待ち時間になります。外部APIが不安定なときに、全ジョブが同時に再試行して負荷をかける事態を和らげます。

調査中に危険な操作を止める権限設定

失敗ジョブの調査では、読むだけのコマンドと、記録を消すコマンドが隣り合っています。queue:flush は期間を問わず失敗ジョブの記録を全部消し、queue:retry all は失敗した全ジョブを一斉に再投入します。horizon:forget --all と horizon:clear も同様に取り返しがつきません。

Claude Codeの権限ルールはdeny、ask、allowの順に評価され、最初に一致したものが結果を決めます。そのため、読む系をallow、再実行をask、消す系をdenyにすると、調査の流れを止めずに事故だけを防げます。

{
  "permissions": {
    "allow": [
      "Bash(php artisan queue:failed *)",
      "Bash(php artisan test *)"
    ],
    "ask": [
      "Bash(php artisan queue:retry *)"
    ],
    "deny": [
      "Bash(php artisan queue:flush *)",
      "Bash(php artisan queue:forget *)",
      "Bash(php artisan horizon:forget *)",
      "Bash(php artisan horizon:clear *)"
    ]
  }
}

queue:retry all はaskに含まれ、実行前に確認が入ります。denyは、Claude Codeが通常出力する形のコマンドを止めるルールで、あらゆる呼び出し方を封じる境界ではない点に注意が必要です。本番のデータベースに繋がる環境では、権限ルールだけに頼らず、接続先そのものを分けておきます。権限ルールの書き方全般はClaude CodeでLaravel開発の権限設計の節にもあります。

CLAUDE.mdには、調査の作法を短く書いておくと毎回の指示が減ります。

## キュー失敗の調査
- 失敗ジョブは queue:failed で読む。queue:flush と horizon:forget は実行しない
- 修正前に、ジョブの $tries / $backoff / $timeout と retry_after を表で突き合わせる
- 再現はテストで行い、本番のキューには投入しない

原因を再現するテストを先に書く

設定を直す前に、失敗を再現するテストを書きます。ジョブを投入する側と、ジョブの中身は、別のテストに分けます。

投入側はQueue::fakeで確かめる

Queue::fake() を呼ぶと、ジョブは実際にはキューへ積まれません。その代わり、積もうとしたことをアサーションで確かめられます。ジョブのコードはここでは実行されないので、ジョブの中身は別のテストで直接 handle を呼んで確かめます。

use App\Jobs\ShipOrder;
use Illuminate\Support\Facades\Queue;
 
public function test_orders_can_be_shipped(): void
{
    Queue::fake();
 
    // 注文を発送する処理を呼ぶ
 
    Queue::assertPushedOn('shipping', ShipOrder::class);
    Queue::assertPushedTimes(ShipOrder::class, 1);
}

特定のジョブだけをfakeしたいときは Queue::fake([ShipOrder::class]) と書きます。それ以外のジョブは通常どおり実行されます。逆に特定のジョブを除いて全部をfakeするなら Queue::fake()->except([...]) です。

ジョブ側はwithFakeQueueInteractionsで確かめる

ジョブが自分でキューに戻る、自分を削除する、失敗する、といった動作は、withFakeQueueInteractions で確かめられます。ジョブを生成して handle を呼び、その後でアサーションを使います。

use App\Exceptions\CorruptedAudioException;
use App\Jobs\ProcessPodcast;
 
$job = (new ProcessPodcast)->withFakeQueueInteractions();
 
$job->handle();
 
$job->assertReleased(delay: 30);
$job->assertFailedWith(CorruptedAudioException::class);

これは公式ドキュメントのサンプルに沿った形です。失敗の再現テストとしては、handle が $this->fail(...) を呼ぶ条件を作り、assertFailedWith で例外の型を確かめます。Claude Codeには「この失敗ジョブの例外を再現する入力でテストを書き、赤になることを確認してから設定を直して」と頼みます。赤から緑に変わる過程が残るため、効かない修正を見分けられます。実行は php artisan test で行い、結果をClaude Codeに読ませて反復させます。

再試行の挙動をテストに残す

tries や backoff を変えた場合は、変えたことをテストに残します。たとえば retryUntil の戻り値を確認するテストや、永続的な例外で FailOnException が働くことを確かめるテストです。FailOnException は、外部APIの一時的な障害では再試行しつつ、権限の取り消しのような永続的な例外ではすぐ失敗にするミドルウェアです。

public function middleware(): array
{
    return [
        new FailOnException([AuthorizationException::class]),
    ];
}

失敗を減らす設定の入れどころ

原因を絞ったあとの直し方は、次のように分けられます。

  • 一時的な外部障害が原因: tries を増やし、backoff を配列にして待ち時間を伸ばします。
  • 時間で区切りたい: retryUntil を使います。tries を併記しても retryUntil が優先されます。
  • 例外の連発だけ止めたい: maxExceptions を設定します。tries を大きくしつつ、未処理の例外が一定回数に達したら失敗にできます。
  • 失敗時に後始末が要る: failed メソッドを定義します。ただし failed が呼ばれるときのジョブは新しいインスタンスなので、handle で変更したプロパティは失われます。
  • タイムアウトで再試行したくない: $failOnTimeout = true を設定します。この場合、tries の値にかかわらず再試行されません。

タイムアウトの設定にはPCNTL拡張が必要です。ジョブのタイムアウトは、外部へのHTTP接続などのブロッキング処理では効かないことがあるため、Guzzleなどの側にも接続とリクエストのタイムアウトを設定します。

失敗ジョブの記録は放っておくと溜まります。queue:prune-failed は既定で24時間より古い記録を消し、--hours で期間を指定できます。定期実行する場合は、cronの失敗調査で述べた「失敗時だけ通知する」書き方と組み合わせると、見落としを減らせます。

よくある質問

Node.jsのキューでも同じ手順でよいですか

Node.jsのキューを使う場合は、BullMQでの再試行とワーカー実装はClaude CodeでBullMQのワーカーを実装するで扱っています。

CIでキューのテストが落ちたらどうしますか

テストの失敗を自動で直す流れはClaude CodeでCIのテスト失敗を自動修正にまとめています。キューのテストでは、Queue::fake の有無で挙動が変わる点を最初に見ます。

まとめ

キューの失敗調査は、例外の本文より前に「何が試行を消費したか」を見るのが近道です。MaxAttemptsExceededException と TimeoutExceededException は、ジョブのバグではなく設定の合図です。調査の前に権限で消す系を止め、修正の前に再現テストを赤にしておけば、効いていない設定変更を見分けられます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. laravel.com/docs/12.x/queues
  2. laravel.com/docs/12.x/horizon
  3. code.claude.com/docs/en/common-workflows
  4. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn