Guide / Reinhardt 0.4.0-alpha.13; moonc 0.10.11+6ff76a5f9

好きな言語でDBマイグレーションシステムを作るには?

この記事の内容

好きな言語でDBマイグレーションシステムを作る仕組みを調べます。 この動画ではMoonBitを実装例に使い、ReinhardtのMigrationの順序と実行の規則を再現します。小さく動く核を作り、好きな言語へ移す際に保つデータ構造と挙動の検査を説明します。

RustとMoonBitの実験をダウンロード。展開してframework-internals/README.mdに従ってください。Rust 1.96.0、Moon CLI 0.1.20260827、moonc 0.10.11+6ff76a5f9、Node 22.22.1、Python 3.12、ネイティブのCツールチェーンで検証しています。初回ビルドでは依存を取得します。SQLiteの実験はそれぞれ別のメモリ内DBを使い、サーバーは不要です。

cd framework-internals
python3 verify.py

適用順序と失敗時の境界を調べる

スキーマ変更が2つあり、最初がusers、そのusersに依存するpostsが次だとします。 変更を逆順に渡してもReinhardtは依存順序を求め、MoonBitでもその動作を再現できます。 実際の実行器をたどり、グラフ、トランザクション、履歴の記録を作り直します。

調べると境界も見えてきます。このリリースでは、履歴の記録より先にスキーマ変更をコミットします。 Reinhardt 0.4.0-alpha.13を使い、実際のDBエラーでその順序を確かめます。

依存関係が適用順序を決める

Migrationは、名前付きの変更、操作、その前に必要な変更を記述します。 Rustの例ではReinhardtのMigrationを使い、postsにusersへの依存を設定します。 グラフは、入力リストの順序にかかわらず、依存先を依存する変更より前に置く必要があります。

usersもpostsに依存すると有効な順序がなくなり、循環として拒否されます。 MoonBitでは、各変更を実行順に加える前に依存先をたどって再現します。 訪問中の集合で循環を検出し、依存先がない場合も、前提を黙って飛ばさずエラーにします。

実行可能なソースからの抜粋です。プログラム全体はダウンロードに含めています。

let mut graph = MigrationGraph::new();
let user_key = MigrationKey::new("demo", "001_users");
let post_key = MigrationKey::new("demo", "002_posts");
graph.add_migration(post_key.clone(), vec![user_key.clone()]);
graph.add_migration(user_key.clone(), vec![]);
assert_eq!(
    graph.topological_sort()?,
    vec![user_key.clone(), post_key.clone()]
);

実際の実行器からDB処理をたどる

実際のDatabaseMigrationExecutorは適用順序を求め、すでに記録されているMigrationを確認します。 記録済みの変更を飛ばし、新しい変更を操作の計画処理とスキーマエディターへ渡します。 今回は範囲を限定し、ReinhardtのRunSQLで、信頼する適用用SQLと明示した逆向きのSQLを使います。

このSQLiteのMigrationでは、スキーマエディターが1つのMigrationの操作をトランザクションに入れられます。 トランザクションはMigrationごとなので、後の失敗で、それ以前に完了した変更が消えるわけではありません。 別のバックエンドや操作については、それぞれソースと実行結果を確認する必要があります。

SQL操作は信頼するソースコードです。この例では利用者から任意のMigrationを受け付けず、ファイルの拡張子から安全なトランザクション方針を推測することもしません。

実行可能なソースからの抜粋です。プログラム全体はダウンロードに含めています。

fn migration(name: &str, sql: &str, reverse: &str) -> Migration {
    Migration::new(name, "demo").add_operation(Operation::RunSQL {
        sql: sql.into(),
        reverse_sql: Some(reverse.into()),
    })
}

適用順序・再試行・ロールバックを検証する

両方の実装を、それぞれ別のメモリ内DBで動かします。 postsをusersより前に渡しても、両方ともusers、postsの順で適用します。 2度目の適用では、両方の履歴が記録されているため、新しい処理をしません。

3つ目のMigrationでscratchを作り、意図的に存在しないテーブルへ書き込みます。 その段階は失敗し、scratchは消えますが、先に作ったusersとpostsは残ります。 ロールバックではusers、postsの順に渡し、実行器がそのリストを逆にして、postsをusersより先に削除します。

ロールバック時に入力順を逆にすることを、任意の依存グラフを並べ直す保証と混同しないでください。

Rust / Reinhardt

input=posts,users executed=users,posts second_run=0
failed_step_rolled_back=true earlier_migrations_preserved=true
rollback=posts,users cycle=rejected
recorder_failure_schema_present=true history_present=false
PASS reinhardt-db=0.4.0-alpha.13; graph, executor and recorder

MoonBit

input=posts,users executed=users,posts second_run=0
failed_step_rolled_back=true earlier_migrations_preserved=true
rollback=posts,users cycle=rejected
recorder_failure_schema_present=true history_present=false
PASS MoonBit migration graph, per-step transaction and post-commit recorder

履歴記録の境界を忠実に再現する

MoonBitの計画には、名前、依存関係、適用用と逆向きのSQLの配列を持たせます。 記録用テーブルでは、アプリ名とMigration名で適用した変更を識別します。 1つのMigrationの操作をトランザクションの関数内で実行し、その後で履歴を挿入します。

この順序は固定した実行器の呼び出し順序履歴の記録処理に従い、apply_migrationの完了後にrecord_appliedを呼びます。 履歴を同じトランザクションに含める設計も考えられますが、それでは調査対象の実装を変えてしまいます。 そのため、再現実装でも境界を明示し、実行できる失敗時の検証を用意します。

履歴テーブルは記録した内容を示し、スキーマの確認は実際に存在するものを示します。この2つは別の観測であり、失敗によって食い違う場合があります。

実行可能なソースからの抜粋です。プログラム全体はダウンロードに含めています。

db.transaction(() => {
  for sql in migration.forward {
    db.batch(sql)
  }
})
// Fidelity matters: alpha.13 records AFTER the schema transaction commits.
db.exec("INSERT INTO reinhardt_migrations(app, name) VALUES (?, ?)", [
  "demo",
  migration.name.to_json(),
])
applied.push(migration.name)

履歴の書き込みを失敗させる

Migrationの履歴テーブルへの新しい行を拒否するDBトリガーを設定します。 次のMigrationは新しいテーブルの作成に成功し、履歴を記録するときに、そのトリガーで拒否されます。 両方とも失敗を返しますが、新しいテーブルは存在し、適用履歴の行はありません。

この観測結果から、検証した経路ではスキーマと履歴が1つの原子的な単位ではないと確認できます。 再試行ではその状態を考慮する必要があり、すべての失敗で両方が元に戻るという説明は正確ではありません。 対象は固定したリリースのSQLiteのSQL操作であり、すべてのMigration機能や将来のリリースではありません。

再現実装から具体的な検証課題を見つける

ダウンロードにはグラフの検証、DBの失敗例、実際のReinhardtのコマンドを含めています。 トランザクションの境界を変更する提案の前に、実行器と記録処理を並べて読んでください。 再現実装では、スキーマの自動検出、統合されたMigrationの置き換え、ロック、バックエンド固有のオンライン操作を省いています。

また、この記録処理の実験で確認していないチェックサムや、不変な先頭部分を求める規則も加えていません。 結果を再現して評価できるよう、報告には正確なバージョンと失敗の証拠を添えてください。 メモリ内の検証では2回目にも接続を再利用しており、プロセスのクラッシュやサーバー再起動を再現したとは主張しません。

Reinhardt Pressは、調査を追いやすくするためにMoonBitの実装を使っています。

好きな言語へ仕組みを持ち帰る

好きな言語で、Migrationのレコード、依存のリスト、順序付きの操作、履歴の検索を定義し、DBドライバーのトランザクションAPIへつなぎます。

Reinhardtを忠実に再現するなら、スキーマのコミットを履歴記録より先に置き、その前後でエラーを検査します。この順序は今回の実装の契約で、あらゆるMigrationに共通の規則ではありません。

ソースの対応と検証の限界

PORTING.mdに、固定したフレームワークの関数と実行できるMoonBit実装の対応、および省略した機能を記載しています。ランタイムの表現は異なり、完全な移植ではなく範囲を限定した調査です。Node 22のSQLite APIではexperimentalの警告が出ます。

Reinhardt contribution guide.