Closed24

Laravelのジョブのリトライの挙動について

ピン留めされたアイテム
tristartristar

諸々試した結果では、以下のような動作と思われます。

  • 例外やPHPエラー、Job::$timeout経過で終了した場合
    • Job::$retryAfter に応じた秒数の時間をおいてリトライされる。
  • 例外なしでJobが終了した場合(何らかの理由でexitしてしまった)
    • config/queue.phpやコマンドラインで定義したretry_afterの秒数だけ待ってリトライされる
  • リトライ回数が上限に達した場合
    • Jobが失敗する
      • 失敗は Job::failed(Exception $e) でハンドルできる

ワーカーのretryAfterとJob::retryAfterが紛らわしいためか、
Laravel v8以降では、Job::retryAfterがJob::backOff にリネームされている。

tristartristar

Laravelのジョブのリトライについて調べる。これまでは以下のような認識だったものの、retryAfterはconfigでconnectionレベルで指定した値だけが適用されているように見えたため。

  • ワーカーが例外を拾った場合は即座にリトライされる
    • この部分は backOff パラメータで調整が可能
  • それ以外のケースでは、 retryAfter を超えてキューに残ったジョブは自動的に再度ワーカーに割り当てられる
    • このため、timeOutよりもretryAfterは長めにしておく必要がある
    • retryAfter はconfig/queue.phpでコネクション毎に定義出来る他、Jobクラス側にも定義できる

ただ、JobクラスにretryAfterを指定してもその値が採用されているように見えない。

tristartristar

最初にQueueフォルダ内で retryAfterが指定されている場所を調べてみる。

DatabaseQueue.php

ワーカーのパラメータとしてretryAfterが指定されている。
retryAfter自体はジョブをワーカーが再処理するかどうかの設定値なので、ワーカー側で持っているのは自然だと思う。

https://github.com/laravel/framework/blob/7e82e1a272a72d75b4cd7f35a2267e9af5d642c2/src/Illuminate/Queue/DatabaseQueue.php#L51-L58

Queue.php

ここでJobのretryAfterから設定値を受け取っている。これもワーカー側の実装。

https://github.com/laravel/framework/blob/6.x/src/Illuminate/Queue/Queue.php#L156-L173

tristartristar

参照されている箇所で気になるのは上記の部分で、特に getJobRetryDelay が気になる。

tristartristar

これだと、公式ドキュメントにあったbackOffとの違いは...?

tristartristar

Laravel8だとドキュメント上で引っかかるものの、Laravel6では引っかからないので、ここまでの話はLaravel6固有の模様...。

実際にソースコードを検索すると、v6.xのブランチではbackOff では一切該当がない(Queueフォルダ内)。

tristartristar

ということは、v8に移行するまでのどこかで、retryAfterをbackOffに置き換える必要が発生しそう。

tristartristar

一旦、ワーカー側のretryAfterの動作を確認する。

tristartristar

先ほどはあまり重要でないと思って控えなかったものの、DatabaseQueue.phpの中では以下で唯一参照されている。

https://github.com/laravel/framework/blob/6.x/src/Illuminate/Queue/DatabaseQueue.php#L259-L274

このメソッドは同じクラス内の getNextAvailableJob() から参照されていて、
「キュー上に残っていて、retryAfter を経過しているジョブを処理の候補として取得する」と考えると今までイメージしていたretryAfterと一致する。
(getNextAvailableJobが本当に次に処理するジョブになるのかまでは確認できていない)

tristartristar

ここまで考えると、ワーカーに設定する retryAfterとジョブのプロパティに指定するretryAfterは別々というのは確かそう。

tristartristar

別の疑問として、タイムアウトの動作についても確認する。

tristartristar

ワーカーのタイムアウトを調べていたら、ワーカー側で以下のようなものが設定されているのを確認した。
意外なところでシグナルが使われていて驚いたけど、よくよく考えると自分自身がフリーズしている状態に割り込むにはこれしか方法がなさそう。
そうしないと、長時間のIOを待っている状況では次に処理できるのはIOが終わった時になってしまう。

https://github.com/laravel/framework/blob/6.x/src/Illuminate/Queue/Worker.php#L157-L176

tristartristar

Laravel6時点では、以下のような動作になるので良いか試してみる。

  • タイムアウトした場合
    • retry回数も考慮せず、即failed_jobsに投入される
    • これは、Jobのfailed()メソッドに MaxAttemptsExceededException が渡されたかで検知できる
  • タイムアウトしていない場合
    • 例外で終了した場合
      • Jobクラスの retryAfter が採用される。
      • 指定していない場合、ワーカーの delay オプションが採用される
    • 例外なしで終了した場合
      • ワーカーの retryAfter オプションが採用される
tristartristar

タイムアウト時の動作については、 artisan queue:work で実行させた上で、ジョブに以下のようなメソッドを持たせることで検知出来た。

    public function failed(\throwable $e)
    {
        if($e instanceof MaxAttemptsExceededException) {
            \Log::debug('タイムアウトしました: ' . $e->getMessage());
        } else {
            \Log::debug('失敗しました: ' . $e->getMessage());

        }
    }
tristartristar

タイムアウトはリトライの都度リセットされている。
なので、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上がっている。

tristartristar

そして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.  
tristartristar

実装を以下のように変えてみると、エラーの都度リトライするようになる。
リトライ間隔は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
tristartristar

retryAfterの動作の確認

以下のような形で、retry_after=60を指定しておく。

config/queue.php
    '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
このスクラップは2022/10/07にクローズされました