古い名札アプリを動かしたまま列を移行する
目標
エイリアン・フェスティバルの名札アプリを、nameからdisplay_nameへ移行します。古いアプリがまだ名前を変更している間も動くようにし、移行用のアプリまで退役したあとで、古い列を削除します。
なぜ重要なのか
列名を1つ変えるだけでも、同時に動いているアプリ・バッチ・参照ツールを壊すことがあります。拡張 → 互換の読み書き → バックフィル → 制約の強化 → 退役の確認 → 縮小を分けて練習します。先に、Pythonの関数・例外、SQLのトランザクション、前の条件付き変更のラボを学習してください。想定所要時間は130分です。期限が切れる前に+時間で延長し、最大180分以内に終えてください。セッションが終わるとファイルが消えます。必要なコードは別に保管してください。
環境と共通契約
成果物は/root/schema/worker.pyです。イメージのPython 3・PostgreSQL 16・psycopg 3.2.3を使い、インストール・ダウンロード・追加の権限は必要ありません。postgresユーザーで/rootに書き込めます。
採点ツールは、ローカルのlabdbの固有の一時スキーマに仮想の名札を準備し、自分が作ったスキーマだけを片付けます。渡されたconのsearch_pathとDSNを使ってください。publicのテーブルには触らず、スキーマ名・データ・IDをハードコードしないでください。テーブル・列・トリガーの名前は下の固定の契約で、SQLのデータの値はパラメータで渡します。
CREATE TABLE badges(id integer PRIMARY KEY, name text NOT NULL);
IDは、boolではない厳密なint 1–2147483647です。名前は1–100文字の厳密なstrで、前後の空白と、文字コード0–31・127を許可しません。ハングル・絵文字・シングルクォートは許可します。バックフィルのidsは1–16個の厳密なlistで、IDの重複は拒否します。誤った直接の入力はValueErrorで、SQLのエラーを任意の成功値に変えません。
すべてのconは借りた接続で、autocommit=True・Read Committedであり、外部からの呼び出しの開始時に開いているトランザクションはありません。接続を閉じず、成功・失敗のあとに開いたトランザクションも残しません。expand・install_bridge・backfill・add_guard・enforce・cutoverは、自分のトランザクションでlock_timeout=500ms・statement_timeout=2000msを適用し、元の接続の設定を成功・失敗のあとに復元します。上限は各SQLごとのもので、関数全体の時間の合計ではありません。これらの関数を、別の外側のトランザクションで包まないでください。cutover_fileだけが接続を所有します。
faultは、なければ省略し、あればステップで指定した文字列を引数にして呼びます。コミット前のフックのエラーは、その関数の変更全体をロールバックし、元のエラーを伝えます。after-commitのエラーは、すでに確定した変更を保持したまま伝えます。expand・install_bridge・add_guard・cutoverは、それぞれ前のステップのあとに1回だけ実行する関数です。成功したDDLの無条件の再実行は約束しません。コミット後に応答を失ったら、現在のスキーマとデプロイの記録を確認する必要があります。
互換トリガーの規則
BEFORE INSERTで、nameだけがあればdisplay_nameへ、display_nameだけがあればnameへコピーします。両方が同じ値ならそのまま許可し、両方がNULLの場合や互いに違う場合は、SQLSTATE 23514で拒否します。
BEFORE UPDATEで、nameだけが変わったなら新しいnameをdisplay_nameへ、display_nameだけが変わったなら新しいdisplay_nameをnameへコピーします。両方の値が変わったなら、互いに同じNULLでない値だけを許可します。どちらも変わっていないのに、display_nameがNULLの既存の行には、nameを埋めます。最終的に2つのフィールドのどちらかにNULLが残る場合や、値が互いに違う場合は、SQLSTATE 23514です。この規則は、有効な単一フィールドの書き込みを同期しますが、NULLで消すリクエストや、矛盾した2つの値を自動で訂正することはしません。
進める順序
拡張の前は、古いクライアントのnameの参照・書き込みが動きます。拡張のあとは、read_compatibleが既存の行を読みます。互換トリガーを設置したあとは、旧・新のクライアントの書き込みを同時に受けながら、対象ごとのバックフィルを行います。NOT VALIDの制約は、全体のバックフィルの前に追加することもできますが、enforceは、残りのNULLがなければ成功します。縮小の前には、実際のNOT NULLと、検証済みの制約、旧・新の名前の同一性まで、すべてが必要です。
legacy_retiredは、nameだけを書く古いアプリの、transition_retiredは、COALESCE(display_name,name)のような移行用の参照・バッチの、退役の承認です。最終アプリは、古い列をまったく参照しません。2つの承認フラグは、外部での確認の記録にすぎず、関数が、組織のすべてのアプリが終了したことを自動で証明するわけではありません。実際のサービスでは、所有者・デプロイされたバージョン・クエリの観測・ロールバック計画を、別に確認する必要があります。
ステップ
- 名札の入力の境界を決めます。Exceptionを継承するConflictと、request(person_id,name)を実装してください。下の入力の契約を検証し、id・nameの2つのキーを持つ新しいdictを返します。誤った入力はValueErrorで、勝手に空白を削ったり、数値に変換したりしません。
- 古い列を保全しながら新しい列を開きます。expand(con,fault=None)は、自分のトランザクションで制限時間を設定し、badgesにnullableなtext列display_nameを追加します。after-columnフック、実際のCOMMITのあとのafter-commitフックの順です。既存のid・name・行・制約を保全し、既存の行の新しい列はNULLです。rename・デフォルト値・即時のバックフィルは行いません。正常な戻り値はNoneです。
- バックフィルの前後を読む、移行用のアプリを作ります。read_compatible(con,person_id)は、IDを検証し、新しい列がNULLでなければ新しい名前を、NULLなら古い名前を返します。存在しないIDはNoneで、データは変更しません。この関数は、拡張のあとから古い列を削除するまでの、移行用のクライアントであり、最終クライアントではありません。
- 旧・新のアプリの書き込みを、あわせて受けます。install_bridge(con,fault=None)は、sync_badge_name()トリガー関数を作ってからafter-function、badge_compat BEFORE INSERT OR UPDATE FOR EACH ROWトリガーを作ってからafter-trigger、実際のCOMMITのあとafter-commitを呼びます。2つのオブジェクトの設置はアトミックで、既存の行はバックフィルしません。トリガーは、下の双方向の規則を守ります。正常な戻り値はNoneです。
- 同時の変更を上書きしないバックフィルを書きます。backfill(con,ids,fault=None)は、対象の一覧を書き込みの前に検証し、自分のトランザクションで制限時間を設定します。指定したIDのうち、現在display_nameがNULLの行だけを、DBの現在のnameで埋めます。変更したIDの昇順のlistを返します。変更のあとにafter-write、実際のCOMMITのあとにafter-commitを呼び、失敗したら今回のバックフィル全体をロールバックします。存在しないID・すでに移行済みのIDは飛ばします。同じ対象の再実行は、空のリストです。
- 既存の行の検査と、新しい書き込みの制限を分けます。add_guard(con)は、制限時間を設定した自分のトランザクションで、display_present CHECK(display_name IS NOT NULL) NOT VALIDを追加して、Noneを返します。enforce(con,fault=None)は、同じ制約をVALIDATEしてからafter-validate、実際の列にSET NOT NULLを実行してからafter-notnull、実際のCOMMITのあとafter-commitを呼んで、Noneを返します。残ったNULLは、元のDBの例外を伝え、自動ではバックフィルしません。
- 2つの世代の退役の承認があってはじめて、古い列を削除します。cutover(con,approvals,fault=None)は、legacy_retired・transition_retiredの2つのキーだけを持つ厳密なdictと、各値が正確にTrueであることを、書き込みの前に検証します。自分のトランザクションで制限時間を設定し、badgesのACCESS EXCLUSIVEロックを取得します。display_nameの実際のNOT NULL、display_presentの検証完了、すべての行のnameとdisplay_nameの一致を確認し、未完了ならConflictです。badge_compatとsync_badge_name()だけを削除してからafter-bridge-drop、name列だけを削除してからafter-column-drop、実際のCOMMITのあとafter-commitの順に呼びます。Trueを返し、既存のIDと新しい名前は保全します。
- 最終アプリと、DDL中のクライアント終了を検証します。read_current(con,person_id)は、IDを検証してdisplay_nameだけを参照し、名前、または存在しないIDならNoneを返します。write_current(con,person_id,name)は、入力を検証してから、自分のトランザクションで、そのIDのdisplay_nameだけを更新し、id・nameのdictを返します。存在しないIDはConflictで、追加はしません。cutover_file(dsn,approvals,fault=None)は、psycopg.connect(dsn,autocommit=True,connect_timeout=2)で接続を所有し、cutoverの結果・エラーを伝え、成功・失敗のどちらでも閉じます。最終アプリは、縮小の前後のどちらでも動作する必要があります。
参考
- 直接の診断: python3 -B /opt/lab/fixtures/schema/check.py 8 /root/schema/worker.py。数字を現在のステップに変えると、累積して検査します。1回の採点の上限は40秒です。
- 別の接続の実際の行ロックのもとで、バックフィルが最新の変更を保全するかを検査します。単に同じSQLの文字列を提出したかどうかは見ません。
- 古い列を削除すると、古いクライアントと移行用のクライアントは、実際にUndefinedColumnエラーが出る必要があります。最終クライアントは動き続ける必要があります。この違いを観察することも、学習の内容です。
- DDL中のクライアント終了は、トリガーの削除のあと・列の削除のあと・コミットのあとの3地点です。DBサーバーは終了しません。ラボイメージのfsyncとfull_page_writesは切られているので、電源障害への耐久性や、無停止のスループットを証明するものではありません。
- 運用DB・実際の個人情報・外部サービスは使わないでください。全体の過程は、受講生ごとの使い捨てのDBの中だけで実行します。
名札の入力の境界を決める
Exceptionを継承するConflictと、request(person_id,name)を実装してください。下の入力の契約を検証し、id・nameの2つのキーを持つ新しいdictを返してください。誤った入力はValueErrorにし、勝手に空白を削ったり、数値に変換したりしないでください。
SQLの構文の文字が入っている名前も、正常なデータです。値の検証と、SQLのパラメータ化を区別してください。
古い列を保全しながら新しい列を開く
expand(con,fault=None)は、自分のトランザクションで制限時間を設定し、badgesにnullableなtext列display_nameを追加してください。after-columnフック、実際のCOMMITのあとのafter-commitフックの順です。既存のid・name・行・制約を保全し、既存の行の新しい列はNULLにしてください。rename・デフォルト値・即時のバックフィルは行わないでください。正常な戻り値はNoneです。
別の接続のSELECTも、DDLを待たせることがあります。ロックを永遠に待たないようにしつつ、元の接続の設定は保全してください。
バックフィルの前後を読む、移行用のアプリを作る
read_compatible(con,person_id)は、IDを検証し、新しい列がNULLでなければ新しい名前を、NULLなら古い名前を返してください。存在しないIDはNoneにし、データは変更しないでください。この関数は、拡張のあとから古い列を削除するまでの、移行用のクライアントであり、最終クライアントではありません。
COALESCEの引数の順序と、SQLに残る列の参照を、あわせて見てください。最初の引数に値があっても、なくなった列を参照することはできません。
旧・新のアプリの書き込みを、あわせて受ける
install_bridge(con,fault=None)は、sync_badge_name()トリガー関数を作ってからafter-function、badge_compat BEFORE INSERT OR UPDATE FOR EACH ROWトリガーを作ってからafter-trigger、実際のCOMMITのあとafter-commitを呼んでください。2つのオブジェクトの設置はアトミックにし、既存の行はバックフィルしないでください。トリガーは、下の双方向の規則を守ってください。正常な戻り値はNoneです。
NULLの比較には、IS DISTINCT FROMが必要です。古い値と新しい値の変化の方向を区別し、矛盾した2つの値を黙って上書きしないでください。
同時の変更を上書きしないバックフィルを書く
backfill(con,ids,fault=None)は、対象の一覧を書き込みの前に検証し、自分のトランザクションで制限時間を設定してください。指定したIDのうち、現在display_nameがNULLの行だけを、DBの現在のnameで埋めてください。変更したIDの昇順のlistを返してください。変更のあとにafter-write、実際のCOMMITのあとにafter-commitを呼び、失敗したら今回のバックフィル全体をロールバックしてください。存在しないID・すでに移行済みのIDは飛ばしてください。同じ対象の再実行は、空のリストにしてください。
SELECTで古い名前をアプリに保管してからUPDATEすると、待機中に確定した変更を失うおそれがあります。UPDATEの現在のNULLの条件と、DBの中での値のコピーを考えてください。
既存の行の検査と、新しい書き込みの制限を分ける
add_guard(con)は、制限時間を設定した自分のトランザクションで、display_present CHECK(display_name IS NOT NULL) NOT VALIDを追加して、Noneを返してください。enforce(con,fault=None)は、同じ制約をVALIDATEしてからafter-validate、実際の列にSET NOT NULLを実行してからafter-notnull、実際のCOMMITのあとafter-commitを呼んで、Noneを返してください。残ったNULLは、元のDBの例外を伝え、自動ではバックフィルしないでください。
NOT VALIDは、制約が無効になるという意味ではありません。既存の行の検査の完了と、pg_attributeの実際のNOT NULLを、それぞれ確認してください。
2つの世代の退役の承認があってはじめて、古い列を削除する
cutover(con,approvals,fault=None)は、legacy_retired・transition_retiredの2つのキーだけを持つ厳密なdictと、各値が正確にTrueであることを、書き込みの前に検証してください。自分のトランザクションで制限時間を設定し、badgesのACCESS EXCLUSIVEロックを取得してください。display_nameの実際のNOT NULL、display_presentの検証完了、すべての行のnameとdisplay_nameの一致を確認し、未完了ならConflictにしてください。badge_compatとsync_badge_name()だけを削除してからafter-bridge-drop、name列だけを削除してからafter-column-drop、実際のCOMMITのあとafter-commitの順に呼んでください。Trueを返し、既存のIDと新しい名前は保全してください。
行数が同じだからといって、名前が同じとは限りません。承認の確認とデータの確認とDDLを、分けてコミットしないでください。
最終アプリと、DDL中のクライアント終了を検証する
read_current(con,person_id)は、IDを検証してdisplay_nameだけを参照し、名前、または存在しないIDならNoneを返してください。write_current(con,person_id,name)は、入力を検証してから、自分のトランザクションで、そのIDのdisplay_nameだけを更新し、id・nameのdictを返してください。存在しないIDはConflictにし、追加はしないでください。cutover_file(dsn,approvals,fault=None)は、psycopg.connect(dsn,autocommit=True,connect_timeout=2)で接続を所有し、cutoverの結果・エラーを伝え、成功・失敗のどちらでも閉じてください。最終アプリは、縮小の前後のどちらでも動作する必要があります。
DDL中に、クライアントのプロセスを実際に終了させます。コミットの前なら全体を復元し、コミットのあとなら新しいスキーマを維持します。応答の消失を、無条件の再呼び出しで処理しないでください。