Laravelのジョブのリトライの挙動について
諸々試した結果では、以下のような動作と思われます。
- 例外やPHPエラー、Job::$timeout経過で終了した場合
-
Job::$retryAfter
に応じた秒数の時間をおいてリトライされる。
-
- 例外なしでJobが終了した場合(何らかの理由でexitしてしまった)
- config/queue.phpやコマンドラインで定義したretry_afterの秒数だけ待ってリトライされる
- リトライ回数が上限に達した場合
- Jobが失敗する
- 失敗は
Job::failed(Exception $e)
でハンドルできる
- 失敗は
- Jobが失敗する
ワーカーのretryAfterとJob::retryAfterが紛らわしいためか、
Laravel v8以降では、Job::retryAfterがJob::backOff にリネームされている。
Laravelのジョブのリトライについて調べる。これまでは以下のような認識だったものの、retryAfterはconfigでconnectionレベルで指定した値だけが適用されているように見えたため。
- ワーカーが例外を拾った場合は即座にリトライされる
- この部分は
backOff
パラメータで調整が可能
- この部分は
- それ以外のケースでは、
retryAfter
を超えてキューに残ったジョブは自動的に再度ワーカーに割り当てられる- このため、timeOutよりもretryAfterは長めにしておく必要がある
-
retryAfter
はconfig/queue.phpでコネクション毎に定義出来る他、Jobクラス側にも定義できる
ただ、JobクラスにretryAfterを指定してもその値が採用されているように見えない。
laravel/framework: v6.20.34
最初にQueueフォルダ内で retryAfterが指定されている場所を調べてみる。
DatabaseQueue.php
ワーカーのパラメータとしてretryAfterが指定されている。
retryAfter自体はジョブをワーカーが再処理するかどうかの設定値なので、ワーカー側で持っているのは自然だと思う。
Queue.php
ここでJobのretryAfterから設定値を受け取っている。これもワーカー側の実装。
参照されている箇所で気になるのは上記の部分で、特に getJobRetryDelay
が気になる。
利用されているのは以下で、追いかけていくと以下でpayloadのdelayパラメータに使用されている。
payloadのdelayはまず、以下で参照されている。
JobのdelaySecondsは以下で参照されていて、これだとワーカーが例外をキャッチしたタイミングでジョブの実行をどれだけずらずかに使われているように見える。
これだと、公式ドキュメントにあったbackOffとの違いは...?
Laravel8だとドキュメント上で引っかかるものの、Laravel6では引っかからないので、ここまでの話はLaravel6固有の模様...。
実際にソースコードを検索すると、v6.xのブランチではbackOff
では一切該当がない(Queueフォルダ内)。
ということは、v8に移行するまでのどこかで、retryAfterをbackOffに置き換える必要が発生しそう。
これだ...。
やはり理由は一貫性のためだった。
リネームだけであれば大がかりにはならなそう。
一旦、ワーカー側のretryAfterの動作を確認する。
先ほどはあまり重要でないと思って控えなかったものの、DatabaseQueue.phpの中では以下で唯一参照されている。
このメソッドは同じクラス内の getNextAvailableJob()
から参照されていて、
「キュー上に残っていて、retryAfter
を経過しているジョブを処理の候補として取得する」と考えると今までイメージしていたretryAfterと一致する。
(getNextAvailableJob
が本当に次に処理するジョブになるのかまでは確認できていない)
ここまで考えると、ワーカーに設定する retryAfterとジョブのプロパティに指定するretryAfterは別々というのは確かそう。
別の疑問として、タイムアウトの動作についても確認する。
タイムアウト時の判定。
maxTriesを意識せず、failさせているように見える。
ワーカーのタイムアウトを調べていたら、ワーカー側で以下のようなものが設定されているのを確認した。
意外なところでシグナルが使われていて驚いたけど、よくよく考えると自分自身がフリーズしている状態に割り込むにはこれしか方法がなさそう。
そうしないと、長時間のIOを待っている状況では次に処理できるのはIOが終わった時になってしまう。
この辺りを見ていると、ジョブがタイムアウトした場合、 MaxAttemptsExceededException
が発生する。
Job::failed()
が呼ばれた場合、JobFailed
イベントが呼ばれているように見えるので、ここで受け取って検知が出来るかもしれない。
ジョブ自身の failedメソッドが呼ばれるようにも見える。
Laravel6時点では、以下のような動作になるので良いか試してみる。
- タイムアウトした場合
- retry回数も考慮せず、即failed_jobsに投入される
- これは、Jobのfailed()メソッドに
MaxAttemptsExceededException
が渡されたかで検知できる
- タイムアウトしていない場合
- 例外で終了した場合
- Jobクラスの
retryAfter
が採用される。 - 指定していない場合、ワーカーの
delay
オプションが採用される
- Jobクラスの
- 例外なしで終了した場合
- ワーカーの
retryAfter
オプションが採用される
- ワーカーの
- 例外で終了した場合
タイムアウト時の動作については、 artisan queue:work
で実行させた上で、ジョブに以下のようなメソッドを持たせることで検知出来た。
public function failed(\throwable $e)
{
if($e instanceof MaxAttemptsExceededException) {
\Log::debug('タイムアウトしました: ' . $e->getMessage());
} else {
\Log::debug('失敗しました: ' . $e->getMessage());
}
}
タイムアウトはリトライの都度リセットされている。
なので、1回1回の実行時間のタイムアウトだった。
(そうでないと扱いにくい)
ジョブクラスが以下のような実装で、 php artisan queue:work
で実行させた場合
class TestJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 14;
public $tries = 3;
public $retryAfter = 1;
public function handle()
{
for($i=0; $i<10; $i++) {
\Log::debug($i);
sleep(3);
// trigger_error('aaa', E_USER_ERROR);
}
}
public function failed(\throwable $e)
{
if($e instanceof MaxAttemptsExceededException) {
\Log::debug('タイムアウトしました: ' . $e->getMessage());
} else {
\Log::debug('失敗しました: ' . $e->getMessage());
}
}
}
出力は以下のようになる。
DEBUG: 0
DEBUG: 1
DEBUG: 2
DEBUG: 3
DEBUG: 4
ここでタイムアウトして、 queue:workが終了するので手動でで再起動する。
この時、jobs.attemptsのカウントが1上がっている。
そして3回ワーカーを再起動させた時点で以下のようなログが残る。
リトライ上限超過だが、例外の種類が同じなのでこのようなログになっている。
DEBUG: 0
DEBUG: 1
DEBUG: 2
DEBUG: 3
DEBUG: 4
DEBUG: タイムアウトしました: App\Jobs\TestJob has been attempted too many times or run too long. The job may have previously timed out.
実装を以下のように変えてみると、エラーの都度リトライするようになる。
リトライ間隔は10秒。
public $timeout = 14;
public $tries = 3;
- public $retryAfter = 1;
+ public $retryAfter = 10;
public function handle()
{
for($i=0; $i<10; $i++) {
\Log::debug($i);
sleep(3);
- // trigger_error('aaa', E_USER_ERROR);
+ trigger_error('aaa', E_USER_ERROR);
}
}
ログの残り方は以下のように変わる。
DEBUG: 0
ERROR: aaa
# ここで10秒空く
DEBUG: 0
ERROR: aaa
# ここで10秒空く
DEBUG: 0
DEBUG: 失敗しました: aaa
ERROR: aaa
retryAfterの動作の確認
以下のような形で、retry_after=60を指定しておく。
'connections' => [
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 60,
],
実装の方は、trigger_errorの代わりにexitで強制終了させるようにする。
すると以下のようにログが残るので、ほぼ1分経って自動でリトライされていることが分かる。
$ docker compose exec app php artisan queue:work
[2022-07-15 03:27:26][43] Processing: App\Jobs\TestJob
# 強制終了するので手動で再起動
$ docker compose exec app php artisan queue:work
[2022-07-15 03:28:28][43] Processing: App\Jobs\TestJob