Appearance
キューという仕組み ― リトライ3回の判断
この章のキーワード
| 用語 | 意味 | なぜ重要か |
|---|---|---|
| Queue(キュー) | 「後でやる」処理を順番に積む仕組み | 重い処理をリクエスト応答から分離する |
| Job | Queue に積まれる処理の単位 | 1つの Job = 1つの非同期タスク |
| 冪等性(idempotency) | 同じ操作を何度やっても結果が同じ | 再送・リトライで二重処理を防ぐ |
| バックオフ(backoff) | リトライ間隔を段階的に広げること | 相手の回復を待つ |
| receipt(受領証) | 「受け取りました」の記録 | 再実行と追跡の土台 |
| dispatch | Job を 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に聞く前に、自分の頭で考えてみましょう。
テキストを入力すると有効になります