送金の途中で失敗しても残高を保つ
目標
減算・加算・送金の記録をアトミックにまとめ、同時の残高競合を再現します。
なぜ重要なのか
送信口座からはお金を引いたものの、受信口座に足す前にプロセスが死にました。リトライには送金idがなかったので、またお金を引きました。逆に、送金の記録だけを先にコミットすると、リトライは完了したと判断して、入金が永遠に抜け落ちます。金額の計算を整数で正確に行っても、トランザクションの境界が間違っていれば、結果は間違います。
ステップ
/root/work/idem-ledger-transfer-lab/service.pyで、init_db(path)は、accounts(id TEXT PRIMARY KEY,balance INTEGER NOT NULL)とtransfers(id TEXT PRIMARY KEY,source TEXT NOT NULL,target TEXT NOT NULL,amount INTEGER NOT NULL)を、冪等に作成します。
最初に1回だけ準備してください。既存のファイルは上書きしません。
mkdir -p /root/work/idem-ledger-transfer-lab
test -e /root/work/idem-ledger-transfer-lab/service.py || cp /opt/fixtures/ten_labs/idem-ledger-transfer-lab/service.py /root/work/idem-ledger-transfer-lab/service.py
cd /root/work/idem-ledger-transfer-lab
-
/root/work/idem-ledger-transfer-lab/service.pyで、add_account(path, account_id, amount)は、boolを除く0以上のintだけを許可し、INSERTで口座を作ります。すでにある口座は、sqlite3.IntegrityErrorで拒否し、残高は保持します。 -
/root/work/idem-ledger-transfer-lab/service.pyで、balance(path, account_id)は、口座の残高を返し、なければKeyErrorです。 -
/root/work/idem-ledger-transfer-lab/service.pyで、validate_transfer(source,target,amount)は、source!=targetで、amountがboolを除く正のintならNone、そうでなければValueErrorです。 -
/root/work/idem-ledger-transfer-lab/service.pyで、transfer(path,tx_id,source,target,amount,fault=lambda:None)は、同じid/内容ならFalse、idの衝突・ない口座・不足した残高はValueErrorです。新しい送金は、減算→fault()→入金→transfersへの挿入を、アトミックに実行してTrueです。 -
/root/work/idem-ledger-transfer-lab/service.pyで、history(path)は、transfersをid昇順の(id,source,target,amount)タプルのリストで返します。 -
/root/work/idem-ledger-transfer-lab/service.pyで、total_balance(path)は、accounts.balanceの合計です。口座がなければ0です。 -
/root/work/idem-ledger-transfer-lab/service.pyで、compete(path,source,target,amount)は、互いに異なるid race-a/race-bで、同じ金額のtransferを、2つのスレッドで実行します。ValueErrorだけをFalseに変換した結果のリストを返します。残高が1回分しかなければ、成功は1つです。
参考
- インターネットやパッケージのインストールなしで、既存のlab-dev環境で行います。
- 各ステップは、45秒の採点予算の中で実行されます。実際のsleepやネットワーク呼び出しを追加しないでください。
- 採点は、提出されたモジュールを新しく読み込み、独立した入力と一時DBで検査します。期待値を定数として返す代わりに、契約を実装してください。
- FastAPI公式ドキュメント・pytest公式ドキュメント・Python sqlite3
- 限界: 実際の金融の元帳の、会計・監査・法的な要件を代替するシステムではありません。通貨・小数の単位・手数料・複数通貨は扱わず、金額は最小単位の正の整数です。外部の決済システムの呼び出しは、このDBトランザクションに含まれないので、別の設計が必要です。
元帳テーブルを作る
/root/work/idem-ledger-transfer-lab/service.pyで、init_db(path)は、accounts(id TEXT PRIMARY KEY,balance INTEGER NOT NULL)とtransfers(id TEXT PRIMARY KEY,source TEXT NOT NULL,target TEXT NOT NULL,amount INTEGER NOT NULL)を、冪等に作成します。
最初に1回だけ準備してください。既存のファイルは上書きしません。
mkdir -p /root/work/idem-ledger-transfer-lab
test -e /root/work/idem-ledger-transfer-lab/service.py || cp /opt/fixtures/ten_labs/idem-ledger-transfer-lab/service.py /root/work/idem-ledger-transfer-lab/service.py
cd /root/work/idem-ledger-transfer-lab
同じ送金idが2回コミットされないように、一意性をDBに置きます。
保存したあと、bash /opt/lab/checks/idem-ledger-transfer-lab/01-contract.shで確認してください。
口座を1回だけ作る
/root/work/idem-ledger-transfer-lab/service.pyで、add_account(path, account_id, amount)は、boolを除く0以上のintだけを許可し、INSERTで口座を作ります。すでにある口座は、sqlite3.IntegrityErrorで拒否し、残高は保持します。
初期化のUPSERTが、実際の残高を初期値に戻してしまわないようにします。
保存したあと、bash /opt/lab/checks/idem-ledger-transfer-lab/02-contract.shで確認してください。
ない口座と0ウォンを区別する
/root/work/idem-ledger-transfer-lab/service.pyで、balance(path, account_id)は、口座の残高を返し、なければKeyErrorです。
ないことを0に変えると、間違った口座への送金を進められてしまいます。
保存したあと、bash /opt/lab/checks/idem-ledger-transfer-lab/03-contract.shで確認してください。
送金の入力を検証する
/root/work/idem-ledger-transfer-lab/service.pyで、validate_transfer(source,target,amount)は、source!=targetで、amountがboolを除く正のintならNone、そうでなければValueErrorです。
同一口座の送金と、負の送金を、事前に拒否します。
保存したあと、bash /opt/lab/checks/idem-ledger-transfer-lab/04-contract.shで確認してください。
3つの書き込みを1つのトランザクションにまとめる
/root/work/idem-ledger-transfer-lab/service.pyで、transfer(path,tx_id,source,target,amount,fault=lambda:None)は、同じid/内容ならFalse、idの衝突・ない口座・不足した残高はValueErrorです。新しい送金は、減算→fault()→入金→transfersへの挿入を、アトミックに実行してTrueです。
減算のあとに強制的に例外を出して、残高と送金の記録が、どちらも元のままかを検査します。
保存したあと、bash /opt/lab/checks/idem-ledger-transfer-lab/05-contract.shで確認してください。
送金履歴を決定的に読む
/root/work/idem-ledger-transfer-lab/service.pyで、history(path)は、transfersをid昇順の(id,source,target,amount)タプルのリストで返します。
入力の順序と取得の順序を混同しないように、ORDER BYを明示します。
保存したあと、bash /opt/lab/checks/idem-ledger-transfer-lab/06-contract.shで確認してください。
保存量を確認する
/root/work/idem-ledger-transfer-lab/service.pyで、total_balance(path)は、accounts.balanceの合計です。口座がなければ0です。
個々のリクエストの戻り値と、全体の金額の保存を、別々に検証します。
保存したあと、bash /opt/lab/checks/idem-ledger-transfer-lab/07-contract.shで確認してください。
同時の送金が残高を超えない
/root/work/idem-ledger-transfer-lab/service.pyで、compete(path,source,target,amount)は、互いに異なるid race-a/race-bで、同じ金額のtransferを、2つのスレッドで実行します。ValueErrorだけをFalseに変換した結果のリストを返します。残高が1回分しかなければ、成功は1つです。
トランザクションの外で残高を先に検査すると、2つのリクエストが同じ残高を見て、両方とも成功することがあります。
保存したあと、bash /opt/lab/checks/idem-ledger-transfer-lab/08-contract.shで確認してください。