Skip to content

キューという仕組み ― リトライ3回の判断

📖この章のキーワード
用語意味なぜ重要か
Queue(キュー)「後でやる」処理を順番に積む仕組み重い処理をリクエスト応答から分離する
JobQueue に積まれる処理の単位1つの Job = 1つの非同期タスク
冪等性(idempotency)同じ操作を何度やっても結果が同じ再送・リトライで二重処理を防ぐ
バックオフ(backoff)リトライ間隔を段階的に広げること相手の回復を待つ
receipt(受領証)「受け取りました」の記録再実行と追跡の土台
dispatchJob を Queue に投入することProcessExternalEventJob::dispatch()

Phase 1: 観察

exemplar 非同期処理とリトライ設計を学ぶための主教材です。

LIBOT には、外部システムからイベントを受信し、LINE ユーザーへのシナリオ実行につなげる機能があります。 この処理は 3 つのクラスに分かれています。まず読んでみましょう。

1. 受理する ― ExternalEventService::accept()

php
// ExternalEventService.php (excerpt)
// Label: exemplar

public function accept(
    PartnerIntegration $integration,
    ?int $credentialId,
    array $payload,
    string $idempotencyKey,
    string $requestHash
): array {
    $externalEventId = $payload['external_event_id'];

    return DB::transaction(function () use (...) {
        // 同じ external_event_id が既にあるか?(排他ロック)
        $existingReceipt = ExternalEventReceipt
            ::where('partner_integration_id', $integration->id)
            ->where('external_event_id', $externalEventId)
            ->lockForUpdate()
            ->first();

        if ($existingReceipt) {
            // 同じハッシュ → リプレイ(安全に再応答)
            if ($existingReceipt->request_hash === $requestHash) {
                return ['receipt' => $existingReceipt, 'replay' => true];
            }
            // 異なるハッシュ → 409 Conflict
            throw new IdempotencyConflictException(
                'Same external_event_id with different payload.'
            );
        }

        // Operation と Receipt を作成
        $operation = PartnerOperation::create([
            'status' => PartnerOperation::STATUS_ACCEPTED,
            'queued_at' => now(),
            // ...
        ]);

        $receipt = ExternalEventReceipt::create([
            'status' => ExternalEventReceipt::STATUS_ACCEPTED,
            'payload' => $payload,
            // ...
        ]);

        // 非同期処理をディスパッチ
        ProcessExternalEventJob::dispatch($receipt->id);

        return ['receipt' => $receipt, 'operation' => $operation, 'replay' => false];
    });
}

注目ポイント:

  • API リクエストを受けた瞬間は receipt を保存して dispatch するだけ
  • 実際の処理(friend 解決、シナリオ実行)は Job に委ねる
  • 冪等性チェック: 同じイベントが再送されても安全に処理する

2. 処理する ― ProcessExternalEventJob

php
// ProcessExternalEventJob.php
// Label: exemplar

class ProcessExternalEventJob implements ShouldQueue
{
    public int $tries = 3;
    public int $timeout = 120;

    public function __construct(
        private int $receiptId,
    ) {}

    public function handle(ExternalEventService $service): void
    {
        $receipt = ExternalEventReceipt::find($this->receiptId);

        if (!$receipt || $receipt->status !== ExternalEventReceipt::STATUS_ACCEPTED) {
            return; // 既に処理済み or 存在しない → 静かに終了
        }

        try {
            $service->processReceipt($receipt);
        } catch (\Exception $e) {
            Log::channel('partner_error')->error('ProcessExternalEventJob failed', [
                'receipt_id' => $this->receiptId,
                'error' => $e->getMessage(),
            ]);

            $receipt->markFailed('PROCESSING_ERROR', $e->getMessage());
            throw $e; // 例外を再 throw → Laravel が retry を判断
        }
    }
}

注目ポイント:

  • $tries = 3 ― 最大 3 回まで実行する
  • $timeout = 120 ― 120 秒でタイムアウト
  • ステータスチェックで二重処理を防ぐ
  • 例外を再 throw することで、Laravel のリトライ機構に判断を委ねる

3. 結果を通知する ― PartnerResultWebhook

php
// PartnerResultWebhook.php (excerpt)
// Label: exemplar

class PartnerResultWebhook implements ShouldQueue
{
    public int $tries = 3;
    public array $backoff = [10, 60, 300]; // 10秒、1分、5分

    public function handle(): void
    {
        $operation = PartnerOperation::find($this->operationId);
        if (!$operation || !$operation->isTerminal()) {
            return;
        }

        $integration = PartnerIntegration::find($this->integrationId);
        if (!$integration || !$integration->shouldSendResultWebhook()) {
            return;
        }

        // 署名付きで Webhook を送信
        $response = Http::timeout(10)
            ->withHeaders([
                'X-Libot-Event' => 'partner.operation.completed',
                'X-Libot-Timestamp' => (string) $timestamp,
                'X-Libot-Signature' => $this->sign($timestamp, $rawBody, $secret),
            ])
            ->post($integration->result_webhook_url, $payload);

        if ($response->successful()) {
            $operation->update([
                'result_webhook_status' => 'delivered',
            ]);
            return;
        }

        $this->handleFailedDelivery($operation, $integration, /* ... */);
    }

    // 全リトライ失敗時のフォールバック
    public function failed(\Throwable $exception): void
    {
        Log::channel('partner_error')->error('Result webhook permanently failed', [
            'operation_id' => $this->operationId,
            'error' => $exception->getMessage(),
        ]);
    }
}

注目ポイント:

  • $backoff = [10, 60, 300] ― 間隔を広げながらリトライ(エクスポネンシャルバックオフ)
  • failed() メソッド ― 3 回全て失敗した後の処理
  • 結果 Webhook が失敗しても、本体の処理結果には影響しない

Phase 2: 判断(AI 禁止ゾーン)

AI禁止ゾーン

以下の 2 つの実装を比べてください。

実装 A: API リクエスト受信中に、friend 解決 → シナリオ実行 → 結果 Webhook 送信まで全て同期で行う

実装 B: receipt を保存して ProcessExternalEventJob::dispatch() し、結果 Webhook は別 Job で再試行する(LIBOT の現行実装)

  • どちらを本番で使いますか?
  • 実装 A の問題点を全て挙げてください
  • 実装 B が guard しているリスクは何ですか?

3 分間、AI に聞かずに考えてみましょう。

AIに聞く前に、自分の頭で考えてみましょう。

テキストを入力すると有効になります