PostgreSQL 二重送信に強いAPIを作る④ 同時実行と運用上の限界を確認する
同じキーを順番に2回送って同じ受付番号が返れば、再送の基本動作は確認できます。しかし、同時に届いた場合や、保存直後に通信が切れた場合も確認しておく必要があります。
最終回では、DBを含めた確認手順と、この仕組みが保証する範囲を整理します。
再送の確認は、内容の組み合わせを変える
| 条件 | 期待する結果 |
|---|---|
| 同じ利用者、同じキー、同じ内容 | 同じ受付番号・受付時刻、DBは1件 |
| 同じ利用者、同じキー、異なる本文 | 409、最初の内容は変わらない |
| 同じ利用者、異なるキー、同じ内容 | 別操作なので2件 |
| 別の利用者、同じキー、同じ内容 | 利用者ごとに1件、他人の結果は返さない |
| キーなし、複数のキー、不正なUUID | 400、保存しない |
| 認証やCSRFが不正 | 保存前に拒否する |
再送確認では、HTTPの成功だけでなくDBの件数も調べます。
同じ操作が1件だけかを確認するSELECT count(*)
FROM inquiry_receipt
WHERE user_id = @user_id
AND operation_key = @operation_key;表示名や本文の一致だけで数えると、正当に作られた別の問い合わせまで重複扱いしてしまいます。
2本のDB接続で、待機する状況を作る
順番にAPIを呼ぶテストでは、未確定のINSERTとの競合を確認できません。保存層のテストでは、次の状況を作ります。
- 接続Aでトランザクションを開始する
- Aから、ある利用者と操作キーでINSERTする。まだコミットしない
- 接続Bから、同じ組み合わせで
ON CONFLICT DO NOTHINGのINSERTを実行する - Bが一意制約の判定で待つことを確認する
- Aをコミットする
- BのINSERTが追加なしで終了することを確認する
- Bから別のSELECTを実行し、Aが保存した行を取得できることを確認する
Aをロールバックする場合も試します。その場合、Bが自分のINSERTを成立させ、結果として受付が1件になることを確認します。
テストにはタイムアウトを設定し、失敗しても接続を閉じてロックを解放してください。実行時間だけを見て「待ったはず」と判断せず、可能ならロック状態を調べるなど、競合が実際に起きたことも確認します。
保存後の応答を失う状況を作る
通信を送信前に切るテストだけでは、今回の中心となる問題を確認できません。
確認したい失敗の位置ブラウザーが送信
↓
DBへの保存が確定
↓
ここで応答を遮断する
↓
ブラウザーは結果不明になるテスト用のプロキシやテスト専用の応答制御を使い、保存が確定した後に応答を返さない状況を作ります。その後、同じキーと本文で再送し、元の受付番号が返ることを確認します。
サーバー側に意図的な失敗を挿入する場合は、テスト環境だけに限定してください。保存前に例外を起こしただけでは、保存後の通信切断を再現したことにはなりません。
再試行できることと、無制限に再試行することは違う
この画面は利用者が操作して再試行します。自動再試行を追加する場合は、上限回数、間隔、タイムアウト、対象のHTTPステータスを決めます。
入力不正の400や内容不一致の409を、時間を置くだけで解決する失敗として扱わないようにします。認証切れの401は、認証状態の回復が先です。
429などへの対応や一時的な通信失敗で再試行する場合も、同じキーと本文を維持します。キーを変えると、サーバーにとっては新しい問い合わせになります。
キーを消せば、過去の操作を識別できなくなる
今回のコードでは受付とキーが同じ行にあり、自動削除を実装していません。保持している間だけ、同じ操作を識別できます。
実運用では、問い合わせ本文をいつまでも残すとは限りません。保存期間を決める際は、次の3つを合わせて考えます。
- クライアントが再送してよい期間
- サーバーが同じ結果を返せる期間
- 本文や操作記録を削除する期間
古い受付を削除したあと、同じキーを再送されると、新規受付として通る可能性があります。重複防止の保証を残すなら、本文とは別に必要最小限の操作記録を残すなど、データ設計を変える必要があります。
期限を設けた場合は、期限切れの操作を自動的に新しいキーへ置き換えず、利用者が受付状況を確認できる導線も用意します。
正規化やAPI契約の変更にも注意する
第1回では、前後の空白と改行だけを正規化しました。このルールをあとから変えると、以前の要求の再送が別の内容と判断されることがあります。
再送期間中は入力形式と正規化ルールの互換性を保ちます。大きく仕様が変わる場合は、操作のバージョンを含めて設計してください。
ハッシュで比較する構成でも、この問題は同じです。ハッシュ化する前の値の作り方が変われば、ハッシュも変わります。
外部のメール送信まで一度だけになるわけではない
このコードで重複を防いでいるのは、DBへの問い合わせ受付です。
コントローラーの成功時にそのままメールを送ると、保存済み結果を返す再送でもメールが送られるかもしれません。また、「初回だけ送る」という分岐だけでは、保存後・送信前に停止したときに通知が抜けます。
通知を追加する場合は、受付と通知予定を同じトランザクションへ保存し、別の処理で送信する方法があります。Transactional Outboxで外部処理につなぐ方法を参照してください。その際は、今回の単一INSERTの保存処理もトランザクション単位へ拡張します。
配信処理が再試行する場合の重複対策は、通知先や配信サービスとの間でも必要です。DBのキーだけで、外部への作用まで一度に限定できるわけではありません。
運用で確認できる情報を残す
ログには、認証済み利用者の内部ID、受付ID、処理の分類、リクエストの追跡IDなどを記録します。問い合わせ本文や認証Cookieを丸ごと記録する必要はありません。
分類は「新規受付」「同じ内容の再送」「同じキーの内容不一致」「DBの待機や失敗」のように分けると、正常な再試行と実装上の問題を調べやすくなります。掲載コードはログ基盤を省略しているため、アプリの仕組みへ追加してください。
別のキーを大量に生成する送信は、この一意制約では止められません。必要なレート制限や問い合わせ件数の制限は、別のルールとして設けます。
既存データの編集競合を防ぎたい場合は、今回の操作キーではなくVersionやETagの出番です。保存競合と入力内容の保護と合わせて、作成の再送と編集の競合を分けて扱ってください。