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

取り消せない変更

お祭りの取消ボタンと既に変わった注文

TT Labで続きを見る

目標

祭りの注文キャンセルの承認を受けたあと、別の担当者がデータを変更しました。承認した顧客・ID・バージョン・数量だけをキャンセルし、途中で衝突したら全体を元のまま保全し、応答を紛失したら確定記録で事実を確認します。

なぜ重要なのか

承認ドキュメントを読んでいる間にも、データは動いています。2件を変更したという数字が合っていても、別の注文をキャンセルしていたなら失敗です。変更条件、外側のトランザクション、レシートと現在の照合を分けて実装し、実際の2つのDB接続とクライアント終了で確認します。PostgreSQL・SQLのUPDATEと、Pythonの例外処理の基礎が必要です。

想定所要時間は100分です。期限が切れる前に+時間を押して、セッションを延長してください。セッション終了後はファイルが消えます。必要なコードは別に保管してください。

環境とデータ契約

成果物は/root/revision/worker.pyです。Python 3・psycopg 3.2.3・PostgreSQL 16がイメージに用意されているので、インターネットからのインストールは不要です。コンテナはpostgresユーザーで実行され、/rootに書き込めるようにイメージで準備されています。ユーザーの切り替えやcapabilityの追加は行いません。

検査ツールは、ラボ用のlabdbの中に別の一時スキーマと次の3つのテーブルを作り、自分が作ったスキーマだけを片付けます。グローバルなpublicテーブルや元のラボデータを直接変更しないでください。提出する関数は、渡されたconのsearch_pathをそのまま使い、スキーマ・DSN・注文の値をハードコードしません。

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));

識別子tenant・change_idは、ASCIIの英数字・アンダースコア・ハイフンの1–64文字からなる、厳密なstrです。形式・範囲のエラーは、書き込みの前にValueErrorで拒否します。SQLの値は、文字列の組み立てではなくパラメータで渡します。注文の正常な変更はrevisionを増やし、changes・auditは確定後に変更しない契約です。この規則を無視する他のプログラムの書き込みまで統制する、権限システムではありません。

外部から貸し出されたconは、autocommit=True・Read Committedで、呼び出しの開始時に開いているトランザクションはありません。関数は、借りた接続を閉じず、成功・失敗のあとに開いたトランザクションを残しません。内部の関数どうしを入れ子で呼ぶときは、外側のトランザクションを保持します。change_fileだけが、接続を新しく所有します。DB接続エラーと未知の例外は、成功として隠さずに伝えます。

ステップ

  1. 承認対象を正規化します。worker.pyに、Exceptionを継承するConflictと、validate_targets(targets)を実装してください。targetsは厳密なlistで、要素は1–16個です。各項目は、id・revision・qtyだけを持つ厳密なdictです。idはintで1–2147483647、revisionはintで0–2147483646、qtyはintで1–1000とし、いずれもboolを拒否します。重複IDと形式・範囲のエラーはValueErrorです。IDの昇順に並べた新しいlistと新しいdictを返し、入力は変更しません。
  2. 顧客と明示したIDで現在の状態を読みます。preview(con,tenant,ids)は、定められた識別子tenantと、厳密なlistであるidsを受け取ります。idsは、重複のない厳密なintのID(1–2147483647)を1–16個含む必要があり、エラーはValueErrorです。ordersから、該当する顧客・明示したID・pending状態を1回のSELECTで読み、ID順のid・revision・qtyのdictのリストを返します。対象が1つでもなかったり、別の顧客・別の状態だったりしたらConflictです。返すデータもvalidate_targetsの契約を守り、DBは変更しません。
  3. 承認したバージョンの1行だけをキャンセルします。cancel_one(con,tenant,target)は、識別子と単一の承認項目を検証し、UPDATEでid・tenant・revision・qtyとpendingを同時に比較します。一致すればstate=cancelled、revision=revision+1に変更し、id・revision・qty・stateのdictを返します。返る行がなければConflictで、他の行は変更しません。単独の呼び出しは1つのトランザクションで完了し、後述のバッチ関数の中でも、外側のトランザクションを早期にコミットしてはいけません。
  4. 途中で衝突したら、先の変更もロールバックします。cancel_many(con,tenant,targets)は、すべての入力を先に検証し、ID順に各対象をキャンセルして、cancel_oneの結果のlistを返します。全体を1つの外側のトランザクションで処理し、1つでも失敗したら、先に変更した行までまとめてロールバックしたうえで、元のエラーを伝えます。監査やレシートは、この関数では作りません。入力と対照群は保全します。
  5. 承認レシート・変更・監査をまとめて確定します。audited_cancel(con,change_id,tenant,targets,fault=None)は、2つの識別子と承認セットを検証します。1つのトランザクションの中で、changesにchange_id・tenant・正規化したtargetsを入れ、バッチ全体をキャンセルしたあと、fault('after-update')を呼び出します。各対象のid・元のrevision・新しいrevision(=元+1)・qtyを、同じchange_idのauditの行として入れてfault('after-audit')を呼び、全体のCOMMITのあとfault('after-commit')を呼び出して、Trueを返します。既存のchange_idに同じtenant・正規化したtargetsがあれば、変更なしでFalseを返し、内容が違えばConflictです。重複として返す場合は、faultを呼びません。同時に同じ要求が来ても、適用するのは1つだけです。faultは、渡されたときだけ呼び出します。コミット前のエラーは全体をロールバックし、コミット後のエラーは確定状態を保持して元のエラーを伝えます。
  6. そのとき確定した承認内容を照会します。inspect_change(con,change_id)は、識別子を検証し、changesのレシートがなければNone、あればchange_id・tenant・targetsのdictを返します。targetsは保存された承認当時の内容であり、その後のordersの変更や戻り値の変更によって、保存されたレシートを変えてはいけません。この関数は、現在のordersの状態で承認内容を上書きして読むことはしません。
  7. 同じ件数ではなく、同じ対象を確認します。reconcile(con,change_id)は、レシートがなければConflictです。レシートのIDを現在のordersから1回のSELECTで照会し、matching・drifted・missingの3つのキーに、IDのlistを入れて返します。存在しなければmissing、tenantが当時の顧客で、revision=承認時の値+1・qty=承認時の数量・state=cancelledがすべて合っていればmatching、それ以外はdriftedです。各listはID順で、レシートにないIDは入れません。読み取りだけを行い、現在の行や監査・レシートを変更しません。
  8. 応答を失った変更を、同じIDで再開します。change_file(dsn,change_id,tenant,targets,fault=None)は、psycopg.connect(dsn,autocommit=True,connect_timeout=2)で自分の接続を開き、audited_cancelの結果を返し、成功・失敗のどちらでも接続を閉じます。DSNは、検査ツールが渡す、信頼できる使い捨てのローカルDB接続設定です。最終検査では、コミット前後に実際のクライアントプロセスを終了させたあとの再接続、同じ変更の同時要求、実際の行ロック待ちのあとの条件の再評価を実行します。DBサーバー自体は終了しません。

参考

承認対象を正規化する

worker.pyに、Exceptionを継承するConflictと、validate_targets(targets)を実装してください。targetsは厳密なlistで、要素は1–16個です。各項目は、id・revision・qtyだけを持つ厳密なdictです。idはintで1–2147483647、revisionはintで0–2147483646、qtyはintで1–1000とし、いずれもboolを拒否します。重複IDと形式・範囲のエラーはValueErrorです。IDの昇順に並べた新しいlistと新しいdictを返し、入力は変更しません。

元のリストと返すリストは、内部のdictまで別のオブジェクトでなければなりません。空の承認を、何の効果もない成功として処理しないでください。

顧客と明示したIDで現在の状態を読む

preview(con,tenant,ids)は、定められた識別子tenantと、厳密なlistであるidsを受け取ります。idsは、重複のない厳密なintのID(1–2147483647)を1–16個含む必要があり、エラーはValueErrorです。ordersから、該当する顧客・明示したID・pending状態を1回のSELECTで読み、ID順のid・revision・qtyのdictのリストを返します。対象が1つでもなかったり、別の顧客・別の状態だったりしたらConflictです。返すデータもvalidate_targetsの契約を守り、DBは変更しません。

pendingの全体件数ではなく、明示した対象の集合を比較してください。承認当時になかった新しい注文を割り込ませません。

承認したバージョンの1行だけをキャンセルする

cancel_one(con,tenant,target)は、識別子と単一の承認項目を検証し、UPDATEでid・tenant・revision・qtyとpendingを同時に比較します。一致すればstate=cancelled、revision=revision+1に変更し、id・revision・qty・stateのdictを返します。返る行がなければConflictで、他の行は変更しません。単独の呼び出しは1つのトランザクションで完了し、後述のバッチ関数の中でも、外側のトランザクションを早期にコミットしてはいけません。

条件のないUPDATEの前のSELECTだけでは、競合を防げません。RETURNINGで、実際に変更した行を確認してください。

途中で衝突したら、先の変更もロールバックする

cancel_many(con,tenant,targets)は、すべての入力を先に検証し、ID順に各対象をキャンセルして、cancel_oneの結果のlistを返します。全体を1つの外側のトランザクションで処理し、1つでも失敗したら、先に変更した行までまとめてロールバックしたうえで、元のエラーを伝えます。監査やレシートは、この関数では作りません。入力と対照群は保全します。

1行の関数の成功は、バッチ全体の確定ではありません。入れ子のtransactionコンテキストとsavepointの関係を確認してください。

承認レシート・変更・監査をまとめて確定する

audited_cancel(con,change_id,tenant,targets,fault=None)は、2つの識別子と承認セットを検証します。1つのトランザクションの中で、changesにchange_id・tenant・正規化したtargetsを入れ、バッチ全体をキャンセルしたあと、fault('after-update')を呼び出します。各対象のid・元のrevision・新しいrevision(=元+1)・qtyを、同じchange_idのauditの行として入れてfault('after-audit')を呼び、全体のCOMMITのあとfault('after-commit')を呼び出して、Trueを返します。既存のchange_idに同じtenant・正規化したtargetsがあれば、変更なしでFalseを返し、内容が違えばConflictです。重複として返す場合は、faultを呼びません。同時に同じ要求が来ても、適用するのは1つだけです。faultは、渡されたときだけ呼び出します。コミット前のエラーは全体をロールバックし、コミット後のエラーは確定状態を保持して元のエラーを伝えます。

レシートの一意キーで同時要求を調整しつつ、衝突したキーの内容まで照合してください。レシートだけをコミットすると、実際の変更がない成功記録になります。

そのとき確定した承認内容を照会する

inspect_change(con,change_id)は、識別子を検証し、changesのレシートがなければNone、あればchange_id・tenant・targetsのdictを返します。targetsは保存された承認当時の内容であり、その後のordersの変更や戻り値の変更によって、保存されたレシートを変えてはいけません。この関数は、現在のordersの状態で承認内容を上書きして読むことはしません。

現在の状態と過去の確定記録は、別々の問いへの答えです。レシートの照会に業務状態を混ぜないでください。

同じ件数ではなく、同じ対象を確認する

reconcile(con,change_id)は、レシートがなければConflictです。レシートのIDを現在のordersから1回のSELECTで照会し、matching・drifted・missingの3つのキーに、IDのlistを入れて返します。存在しなければmissing、tenantが当時の顧客で、revision=承認時の値+1・qty=承認時の数量・state=cancelledがすべて合っていればmatching、それ以外はdriftedです。各listはID順で、レシートにないIDは入れません。読み取りだけを行い、現在の行や監査・レシートを変更しません。

数量が同じでも、revisionのほうが高ければ、その後の変更です。元のIDがなくなって新しいIDができたことを、件数で相殺しないでください。

応答を失った変更を、同じIDで再開する

change_file(dsn,change_id,tenant,targets,fault=None)は、psycopg.connect(dsn,autocommit=True,connect_timeout=2)で自分の接続を開き、audited_cancelの結果を返し、成功・失敗のどちらでも接続を閉じます。DSNは、検査ツールが渡す、信頼できる使い捨てのローカルDB接続設定です。最終検査では、コミット前後に実際のクライアントプロセスを終了させたあとの再接続、同じ変更の同時要求、実際の行ロック待ちのあとの条件の再評価を実行します。DBサーバー自体は終了しません。

コミット後に応答を紛失した場合は、レシートで再試行を判断します。接続を借りた関数と、接続を所有する関数を区別してください。