Skip to main content

タスクスケジューラ — 定期処理を cron 1 行に集約する

ここまでの章で、「あとで掃除します」と書いたまま先へ進んだ処理が溜まりました。

第11章の期限切れトークン、第14章の負の在庫、第19章の孤児のファイル、そして第19章で予告した未決済注文のキャンセル。4 つとも誰も呼ばないのに走らせたい処理です。API のリクエストからは呼ばれず、誰かが叩くわけでもなく、時間が来たら勝手に動いてほしい。

素直に作るなら、サーバーの crontab に 4 行足すことになります。この章では 1 行だけ足して、残りをアプリケーション側に持ちます。どこに何を足したかが git log に残るのが、この形を選ぶ理由です。

最後の「未決済注文のキャンセル」だけは、他の 3 つと性質が違います。削除ではなく状態の変更で、間違えると課金済みの注文を消します。この章のいちばん長い節をそこに使います。

cron に登録するのは 1 行だけ

スケジュールは routes/console.php に書きます。このファイルは第2章で見た bootstrap/app.php で登録済みです。

bootstrap/app.php (第2章より)
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)

定期処理を 1 つ書いてみます。

routes/console.php
<?php

use Illuminate\Support\Facades\Schedule;

Schedule::command('sanctum:prune-expired --hours=24')->daily();

サーバー側には、次の 1 行を crontab に入れます1

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

毎分 schedule:run が起動します。 起動したうえで「今このタイミングで走るべき処理はあるか」を判定し、無ければ何もせずに終わります。daily() と書いた処理は、1 日のうち 1 回だけ実際に動きます。

なぜ crontab に 4 行書かないのか

処理を 4 つ登録するとき、cron に 4 行書く形と、cron 1 行 + アプリ側に 4 つ書く形があります。動く結果は同じです。違うのは変更の履歴がどこに残るかです。

cron に直接書くと、スケジュールの変更はサーバーへの ssh になります。誰がいつ何を変えたかはサーバーの crontab にしか残らず、レビューもされません。ステージングと本番で内容がずれても、突き合わせるまで気づけません。

routes/console.php に書けば、スケジュールの変更はコードの変更になります。プルリクエストに載り、レビューを通り、デプロイで反映されます。cron に触るのは最初の 1 回だけです。

手元で動かす

ローカルでは cron を登録しません。代わりに schedule:work を使います。

php artisan schedule:work

フォアグラウンドで動き続け、毎分スケジューラを呼びます。Ctrl+C で止まります。

登録内容の確認には schedule:list を使います。

php artisan schedule:list

次に走る時刻まで出るので、書いたつもりの頻度と実際の頻度がずれていないかをここで見ます。daily()dailyAt('03:00') を書き間違えても構文エラーにはならないので、目で確かめる手段が要ります。

期限切れのトークンを消す

第11章で、Sanctum のトークンに有効期限を設定しました。期限が切れたトークンは認証に通らなくなりますが、レコードは残り続けます。消す担当がいないためです。

Sanctum は削除用のコマンドを持っています。

routes/console.php
Schedule::command('sanctum:prune-expired --hours=24')->daily();

--hours=24 は「期限切れから 24 時間経ったものを消す」の意味です。切れた瞬間に消さないのは、調査の余地を残すためです。「ログインできない」という問い合わせが来たとき、レコードが残っていれば期限切れだったと確認できます。

この節でやることはこれだけです。 既製のコマンドが用意されている場合、スケジューラの仕事は「いつ走らせるか」を決めることだけになります。

負の在庫を見つけて知らせる

第14章で、在庫が負になるのを CHECK 制約で禁じるかどうかを検討し、入れないと決めました。通常の注文経路は条件付き UPDATE が塞いでいて、残るのは管理画面や移行スクリプトからの書き込みだけだったからです。

そのとき、代わりに「定期的に負の在庫を検索して通知する仕組みで補います」と書きました。ここで作ります。

コマンドを作る

この連載で Artisan コマンドを自作するのは初めてです。

php artisan make:command CheckNegativeStock

app/Console/Commands/CheckNegativeStock.php ができます。

app/Console/Commands/CheckNegativeStock.php
<?php

namespace App\Console\Commands;

use App\Models\Stock;
use App\Notifications\NegativeStockDetected;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Notification;

class CheckNegativeStock extends Command
{
protected $signature = 'stock:check-negative';

protected $description = '在庫が負になっている商品を検索して通知する';

public function handle(): int
{
$negatives = Stock::where('quantity', '<', 0)->get();

if ($negatives->isEmpty()) {
$this->info('負の在庫はありません。');

return self::SUCCESS;
}

Notification::route('mail', config('services.ops.mail'))
->notify(new NegativeStockDetected($negatives));

$this->error("負の在庫が {$negatives->count()} 件あります。");

return self::FAILURE;
}
}

$signature がコマンド名です。 stock:check-negative の形にしたのは、sanctum:prune-expired と同じく「対象:動詞」で読めるようにするためです。

戻り値は終了コードです。 見つかったときに self::FAILURE を返すのは、次節で扱う「失敗に気づく」仕組みが終了コードを見るためです。self::SUCCESS は 0、self::FAILURE は 1 に対応します。

通知の中身は第15章と同じ形なので省略します。宛先を Notification::route() で直接指定しているのは、受け取るのが利用者ではなく運用者だからです。User モデルに運用者を混ぜずに済みます。

宛先は config/services.php に足します。第2章で決めたとおり、env() を呼ぶのは config/ の中だけです。

config/services.php
'ops' => [
'mail' => env('OPS_MAIL_ADDRESS'),
],

スケジュールに載せる

routes/console.php
Schedule::command('stock:check-negative')->hourly();

頻度を hourly() にしたのは、負の在庫が在庫の実数と帳簿がずれた状態だからです。1 日放置すれば、その間の注文がすべて誤った在庫数の上で処理されます。第14章が「通常の経路では起きない」と書いたとおり、これが動くこと自体が異常なので、見つけたら早いほうがいいです。

孤児のファイルを掃除する

第19章で、ファイルと行の書き込み順を決めるとき「静かなほうへ倒す」と判断しました。ファイルを先に書き、行の更新が失敗したら誰からも参照されないファイルが残る形です。

孤児のファイルができる経路は 3 つあります。

経路何が起きるか
保存の途中で失敗ファイルは書けたが image_path が更新されない
差し替え時の削除に失敗古いファイルが残る
一括削除Product::where(...)->delete() でオブザーバが発火せずファイルが残る

3 つとも結果は同じです。 ディスクの上にあり、products.image_path のどこからも指されていません。

DB からは逆引きできません

掃除のコマンドを書く前に、探し方を決めます。

第19章で、商品画像はランダムな名前のままにしました。products/{商品 ID}.jpg に固定すると URL が変わらず、CDN が古い画像を返し続けるためです。

この判断の代償がここに出ます。ファイル名から商品 ID を逆算できません。 ディスクにある products/8fK2mZ.jpg が、どの商品のものだったか、そもそも商品のものだったかは、名前を見ても分かりません。

さらに一括削除の経路では、対応する products の行が既に存在しません。行を起点にした探索では、いちばん見つけたいものが見つからないわけです。

残る方法は 1 つです。ディスクにあるファイルを全部並べ、products.image_path にある値を引きます。 引き算で残ったものが孤児です。

app/Console/Commands/PruneOrphanImages.php
public function handle(): int
{
$referenced = Product::whereNotNull('image_path')
->pluck('image_path')
->flip();

$threshold = now()->subHours(1);
$deleted = 0;

foreach (Storage::disk('public')->files('products') as $file) {
if ($referenced->has($file)) {
continue;
}

if (Storage::disk('public')->lastModified($file) > $threshold->timestamp) {
continue;
}

Storage::disk('public')->delete($file);
$deleted++;
}

$this->info("孤児のファイルを {$deleted} 件削除しました。");

return self::SUCCESS;
}

pluck() のあとに flip() を呼んでいます。 値をキーに移すと、has() がハッシュの参照になります。contains() のままだとファイル 1 件ごとに配列を走査するので、商品が増えるほど遅くなります。

1 時間の猶予を置く理由

lastModified を見て、1 時間以内に書かれたファイルは残しています。

第19章の保存処理は、ファイルを書いてから行を更新します。この 2 つのあいだには、短いながら隙間があります。そこにこのコマンドが走ると、まだ正常な処理の途中にあるファイルを消します。 行の更新はそのあとで成功するので、「行はパスを指しているのにファイルが無い」状態が残ります。第19章が避けたかった、いちばん見える壊れ方です。

猶予を 1 時間にしたのは、アップロード処理が 1 時間かかることは無いからです。この数字に根拠のある最小値はありません。 ファイルの書き込みから行の更新までにかかる時間より十分長ければよく、短くする理由もありません。

routes/console.php
Schedule::command('images:prune-orphans')->dailyAt('03:10');

dailyAt('03:10') と時刻をずらしたのは、深夜に走る処理を同じ分に集めないためです。ディスクの全走査は重く、他の処理と重なると I/O を奪い合います。

「未決済」をどう判定するか

第19章で「未決済の注文を自動でキャンセルする処理も、ここで作ります」と書きました。この節がそれです。

作るものは単純に見えます。しばらく支払われていない注文をキャンセルし、確保していた在庫を戻す。 実装に入る前に、「支払われていない」をどう判定するかを決めます。ここを間違えると、課金済みの注文をキャンセルして在庫を戻します。

status は未決済を意味しません

orders.status には OrderStatus::Pending が入っています。名前のとおり「保留中」です。

ところが、この連載のコードを全部見ても、statusConfirmed を書く処理はどこにもありません。 注文を作るとき Pending を入れて、それきりです。第17章で決済の webhook を受けて payments に行を作りますが、RecordPayment が触るのは payments テーブルと NotifyWarehouse の投入だけです。

つまり決済が済んだ注文も Pending のままです。status === Pending で絞ると、支払い済みの注文が全部入ります。

2 つの穴を両方塞ぐ

やることは 2 つあります。

1 つは、決済が済んだら status を進めることです。 第17章の練習問題で「注文のステータスを更新するジョブ」を扱いましたが、本実装には入っていませんでした。ここで入れます。

app/Jobs/RecordPayment.php (第17章に追加)
public function handle(): void
{
$payment = Payment::firstOrCreate(
['provider_event_id' => $this->eventId],
['order_id' => $this->orderId, 'amount' => $this->amount],
);

if (! $payment->wasRecentlyCreated) {
return;
}

$payment->order->update(['status' => OrderStatus::Confirmed]); // ← 追加

NotifyWarehouse::dispatch($payment->order);
}

wasRecentlyCreated のガードより後ろに置いたので、同じ webhook が 2 回届いても 1 回しか実行されません。 第17章で作った冪等性がそのまま効きます。

もう 1 つは、キャンセルの条件に payments を入れることです。 ステータスの更新を足しても、それだけでは足りません。webhook を受けてジョブを積んだ直後、まだ実行されていない注文は Pending のままです。第17章で見たとおり、キューは滞留します。

Orderpayments() を足します。第15章で user() を足したのと同じ形です。

app/Models/Order.php
public function payments(): HasMany
{
return $this->hasMany(Payment::class);
}

抽出と更新のあいだが空く

条件が決まったので、キャンセルの処理を書きます。素直に書くと壊れます。

❌ Bad: 抽出してから更新する
$orders = Order::where('status', OrderStatus::Pending)
->whereDoesntHave('payments')
->where('created_at', '<', now()->subMinutes(30))
->get();

foreach ($orders as $order) {
$order->update(['status' => OrderStatus::Cancelled]);
$stockChecker->restore($order->items->toArray());
}

抽出した時点では payments が空でも、ループを回している最中に webhook が届けば行が入ります。 更新はその変化を見ないので、支払われた注文をキャンセルして在庫を戻します。

塞ぎ方は第14章と同じです。判定を UPDATE の WHERE に入れて、影響行数を見ます。

app/Services/OrderCancellationService.php
public function cancelStale(Order $order): void
{
$affected = Order::whereKey($order->id)
->where('status', OrderStatus::Pending)
->whereDoesntHave('payments')
->update(['status' => OrderStatus::Cancelled]);

if ($affected === 0) {
throw new OrderNotCancellableException($order->id);
}

$this->stockChecker->restore($order->items->toArray());
}

在庫を戻すのは、影響行数が 1 だったときだけです。 第17章の練習問題で見たとおり、increment は実行した回数だけ増えます。UPDATE が 0 行だったのに在庫を戻すと、キャンセルしていない注文の在庫が増えます。

第14章の decrease()where('quantity', '>=', ...) を条件に持てましたが、戻す側に対応する条件はありません。在庫を増やす操作に「増やしすぎ」の上限が無いためです。だから状態遷移の側で 1 回に絞ります。

戻す側は第14章の decrease() と対になる形です。

app/Services/StockChecker.php (追加)
public function restore(array $items): void
{
foreach ($items as $item) {
Stock::where('product_id', $item['product_id'])
->increment('quantity', $item['quantity']);
}
}

decrease() と違って影響行数を見ていません。戻す操作が失敗する条件が無いためです。商品ごと消えていれば 0 行になりますが、そのときは在庫を戻す先もありません。

例外は第9章の形に合わせて、クラスの中で 409 へ変換します。

app/Exceptions/OrderNotCancellableException.php
class OrderNotCancellableException extends Exception
{
public function __construct(public readonly int $orderId)
{
parent::__construct("注文 {$orderId} はキャンセルできる状態ではありません。");
}

public function render(Request $request): Response
{
return response()->json([
'message' => $this->getMessage(),
'code' => 'ORDER_NOT_CANCELLABLE',
'details' => ['order_id' => $this->orderId],
], 409);
}
}
WHERE の述語は、隙間で変わるものを見てください

where('status', Pending) だけを条件にしても、この隙間は塞がりません。隙間のあいだに変化するのは payments の行であって、status は誰も動かさないからです。UPDATE は必ず 1 行に当たり、ガードとして働きません。

条件付き UPDATE を安全策として置くときは、「抽出から更新までのあいだに何が変わるか」と「WHERE が何を見ているか」が一致しているかを確かめてください。

例外を投げる理由

cancelStale() は、キャンセルできなかったときに例外を投げます。bool を返す形にしていません。

第12章で、状態が不正なキャンセルは 409 を返すと決めました。そのとき「サービスクラスで例外を投げ、第9章の変換で 409 にする」と書いています。HTTP 経路からこのサービスを呼ぶときに、同じ形で動く必要があります。

ただし、この連載にキャンセルの API エンドポイントはまだありません。 現時点で cancelStale() を呼ぶのはこの章のバッチだけで、例外の到達先もバッチだけです。将来エンドポイントを足したときに 409 が返る形にしてある、という状態です。

1 件の失敗で全部を止めない

コマンド側でループを回します。

app/Console/Commands/CancelStaleOrders.php
public function handle(OrderCancellationService $service): int
{
$cancelled = 0;
$skipped = 0;

Order::where('status', OrderStatus::Pending)
->whereDoesntHave('payments')
->where('created_at', '<', now()->subMinutes(30))
->chunkById(100, function ($orders) use ($service, &$cancelled, &$skipped) {
foreach ($orders as $order) {
try {
$service->cancelStale($order);
$cancelled++;
} catch (OrderNotCancellableException) {
$skipped++;
}
}
});

$this->info("キャンセル {$cancelled} 件 / スキップ {$skipped} 件");

return self::SUCCESS;
}

例外を 1 件ずつ捕まえています。 捕まえないと、100 件のうち 1 件が「処理中に支払われた」だけで、残りが未処理のまま終わります。次の実行で拾えるとはいえ、1 件の正常な競合が 99 件を止めるのは割に合いません。

スキップした件数を出力しているのは、この数が異常に多いときに気づけるようにするためです。ゼロが普通で、たまに 1 か 2 なら競合です。毎回 50 件スキップされているなら、抽出条件が間違っています。

chunkById() で 100 件ずつ処理するのは、対象が多いときにメモリへ全部載せないためです。chunk() ではなく chunkById() を使うのは、ループの中で status を書き換えているからです。chunk() は OFFSET で位置を進めるので、更新によって条件から外れた行の分だけ、後続のページがずれて取りこぼします。

この判定で拾えないもの

ここまでの条件でも、課金済みの注文をキャンセルする経路が残ります。

payments に行があるかどうかは、「支払われたか」ではなく「RecordPayment が走り終えたか」です。第17章で見たとおり、ジョブは失敗します。試行回数を使い切れば failed_jobs へ落ち、そのまま放置すれば payments の行は永久に現れません。 猶予時間をいくら延ばしても届きません。

さらに、queue:retry all で積み直したときに問題が表面化します。firstOrCreate は初回作成として振る舞うので wasRecentlyCreated が true になり、既にキャンセルした注文に対して倉庫への通知と領収書の生成が走ります。

アプリケーションの中の情報だけでは、この状態を判定できません。 「決済プロバイダには記録があるが、こちらには無い」を知る方法が無いためです。確実にするには、プロバイダ側の記録と突き合わせる照合処理が要ります。この連載では扱いません。

代わりに前提を置きます。failed_jobs が溜まっていないことを、この処理は前提にしています。 第17章で失敗の検知を扱い、通知の設計は次章で扱います。自動キャンセルを本番で動かすなら、その 2 つが先です。

返金と部分入金

payments に行があれば対象から外れるので、返金済みの注文は Pending のまま残り続けます。部分入金も同じです。この連載は返金を扱わないので、そういう状態が残ることだけ書いておきます。

同じ処理が二重に走る

前節のコマンドは 30 分ぶんの注文を処理します。対象が増えれば実行時間が伸び、前回が終わる前に次が始まることがあります。

スケジューラは 3 つの制御を持っています。

前回が走っていたら飛ばす

routes/console.php
Schedule::command('orders:cancel-stale')->everyFiveMinutes()->withoutOverlapping();

ロックを取ってから実行し、取れなければ何もせずに終わります。 ロックの有効期限は既定で 24 時間です。引数を渡せば分で指定できます。

期限が要るのは、プロセスが強制終了したときにロックが残るからです。期限が無ければ、一度異常終了したきりコマンドが二度と動かなくなります。 24 時間という既定値は「その日のうちに誰かが気づく」ことを想定した長さで、5 分ごとに走る処理には長すぎます。実行時間の見積もりに余裕を足した値を明示するほうが安全です。

Schedule::command('orders:cancel-stale')->everyFiveMinutes()->withoutOverlapping(10);
第17章の WithoutOverlapping とは別物です

第17章で、ジョブのミドルウェアとして WithoutOverlapping を扱いました。名前は同じですが別の仕組みです。

ジョブ側はキューに積まれた個々のジョブが対象で、キーを自分で決めます (注文 ID ごと、など)。スケジューラ側はスケジュール定義が単位で、キーはコマンド名から自動で作られます。ロックの既定期限も違います。

1 台だけで走らせる

アプリケーションサーバーを複数台に増やすと、全部の台で cron が動きます。 同じ時刻に同じコマンドが台数ぶん起動します。

routes/console.php
Schedule::command('orders:cancel-stale')->everyFiveMinutes()->onOneServer();

キャッシュを使って先着 1 台だけを通します。 そのため、database / memcached / dynamodb / redis のいずれかがキャッシュドライバに設定されていて、全台が同じキャッシュを見ていることが前提です。filearray では台ごとに別のロックになるので、何も防げません。

クロージャを直接スケジュールする場合は、名前を付けないとロックのキーが決まりません。

Schedule::call(fn () => Cache::forget('stats'))
->name('forget-stats')
->daily()
->onOneServer();

並行して走らせる

runInBackground() を付けると、複数のコマンドを順番待ちさせずに同時に起動できます。commandexec でのみ使えます。

この章のコマンドには付けません。実行時間が短く、並行にする利得が小さいうえ、次節のテストで副作用を同期的に観測できなくなるためです。

失敗に気づく

コマンドが失敗しても、既定では誰も気づきません。

理由は cron の 1 行にあります。

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

>> /dev/null 2>&1 が出力を全部捨てています。 標準出力も標準エラーも /dev/null へ行くので、$this->error() で書いた文字列も、PHP の致命的エラーも残りません。

この 1 行は Laravel の公式ドキュメントがそのまま載せている形です。捨てているのは、捨てないと cron がメールを送ろうとするからです。毎分の実行結果がメールで届くのは実用的ではありません。

代わりに、必要な出力だけを個別に拾います。

routes/console.php
Schedule::command('stock:check-negative')
->hourly()
->emailOutputOnFailure(config('services.ops.mail'));

終了コードが 0 以外のときだけ、出力をメールで送ります。 前節で CheckNegativeStock が負の在庫を見つけたときに self::FAILURE を返したのは、この分岐に乗せるためです。

ファイルに残す形もあります。

Schedule::command('images:prune-orphans')
->dailyAt('03:10')
->appendOutputTo(storage_path('logs/prune-orphans.log'));

sendOutputTo は毎回上書き、appendOutputTo は追記です。どちらも commandexec でのみ使えます。

メールとファイルは、どちらも「誰かが見に行く」前提です。 監視サービスへ送る、チャットへ流すといった形にどう寄せるかは、ログの設計と一緒に次章で扱います。

テスト

テストは 2 層に分けます。コマンドが正しく動くことと、そのコマンドがスケジュールに載っていることは別の問題だからです。

コマンドの振る舞い

第10章で予告した travelTo() を使います。

tests/Feature/CancelStaleOrdersTest.php
use App\Enums\OrderStatus;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Stock;
use Illuminate\Foundation\Testing\RefreshDatabase;

pest()->use(RefreshDatabase::class);

test('30 分以上前の未決済注文がキャンセルされ在庫が戻る', function () {
$stock = Stock::factory()->create(['quantity' => 8]);

travelTo(now()->subMinutes(40));
$order = Order::factory()->create(['status' => OrderStatus::Pending]);
$order->items()->create([
'product_id' => $stock->product_id,
'quantity' => 2,
'unit_price' => 1000,
]);
travelBack();

$this->artisan('orders:cancel-stale')->assertSuccessful();

expect($order->fresh()->status)->toBe(OrderStatus::Cancelled);
expect($stock->fresh()->quantity)->toBe(10);
});

test('決済済みの注文はキャンセルされない', function () {
travelTo(now()->subMinutes(40));
$order = Order::factory()->create(['status' => OrderStatus::Pending]);
Payment::create([
'order_id' => $order->id,
'provider_event_id' => 'evt_12345',
'amount' => 3600,
]);
travelBack();

$this->artisan('orders:cancel-stale')->assertSuccessful();

expect($order->fresh()->status)->toBe(OrderStatus::Pending);
});

travelTo() で過去へ移動してから注文を作っています。 created_at は保存時の時刻が入るので、時計を戻した状態で作れば 40 分前の注文になります。travelBack() で現在に戻してからコマンドを走らせます。

2 つ目のテストが本体です。payments に行があれば対象から外れることを固定しています。この 1 件が無いと、抽出条件から whereDoesntHave('payments') を消しても全部通ります。

スケジュールへの配線

第19章で「足した配線には、それが消えたら落ちるテストを付けてください」と書きました。スケジュールへの登録も配線です。

schedule:run を走らせる形は使えません。 Schedule::command() の実行は Symfony の Process 経由で別プロセスの php artisan を起動します2。子プロセスは RefreshDatabase が張ったままのトランザクションも、Storage::fake() で差し替えたディスクも見ません。テスト用の状態が何も届かないので、副作用を観測できません。

代わりに、登録されたコマンドの一覧を見ます。

tests/Feature/ScheduleTest.php
use Illuminate\Console\Scheduling\Schedule;

test('定期処理がスケジュールに登録されている', function () {
$commands = collect(app(Schedule::class)->events())
->map(fn ($event) => $event->command)
->filter(); // Schedule::call() のクロージャは command が null

foreach ([
'sanctum:prune-expired',
'stock:check-negative',
'images:prune-orphans',
'orders:cancel-stale',
] as $expected) {
expect($commands->contains(
fn (string $command) => str_contains($command, $expected)
))->toBeTrue();
}
});

routes/console.php から行を消せば、このテストが落ちます。

このテストが見ていないものもあります。 コマンド名の文字列があるかどうかだけなので、daily()everyMinute() に書き間違えても通ります。コマンドクラスを消しても、スケジュールの登録が残っていれば通ります。頻度の確認は schedule:list を目で見るほうが確実です。

events() が空なら、この形は使えません

routes/console.php の登録がテスト実行時のコンテナに載るかどうかは、環境によります。まず app(Schedule::class)->events() の件数を出力して、期待どおりの数が入っていることを確かめてから assert を書いてください。0 件なら、このテストは常に落ちるか、foreach が回らずに常に通るかのどちらかです。

本番で効く注意点

cron の登録を忘れると、何も起きません。 エラーも出ません。routes/console.php に書いた処理が 1 つも動かないだけです。デプロイ手順に入れるものが第19章の storage:link に続いて 2 つ目になります。新しいサーバーを立てたときのチェック項目として持ってください。

確認は schedule:run の実行ログではなく、処理の結果で行います。 スケジューラが動いているかを直接知る方法は無いので、「期限切れトークンが減っているか」「孤児のファイルが増え続けていないか」を見ます。

タイムゾーンは config/app.phptimezone に従います。 daily() は「アプリケーションのタイムゾーンでの 0 時」です。サーバーの時刻が UTC でアプリケーションが Asia/Tokyo なら、cron が UTC の各分に起動し、Laravel が JST で判定します。ずれるのは、片方だけを変えたときです。

schedule:run が止まっていることには気づけません。 cron が止まっても、プロセスが起動に失敗しても、静かに何も起きないだけです。定期処理に依存した運用をするなら、「最後に成功した時刻」をどこかに記録して、それが古くなったら知らせる仕組みが要ります。

複数台に増やすときは onOneServer() を先に入れてください。 増やしてから気づくと、その間の重複実行が何を起こしたかを後から調べることになります。この連載の 4 つのうち、削除系の 3 つは 2 回走っても結果が変わりませんが、キャンセルは在庫を動かすので影響が残ります。

まとめ

  • スケジュールは routes/console.php に書き、cron には schedule:run の 1 行だけを登録する。変更がコードとして残り、レビューを通るのが理由
  • ローカルでは schedule:work、登録内容の確認は schedule:list書いたつもりの頻度と実際の頻度は目で確かめる
  • 既製コマンドがあるなら、スケジューラの仕事は「いつ走らせるか」だけになる (sanctum:prune-expired)
  • 自作コマンドの終了コードは emailOutputOnFailure の分岐に使われる。見つけてほしい異常では self::FAILURE を返す
  • 孤児のファイルはディスク側から探す。ランダムな名前を選んだ代償で、DB からの逆引きができない
  • 書き込み直後のファイルには猶予を置く。正常な処理の途中にあるファイルを消さないため
  • statusPending でも未決済とは限らない。連載に Confirmed を書く処理が無かったので、決済済みも Pending のまま残っていた
  • 抽出と更新のあいだに変化するものを、UPDATE の WHERE に入れるstatus だけを条件にしてもガードにならない
  • 在庫を戻すのは影響行数が 1 のときだけincrement には「増やしすぎ」を防ぐ条件が無いので、状態遷移の側で 1 回に絞る
  • ループ内で例外を捕まえ、1 件の競合で残り 99 件を止めない。スキップ数を出して異常に気づけるようにする
  • chunk() ではなく chunkById()ループの中で条件列を書き換えているので OFFSET では取りこぼす
  • withoutOverlapping() のロック期限は既定 24 時間。短い周期の処理には明示する
  • onOneServer() は共有キャッシュが前提。file / array では何も防げない
  • cron の >> /dev/null 2>&1 が出力を捨てている。必要な出力だけを個別に拾う
  • Schedule::command()別プロセスで動くので、schedule:run 経由では副作用をテストできない。配線は登録の一覧を見て固定する
  • payments の行は「支払われたか」ではなく「ジョブが走り終えたか」failed_jobs に落ちた決済は判定できない

次に読む

次章はログ・監視とデプロイです。この章で 2 回「次章で扱います」と書いた失敗の届け先が最初の題材になります。第9章の error_id、第11章の機密情報のマスク、第13章の 429 の記録、第17章の queue:restart も、そこで 1 つの手順にまとまります。この章で足した cron の 1 行も、デプロイ手順の一部として扱います。

練習問題

次のスケジュール定義には問題があります。指摘してください
routes/console.php
Schedule::call(function () {
Order::where('status', OrderStatus::Pending)
->where('created_at', '<', now()->subMinutes(30))
->update(['status' => OrderStatus::Cancelled]);
})->everyMinute()->onOneServer();

解答例

在庫が戻りません。 ステータスだけを書き換えているので、確保したままの在庫が解放されずに残ります。注文はキャンセル済みなのに商品は売れない状態が積み上がります。

payments を見ていません。 本文で扱ったとおり、決済が済んだ注文も statusPending のままです。この定義は支払い済みの注文を 30 分でキャンセルします。

onOneServer() に名前がありません。 クロージャをスケジュールする場合、ロックのキーを作るために name() が要ります。付けないと onOneServer() は働きません。

everyMinute() は過剰です。 30 分の猶予を持つ処理を毎分走らせても、拾えるのは「ちょうど今 30 分を越えた注文」だけです。5 分おきでも、遅れは最大 5 分にしかなりません。

routes/console.php にロジックが入っています。 この形ではテストから呼べません。第6章で決めたとおり、処理はサービスクラスへ置き、コマンドから呼びます。

「孤児ファイルの掃除コマンドを本番で流したら、表示中の商品画像が消えた」と報告されました。何が起きたと考えられますか

解答例

コマンドは products.image_path にある値とディスク上のファイル名を突き合わせます。この 2 つが一致しない状態があれば、参照されているファイルを孤児と誤判定します。

いちばん疑わしいのはディスクの指定です。 本文の実装は Storage::disk('public') を明示していますが、どこかで省略していれば既定のディスク (local) を見ます。第19章で扱ったとおり、localpublic は置き場所が違います。別のディスクを列挙して、public のパスと突き合わせているなら、全ファイルが「参照されていない」と判定されます。

パスの形も一致が要ります。 image_pathproducts/abc.jpg が入っているとき、Storage::files('products') が返すのも products/abc.jpg です。ここで basename() を挟んだり、先頭に / を足したりすると、文字列として一致しなくなります。一致しない側は全部孤児になります。

確認の順番は、まず消さずに数えることです。 削除の行をコメントアウトして件数だけ出せば、想定と桁が違うことがすぐ分かります。ディスクを触るコマンドは、最初の 1 回を空振りさせてから本番へ入れてください。

なお、この事故は復旧できません。Storage::delete() はゴミ箱を経由しないためです。バックアップから戻すか、画像を再アップロードするかになります。

Footnotes

  1. Laravel 13 公式ドキュメント Task Scheduling の Running the Scheduler より。

  2. Illuminate\Console\Scheduling\Event::execute()Process::fromShellCommandline() を呼びます。in-process で実行する経路はありません (framework 13.x のソース)。