それマグで!

知識はカップより、マグでゆっくり頂きます。 takuya_1stのブログ

習慣に早くから配慮した者は、 おそらく人生の実りも大きい。

laravelのジョブ・キュー・ワーカーを試す。

laravel ジョブ・キューモデル

laravel には、ジョブ・ワーカーとキューがデフォルトで用意されています。

これを少し試してみました。

今回試すこと

キューにジョブをいれて、ワーカーで処理をする。

queue でできること

ジョブを待ち続けて実行する。

arisan queue:work # 無限ループによるキュー待ち

キューに値が入ると処理を始めるワーカーを起動します。

今回やらないこと。(並列処理)

こんかいは並列処理試さない。特に複数マシンでジョブの共有。ジョブの並列起動。これらは試しません。ネットワーク超えてジョブを共有して処理をすれば便利なのだろうが考えることが多くなるので今回は試しません。

laravelのジョブキュー並列処理は、基本機能をあれこれ触って考えるよりも、Redis 使って処理するか、laravel/horizon を使うべきだと思う。

基本的に出来ないと思っておいたら無難ってだけで、実現する方法はいっぱいあしライブラリもいっぱいあるし、php8.1で進化したFiberとかもそのうちサポートされるだろうし、基本的なところを抑えておき基本的な機能で作っておくほうが最初は無難だと思われる。

laravel ジョブ・キューモデル-その1sync

ジョブの作成と実行

ジョブクラスを作成し、コマンドからジョブクラスを登録し、コマンドからジョブを実行する

ジョブクラスを作る app/Jobs/MyFirstJob.php が作成される。

php artisan make:job MyFirstJob

app/Jobs/MyFirstJob.php 編集してechoするだけの仕事を定義する。

<?php
class MyFirstJob implements ShouldQueue {
  use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
  
  public function handle () {
    echo "Hello from MyFirstJob\n";
  }
}

ジョブ登録する

ジョブを登録するために、artisan コマンドで登録専用のコマンドを作る。

php artisan make:command EnqueueMyFirstJob

app/Jobs/MyFirstJob.php ジョブ登録するコマンドを作る

<?php
class EnqueueMyFirstJob extends Command {
  protected $signature = 'myfirstjob:add';
  protected $description = 'MyFirstJobをキュー追加';
  
  public function handle () {
    MyFirstJob::dispatch();
    return 0;
  }
}

artisan を実行する(ジョブ登録する)

php artisan myfirstjob:add
Hello from MyFirstJob

ジョブはその場で実行されます。

即座に実行されます。Jobで定義した仕事は 追加された瞬間に実行されます。

開発時やお仕事(ジョブ)の定義を作るときは、このままで大丈夫です。

しっかりジョブキューとして使うには、分離する必要があります。

laravel ジョブ・キューモデル-その2db

先程作ったジョブは、その場で実行されます。ジョブなのにその場で同期的に実行されました。これは、デフォルトの実行タイミングがsync となっていて、呼び出した側でそのまま実行されるからです。

次に、sync をやめて、ワーカーに処理を任せるように変えて、キューイングしたいと思います。

分離する

ジョブを即時処理せずに、データベースにためておく。

データベースにジョブをためておく事ができます。そのためにデータベースにテーブルを作ります。これは最初から作成キットがコマンドとして用意されていて、それを実行するだけです。

データベースにキュー管理テーブルを作る

php artisan queue:table
php artisan migrate

データベースにジョブを登録するコマンドを作ります。

artisan make:command EnqueueJobIntoDatabase

EnqueueMyFirstJobを改造します。

最初に作ったジョブ登録コマンドを改造して、データベースにキューとして積上げていきます。

<?php
class EnqueueJobIntoDatabase extends Command {
  protected $signature = 'myfirstjob:add_to_db';
  public function handle () {
    app('queue')->connection('database')->push(new MyFirstJob());
    return 0;
  }
}

ジョブを登録します。

php artisan myfirstjob:add_to_db

データベースに登録したジョブを実行します。

php artisan queue:work --once database

これで、登録と実行が分離されて、workder によりジョブが実行されます。

queue:work の実行書式について

実行書式がわかりにくいので、分割して見おきます。

artisan queue:work --once 接続名  

登録されたジョブを1つだけ処理をする。

artisan queue:work --once

ジョブを取り出す接続先を指定する。

artisan queue:work 接続名  

指定した接続先から1つ取り出して実行する。2オプションをまとめる

artisan queue:work --once 接続名

上記の実行方法が、最初の例で、ジョブを実行したコマンドになります。

実験中は、ジョブをまとめて処理する必要が殆どないので、1回ずつ --onece をつけて実行することがほとんどかなと思います。

ジョブの登録の箇所について

データベースにジョブを登録した箇所を見ておきます。

Laravelコンテナから、Queueクラスを取り出して実行している。

app('queue')->connection('database')->push(new MyFirstJob());

次と同じである。

$con = \Illuminate\Support\Facades\Queue::connection('database');
$con->push( new MyFirstJob() );

databaseとは、単純にデータベースを示す様に見えます。しかし実際には、laravel 設定から、databaseという名前で登録されたキューの名前から、接続を取り出しています。

ジョブの監視。(ワーカー実行)

データベースにジョブが登録されるまで、ずっと待ってキューが来たら処理をするのがワーカー

php artisan queue:listen  database

登録したら、ジョブが実行される。

画面左側が、ワーカーです。画面右側でジョブを登録をしています。

f:id:takuya_1st:20220125175057p:plain

監視間隔

ワーカーは、無限ループで待ってくれる。実行間隔は1秒程度。

ちなみに、本番環境では エラーを考慮して listenよりworkを使うかなと思います。

### デプロイ先(本番環境など)
php artisan queue:work  database

いい感じにフォークしてくれて、例外でも停止しないで監視を継続してくれます。(あとで試します)

ジョブ・キューの接続先の設定

毎回、接続先を指定してられないし、接続名がソースコードに残るのも好ましくない。

デフォルト接続先

そこで、接続先のデフォルトを変えておく

設定は、次のようになっている。デフォルトはsync でキューに入れずに直接実行です。

config/queue.php

 'default' => env('QUEUE_CONNECTION', 'sync'),

設定なので、環境変数env で指定するか、configで指定するか。の2択があります。

envで指定する場合

env ファイルに記述すればいいです。

QUEUE_CONNECTION=database

config で指定する場合。

設定ファイルに記述します。

config/queue.php

return [
 'default' => env('QUEUE_CONNECTION', 'datanase'),

...(snip)

configを変えたら、キャッシュのクリア。

artisan config:clear
artisan config:cache

これで、ひとまず、一通りのジョブキュー実行が行えるはずである。

キューの接続先を「設定ファイル」で候補を作っておき、本番用・実験用でデフォルトを切り替えておくのが便利なかと思います。

ジョブを1分間に1度まとめて処理する。

php artisan queue:work --sleep=60

60秒に一度ジョブを全部取得して実行する。仕事がなくなったら sleep します。

このオプションから分かる通り、ワーカーは仕事をまとめて取りに行く。

60秒毎回チェックするというわけでなく、スリープ間隔が60秒ですね。

仮に、60秒ごとに起動したいなら schedule:work の方を使うべきでしょうね。

laravel ジョブ・キューモデル-その3クラス定義更新

worker に登録したら、ジョブのクラスがなくなったどうなるでしょうか。

ジョブはクラスを登録します。クラス定義を書いてジョブとして登録してるわけです、だったらキューに溜まったままクラス名を変えたらどうなるでしょうか。

キューに予約タイミングでロードなのか、実行にクラスをロードしているのか、laravelはどちらなのでしょうか。確認しておきます。

実行前にJobクラスを変更したら?

ジョブには、クラス名だけが登録されています。

キューの中身を確認する

ジョブの一覧を見るコマンドを作ってみます。

artisan make:command Joblist

クラスにジョブを吐き出させます。

<?php
class Joblist extends Command {
  protected $signature = 'queue:list';
  public function handle () {
    $ret = \DB::table('jobs')->get('payload');
    foreach( $ret as $idx=> $r){
      dump(json_decode($r->payload));
    }
    return 0;
  }
}

実行します。

 artisan queue:list

キューの中身はSerializeみたいです。

クラスがシリアライズされているのがわかります。

{#875
  +"uuid": "6f0c4b73-cde7-4da2-ae8e-6124a3622f23"
  +"displayName": "App\Jobs\MyFirstJob"
  +"job": "Illuminate\Queue\CallQueuedHandler@call"
  +"maxTries": null
  +"maxExceptions": null
  +"failOnTimeout": false
  +"backoff": null
  +"timeout": null
  +"retryUntil": null
  +"data": {#887
    +"commandName": "App\Jobs\MyFirstJob..."
    +"command": "O:19:"App\Jobs\MyFirstJob":10..."
  }

しかもクラス名もシリアライズされている。なのでオブジェクトをダンプしてるとはいえ、クラス定義まではダンプしていない事がわかります。

ジョブ(タスク)登録後にジョブのクラス定義を変えると、実行時タイミング最新版のコードで実行されるとわかります。登録時のクラスの定義までは持っていってないことがわかります。

ここから複数のワーカーを別マシンで動かすときに、クラスファイルがないと動かないこともわかります。つまりワーカー専用のマシンを用意し、複数のいろいろなlaravelプロジェクトからジョブを受け取って、複数のプロジェクトでワーカーをシェアする設計は面倒くさいとわかります。laravelのキュー・ワーカーを使っただけでは、aws lambdaみたいな関数実行専用サーバー作ることが困難であるとも言えます。

Jobクラス定義を変えてみます。

ジョブを登録します。

for i in {1..10}; do php artisan myfirstjob:add_to_db ; done

登録した段階では、まだワーカーを動かしません。

登録後、絶対失敗するジョブにししておきます。がまだ保存しません。

<?php
class MyFirstJob implements ShouldQueue {
  use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
   
  public function handle () {
    throw new \Exception('failed on purpose.');
    echo "Hello from MyFirstJob\n";
  }
}

ワーカーを起動したタイミングでクラス定義を更新しちゃいます。

起動と、コードの保存を併せてやります。

php artisan queue:listen  database --sleep=30

クラス定義変更で実行エラー

実行ログをみるとソースを保存した後。新しい定義にした箇所から、実行に失敗しログに出てきます。

php artisan queue:listen  database --sleep=30
[2021-08-23 10:15:11][41] Processed:  App\Jobs\MyFirstJob
[2021-08-23 10:15:12][42] Processing: App\Jobs\MyFirstJob
Hello from MyFirstJob
[2021-08-23 10:15:12][42] Processed:  App\Jobs\MyFirstJob
[2021-08-23 10:15:13][43] Processing: App\Jobs\MyFirstJob
Hello from MyFirstJob
[2021-08-23 10:15:13][43] Processed:  App\Jobs\MyFirstJob
[2021-08-23 10:15:14][44] Processing: App\Jobs\MyFirstJob
[2021-08-23 10:15:14][44] Failed:     App\Jobs\MyFirstJob
[2021-08-23 10:15:15][45] Processing: App\Jobs\MyFirstJob
[2021-08-23 10:15:16][45] Failed:     App\Jobs\MyFirstJob
[2021-08-23 10:15:17][46] Processing: App\Jobs\MyFirstJob
[2021-08-23 10:15:17][46] Failed:     App\Jobs\MyFirstJob

ということで、ジョブ定義をキューに溜まってるときは変えるべきじゃありません。

キューイングされているジョブ定義のクラス変更が困難です。

このことから、git push からの自動デプロイに懸念が残ります。

本番環境で git pull してソース更新するとき、一旦ワーカーを止めておかないと誤作動しそうですね。

今回は、キュー先がデータベースで試しましたが。Redisでも多分同じだろうと思います。試してません。

Synfomy::Processが詰まって60秒くらいで終了してしまう理由

symfony processが詰まる理由

symfony processを使っていると、プロセスが60秒で終了してしまう。

Symfony Processのデフォルトタイムアウトかな。

Exit code 143 corresponds to SIGTERM, which is the signal sent by default when you run kill . Did you or the OS kill the process? Is it an infinite loop that you eventually killed? https://stackoverflow.com/questions/58676871/when-i-run-this-code-it-return-that-exit-status-143-in-java

パイプが詰まってる

Symfonyのプロセスを見ていると、UNIXのPIPEや php://temp を使うように設計されている。

  -stdout: stream resource {@844
    wrapper_type: "PHP"
    stream_type: "TEMP"
    mode: "w+b"
    unread_bytes: 0
    seekable: true
    uri: "php://temp/maxmemory:1048576"
    options: []
  }
  -stderr: stream resource {@846
    wrapper_type: "PHP"
    stream_type: "TEMP"
    mode: "w+b"
    unread_bytes: 0
    seekable: true
    uri: "php://temp/maxmemory:1048576"
    options: []
  }

これはPIPEが詰まりますわ。

逐次読み出しておく。

Processからのアウトプットが大量に来るとき(たとえばffmpegで変換していて、php側の変数に受ける)とかやりたいときは、いったん適当なファイルにパイプを読み出しておかないと詰まってデッドロックしそうですね。

laravel のスケジュール実行とジョブと例外処理について調べておいた。

laravel のスケジュール実行

laravel で cron のようなジョブ実行ができる。cron書式で書いて旧世代のワタシに理解しやすくて便利そうなので、スケジュールをつかってcronを管理できたらいいなと思った

laravel のスケジュールの流れ

  • Kernel.phpに記述
  • artisan で実行
  • artisan がKernelを呼び出し
  • KernelがShcedule を呼び出し
  • Shceduleがcron書式をチェックする
  • cron書式がいまの日時分なら実行する
  • スケジュールがなくなるまで繰り返し。

スケジュールを作って実行する

最初に、スケジュールで実行したいジョブを作る。 app/Console/Kernel.php

class Kernel extends ConsoleKernel {
    ## .. snip
  protected function schedule ( Schedule $schedule ) {
    $schedule->exec( 'ls -alt /' )->everyMinute();
  }
    ## .. snip
  }

正しく登録されたか、確認しておく。

$ php artisan schedule:list
+-----------+-----------+-------------+----------------------------+
| Command   | Interval  | Description | Next Due                   |
+-----------+-----------+-------------+----------------------------+
| ls -alt / | * * * * * |             | 2021-08-23 12:41:00 +00:00 |
+-----------+-----------+-------------+----------------------------+

実行してみる。

php artisan schedule:run

実行結果。

$ php artisan schedule:run
[2021-08-23T12:41:38+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1

連続実行するとどうなるか。

スケジュールの定義は、ソースコードに書いているだけです。さて、数秒間にartisanを連続起動するとどうなるでしょうか。

 for i in {1..10} ; do php artisan schedule:run ; done
[2021-08-23T12:43:07+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1
[2021-08-23T12:43:08+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1
[2021-08-23T12:43:09+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1
[2021-08-23T12:43:09+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1
...

上記の通り、artisan が実行されたら実行される。

毎回毎回、時刻と一致するか判定しTrueになるので毎回実行されちゃう。これは、arrtisanが実行されたタイミングに依存するので、10回artisanを起動させたら10回ジョブが起動する。これが公式の機能です。

これはcrontab に登録をモデルにしているからだと思われます。

そして、cronのようなタイマーに artisan schedule:run を仕込むことを前提としているからです。

crontab を使って毎分実行

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

これで、schedule:run が毎分1回実行されることが保証されます。これでいいのか。と疑問に思うけど、そういうもんです。

schedule:work / 単体でスケジュールを回す

無限ループの中で、1分に一度実行して、時間になるまで待って、時刻が到来したら更新がしたいとき、systemdやcrontab のタイマーに任せない環境があると思います。そういうときはどうすればいいのでしょうか。docker に systemd/crontab を入れろというのでしょうか。それは困ります。

定期ジョブ実行に特化したワーカープロセスが用意されています。

php artisan schedule:work

実行してみましょう。

$ php artisan schedule:work
Schedule worker started successfully.

[2021-08-23T12:48:01+00:00] Execution #1 output:
[2021-08-23T12:48:00+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1
[2021-08-23T12:49:00+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1

work 中にスケジュールの定義を変えてみる。

定期ジョブを実行するプロセスartisan schedule:work を実行中に、ジョブの定義を変えてみたらどうなるでしょうか。

  protected function schedule ( Schedule $schedule ) {
    $schedule->exec( 'ls -alt /' )->everyMinute();
      ## 追加する。
    $schedule->exec( 'pwd /' )->everyMinute();
  }

work の出力を待ってみると。変わっています。

[2021-08-23T12:50:01+00:00] Execution #3 output:
[2021-08-23T12:50:01+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1
[2021-08-23T12:50:01+00:00] Running scheduled command: pwd / > '/dev/null' 2>&1

これは、安心です。 laravel のスケジューリングは、時間になったら 最初からサービスを起動し、Kernelを再評価してくれるようです。

となると、心配なのがシンタックスエラー

わざとエラーにしてみます。

  protected function schedule ( Schedule $schedule ) {
    $schedule->exec( 'ls -alt /' )->everyMinute();
    $schedule->exec( 'pwd /' )->everyMinute();
    あああ
  }

schedule:work の実行結果を見てみると

[2021-08-23T12:52:00+00:00] Execution #5 output:
PHP Parse error:  syntax error, unexpected '}' in ..../app/Console/Kernel.php on line 28
[2021-08-23T12:53:00+00:00] Execution #6 output:
PHP Parse error:  syntax error, unexpected '}' in ..../app/Console/Kernel.php on line 28```

エラーになります。しかし、エラーでもwork自体は止まりません。いいじゃん。

タイマーのの実行結果はどこに行くのか。

exec でコマンド実行したときのログを見ていると

Running scheduled command: ls -alt / > '/dev/null' 2>&1

このように記載されています。つまりコマンドの実行結果は、/dev/null へ。宇宙の彼方へ捨てられます。

これでは、困るのでなにか手を考えないといけません。

ログファイルに書き出すのは簡単ですね。

$schedule
    ->exec('ls -alt')
    ->appendOutputTo(storage_path('logs/my-job.log'));

ただし、ログのファイル名を変えたいとか、特定のエラーを捕まえたいとかそういう事はできないし想定されてないと思います。ログを書き出し、ログをrsyslogdでZabbixに流して監視的な形になると思います。ですが、やっぱりLaravel内部で完結させたいところです。

実行ログを保存するジョブを作る

実行ログを保存したり、実行中に条件分岐を作ったり、タイマー実行時になにか処理を加えたいときは、ジョブを作るのがベターだと思われます。

ジョブは、キューと同じジョブです。Queueで使うJobをスケジュール実行で使えます。

ジョブを作成する

キューを作るときと同じで、Jobは make:jobで作れます。

artisan make:job MyCronJob

app/Jobs/MyCronJob.php

class MyCronJob implements ShouldQueue {
  use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
  
  public function handle () {
    $proc=new Process(['ls','-alt']);
    $proc->run();
    dump($proc->getOutput());
  }
}

ジョブをスケジュールに登録

作ったジョブをスケジュールに登録します。

  protected function schedule ( Schedule $schedule ) {
    $schedule->job(new MyCronJob())->everyMinute();
    //$schedule->exec( 'ls -alt /' )->everyMinute();
    //$schedule->exec( 'pwd /' )->everyMinute();
  }

登録を確認します。

$ php artisan schedule:list
+---------+-----------+--------------------+----------------------------+
| Command | Interval  | Description        | Next Due                   |
+---------+-----------+--------------------+----------------------------+
|         | * * * * * | App\Jobs\MyCronJob | 2021-08-23 13:01:00 +00:00 |
+---------+-----------+--------------------+----------------------------+

実行します。

php artisan schedule:run
[2021-08-23T13:02:11+00:00] Running scheduled command: App\Jobs\MyCronJob
"""
drwxrwxrwx 1 takuya takuya    4096 Aug 23 21:23 database\n
drwxrwxrwx 1 takuya takuya    4096 Aug 23 17:52 app\n
drwxrwxrwx 1 takuya takuya    4096 Aug 23 16:39 public\n
drwxrwxrwx 1 takuya takuya    4096 Aug 23 16:39 node_modules\n
-rwxrwxrwx 1 takuya takuya  323529 Aug 23 16:39 package-lock.json\n
-rwxrwxrwx 1 takuya takuya    1890 Aug 23 16:37 composer.json\n
"""

ちゃんと実行されることがわかります。 これで、Jobを定義すれば、スケジュールの中で複雑なことができるとわかります。

時間のかかるジョブはどうなるのか。

時間のかかるジョブをどうするのかが問題です。

ジョブを作るとき、時間のかかる処理は小分けにして実行するのが基本です。たとえば1000件メールを送るのであれば、1件送信を1000回起動します。それで細切れにします。またジョブキューの仕組みを使ってワーカーに処理を任せてもいいでしょう。

でも、どうしても時間のかかる処理は出てきます。たとえば、画像を変換したり、動画を変換したり、pupeteer でスクレーピングしたりです。

時間のかかる処理がどうなるか見ておきましょう。 app/Console/Kernel.php

  protected function schedule ( Schedule $schedule ) {
    $schedule->exec( 'echo スケジュール開始' )->everyMinute();
    $schedule->exec( 'sleep 30' )->everyMinute();
    $schedule->exec( 'echo スケジュール 終了' )->everyMinute();
  }

sleep で お長時間時間を掛けてみます。

30秒くらいなら、なんともなさそうです。 '13:10:01+00'に開始したタスクは '13:10:31+00' には終わっています。 workで起動後、 毎分00+00に開始するので、次の時間にズレ込みません。大丈夫です。

$  php artisan schedule:work
[2021-08-23T13:09:01+00:00] Execution #5 output:
[2021-08-23T13:09:00+00:00] Running scheduled command: echo スケジュール開始 > '/dev/null' 2>&1
[2021-08-23T13:09:01+00:00] Running scheduled command: sleep 30 > '/dev/null' 2>&1
[2021-08-23T13:09:31+00:00] Running scheduled command: echo スケジュール 終了 > '/dev/null' 2>&1

[2021-08-23T13:10:01+00:00] Execution #6 output:
[2021-08-23T13:10:00+00:00] Running scheduled command: echo スケジュール開始 > '/dev/null' 2>&1
[2021-08-23T13:10:01+00:00] Running scheduled command: sleep 30 > '/dev/null' 2>&1
[2021-08-23T13:10:31+00:00] Running scheduled command: echo スケジュール 終了 > '/dev/null' 2>&1

60 秒掛けてみたらどうなるでしょうか。

  protected function schedule ( Schedule $schedule ) {
    $schedule->exec( 'echo スケジュール開始' )->everyMinute();
    $schedule->exec( 'sleep 61' )->everyMinute();
    $schedule->exec( 'echo スケジュール 終了' )->everyMinute();
  }

ちょっとややこしいですが、出力をよく見てみましょう。

[2021-08-23T13:11:01+00:00] Execution #7 output:
[2021-08-23T13:11:00+00:00] Running scheduled command: echo スケジュール開始 > '/dev/null' 2>&1
[2021-08-23T13:11:01+00:00] Running scheduled command: sleep 61 > '/dev/null' 2>&1

[2021-08-23T13:12:01+00:00] Execution #8 output:
[2021-08-23T13:12:01+00:00] Running scheduled command: echo スケジュール開始 > '/dev/null' 2>&1
[2021-08-23T13:12:01+00:00] Running scheduled command: sleep 61 > '/dev/null' 2>&1

[2021-08-23T13:12:02+00:00] Execution #7 output:
[2021-08-23T13:12:02+00:00] Running scheduled command: echo スケジュール 終了 > '/dev/null' 2>&1

前回のスケジュールが実行中に次のジョブが開始します。 |タスク| 13:11 | |13:12||13:13| |---|---|---|---|---|---|---| |1| 開始|sleep|終了| |2| |開始|sleep|終了| |3| ||開始|sleep|終了|

このように、前回のタスク終了前に、次回のスケジュールが起動します。

これは、思わぬトラブルを引き起こす可能性がありますね。

複数の sleep ジョブが溜まっていきます・・・・ dc452a4a2471dc94de51855926363368.png

そこで、取りうる選択肢として、オーバーラップさせない、若しくは起動して切り離す。あとは知らん。

オーバーラップを許さない場合 withoutOverlapping で修飾しておけば、重複起動しません。 前回のタスクが終わるまで次のタスクに入りません。

  protected function schedule ( Schedule $schedule ) {
    $schedule->exec( 'echo スケジュール開始' )->everyMinute();
    $schedule->exec( 'sleep 61' )->everyMinute()->withoutOverlapping();
    $schedule->exec( 'echo スケジュール 終了' )->everyMinute();
  }

withoutOverlapping をつけておけば、同一時間に複数起動することがなくなります。

$ php artisan schedule:run &
$ php artisan schedule:run 

すでに起動していたら、スキップされます。

タスク 13:11 13:12 13:13
1 echo 開始 sleep echo 終了
2 echo 開始 スキップ echo 終了
3 echo開始 sleep echo 終了

ただ、重複チェックが誤作動したので、php artisan cache:clear しないといけなかったので、自信を持って押せない機能ですね。

バックグラウンド実行 バックグラウンドで実行させて、処理を切り離すことも出来ますね。

$schedule->exec( 'sleep 120' )->everyMinute()->runInBackground();

シェルのバックグラウンド実行と、サブシェル( sub-shell ; command )機能を使って処理が終わったらartisan を呼び出して処理が終わったことを確認しているようです。

Linuxで実行した場合、実行元シェルから切り離されていた 。sh -c' ( command ) > &'ですね。

[2021-08-23T13:42:06+00:00] Running scheduled command: (sleep 120 > '/dev/null' 2>&1 ; '/usr/bin/php7.4' 'artisan' schedule:finish "framework/schedule-578c2e34750ad5f0982ff5a1a9f68959495cb9d0" "$?") > '/dev/null' 2>&1 &

バックグラウンドで時間のかかる処理を放置してもいいかもしれません。

f:id:takuya_1st:20220124115956p:plain

スケジュールで実行できるもの

  • artisan コマンド
  • exec シェルコマンド
  • Job クラス(shouldQueue)

になります。これらを組み合わせて使えばいいようですね。

スケジュールの実行結果を保存する。

ログファイルに書き出すのは簡単ですね。

$schedule
    ->exec('ls -alt')
    ->appendOutputTo(storage_path('logs/my-job.log'));

ただ、DBに書き出すとなると大変です。EventデザパタでonXXX に登録して、ハンドラでもらってこないといけない。

$schedule->exec('ls -alt')
  ->onFailureWithOutput(function($sterr){
    echo "error\n";
    echo $sterr;
  })
  ->onSuccessWithOutput(function($output){
    echo "success\n";
    echo $output;
  });

ここまでくると、独自のJob・artisanコマンドを実装したほうが楽そうです。

schedule->job の怖いところ

ジョブを作成して、スケジュールで回してもほぼ同じことができるのですが。 ジョブをQueableで作っていまうと、スケジューラーは、ジョブを実行せずに、キューに送ってしまう。

$schedule->job(new MyCronJob(),'default','database');

Schedule#jobの実装を見てみると・・・

  public function job ( $job, $queue = null, $connection = null ) {
    return $this->call( function() use ( $job, $queue, $connection ) {
      $job = is_string( $job ) ? Container::getInstance()->make( $job ) : $job;
      
      if ( $job instanceof ShouldQueue ) {
        $this->dispatchToQueue( $job, $queue ?? $job->queue, $connection ?? $job->connection );
      } else {
        $this->dispatchNow( $job );
      }
    } )->name( is_string( $job ) ? $job : get_class( $job ) );
  }

スケジュールに登録したジョブは、キューとして取り扱いできれば、すべてキューイングされている。キューのデフォルトがsync (即時実行)ならいいのだが、デフォルトがRedisなどなら、そちらに送られるだけで実行はされない。

次のように書いたジョブをスケジュール実行してもキューイングされるだけである。

class MyCronJob implements ShouldQueue {
  use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
  
  public function __construct () {
    $this->connection='database';
  }
  public function handle () {
    $proc=new Process(['ls','-alt']);
    $proc->run();
    dump($proc->getOutput());
  }
}

スケジュールで実行に登録しても、キューイングされるだけ。

$schedule->exec( 'echo スケジュール開始' )->everyMinute();
$schedule->job(new MyCronJob());
$schedule->exec( 'echo スケジュール 終了' )->everyMinute();

スケジュールと、キューは密接な関係にある。こういう仕様はちょっと予想を裏切るのでジョブキューのJobとスケジュールのJobは定義を分けてほしいかブリッジを入れてほしい。と思うことになるので、スケジュール用に使うジョブの雛形は、artisan make:job で作らないほうがいいかもしれない。

handle があるだけで、schedule->job からその場で十分に動くクラスとして使える。

class MySimpleCronJob  {
  public function handle () {
    $proc=new Process(['ls','-alt']);
    $proc->run();
    dump($proc->getOutput());
  }
}

また、ジョブで実行した場合、出力が取れない。

The emailOutputTo, emailOutputOnFailure, sendOutputTo, and appendOutputTo methods are exclusive to the command and exec methods.

また、ジョブで実行した場合、例外発生は、ログのみ記録される

スケジュール例外。これが一番問題で厄介な課題だとおもう。スケジュール実行した場合の例外は、ログでしかわからない。スケジュールでジョブ実行した場合、実行主体はスケジュールなので、例外はスケジュールの例外として記録される。オブジェクトが自分がどう処理されるか知っているというオブジェクト指向的には嬉しくない。

スケジュールで実行された・スケジュールでエラーになったを追わないと、ジョブ自体の例外によるデバッグが困難になる。なのでジョブ自体で完結するように作る必要があると思う。またLaravel自体の例外管理(App\Exception\Handler)を使うべきなのかもしれない。

スケジュールとジョブに関する例外はジョブの中で完結したいところではある。

ジョブ内部のエラー処理

ジョブ内部のエラー処理をどうするのか。 failed() を使う

class MyJob{
  public function failed ( $exception = null ) {
    dd($exception);
  }
}

しかし、ジョブのなかで failed を実装すると、デフォルトのジョブの失敗処理は動かない。まいった問題。

このあたりから考えると、スケジューリングで job を作って即時実行は望ましい処理じゃないとかもしれない。設計的にはLaravelはキューイングを前提にしていると思われる。$schedule->job() はマニュアルとどおり登録するためのものと割り切るほうが良さそう。

スケジュールで任意の関数・コードを実行/call

じゃあ、シェルコマンドでもなく、ジョブでもなく、任意のphpコードを実行するにはどうしたらいいのか。そこでcallableを渡すのである。

$schedule->call(function(){echo "aaaa";})->everyMinute();

でもcallable はめんどくさいなと思うと __invokeを実装しておけばいいのである。

In addition to scheduling using closures, you may also schedule invokable objects. Invokable objects are simple PHP classes that contain an __invoke method:

しかし、今度はジョブの失敗を クラス側で取れなくなる。

$schedule->call(new MyJob($job))->everyMinute();
class MyJob{
  public function __invoke () {
    throw  new \Exception('error');
  }
  public function failed ( $exception = null ) {
    dd($exception);
  }
}

また、実行時のログを見ても何が実行されたのかまるでわからない。

[2021-08-24T06:38:42+00:00] Running scheduled command: Callback

また、一覧をみても、何が実行されるのか、全くわからない。

php artisan schedule:list
+---------+-----------+-------------+----------------------------+
| Command | Interval  | Description | Next Due                   |
+---------+-----------+-------------+----------------------------+
|         | * * * * * |             | 2021-08-24 06:40:00 +00:00 |
+---------+-----------+-------------+----------------------------+

もう、手詰まり感がやばいですね。

コードを実行することもできるが、細かいことはできない。

スケジュールに登録するコードに名前をつける/name

実行されるCallableに名前をつける。

$schedule
    ->call(new MyJob())
    ->everyMinute()
    ->name(MyJob::class);

名前をつけておけば、 schecule:list で見られるようになる。

$ php artisan schedule:run
[2021-08-24T06:42:15+00:00] Running scheduled command: App\Jobs\MyFirstJob
$ php artisan schedule:list

+---------+-----------+---------------------+----------------------------+
| Command | Interval  | Description         | Next Due                   |
+---------+-----------+---------------------+----------------------------+
|         | * * * * * | App\Jobs\MyJob | 2021-08-24 06:41:00 +00:00 |
+---------+-----------+---------------------+----------------------------+

call 時は名前付け推奨ですね。

関数・コード実行(call)時の例外処理。

例外処理はどうするのか。

call時に、例外発生したら 、laravel の例外ハンドラによりログで処理される。

でも、エラー時の実行結果・成功時の実行結果を取ることが出来ない。

class MyJob{
  public function __invoke () {
    echo "aaaaaaaaaa";
    return 0;
  }
}

成功・エラーに関わらずエラーが取れない

$schedule->call(new MyJob($job))
    ->everyMinute()
    ->onSuccess(function(Stringable $args){
          dump('succeed');
          dump($args);// 取れない!
    })
    ->name(MyJob::class)

ちなみに、exec でも出力取れない

$schedule->exec('whoami')->onSuccess(function(Stringable $a){
    dump('exec succeed');
    dump($a);
});
$ php artisan schedule:run
[2021-08-24T07:14:41+00:00] Running scheduled command: whoami > '/dev/null' 2>&1
"exec succeed"
Illuminate\Support\Stringable^ {#884
  #value: ""
}

exce で出力を取る。動かない・・・と思ったら、変数名がoutput限定だった。そんなバカな。

$schedule->exec('whoami')->onSuccessWithOutput(function($output){
    dump('exec succeed');
    dump($output);
});

変数名が固定で入ってるやん。 src/Illuminate/Console/Scheduling/Event.php

    /**
     * Get a callback that provides output.
     *
     * @param  \Closure  $callback
     * @param  bool  $onlyIfOutputExists
     * @return \Closure
     */
    protected function withOutputCallback(Closure $callback, $onlyIfOutputExists = false)
    {
        return function (Container $container) use ($callback, $onlyIfOutputExists) {
            $output = $this->output && is_file($this->output) ? file_get_contents($this->output) : '';

            return $onlyIfOutputExists && empty($output)
                            ? null
                            : $container->call($callback, ['output' => new Stringable($output)]);
        };
    }

やってられん。Laravel使うときはコールバック変数名に注意しましょう。

Job で sync なタスクのバックグラウンド化

出来ません。すべてのジョブ実行を書き直すくらいの覚悟がないと出来ません。

runInBackground() していたとしても、ジョブにはrunInBackgroundが通知されません。

Illuminate\Console\Scheduling\Event Illuminate\Console\Scheduling\CallbackEvent

で定義されてますが、そこには__invoke時にバックグラウンドで調整するようなものが何もありません。

laravelのスケジュール機能は、キューイングとセットで使うと割り切ったほうが楽になると思います。

私は、schedule:work でキューイングして queue:work を同時に起動することにしました。