zukucode
主にWEB関連の情報を技術メモとして発信しています。

PostgreSQL 二重送信に強いAPIを作る④ 同時実行と運用上の限界を確認する

同じキーを順番に2回送って同じ受付番号が返れば、再送の基本動作は確認できます。しかし、同時に届いた場合や、保存直後に通信が切れた場合も確認しておく必要があります。

最終回では、DBを含めた確認手順と、この仕組みが保証する範囲を整理します。

  1. 操作の単位とキーを決める
  2. 受付結果をDBに保存する
  3. 同じ操作をReactから再送する
  4. 同時実行と運用上の限界を確認する(この記事)

再送の確認は、内容の組み合わせを変える

条件期待する結果
同じ利用者、同じキー、同じ内容同じ受付番号・受付時刻、DBは1件
同じ利用者、同じキー、異なる本文409、最初の内容は変わらない
同じ利用者、異なるキー、同じ内容別操作なので2件
別の利用者、同じキー、同じ内容利用者ごとに1件、他人の結果は返さない
キーなし、複数のキー、不正なUUID400、保存しない
認証やCSRFが不正保存前に拒否する

再送確認では、HTTPの成功だけでなくDBの件数も調べます。

同じ操作が1件だけかを確認する
SELECT count(*)
FROM inquiry_receipt
WHERE user_id = @user_id
  AND operation_key = @operation_key;

表示名や本文の一致だけで数えると、正当に作られた別の問い合わせまで重複扱いしてしまいます。

2本のDB接続で、待機する状況を作る

順番にAPIを呼ぶテストでは、未確定のINSERTとの競合を確認できません。保存層のテストでは、次の状況を作ります。

  1. 接続Aでトランザクションを開始する
  2. Aから、ある利用者と操作キーでINSERTする。まだコミットしない
  3. 接続Bから、同じ組み合わせでON CONFLICT DO NOTHINGのINSERTを実行する
  4. Bが一意制約の判定で待つことを確認する
  5. Aをコミットする
  6. BのINSERTが追加なしで終了することを確認する
  7. 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の出番です。保存競合と入力内容の保護と合わせて、作成の再送と編集の競合を分けて扱ってください。


関連記事