【Laravel】 Cron タスクスケジューラの onOneServer() と withoutOverlapping() の違い

# はじめに

Laravel には Cron を利用したタスクスケジューリング機能があります。

- [Task Scheduling - Laravel - The PHP Framework For Web Artisans](https://laravel.com/docs/8.x/scheduling)
- [タスクスケジュール 8.x Laravel](https://readouble.com/laravel/8.x/ja/scheduling.html)

ところが素でこれをそのまま使うと，ワーカーとして複数インスタンスを立ててスケーリングしている場合などに，重複実行されてしまう問題があります。これを防ぐためには基本は `onOneServer()` を使っておけばいいのですが，なにやら `withoutOverlapping()` というものも存在することを知りました。微妙に用途が異なるみたいなので，ここで整理しておこうと思います。

現在時刻が「$H$ 時 $(i - 1)$ 分 $59$ 秒」であるとします。ここから1秒経過して「$H$ 時 $i$ 分 $0$ 秒」になった瞬間のことを考えます。

| メソッド | 目的 |
|:---|:---|
| `onOneServer()` | *$command* の **$H$ 時 $i$ 分 における実行開始** 処理が既に別の場所で発生済みであるとき，ここでの実行をスキップする |
| `withoutOverlapping()` | *$command* が **現在実行中** である場合， ここでの実行をスキップする |

この 2 つのオプションは複合するため，以下のような 4 通りのパターンがあります。

```php
$schedule->command($command)->everyMinute();
```

```php
$schedule->command($command)->everyMinute()->onOneServer();
```

```php
$schedule->command($command)->everyMinute()->withoutOverlapping();
```

```php
$schedule->command($command)->everyMinute()->onOneServer()->withoutOverlapping();
```

# 図解

所要時間が **1分10秒** である `$command` というコマンドを考えます。これを **1 分ごと** に実行します。またワーカーは **2 インスタンス** 立っているとします。

それぞれ順番にタイムチャートで見てみましょう。多分，図を見れば一発で理解できると思います。

## オプション無し

```php
$schedule->command($command)->everyMinute();
```

```
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|   Time   | 0 | ... | 50 | 60 | 70 | ... | 110 | 120 | 130 | ... | 170 | 180 | 190 | ... |
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Worker 1 | *********************                 *******************************        |
|          |                 *****************************                                |
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Worker 2 | *********************                 *******************************        |
|          |                 *****************************                                |
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
```

何の工夫もしない場合です。これはおそらく意図されていない結果です。

## `onOneServer()`

```php
$schedule->command($command)->everyMinute()->onOneServer();
```

```text:キャッシュドライバに&#32;Redis&#32;または&#32;Memcached&#32;を使っている場合
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|   Time   | 0 | ... | 50 | 60 | 70 | ... | 110 | 120 | 130 | ... | 170 | 180 | 190 | ... |
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Worker 1 | *********************                 *******************************        |
|          |                 *****************************                                |
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Worker 2 |                                                                              |
|          |                                                                              |
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
```

```text:（参考）キャッシュドライバにファイルを使っている場合
オプション無しと同じ
```

**ほとんどの軽量な短周期バッチではこれを選択するとよいでしょう。**ワーカーを水平分散したとき， １ インスタンスだけでタスクが実行されて欲しいと考えるのは自然な思考です。

### 補足

すべてのインスタンスが共通のキャッシュを見る必要があるため，**キャッシュドライバとして Redis または Memcached が必須** 要件であることに注意してください。 構成によっては Database でも問題ないように思われますが，アトミックな読み書きが確実に保証されているのは Redis と Memcached のみです。
 
## `withoutOverlapping()`

```php
$schedule->command($command)->everyMinute()->withoutOverlapping();
```

```text:キャッシュドライバにファイルを使っている場合
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|   Time   | 0 | ... | 50 | 60 | 70 | ... | 110 | 120 | 130 | ... | 170 | 180 | 190 | ... |
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Worker 1 | *********************                 *******************************        |
|          |                                                                              |
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Worker 2 | *********************                 *******************************        |
|          |                                                                              |
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
```

```text:（参考）キャッシュドライバに&#32;Redis&#32;または&#32;Memcached&#32;を使っている場合
onOneServer() + withoutOverlapping() と同じになる可能性が高いが保証はされていない
```

`withoutOverlapping()` は単体ではあまり意味のない選択肢です。これも意図されているケースは無いはずです。

### 補足

今回のコマンドの場合は Redis または Memcached を使っていると，後述の `onOneServer()` + `withoutOverlapping()` の動作と実質同じ動きをしてくれると思いますが，厳密には保証されていません。具体的には，以下のようなケースで失敗します。実行開始時刻にばらつきがあり，コマンドの実行時間がスケジュール周期に対して十分短い場合です。

```php
sleep(mt_rand(0, 5));
$schedule->command('1秒で終わるコマンド')->everyMinute()->withoutOverlapping();
```

`withoutOverlapping()` は「コマンド実行中の状態が重複しないこと」しか見てくれません。例えば，Worker 2 で開始されるときに Worker 1 での実行が終了していると，重複して実行されてしまいます。その逆も然りです。

```text
+----------+---+---+---+---+---+-----+----+----+----+----+----+
|   Time   | 0 | 1 | 2 | 3 | 4 | ... | 60 | 61 | 62 | 63 | 64 |
+----------+---+---+---+---+---+-----+----+----+----+----+----+
| Worker 1 |     *****                                 *****  |
|          |                                                  |
+----------+---+---+---+---+---+-----+----+----+----+----+----+
| Worker 2 |             *****          *****                 |
|          |                                                  |
+----------+---+---+---+---+---+-----+----+----+----+----+----+
```

## `onOneServer()` + `withoutOverlapping()`

```php
$schedule->command($command)->everyMinute()->onOneServer()->withoutOverlapping();
```

```text:キャッシュドライバに&#32;Redis&#32;または&#32;Memcached&#32;を使っている場合
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|   Time   | 0 | ... | 50 | 60 | 70 | ... | 110 | 120 | 130 | ... | 170 | 180 | 190 | ... |
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Worker 1 | *********************                 *******************************        |
|          |                                                                              |
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Worker 2 |                                                                              |
|          |                                                                              |
+----------+---+-----+----+----+----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
```

```text:キャッシュドライバにファイルを使っている場合
withoutOverlapping() と同じ
```

**`withoutOverlapping()` は `onOneServer()` と併用して，初めて意味のある選択肢になります。中程度の周期のバッチの実行時間が，突発的に肥大化したりする場合に向いている選択肢です。「タスクが同時実行されると都合が悪いかどうか」**で考えるといいでしょう。

- **「人が手作業で行ってもいい作業を，適当に設定したサイクルで回るように自動化している」** という大雑把なスケジュールの場合， `onOneServer()` + `withoutOverlapping()` が適当と言える可能性が高そうです。
- **「毎回実行することに意味があり，たとえ前のタスクが長引いても毎回実行しなければならない」**という正確なスケジュールを求めている場合， `onOneServer()` のみのほうが適当です。

### 補足

2 つを併用することで，先程問題になっていた，実行開始時刻にばらつきがあり，コマンドの実行時間がスケジュール周期に対して十分短い場合でも確実に対応することができます。

```text
+----------+---+---+---+---+---+-----+----+----+----+----+----+
|   Time   | 0 | 1 | 2 | 3 | 4 | ... | 60 | 61 | 62 | 63 | 64 |
+----------+---+---+---+---+---+-----+----+----+----+----+----+
| Worker 1 |     *****                                 *****  |
|          |                                                  |
+----------+---+---+---+---+---+-----+----+----+----+----+----+
| Worker 2 |                                                  |
|          |                                                  |
+----------+---+---+---+---+---+-----+----+----+----+----+----+
```


# まとめ

| メソッド | 目的 |
|:---|:---|
| `onOneServer()` | *$command* の **$H$ 時 $i$ 分 における実行開始** 処理が既に別の場所で発生済みであるとき，ここでの実行をスキップする |
| `withoutOverlapping()` | *$command* が **現在実行中** である場合， ここでの実行をスキップする |

```php
$schedule->command($command)->everyMinute()->onOneServer();
```

```php
$schedule->command($command)->everyMinute()->onOneServer()->withoutOverlapping();
```

- **`onOneServer()` は，スケーリングを前提とするのであれば<font color="red">すべて</font>のコマンドに付けておくべき。また実効性を生むためには Redis か Memcached が必須。**
- `withoutOverlapping()` は，前のコマンドの実行が終わっていないときにスキップして欲しい場合は **`onOneServer()` と組み合わせて**付けておく。実質的には無くても問題ないケースが多い。