TT Lab
はじめる
学ぶ 学習パス コース

冪等性 — 二度押しても決済は一度だけ

トランザクショナルアウトボックスで注文とイベントを守る

TT Labで続きを見る

目標

SQLiteのトランザクション・障害の注入・再送・コンシューマーの重複排除を、実際のファイルの状態で検証します。

なぜ重要なのか

正常なリクエストが1回成功しても、境界値と障害からの復旧は保証されません。このラボは、各関数の契約を小さく実装またはテストしたあと、実際の実行につなげます。コードが存在するかどうかやレポートの文言だけを見るのではなく、結果・例外・保存状態を検査します。前のステップのコードを維持しながら、次のステップに進んでください。

ステップ

  1. /root/work/idem-outbox-lab/service.pyで、init_db(path)は、orders(id TEXT PRIMARY KEY, amount INTEGER NOT NULL)、outbox(id TEXT PRIMARY KEY, payload TEXT NOT NULL, sent INTEGER NOT NULL DEFAULT 0)、consumed(id TEXT PRIMARY KEY)、totals(name TEXT PRIMARY KEY, amount INTEGER NOT NULL)を作成し、totalsにsales=0を重複なく入れてください。最初の準備は、次のコマンドで行います。
mkdir -p /root/work/idem-outbox-lab
cp /opt/fixtures/practice_depth/idem-outbox-lab/* /root/work/idem-outbox-lab/
cd /root/work/idem-outbox-lab
  1. /root/work/idem-outbox-lab/service.pyで、canonical(order_id, amount)は、空でない文字列のidと正のint金額だけを受け取り、{id, amount}を、sort_keys=True、separators=(',', ':')、ensure_ascii=FalseのJSONで返してください。boolは金額として受け付けず、不正な入力はValueErrorです。
  2. /root/work/idem-outbox-lab/service.pyで、enqueue(path, order_id, amount, fault=lambda:None)は、BEGIN IMMEDIATEで、注文の挿入 → fault() → outboxの挿入をコミットしてTrueを返してください。同じid・金額の再リクエストはFalse、金額の衝突はValueErrorです。faultが例外を出したら、両方ともロールバックします。
  3. /root/work/idem-outbox-lab/service.pyで、pending(path, limit=10)は、sent=0の(id, payload)タプルを、id昇順で最大limit個返してください。limitはboolを除く整数の1–100で、違反するとValueErrorです。
  4. /root/work/idem-outbox-lab/service.pyで、acknowledge(path, event_id)は、sent=0の行をsent=1に変えたらTrue、ないか、すでに送信済みならFalseを返してください。
  5. /root/work/idem-outbox-lab/service.pyで、dispatch(path, publish, limit=10)は、pendingの各行をpublish(id, payload)に渡し、成功したあとにacknowledgeしてください。発行の例外は伝播させ、未送信の状態を保持します。成功して処理した個数を返します。
  6. /root/work/idem-outbox-lab/service.pyで、consume(path, event_id, payload)は、JSONのid・金額を検証し、event_idの一致を確認してください。同じトランザクションで、consumedにidを記録し、totalsのsalesに金額を加算します。最初の処理はTrue、重複はFalseで、不正な値はValueErrorです。
  7. /root/work/idem-outbox-lab/service.pyで、total(path)は、salesの合計を返してください。publishがconsumeを成功させた直後にConnectionErrorを出すようにしてから、dispatchをもう一度実行してみてください。イベントは2回配信されても、合計は1回しか増えず、outboxは最終的にsent=1である必要があります。

参考

注文・イベント・消費の記録を分ける

/root/work/idem-outbox-lab/service.pyで、init_db(path)は、orders(id TEXT PRIMARY KEY, amount INTEGER NOT NULL)、outbox(id TEXT PRIMARY KEY, payload TEXT NOT NULL, sent INTEGER NOT NULL DEFAULT 0)、consumed(id TEXT PRIMARY KEY)、totals(name TEXT PRIMARY KEY, amount INTEGER NOT NULL)を作成し、totalsにsales=0を重複なく入れてください。最初の準備は、次のコマンドで行います。

mkdir -p /root/work/idem-outbox-lab
cp /opt/fixtures/practice_depth/idem-outbox-lab/* /root/work/idem-outbox-lab/
cd /root/work/idem-outbox-lab

CREATE IF NOT EXISTSとINSERT OR IGNOREを使って、再起動のときに合計を初期化しないようにします。

採点はbash /opt/lab/checks/idem-outbox-lab/01-contract.shで直接再現できます。ファイルを保存してから、もう一度実行してください。

検証済みのイベント本文を作る

/root/work/idem-outbox-lab/service.pyで、canonical(order_id, amount)は、空でない文字列のidと正のint金額だけを受け取り、{id, amount}を、sort_keys=True、separators=(',', ':')、ensure_ascii=FalseのJSONで返してください。boolは金額として受け付けず、不正な入力はValueErrorです。

JSON文字列の空白とキーの順序を正規化します。floatやboolを、黙って整数に変えてはいけません。

採点はbash /opt/lab/checks/idem-outbox-lab/02-contract.shで直接再現できます。ファイルを保存してから、もう一度実行してください。

2つの書き込みの間の障害をロールバックする

/root/work/idem-outbox-lab/service.pyで、enqueue(path, order_id, amount, fault=lambda:None)は、BEGIN IMMEDIATEで、注文の挿入 → fault() → outboxの挿入をコミットしてTrueを返してください。同じid・金額の再リクエストはFalse、金額の衝突はValueErrorです。faultが例外を出したら、両方ともロールバックします。

withの接続ブロックの中で、例外を握りつぶしてはいけません。fault呼び出しの前にコミットすると、注文だけが残る事故が再現されます。

採点はbash /opt/lab/checks/idem-outbox-lab/03-contract.shで直接再現できます。ファイルを保存してから、もう一度実行してください。

未送信のイベントを、制限されたバッチで読む

/root/work/idem-outbox-lab/service.pyで、pending(path, limit=10)は、sent=0の(id, payload)タプルを、id昇順で最大limit個返してください。limitはboolを除く整数の1–100で、違反するとValueErrorです。

バッチの上限は、障害からの復旧時に、一度にメモリを使い果たさないようにします。ORDER BYを省略しないでください。

採点はbash /opt/lab/checks/idem-outbox-lab/04-contract.shで直接再現できます。ファイルを保存してから、もう一度実行してください。

発行の確認を冪等に記録する

/root/work/idem-outbox-lab/service.pyで、acknowledge(path, event_id)は、sent=0の行をsent=1に変えたらTrue、ないか、すでに送信済みならFalseを返してください。

UPDATEの条件とrowcountで、最初の確認と重複した確認を区別します。

採点はbash /opt/lab/checks/idem-outbox-lab/05-contract.shで直接再現できます。ファイルを保存してから、もう一度実行してください。

発行に成功したあとにだけ完了を記録する

/root/work/idem-outbox-lab/service.pyで、dispatch(path, publish, limit=10)は、pendingの各行をpublish(id, payload)に渡し、成功したあとにacknowledgeしてください。発行の例外は伝播させ、未送信の状態を保持します。成功して処理した個数を返します。

acknowledgeをpublishの前に置くと、障害のときにイベントを永遠に失います。空のバッチは0です。

採点はbash /opt/lab/checks/idem-outbox-lab/06-contract.shで直接再現できます。ファイルを保存してから、もう一度実行してください。

消費の記録と効果をアトミックにまとめる

/root/work/idem-outbox-lab/service.pyで、consume(path, event_id, payload)は、JSONのid・金額を検証し、event_idの一致を確認してください。同じトランザクションで、consumedにidを記録し、totalsのsalesに金額を加算します。最初の処理はTrue、重複はFalseで、不正な値はValueErrorです。

BEGIN IMMEDIATEで、重複の確認と効果の反映をまとめます。consumedだけを先にコミットすると、効果が消えることがあります。

採点はbash /opt/lab/checks/idem-outbox-lab/07-contract.shで直接再現できます。ファイルを保存してから、もう一度実行してください。

配信2回・効果1回を再現する

/root/work/idem-outbox-lab/service.pyで、total(path)は、salesの合計を返してください。publishがconsumeを成功させた直後にConnectionErrorを出すようにしてから、dispatchをもう一度実行してみてください。イベントは2回配信されても、合計は1回しか増えず、outboxは最終的にsent=1である必要があります。

送信の成功と確認の記録の間の隙間は残ります。この隙間を隠さず、コンシューマーの重複排除で耐えられるかを確認します。

採点はbash /opt/lab/checks/idem-outbox-lab/08-contract.shで直接再現できます。ファイルを保存してから、もう一度実行してください。