再開したお祭りと危険な取り消し
目標
キャンセルしたお祭りが、再び開催されます。キャンセル直後の状態が保たれている注文だけを復旧し、待ち時間を制限し、応答を失っても補償レシートで再開します。
なぜ重要なのか
バックアップで丸ごと上書きすると、キャンセルのあとに別の担当者が行った変更を消してしまうおそれがあります。補償も、新しい承認と記録を伴う変更です。先に、前の承認バージョンのラボと、Pythonの例外・SQLのトランザクションを学習してください。想定所要時間は110分です。期限が切れる前に+時間でセッションを延長してください。セッションが終わるとファイルが消えます。必要なコードは別に保管してください。
環境と共通契約
成果物は/root/compensation/worker.pyです。PostgreSQL 16・psycopg 3.2.3・Python 3がイメージに用意されていて、インターネットからのインストールは不要です。postgresユーザーで/rootに書き込めるので、ユーザーの切り替えや追加のcapabilityは必要ありません。
検査ツールは、ローカルのlabdbの別の一時スキーマに、次のテーブルと仮想の元の変更を準備し、自分が作ったスキーマだけを片付けます。提出する関数は、渡された接続のsearch_pathを使います。スキーマ・注文ID・顧客・DSNをハードコードしたり、publicの元のデータを変更したりしないでください。SQLの値はパラメータで渡します。
CREATE TABLE orders(id integer PRIMARY KEY,tenant text NOT NULL,
qty integer NOT NULL CHECK(qty BETWEEN 1 AND 1000),
state text NOT NULL CHECK(state IN ('pending','paid','cancelled')),
revision integer NOT NULL CHECK(revision>=0));
CREATE TABLE changes(change_id text PRIMARY KEY,tenant text NOT NULL,targets jsonb NOT NULL);
CREATE TABLE audit(change_id text REFERENCES changes(change_id),id integer NOT NULL,
previous_revision integer NOT NULL,new_revision integer NOT NULL,qty integer NOT NULL,
PRIMARY KEY(change_id,id));
CREATE TABLE undo_records(undo_id text PRIMARY KEY,
original_id text NOT NULL UNIQUE REFERENCES changes(change_id),reason text NOT NULL,
tenant text NOT NULL,targets jsonb NOT NULL);
CREATE TABLE undo_audit(undo_id text REFERENCES undo_records(undo_id),id integer NOT NULL,
previous_revision integer NOT NULL,new_revision integer NOT NULL,qty integer NOT NULL,
PRIMARY KEY(undo_id,id));
識別子undo_id・original_id・tenantは、ASCIIの英数字・アンダースコア・ハイフンの1–64文字からなる、厳密なstrです。reasonは1–200文字の厳密なstrで、前後の空白と、コードポイント0–31・127を許可しません。リクエストを自動で修正しません。対象はid・revision・qtyだけを持つ厳密なdictで、idはint 1–2147483647、期待するrevisionはint 0–2147483646、qtyはint 1–1000です。boolはすべて拒否します。targetsは、重複のないIDを1–16個含む厳密なlistで、ID順の新しいlistとdictに正規化します。planはoriginal_id・tenant・targetsだけを持つ厳密なdictです。直接渡された入力のエラーは、書き込みの前にValueErrorにします。
lock_msは50–500の厳密なint、statement_msはlock_msより大きく2000以下の厳密なintです。上限は、各ロックの試行と各SQLごとのもので、バッチ全体の経過時間を保証するものではありません。補償レシートの挿入も待たされることがあるので、compensateは最初のDB操作から、自分のトランザクションの予算を適用します。
借りたconはautocommit=True・Read Committedで、外部からの呼び出しの開始時にトランザクションはありません。すべての関数は、借りた接続を閉じず、成功・失敗のあとに開いたトランザクションと設定の変更を残しません。restore_one・restore_batchの内部の入れ子の呼び出しは、外側のトランザクションを保持します。compensateを別の外側のトランザクションで包まないでください。undo_fileだけが、接続を新しく所有します。記録は確定後に変更せず、注文の通常の書き込みはrevisionを増やす、という契約です。この前提を守らない運用プログラムの書き込み権限までは統制しません。
ステップ
- 補償リクエストを明確にします。Exceptionを継承するConflictと、request(undo_id,original_id,reason)を実装してください。下の識別子・理由の契約を検証し、3つのキーを持つ新しいdictを返します。形式のエラーはValueErrorで、理由やIDを黙って修正しません。
- 元のレシートと監査を照合します。load_plan(con,original_id)は、元のchangesとauditを読んで、original_id・tenant・targetsのdictを返します。targetsはID順で、元の承認時のrevisionに1を足した、キャンセル直後の期待値です。元の記録がない場合、監査のID・前後のバージョン・数量が一致しない場合、記録が有効でない場合、補償後にintegerの上限を超える場合は、Conflictです。元の承認時のrevisionは、0–2147483645だけを許可します。元の記録やordersは変更せず、返した値を変更しても元の記録に影響しません。
- キャンセル直後の状態のときだけ復旧します。restore_one(con,tenant,target)は、入力を検証し、id・tenant・期待するrevision・qty・cancelled状態をUPDATEの条件に入れます。一致すればpendingに変更し、revisionを1増やして、id・revision・qty・stateのdictを返します。存在しない場合や変わっている場合はConflictです。単独では自分のトランザクションで処理しますが、内部の呼び出しでは、外側のトランザクションを早期にコミットしません。監査やレシートは書きません。
- 待ち時間を制限し、バッチ全体を保護します。restore_batch(con,plan,lock_ms=100,statement_ms=800)は、下の計画と予算の契約を、書き込みの前に検証します。外側のトランザクションの中で上限を適用し、ID順にすべて復旧して、restore_oneの結果のlistを返します。どのエラーでも、先の行を含めて全体をロールバックし、元のエラーを伝えます。成功・失敗のあとも、借りた接続のlock_timeout・statement_timeoutを元の値のまま保ちます。元の記録と対照群は変更しません。
- 補償の記録と業務上の変更をまとめて確定します。compensate(con,undo_id,original_id,reason,lock_ms=100,statement_ms=800,fault=None)は、リクエストと予算を検証したあと、1つのトランザクションの中で上限を適用し、元の補償計画を読みます。undo_recordsに、補償ID・元のID・理由・顧客・期待するtargetsを入れ、バッチで復旧してからfault(after-restore)を呼びます。すべての対象について、キャンセル直後のrevision・新しいrevision・qtyをundo_auditに入れてからfault(after-audit)を呼び、実際のCOMMITのあとfault(after-commit)を呼んで、Trueを返します。同じID・元のID・理由・計画なら、変更なしでFalseを返し、フックも呼びません。同じ補償IDで内容が違う場合や、別の補償IDで同じ元の変更をもう一度補償する場合は、Conflictです。フックは、渡されたときだけ呼びます。コミット前のエラーは全体をロールバックし、コミット後のエラーは確定状態を保持して元のエラーを伝えます。元のchangesとauditは変更しません。
- 補償時点と現在を分けて読みます。inspect_undo(con,undo_id)は、なければNone、あればundo_id・original_id・reason・tenant・targetsのdictを返します。targetsは、補償時点の期待値のコピーです。reconcile_undo(con,undo_id)は、レシートがなければConflictで、あればそのIDの現在のordersを1回のSELECTで読み、matching・drifted・missingのID順のlistを返します。顧客・qty・pending・revision=レシートの期待値+1がすべて合っていればmatching、IDがなければmissing、それ以外はdriftedです。どちらの関数も、読み取りだけを行います。
- 応答を失った補償を再開します。undo_file(dsn,undo_id,original_id,reason,lock_ms=100,statement_ms=800,fault=None)は、psycopg.connect(dsn,autocommit=True,connect_timeout=2)で接続を所有し、compensateを呼んで同じ結果を返します。成功・失敗のどちらでも接続を閉じ、エラーを隠しません。実際のクライアント終了と、同じ補償ID・異なる補償IDの2つのプロセスの競合を検査します。
- ロックのエラーだけを、決められた回数だけ再試行します。retry_undo(action,attempts,pause)は、厳密なintのattempts 1–3だけを許可し、エラーは最初の呼び出しの前にValueErrorにします。action()をすぐに呼び、True・Falseを含む正常な結果はそのまま返します。psycopg.errors.LockNotAvailableだけを再試行し、最後の失敗は同じエラーを伝えます。試行が残っているときだけ、pause(いま失敗した試行の番号)を呼びます。最初を含めて合計attempts回、pauseは最大attempts-1回です。文のキャンセル・Conflict・入力や接続のエラーと、pauseのエラーは、追加の呼び出しなしで伝えます。実際の検査のactionは、同じIDでundo_fileを呼びます。
参考
- 直接の診断: python3 -B /opt/lab/fixtures/compensation/check.py 8 /root/compensation/worker.py。8を現在のステップに変えると、そのステップまで検査します。1回の採点の上限は30秒です。
- 渡されたDSNは、信頼できる使い捨てのローカルDB接続設定です。実際の顧客データや外部のDBは入れないでください。認証・顧客ごとの権限と、外部の決済・発送のキャンセルは、別の問題です。
- fsyncとfull_page_writesが切られている既存のラボイメージで、DBサーバーは生かしたままクライアントだけを終了します。サーバーの電源障害・ディスク破損・運用バックアップの耐久性を検証したものではありません。
補償リクエストを明確にする
Exceptionを継承するConflictと、request(undo_id,original_id,reason)を実装してください。下の識別子・理由の契約を検証し、3つのキーを持つ新しいdictを返してください。形式のエラーはValueErrorで、理由やIDを黙って修正しないでください。
識別子はASCIIの規則を、理由は長さと制御文字を、別々に確認してください。
元のレシートと監査を照合する
load_plan(con,original_id)は、元のchangesとauditを読んで、original_id・tenant・targetsのdictを返してください。targetsはID順で、元の承認時のrevisionに1を足した、キャンセル直後の期待値にしてください。元の記録がない場合、監査のID・前後のバージョン・数量が一致しない場合、記録が有効でない場合、補償後にintegerの上限を超える場合は、Conflictにしてください。元の承認時のrevisionは、0–2147483645だけを許可してください。元の記録やordersは変更せず、返した値を変更しても元の記録に影響しないようにしてください。
現在cancelledだという観測だけで、どのキャンセルの結果かを推測しないでください。元の監査の一覧全体を、正規化した承認と比べてください。
キャンセル直後の状態のときだけ復旧する
restore_one(con,tenant,target)は、入力を検証し、id・tenant・期待するrevision・qty・cancelled状態をUPDATEの条件に入れてください。一致すればpendingに変更し、revisionを1増やして、id・revision・qty・stateのdictを返してください。存在しない場合や変わっている場合はConflictにしてください。単独では自分のトランザクションで処理し、内部の呼び出しでは、外側のトランザクションを早期にコミットしないでください。監査やレシートは書かないでください。
過去のバージョンに下げると、古い承認がまた合っているように見えます。RETURNINGで新しいバージョンを確認してください。
待ち時間を制限し、バッチ全体を保護する
restore_batch(con,plan,lock_ms=100,statement_ms=800)は、下の計画と予算の契約を、書き込みの前に検証してください。外側のトランザクションの中で上限を適用し、ID順にすべて復旧して、restore_oneの結果のlistを返してください。どのエラーでも、先の行を含めて全体をロールバックし、元のエラーを伝えてください。成功・失敗のあとも、借りた接続のlock_timeout・statement_timeoutを元の値のまま保ってください。元の記録と対照群は変更しないでください。
トランザクション単位のset_configを使ってください。ロックの上限と文の上限と、バッチ全体の経過時間は同じではありません。
補償の記録と業務上の変更をまとめて確定する
compensate(con,undo_id,original_id,reason,lock_ms=100,statement_ms=800,fault=None)は、リクエストと予算を検証したあと、1つのトランザクションの中で上限を適用し、元の補償計画を読んでください。undo_recordsに、補償ID・元のID・理由・顧客・期待するtargetsを入れ、バッチで復旧してからfault(after-restore)を呼んでください。すべての対象について、キャンセル直後のrevision・新しいrevision・qtyをundo_auditに入れてからfault(after-audit)を呼び、実際のCOMMITのあとfault(after-commit)を呼んで、Trueを返してください。同じID・元のID・理由・計画なら、変更なしでFalseを返し、フックも呼ばないでください。同じ補償IDで内容が違う場合や、別の補償IDで同じ元の変更をもう一度補償する場合は、Conflictにしてください。フックは、渡されたときだけ呼んでください。コミット前のエラーは全体をロールバックし、コミット後のエラーは確定状態を保持して元のエラーを伝えてください。元のchangesとauditは変更しないでください。
レシートだけが残った補償と、元の記録を消した補償は、失敗です。2つの一意キーの衝突が持つ意味を区別してください。
補償時点と現在を分けて読む
inspect_undo(con,undo_id)は、なければNone、あればundo_id・original_id・reason・tenant・targetsのdictを返してください。targetsは、補償時点の期待値のコピーにしてください。reconcile_undo(con,undo_id)は、レシートがなければConflictにし、あればそのIDの現在のordersを1回のSELECTで読んで、matching・drifted・missingのID順のlistを返してください。顧客・qty・pending・revision=レシートの期待値+1がすべて合っていればmatching、IDがなければmissing、それ以外はdriftedです。どちらの関数も、読み取りだけを行ってください。
新しい注文の同じ値で、元のIDの欠落を埋めないでください。現在の変化を理由に、過去のレシートを直すこともしません。
応答を失った補償を再開する
undo_file(dsn,undo_id,original_id,reason,lock_ms=100,statement_ms=800,fault=None)は、psycopg.connect(dsn,autocommit=True,connect_timeout=2)で接続を所有し、compensateを呼んで同じ結果を返してください。成功・失敗のどちらでも接続を閉じ、エラーを隠さないでください。実際のクライアント終了と、同じ補償ID・異なる補償IDの2つのプロセスの競合を検査します。
DBが確定した補償と、呼び出し側が受け取った応答は別のものです。再試行には、同じ補償番号と内容を使ってください。
ロックのエラーだけを、決められた回数だけ再試行する
retry_undo(action,attempts,pause)は、厳密なintのattempts 1–3だけを許可し、エラーは最初の呼び出しの前にValueErrorにしてください。action()をすぐに呼び、True・Falseを含む正常な結果はそのまま返してください。psycopg.errors.LockNotAvailableだけを再試行し、最後の失敗は同じエラーを伝えてください。試行が残っているときだけ、pause(いま失敗した試行の番号)を呼んでください。最初を含めて合計attempts回、pauseは最大attempts-1回です。文のキャンセル・Conflict・入力や接続のエラーと、pauseのエラーは、追加の呼び出しなしで伝えてください。実際の検査のactionは、同じIDでundo_fileを呼びます。
全体の経過時間の制限ではなく、試行回数の契約です。ロックが解けたあとに別のバージョンが見えても、新しい承認を自動で作らないでください。