* * * * * php /path-to-your-project/artisan schedule:run >> /dev/null 2>&1
これがどうやって動いているかを考える。
Schedule コマンド
コマンドの本体は 以下にある。
public function handle()
{
$eventsRan = false;
foreach ($this->schedule->dueEvents($this->laravel) as $event) {
if (! $event->filtersPass($this->laravel)) {
continue;
}
$this->line('<info>Running scheduled command:</info> '.$event->getSummaryForDisplay());
$event->run($this->laravel);
$eventsRan = true;
}
if (! $eventsRan) {
$this->info('No scheduled commands are ready to run.');
}
}
となっていて、$this->schedule
は \Illuminate\Console\Scheduling\Schedule
のよう。
Schedule サービス
\Illuminate\Console\Scheduling\Schedule
は、 \App\Console\Kernel::schedule
にて引数に落ちてくるオブジェクト。 スケジュールの設定を行う際に触るオブジェクトである。
foreach で参照されている、 dueEvents
は以下のようになっている。
public function dueEvents($app)
{
return collect($this->events)->filter->isDue($app);
}
この$this->events
は \Illuminate\Console\Scheduling\Event
の配列。
$this->events
は、\App\Console\Kernel::schedule
内で $schedule->command("hoge:piyo")
の様に command
メソドを実行した際に 追加されていく。
以下は、\Illuminate\Console\Scheduling\Schedule::command
の中身。
public function command($command, array $parameters = [])
{
if (class_exists($command)) {
$command = Container::getInstance()->make($command)->getName();
}
return $this->exec(
Application::formatCommandString($command), $parameters
);
}
public function exec($command, array $parameters = [])
{
if (count($parameters)) {
$command .= ' '.$this->compileParameters($parameters);
}
$this->events[] = $event = new Event($this->mutex, $command);
return $event;
}
$this->events
の正体がつかめたところで、\Illuminate\Console\Scheduling\Schedule::dueEvents
の記述に戻る。
public function dueEvents($app)
{
return collect($this->events)->filter->isDue($app);
}
collect
は new Collection
なのだけども、ここで Collection::$filter
は存在しない。
ややこしいけど色々マジック的な魔法を使っていて、上記の記述は以下とほぼ等価になる。
public function dueEvents($app)
{
return collect($this->events)->filter(function(Event $event)use($app){
return $event->isDue($app)
});
}
絶対こっちの方が可読性は高いと思う。
Event オブジェクト
次に Event オブジェクトを見ていく。 Event オブジェクトは Schedule::command
から生成されリストに追加されていくもので、
そのリストのフィルタ関数内でコールされている \Illuminate\Console\Scheduling\Event::isDue
は以下のような形になっている。
public function isDue($app)
{
if (! $this->runsInMaintenanceMode() && $app->isDownForMaintenance()) {
return false;
}
return $this->expressionPasses() &&
$this->runsInEnvironment($app->environment());
}
最初のセクションはメンテナンスチェック。常に false となる。
お目当ては、\Illuminate\Console\Scheduling\Event::expressionPasses
。
\Illuminate\Console\Scheduling\Event::runsInEnvironment
は発行される環境の制限。Kernel 側で以下のようにして実行環境を制限出来るよう。
$schedule->command('emails:send --force')->environment([...])
\Illuminate\Console\Scheduling\Event::expressionPasses
の方は以下のようになっている。
protected function expressionPasses()
{
$date = Carbon::now();
if ($this->timezone) {
$date->setTimezone($this->timezone);
}
return CronExpression::factory($this->expression)->isDue($date->toDateTimeString());
}
ここで $this->expression
は 次の実行時刻の生成に用いられる。
\Cron\CronExpression::isDue
は色々あるものの、最終的には、
public function isDue($currentTime = 'now')
{
// 割愛: $currentTime を timestamp 変換する処理
try {
return $this->getNextRunDate($currentDate, 0, true)->getTimestamp() == $currentTime;
} catch (Exception $e) {
return false;
}
}
となるため、スケジューラは毎分実行しなければならない(前回実行したのがいつか…などを知る術はこの記述の中にはない)。
重複制御の仕組み
withoutOverlapping
メソドに関しては、
public function withoutOverlapping($expiresAt = 1440)
{
$this->withoutOverlapping = true;
$this->expiresAt = $expiresAt;
return $this->then(function () {
$this->mutex->forget($this);
})->skip(function () {
return $this->mutex->exists($this);
});
}
public function then(Closure $callback)
{
$this->afterCallbacks[] = $callback;
return $this;
}
public function skip($callback)
{
$this->rejects[] = is_callable($callback) ? $callback : function () use ($callback) {
return $callback;
};
return $this;
}
then
と skip
の引数取扱の違いは謎だが、とりあえず上記の様になっている。
中でも skip
で登録される、reject
は \Illuminate\Console\Scheduling\Event::filtersPass
の中で以下のように利用されている。
public function filtersPass($app)
{
foreach ($this->filters as $callback) {
if (! $app->call($callback)) {
return false;
}
}
foreach ($this->rejects as $callback) {
if ($app->call($callback)) {
return false;
}
}
return true;
}
filterPass
は Scheduler::handle
のイベントループ内で最初にコールされるため、rejects
内の callable
がfalseを返せば、イベントはスキップされる仕組みになっている。
Mutex
先程からちょくちょく挙がってくる mutex
は \Illuminate\Console\Scheduling\Mutex
で、\Illuminate\Console\Scheduling\Schedule::__construct
にて登録されるものが、$event
等にも Pass されている。
public function __construct()
{
$container = Container::getInstance();
$this->mutex = $container->bound(Mutex::class)
? $container->make(Mutex::class)
: $container->make(CacheMutex::class);
}
Mutex
はタスクの実行記録等を行うサービスで、Mutex
そのものはインターフェイスとなっている。サービスプロバイダ等で紐付けを行っていない限り、実装クラスの CacheMutex
が用いられる。CacheMutex
は Laravel の Cache を用いた Mutex 実装のため、ストレージの種別は Cache の設定に従う事となる。 expire の有効・無効もキャッシュの設定次第なので注意が必要
\Illuminate\Console\Scheduling\Event::run
は以下のような実装になっており、実行前に mutex の登録が実施されることが伺える。
public function run(Container $container)
{
if ($this->withoutOverlapping &&
! $this->mutex->create($this)) {
return;
}
$this->runInBackground
? $this->runCommandInBackground($container)
: $this->runCommandInForeground($container);
}
protected function runCommandInForeground(Container $container)
{
$this->callBeforeCallbacks($container);
(new Process(
$this->buildCommand(), base_path(), null, null, null
))->run();
$this->callAfterCallbacks($container);
}
結論
コードを見る限りでは
- 複数台サーバで CRON 実行しても、redis 等で mutex を共有すれば、重複制御は可能
- Heroku など 10分に1回の ツールでタタイても、
everyMinutes
のコマンドは5分に一回しか処理されない - 逆に(複数台の運用も含めて)1分に1回 以上実行しても、その度に
everyMinutes
コマンドは実行される。
と言う感じのよう。
思っていること
- どっかでイベント取って、実行スケジュールをログ吐き出し出来ないか。
- then で確実に forgetするのではなく、expire を調整する形で、永続化 mutex に載せた、next due time までの overlapping 制御は出来ないか。
どちらも overlaping 前提であれば、Mutex の実装で結構簡単にできそう。
前者は Mutex::create
の実装で。
後者は Mutex::forget
の実装で。 引数で落ちてくる $event は expression をもっている。
Task Hook という仕組みもあるよう。
面倒な時はもはや自分でイベントリストを作っても良いかも