はじめに
飲食店向けモバイルオーダー MasterOrder を個人開発しています。在庫アーキテクチャ や Firestore 読み取り最適化 の記事に続き、今回は来客が注文を確定するときの話です。
確定ボタンの二重タップ、通信が遅いときの再タップ、回線の自動リトライ——来客UIでは毎日起きます。MasterOrder でいちばん避けたい事故のひとつが、同じ注文が厨房に2枚届く ことでした。
Idempotency-Key だけでは足りません。実際に困ったのは次の3パターンです。
| パターン | 典型原因 | キーだけでは |
|---|---|---|
| A | 同じ POST が2回来る | 防げる |
| B | 連打で キーだけ毎回変わる | 抜ける |
| C | 1回目は成功したが レスポンスが届かない | 再送と二重登録の境界が曖昧 |
最終的に、クライアント・協調KV・セッションロック・結果キャッシュを重ねて、A〜C をまとめて潰しました。
クライアント:サーバーに届く前に1本に束ねる
requestId をカート画面で1回だけ発行
来客UIは カート画面を開いたタイミング で requestId を1回発行し、確定〜送信完了まで同じ値を使います。連打しても再送しても、同じ ID です。
// order-sdk.js — カート表示で1回。連打・再送は同一 ID
function generateRequestId() {
return generateOrderIdempotencyKey(); // crypto.randomUUID()
}
function refreshCartRequestId() {
state.cartRequestId = orderSdk.generateRequestId();
}
// showScreen('Cart') → refreshCartRequestId()
// sendOrder() → const requestId = state.cartRequestId;
HTTP ヘッダー:
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
X-MasterOrder-Client-Id: guest-<端末ごとに localStorage 永続>
X-Session-PIN: <卓セッション PIN>
送信中は in-flight を join
planGuestOrderSubmit() は、同じ sessionId + 同じカート内容(payloadSig)で送信中なら、新しい HTTP を張らず進行中の Promise を join します。
if (slot && slot.inFlightPromise) {
if (slot.payloadSig === payloadSig) {
return { mode: 'join', promise: slot.inFlightPromise };
}
return { mode: 'blocked', message: '…' };
}
409 でも「もう成功してるかも」を照合
サーバーが 409 CONFLICT を返しても、1回目は通っていることがあります。クライアントは注文履歴 API を最大5回ポーリングし、同内容の注文が見つかれば成功扱いにして idempotentReplay: true を返します。
// reconcileGuestOrderAfterDuplicate — 400ms × attempt のバックオフ
return getGuestConnectOrderHistory(sessionId, pin, shopId, 20)
.then(function (orders) {
var match = findRecentGuestOrderMatch(orders, items);
if (match) return { orderId: match.orderId, ... };
if (tryNo >= 4) return null;
return delayMs(400 * (tryNo + 1)).then(...);
});
409 をそのままエラー画面に出すと、ランチピークで来客がパニックになります。ここは「失敗」より「処理中」の方が現場に優しい、という判断です。
サーバー入口:協調KVで「受け付けるか」を先に決める
API ノードが複数あっても、全ノード共通の協調 KV で受付を一本化します。
OrderController.guestOrderWithPin() の流れ:
PIN 認証 → カタログ ready 確認 → tryAdmit() → [ADMITTED なら] sessionLock → saveOrder()
↓ 重複
結果キャッシュがあれば 200 + idempotentReplay
↓ なければ 409
tryAdmit() のチェック順
GuestOrderSubmissionGuardService.tryAdmit() は、短い TTL のロックを段階的に取ります。
// 1. 端末クールダウン(直前の成功後 N 秒)
if (coordinationKvStore.hasKey(cooldownKey(clientId)))
return COOLDOWN_ACTIVE; // → 429
// 2. セッション burst(同一セッションの POST 最小間隔)
if (!setIfAbsent(sessionBurstKey(sessionId), "1", sessionBurst))
return SESSION_ORDER_IN_PROGRESS; // → 409
// 3. セッション処理中ロック(最大90秒)
if (!setIfAbsent(sessionInflightKey(sessionId), idempotencyKey, 90s))
return SESSION_ORDER_IN_PROGRESS;
// 4. 同一カート内容フィンガープリント(キーだけ変わる連打対策)
String payloadKey = payloadKey(sessionId, items); // SHA-256
if (!setIfAbsent(payloadKey, "1", duplicatePayloadWindow))
return DUPLICATE_PAYLOAD; // → 409
// 5. Idempotency-Key 本体(UUID v4 必須、TTL 60分)
if (!setIfAbsent(idempotencyKey(normalizedKey), "1", idempotencyTtl))
return DUPLICATE_IDEMPOTENCY_KEY; // → 409
return ADMITTED;
パターン B は ④ payload fingerprint で止めます。GuestOrderPayloadFingerprint が sessionId + menuId:quantity:toppings を正規化して SHA-256 化します。クライアントが毎回新 UUID を付けてしまう実装でも、サーバー側で内容が同じなら弾けます。
成功結果をキャッシュ(パターン C)
saveOrder() 成功後:
guestOrderSubmissionGuardService.recordSuccessfulGuestOrder(
requestId, sessionId, orderItems,
new GuestOrderCachedResult(orderId, status, totalPrice));
guestOrderSubmissionGuardService.markSubmissionSucceeded(clientId, sessionId);
同一 Idempotency-Key または同一 payload で再 POST された場合、findCachedGuestOrder() が JSON を返し、新規 saveOrder は走りません。
{
"requestId": "550e8400-…",
"allowed": true,
"orderId": 12345678,
"idempotentReplay": true
}
ヘッダー X-MasterOrder-Idempotent-Replay: true でも判別できます。
失敗時は releaseAfterFailedSubmission() でロックを解放し、正当な再送を許可します。一方、結果キャッシュ保存に失敗しても idempotency ロックは TTL まで残す 設計です。成功直後の再 POST で二重注文が通るのを防ぐためです。
if (admission == Admission.DUPLICATE_IDEMPOTENCY_KEY) {
return false; // TTL 自然失効まで待つ
}
セッション単位の排他
受付を通過したあとも、同一卓セッション内の saveOrder / checkout で TOCTOU が起きます。SessionOperationLockService で卓単位に直列化します。
return sessionOperationLockService.executeWithLock(sessionId, () -> {
TableSession lockedSession = tableSessionRepository.findById(sessionId)...;
if (!lockedSession.isActive()) return 410 GONE;
return saveOrder(lockedSession, orderItems, ...);
});
ロックは CoordinationKvStore.setIfAbsent("lock:session:{sessionId}", token, ttl) をリトライ。取れなければ SessionOperationBusyException → 409 SESSION_BUSY。ランチピークに同一卓から注文がほぼ同時に来ても、ここで1本ずつ処理されます。
どこに何を書くか
| 領域 | 保存先 | 役割 |
|---|---|---|
| 協調ロック・冪等キー・結果キャッシュ | 協調 KV(Gate Worker 経由 D1 等) | 全 API ノード共有の受付ゲート |
| 卓セッション・メニュー参照 | PostgreSQL(+ キャッシュ) | 認可・価格検証・在庫減算の前提 |
| アクティブ注文 | Firestore …/orders/{orderId} |
厨房・スタッフが読む営業フロア |
| 会計後 | Gate KV / ローカルノード等 | 退店後の長期保存 |
saveOrder() 本体:
// 1. メニュー解決・トッピング検証
// 2. orderPriceVerificationService.verifyBeforePersist(...)
// 3. inventoryService.decreaseByOrderWithToppings(...) // 在庫不足 → 409
// 4. FirestoreActiveOrderRepository.save()
// 5. orderSseService.broadcast(...) // 厨房向け通知
Firestore の orderId はランダム整数 + lookup 衝突チェック(最大8回)で採番します。PostgreSQL シーケンスに依存しないので、複数ノードから同時書き込みしても衝突しにくいです。
private int generateOrderId() {
for (int attempt = 0; attempt < 8; attempt++) {
int candidate = ThreadLocalRandom.current().nextInt(1, Integer.MAX_VALUE);
if (!orderLookupRef(candidate).get().exists()) return candidate;
}
throw new IllegalStateException("Failed to allocate unique orderId");
}
冪等性の正本は Firestore ではなく協調 KV です。Firestore は「通った注文の置き場」、PostgreSQL は「ビジネスルール検証」、KV は「二重通過防止」——在庫記事 で書いた倉庫・フロア・事務所の分担と同じ考え方です。
Idempotency-Key は UUID v4 に限定しています。任意文字列だとキー空間の衝突やログ injection の余地が残るためです。スタッフ向け POST /orders?sessionId= は Idempotency-Key 必須にしていません。UI 制御とセッションロック中心で、来客 API ほど攻撃面が広くないからです。
現場で想定したケース
連打(同一 requestId)
[Client] POST ×3(同一 Idempotency-Key)
[Guard] 1本目 ADMITTED → saveOrder 成功 → 結果キャッシュ
2・3本目 DUPLICATE_IDEMPOTENCY_KEY → キャッシュ返却 200
厨房には1枚。来客には成功(2回目以降は idempotentReplay)。
キーだけ毎回変わる連打
[Guard] payload fingerprint が同一 → DUPLICATE_PAYLOAD(30秒窓)
キャッシュがあれば 200、なければ 409
[Client] reconcile が履歴から orderId を拾う
1回目成功・レスポンスロスト・再送
[Server] saveOrder 成功 → recordSuccessfulGuestOrder
[Client] タイムアウト → 同一 requestId で再 POST
[Guard] DUPLICATE_IDEMPOTENCY_KEY + キャッシュヒット → 200 idempotentReplay
saveOrder 失敗(在庫不足・価格不一致)
[Guard] ADMITTED → saveOrder 409/400 → releaseAfterFailedSubmission()
[Client] カートを開き直して新しい cartRequestId で再送
ランチピーク(多卓・同一卓)
別卓は sessionId 単位で独立。同一卓の同時 POST は burst + inflight + SessionOperationLock で直列化。複数 API ノード間は GateCoordinationKvStore が D1 上の coordination_locks を共有します。
HTTP ステータスの整理
| Admission / 例外 | HTTP | 来客向けメッセージ例 |
|---|---|---|
DUPLICATE_* / SESSION_ORDER_IN_PROGRESS |
409 | 「同じ注文を処理しています…」 |
COOLDOWN_ACTIVE |
429 | 「送信が早すぎます…」 |
SessionOperationBusyException |
409 | 「セッション処理中です…」 |
| キャッシュヒット(重複だが成功済み) | 200 | idempotentReplay: true |
監査ログ(GuestOrderAccessAudit)に requestId, clientId, idempotentReplay, reasonCode を残しています。厨房トラブルで「本当に2枚来たのか / 1枚の表示ズレか」を切り分けるときに効きます。
シーケンス図
sequenceDiagram
participant G as 来客ブラウザ
participant SDK as order-sdk
participant API as OrderController
participant Guard as GuestOrderSubmissionGuard
participant KV as Coordination KV
participant Lock as SessionOperationLock
participant FS as Firestore
G->>SDK: カート画面表示 → requestId 発行
G->>SDK: 確定(連打)
SDK->>SDK: 同一 payload → in-flight join
SDK->>API: POST + Idempotency-Key + Client-Id
API->>Guard: tryAdmit()
Guard->>KV: setIfAbsent (burst/inflight/payload/idempotency)
alt 重複 & キャッシュあり
Guard-->>API: cached result
API-->>SDK: 200 idempotentReplay
else ADMITTED
API->>Lock: executeWithLock(sessionId)
Lock->>KV: lock:session:{id}
API->>API: saveOrder (在庫・価格検証)
API->>FS: orders/{orderId}
API->>Guard: recordSuccessfulGuestOrder
Guard->>KV: result JSON
API-->>SDK: 200 orderId
end
注文が厨房に載るまでの経路は 次の記事 で書きました。signals / SSE / pending 一覧の話です。