poem

Laravel Scheduler のコードリーディング

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

これがどうやって動いているかを考える。

Schedule コマンド

コマンドの本体は 以下にある。

https://github.com/laravel/framework/blob/5.1/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php

    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 にて引数に落ちてくるオブジェクト。 スケジュールの設定を行う際に触るオブジェクトである。

https://github.com/laravel/framework/blob/5.1/src/Illuminate/Console/Scheduling/Schedule.php

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);
    }

collectnew 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;
    }

thenskip の引数取扱の違いは謎だが、とりあえず上記の様になっている。

中でも 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;
    }

filterPassScheduler::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 という仕組みもあるよう。

https://laravel.com/docs/5.5/scheduling#task-hooks

面倒な時はもはや自分でイベントリストを作っても良いかも