メインコンテンツまでスキップ

キューを本番で運用する — 冪等性・リトライ・failed_jobs

前章でメール送信をリクエストの外へ出しました。動くようにはなりましたが、任せきりにできる状態ではありません。

ワーカーが落ちても誰も気づきません。失敗したジョブは記録されるだけで、誰も見ていません。そして、同じジョブが 2 回実行されることがあります。この章で、その 3 つを扱います。

ジョブは 2 回実行されます

キューの仕組みを使ううえで、最初に受け入れる前提があります。

ジョブは「ちょうど 1 回」実行されるとは限りません。 少なくとも 1 回は実行されますが、2 回以上になることがあります。

なぜそうなるかを 1 つの例で見ます。

  1. ワーカーがジョブを取り出す
  2. メールを送る (成功)
  3. ジョブを完了として消す直前に、ワーカーのプロセスが落ちる
  4. 一定時間後、そのジョブが未処理として再び取り出される
  5. メールがもう 1 通送られる

処理そのものは成功しているのに、完了を記録する前に落ちました。「送った」と「送ったことを記録した」のあいだには必ず隙間があります。 サーバーの再起動、デプロイ、メモリ不足、どれでもこの隙間に入り込めます。

第14章で扱った「確認と更新のあいだに別のリクエストが入る」と同じ構造です。あのときはデータベースの制約で塞ぎました。ジョブでは、2 回実行されても結果が変わらないように書きます。

冪等に書く

第14章で、冪等性を「同じリクエストを何度送っても結果が 1 つになること」と定義しました。ジョブでも同じです。

第13章で触れた決済 Webhook を例にします。「入金を記録する」ジョブが 2 回動けば、二重に記録されます。

php artisan make:job RecordPayment でジョブを作ります。第13章で作ったコントローラから投入します。配線はジョブが完成してから書くので、まずは受け取る引数だけ決めます。$eventId は決済業者が各通知に振る識別子で、再送されても同じ値になります。

app/Jobs/RecordPayment.php
class RecordPayment implements ShouldQueue
{
use Queueable;

public function __construct(
public readonly int $orderId,
public readonly string $eventId,
public readonly int $amount,
) {}

public function handle(): void
{
// ↓ ここを設計する
}
}

素直に書くとこうなります。

❌ Bad: 2 回実行すると 2 件記録される
public function handle(): void
{
Payment::create([
'order_id' => $this->orderId,
'provider_event_id' => $this->eventId,
'amount' => $this->amount,
]);
}

第14章と同じ道具で塞ぎます。決済業者が振るイベント ID に一意制約を置きます。

この章で使う列をまとめて足しておきます。

database/migrations/xxxx_create_payments_table.php
public function up(): void
{
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained();
$table->string('provider_event_id')->unique();
$table->integer('amount');
$table->timestamps();
});
}
database/migrations/xxxx_add_queue_columns_to_orders_table.php
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->timestamp('confirmation_sent_at')->nullable();
$table->string('shipment_status')->nullable();
});
}

provider_event_id の一意制約が、この節の主役です。

モデル側の #[Fillable] も忘れずに直します。 第14章で「移行ファイルだけ作って属性を足し忘れる形が起こりやすい」と書きました。ここがまさにその場面です。

app/Models/Payment.php
#[Fillable(['order_id', 'provider_event_id', 'amount'])]
class Payment extends Model
{
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}
app/Models/Order.php
#[Fillable([
'user_id', 'customer_email', 'status', 'total_amount',
'idempotency_key', 'confirmation_sent_at', 'shipment_status',
])]

Order に 2 列を足していないと、この章のコードは静かに壊れます。 第3章で扱ったとおり、#[Fillable] に無い属性は update()黙って捨てられます。エラーは出ません。次に書く「送信済みかどうかの記録」が毎回 null のままになり、リトライのたびに確認メールが再送されます

✅ Good: 2 回目は何もしない
public function handle(): void
{
$payment = Payment::firstOrCreate(
['provider_event_id' => $this->eventId],
['order_id' => $this->orderId, 'amount' => $this->amount],
);

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

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

早期 return の下に、初回だけやりたい処理を置きます。 ここでは前章で作った出荷指示のジョブを積んでいます。

この行があることが、wasRecentlyCreated を見る理由です。 記録が 1 件に保たれても、出荷指示が 2 回積まれては意味がありません。

前章の RequestShipment は外します

前章では、注文が確定した時点で RequestShipment リスナーが NotifyWarehouse を積んでいました。入金の確認前に出荷指示を出していたことになります。 キューの書き方を示すための簡略化で、業務としては早すぎます。

出荷指示の投入をこのジョブへ移し、app/Listeners/RequestShipment.php は削除してください。残したままだと、注文時と入金時の 2 回、倉庫へ指示が飛びます。

前章で書いた tests/Feature/WarehouseNotificationTest.php も一緒に直します。あのテストは「注文すると倉庫への通知ジョブが積まれる」を固定していましたが、その振る舞いはいま無くなりました。第11章で認証を入れたときと同じで、消すのではなく新しい期待へ移します。この章のテストで RecordPayment を 2 回実行して Queue::assertPushed(NotifyWarehouse::class, 1) を見るのが、その移し先です。

「入金を記録する」と「出荷を指示する」が 1 つのジョブに入るのが気になるなら、PaymentRecorded イベントを発行してリスナーで受ける形にもできます。第15章で扱った切り分けと同じ判断です。

firstOrCreate は、条件に合う行があればそれを返し、無ければ作ります。wasRecentlyCreated で「今作ったのか、既にあったのか」を判断できます。 第14章のコントローラでステータスコードを分けたときに使ったのと同じ属性です。

一意制約は必ず張ってください。 firstOrCreate は「探して、無ければ作る」の 2 段階なので、同時に 2 つのワーカーが動くと両方とも「無い」と判断します。第14章と同じ隙間です。データベースの制約が最後の砦になります。

受け口とつなぐ

ジョブができたので、第13章のコントローラから投入します。決済サービスが送ってくる通知は、こういう形をしています。

{
"id": "evt_1a2b3c",
"event": "payment.succeeded",
"order_id": 12,
"amount": 5400
}

id が通知ごとの識別子で、再送されても変わりません。これが $eventId になります。

app/Http/Controllers/PaymentWebhookController.php
use App\Jobs\RecordPayment;

public function handle(Request $request)
{
if ($request->input('event') !== 'payment.succeeded') {
return response()->json(['status' => 'ignored']);
}

RecordPayment::dispatch(
$request->integer('order_id'),
(string) $request->input('id'),
$request->integer('amount'),
);

return response()->json(['status' => 'accepted']);
}

200 を返すのは、記録が終わったからではありません。 受け取ってキューに積んだところまでです。決済サービスに待たせる理由がないので、この形が正しい応答です。処理が失敗したときに気づく仕組みは、この章の failed_jobs が担います。

第13章で書いた tests/Feature/PaymentWebhookTest.php の正常系を、この形に差し替えます。

tests/Feature/PaymentWebhookTest.php
use App\Jobs\RecordPayment;
use Illuminate\Support\Facades\Queue;

test('署名が正しいWebhookはジョブを積む', function () {
Queue::fake();
config(['services.payment.webhook_secret' => 'test-secret']);

$payload = [
'id' => 'evt_1a2b3c',
'event' => 'payment.succeeded',
'order_id' => 12,
'amount' => 5400,
];
$body = json_encode($payload);
$signature = hash_hmac('sha256', $body, 'test-secret');

$this->call(
'POST',
'/api/v1/webhooks/payment',
content: $body,
server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_PAYMENT_SIGNATURE' => $signature,
],
)->assertStatus(200);

Queue::assertPushed(RecordPayment::class);
});

Queue::fake() が要ります。 テスト環境の QUEUE_CONNECTIONsync なので、fake しないと投入した瞬間にジョブがその場で走ります。order_id に対応する注文の行は用意していないので、payments の外部キー制約で落ちます。このテストが確認したいのは「署名を通って、ジョブが積まれたか」までです。 ジョブの中身は次のテスト節で別に確認します。

全部が冪等にできるわけではありません

メール送信のように、外部へ送ってしまうと取り消せない処理もあります。「送った」という事実をこちら側に記録しておけば、2 回目を防げます。

第15章で書いたリスナーに、その記録を足します。前章で ShouldQueue を付けたので、このリスナーはいまキュー経由で動いています。

app/Listeners/SendOrderConfirmation.php
public function handle(OrderPlaced $event): void
{
$order = $event->order;

if ($order->confirmation_sent_at !== null) {
return;
}

$order->user->notify(new OrderConfirmed($order));

$order->update(['confirmation_sent_at' => now()]);
}

これでも完全ではありません。通知の直後・記録の直前に落ちれば、2 通目が送られます。 隙間を狭めただけです。

どこまでやるかは、二重になったときの被害で決めます。確認メールが 2 通届くのは、決済が 2 回走るのとは重さが違います。 送金や課金のように取り返しがつかないものは、外部サービス側の冪等キー (第14章で扱った Idempotency-Key と同じ考え方) を使います。

リトライを設計する

失敗したジョブは、既定では何度か試されてから諦めます。この回数と間隔は、失敗の理由によって適切な値が違います。

Laravel 13 では属性で書きます1

app/Jobs/NotifyWarehouse.php
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Tries;

#[Tries(5)]
#[Backoff([10, 30, 120])]
class NotifyWarehouse implements ShouldQueue
{
// ...
}

Tries は試行の総回数、Backoff は失敗後に待つ秒数です。配列を渡すと、1 回目は 10 秒後、2 回目は 30 秒後、3 回目以降は 120 秒後になります。

同じ設定は public $tries = 5; のようなプロパティでも書けます。tries() メソッドを定義すると、そちらが優先されます。この連載では属性を使います。 第3章の #[Fillable] と同じく、Laravel 13 のドキュメントが示す形に揃えます。

ワーカー起動時の --tries=3 は、ジョブ側で何も指定しなかった場合の既定値です。 ジョブに属性やプロパティがあれば、そちらが使われます。「ワーカーで 3 回と決めたのに 5 回試された」と見えたときは、ジョブ側の指定を疑ってください。

なぜ間隔を空けるのか

倉庫システムが落ちているとき、間隔を空けずに 5 回試しても 5 回とも失敗します。相手が復旧する時間を与えていません。

しかも、こちらのジョブが大量にあると、復旧しかけた相手を再びリクエストで潰します。間隔を空けるのは、相手のためでもあります。

回数と間隔は、失敗の理由で決めます

失敗の理由望ましい形
相手の一時的な障害・ネットワークの瞬断何度か、間隔を空けて試す
リクエストの内容が不正 (400 番台)1 回で諦める。何度送っても通らない
相手の負荷が高い (429)相手が指定した時間だけ待つ

すべての失敗を同じ回数試すのは雑です。 内容が不正なジョブを 5 回試しても、5 回失敗するだけで、その間キューが詰まります。

期限で区切る

回数ではなく時刻で区切ることもできます1

public function retryUntil(): DateTime
{
return now()->plus(minutes: 30);
}

30 分のあいだは何度でも試し、過ぎたら諦めます。 「復旧するまで試し続けたいが、いつまでもは困る」という場合に向きます。

例外の数で区切る

Tries を大きくしたうえで、例外が一定回数出たら諦める指定もできます1

#[Tries(25)]
#[MaxExceptions(3)]

外部サービスのレート制限に当たって待ち直すような処理では、試行回数は多く必要ですが、本当のエラーが 3 回出たら止めたいことがあります。試行回数と失敗回数を別に数えられます。

失敗したジョブを扱う

前章で触れたとおり、試行回数を使い切ったジョブは failed_jobs テーブルに記録されます。

php artisan queue:failed # 一覧を見る
php artisan queue:retry <uuid> # 指定したものを積み直す
php artisan queue:retry all # 全部積み直す
php artisan queue:flush # 記録を消す

再実行できるのが、キューにしたことの見返りです。 同期で送っていた頃は、メール送信が失敗したらそれで終わりでした。

失敗に気づく仕組みを作る

記録されていても、見ていなければ無いのと同じです。前章の最後に書いたとおり、ここが本番運用の分かれ目になります。

失敗したときに呼ばれる処理を登録できます1

app/Providers/AppServiceProvider.php
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Queue;

public function boot(): void
{
Queue::failing(function (JobFailed $event) {
Log::error('ジョブが失敗しました', [
'connection' => $event->connectionName,
'job' => $event->job->resolveName(),
'exception' => $event->exception,
]);
});
}

register ではなく boot に書きます。 第2章で扱った理由がここでも当てはまります。

ログに出すだけでは、結局誰かが見に行く必要があります。チャットへ流す、監視サービスへ送る、といった「向こうから来る」形にしてください。 第21章でログと監視をまとめて扱います。

ジョブ側で後始末をする

ジョブクラスに failed() メソッドを定義すると、諦めが決まったときに呼ばれます。

app/Jobs/NotifyWarehouse.php に追加
public function failed(?Throwable $exception): void
{
$this->order->update(['shipment_status' => 'failed']);
}

状態を「失敗した」と記録しておけば、あとから拾えます。 何も記録しないと、注文は「処理中」のまま止まります。利用者からは、いつまでも発送されない注文に見えます。

同じジョブを重ねない

2 回実行される話とは別に、同じジョブが同時に 2 つ動く問題があります。

在庫の集計を 5 分ごとに走らせるとします。集計に 7 分かかると、前の回が終わる前に次が始まります。2 つが同じデータを同時に書き換えます。

道具が 2 つあります。

ShouldBeUnique — 積ませない

ShouldBeUnique を実装すると、同じジョブが既にキューにあるあいだは、新しく積まれません1

use Illuminate\Contracts\Queue\ShouldBeUnique;

class AggregateStock implements ShouldQueue, ShouldBeUnique
{
// ...
}

積む側で弾くので、キューが同じジョブで埋まりません。

WithoutOverlapping — 実行を重ねない

WithoutOverlapping は、積むのは許すが、同時に実行させないミドルウェアです1

app/Jobs/NotifyWarehouse.php に追加
use Illuminate\Queue\Middleware\WithoutOverlapping;

public function middleware(): array
{
return [new WithoutOverlapping($this->order->id)];
}

引数のキーが同じジョブは、前のものが終わるまで待たされます。注文ごと・顧客ごとに直列化したいときに使います。注文 A と注文 B は並行に処理され、同じ注文 A に対する 2 つのジョブだけが順番に並びます。

どちらもキャッシュドライバに依存します

この 2 つはアトミックロックに対応したキャッシュドライバを必要とします。対応しているのは memcached / redis / dynamodb / database / file / array です1

とくに注意が要るのは、複数のサーバーでアプリケーションを動かす場合です。file ドライバはサーバーごとに別のファイルを見るので、サーバー A とサーバー B で同じジョブが 1 つずつ積まれます。ロックの意味がありません。

複数サーバーなら、全サーバーが同じ場所を見るドライバ (Redis など) が必要です。 uniqueVia() メソッドを定義すれば、ロックにだけ別のドライバを指定できます1

ワーカーを常駐させる

queue:work はただのプロセスです。ターミナルを閉じれば止まります。 サーバーが再起動しても、例外で異常終了しても止まります。

止まったまま放置されると、ジョブが溜まり続けます。プロセスを見張って、落ちたら起動し直す仕組みが要ります。

Linux では Supervisor がよく使われます。設定は「このコマンドを常に動かしておく」という宣言です。

/etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600

process_namenumprocs を 2 以上にするなら必須です。 プロセスごとに違う名前を付ける必要があり、%(process_num) が入っていないと Supervisor が設定を読み込めません。

numprocs で同時に動かす数を決めます。 増やせば処理は速くなりますが、データベースへの接続数も増えます。

user を指定します。 指定しないと root で動き、ワーカーが作るログやキャッシュのファイル所有者が Web サーバーとずれます。

stopwaitsecs を長めにしています。 Supervisor がワーカーを止めるとき、処理中のジョブが終わるのを待つ時間です。短いと、処理の途中で強制終了されます

--max-time でワーカー自身の寿命を区切っています。 長く動かし続けるとメモリが少しずつ増えるので、定期的に自分で終了させ、Supervisor に起動し直させます。

マネージドな環境では別の形になります

コンテナで動かす場合は、Supervisor ではなくコンテナオーケストレーション側が同じ役割を担います。ワーカーを別のコンテナとして定義し、落ちたら再起動する設定にします。

大事なのは Supervisor そのものではなく、「監視して起動し直す担当がいる」ことです。

デプロイのたびに再起動する

前章で触れた問題を回収します。ワーカーは起動時にコードを読み込み、そのまま動き続けます。

デプロイして新しいコードを配置しても、動いているワーカーは古いコードのまま処理を続けます。バグを直したのに、キュー経由の処理だけ直らない、という状態になります。

php artisan queue:restart

デプロイの最後にこれを打ちます。実行中のジョブを中断するわけではありません。「いま処理しているものが終わったら終了しなさい」という合図を送ります。あとは Supervisor が新しいプロセスを起動し、そちらが新しいコードを読みます。

この 1 行を忘れると、デプロイしたのに何も変わりません。 デプロイ手順に組み込んでください。第21章で扱います。

テスト

前章では Queue::fake() で投入を確認しました。この章の関心は実行側なので、ジョブを直接呼びます。

2 回実行しても 1 件のままであること

冪等性は、同じジョブを 2 回実行して確かめます

tests/Feature/RecordPaymentTest.php
use App\Jobs\NotifyWarehouse;
use App\Jobs\RecordPayment;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;

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

test('同じイベントIDのジョブを2回実行しても入金は1件だけ', function () {
Queue::fake();

$order = Order::factory()->create();

$job = new RecordPayment($order->id, 'evt_12345', 3600);

$job->handle();
$job->handle();

$this->assertDatabaseCount('payments', 1);
Queue::assertPushed(NotifyWarehouse::class, 1);
});

handle() を 2 回呼ぶだけです。 実際の再実行と同じ状況を、単一プロセスで再現できます。第14章の並行実行と違い、こちらは素直にテストできます。

確認しているのは 2 つです。入金が 1 件だけであることと、出荷指示が 1 回しか積まれていないことです。

2 つ目が要る理由があります。 1 つ目だけだと、wasRecentlyCreated による早期 return を丸ごと消しても green のままです。firstOrCreate が 2 回目に既存の行を返すので、payments は 1 件で変わりません。「二重に記録されない」ことと「初回だけの処理が二重に走らない」ことは別物です。 実害が出るのは後者のほうで、そちらを固定しないと意味がありません。

firstOrCreate を単なる create に戻せば 1 つ目が red になり、早期 return を消せば 2 つ目が red になります。

失敗したときに状態が残ること

tests/Feature/NotifyWarehouseFailureTest.php
test('ジョブが失敗したら注文に失敗が記録される', function () {
$order = Order::factory()->create();

(new NotifyWarehouse($order))->failed(new RuntimeException('倉庫APIが応答しません'));

expect($order->fresh()->shipment_status)->toBe('failed');
});

failed() も普通のメソッドなので、直接呼べます。「失敗したときに何が残るか」は、成功時と同じくらい確認する価値があります。

リトライの回数そのものはテストしません

#[Tries(5)] が効いていることをテストで確かめるのは、割に合いません。ワーカーを実際に動かし、失敗を 5 回起こす必要があります。

属性は設定であって、ロジックではありません。 確認するなら、実際にワーカーを動かして failed_jobs に落ちるまでを一度見てください。テストで固定するのは、冪等性のように壊れたときに気づきにくいものに絞ります。

本番で効く注意点

Horizon は Redis 専用です

Laravel には Horizon という、キューの状態を画面で見られるツールがあります。キューを Redis で動かしている場合にだけ使えます2database ドライバでは動きません。Redis Cluster にも対応していません。

規模が大きくなって Redis へ移すときに、あわせて検討する対象です。ジョブの待ち行列の長さ、処理にかかった時間、失敗したジョブが画面で見えるようになります。

タイムアウトは retry_after より短くする

ワーカーには 1 つのジョブにかけてよい時間の上限があり、キューの接続設定には「この秒数を過ぎたジョブは死んだとみなして積み直す」という値 (retry_after) があります。

後者が前者より短いと、まだ動いているジョブが積み直されます。 同じジョブが 2 つ同時に動きます。config/queue.phpconnections.database.retry_after が既定で 90 なので、90 秒を超えるジョブがあるなら、この値かジョブ側のタイムアウトを見直してください。この章の冒頭で書いた「2 回実行される」の、最も避けやすい原因です。

1 つのジョブを大きくしない

前章でも触れましたが、運用に入るとより効いてきます。大きなジョブは、失敗したときに失うものが大きくなります。

1 万件を 1 ジョブで処理して 9 千件目で落ちると、再実行では 1 件目からやり直します。冪等に書いてあれば結果は正しくなりますが、時間は倍かかります。100 件ずつに分けておけば、失った分だけをやり直せます。

キューを分ける

すべてのジョブを 1 つのキューに流すと、重いジョブが軽いジョブを待たせます。PDF 生成が 10 件詰まっているあいだ、確認メールが送られません。

前章の Queue::route() で行き先を分け、ワーカーもキューごとに分けます。利用者を待たせるものと、そうでないものを混ぜないでください。

本番の設定を確認する

前章の繰り返しになりますが、ここが最も多い事故です。QUEUE_CONNECTIONsync のままなら、非同期の仕組みは何も動いていません。エラーは出ません。遅いだけです。

まとめ

  • ジョブは 2 回実行されうる。「処理した」と「処理を記録した」のあいだに必ず隙間がある
  • 冪等に書く。一意制約 + firstOrCreate + wasRecentlyCreated が基本形
  • 取り消せない処理は完全には守れない。隙間を狭めたうえで、二重になったときの被害で判断する
  • リトライは #[Tries]#[Backoff]間隔を空けるのは相手のためでもある
  • 内容が不正な失敗を何度も試さない。回数は失敗の理由で決める
  • 失敗したジョブは failed_jobs に残り queue:retry で積み直せる
  • 記録するだけでなく、気づく仕組みを作るQueue::failing で通知へ流す
  • failed() メソッドで状態を記録する。何もしないと「処理中」のまま止まる
  • ShouldBeUnique は積ませない、WithoutOverlapping は重ねて実行させない
  • どちらもアトミックロック対応のキャッシュが要る。複数サーバーなら共有のドライバが必須
  • ワーカーは Supervisor 等で常駐させる。デプロイの最後に queue:restart

次に読む

キューが運用できる状態になりました。ここまでは「遅い処理を後ろへ回す」話でしたが、次章 キャッシュ — 速くするために、正しさをどこまで諦めるか ではそもそも処理をしない方法を扱います。同じ問い合わせに毎回同じ答えを返しているなら、覚えておけば済みます。何をキャッシュしてよいか、いつ捨てるか、そして速さと引き換えに何を諦めるのかを設計します。

練習問題

次のジョブは 2 回実行されると問題が起きます。どう直しますか
public function handle(): void
{
$this->order->update(['status' => OrderStatus::Confirmed]);

$this->order->user->increment('loyalty_points', 100);
}

解答例

ポイントが 200 加算されます。

ステータスの更新は問題ありません。Confirmed を 2 回入れても Confirmed のままです。同じ値を書く操作は、何度やっても結果が変わりません。

問題は increment です。現在の値に足す操作なので、実行した回数だけ増えます。 2 回実行されれば 200 になります。

直し方は 2 つあります。

1 つは、状態の変化を条件にすることです。まだ Confirmed でない場合だけ処理します。

if ($this->order->status === OrderStatus::Confirmed) {
return;
}

ただしこれだけでは、2 つのワーカーが同時に動いたときに両方が通り抜けます。第14章と同じ隙間です。行をロックするか、WithoutOverlapping で注文ごとに直列化します。

もう 1 つは、加算の記録を別に残すことです。「この注文に対するポイント付与」を一意制約付きのテーブルに記録し、既にあれば何もしません。加算そのものを冪等にはできないので、加算してよいかの判断を冪等にします。

どちらを選ぶかは、ポイント付与の履歴を残す必要があるかで決めます。残すなら後者が自然です。

「失敗したジョブは queue:retry で戻せるので、リトライ回数は 1 回でよい」という判断にどう応じますか

部分的には正しいのですが、運用の負担を人間に寄せています。

queue:retry があるので、失敗しても失われないのは事実です。問題は、誰かが気づいて、コマンドを打つまで処理が進まないことです。

ネットワークの瞬断のように数秒後に再試行すれば通る失敗まで人間に回すと、夜間や休日に止まります。確認メールが翌朝まで送られない、といったことが起きます。自動で再試行すれば、誰も気づかないうちに解決します。

一方で、回数を増やせばよいわけでもありません。リクエストの内容が不正な場合は、何度試しても通りません。試行のあいだキューを占有し、他のジョブを待たせます。

応じ方としては、失敗の種類を分けて考えることを提案します。一時的な障害は自動で数回、間隔を空けて。恒久的な失敗は 1 回で諦めて failed_jobs へ。そのうえで、failed_jobs に落ちたことを通知する仕組みを作ります。

「戻せる」ことと「戻す人がいる」ことは別です。queue:retry を最後の手段として残しつつ、そこに到達する件数を減らすのがリトライ設計です。


Footnotes

  1. 出典: Queues(Laravel 公式ドキュメント 13.x)。次の 7 点について。#[Tries] / #[Backoff] / #[MaxExceptions] 属性による試行回数・待ち時間・許容例外数の指定。retryUntil() による時刻での打ち切り。queue:failed / queue:retry / queue:flush コマンド。Queue::failing によるジョブ失敗時のコールバック登録。ShouldBeUnique が処理中の同一ジョブの投入を防ぐこと。WithoutOverlapping ミドルウェアが同一キーのジョブの同時実行を防ぎ、キューへ戻すこと。両者がアトミックロック対応のキャッシュドライバ (memcached / redis / dynamodb / database / file / array) を必要とし、uniqueVia() でロック用のドライバを指定できること。 2 3 4 5 6 7 8

  2. 出典: Laravel Horizon(Laravel 公式ドキュメント 13.x)。Horizon がキューの動作に Redis を必要とし、Redis Cluster には対応しないことについて。