12. スキーマ進化と field ID
samples/02_schema_evolution.py は、列の追加・改名・型変更・削除を順に実行し、そのたびにスキーマの中身を表示します。注目するのは列名ではなく field ID です。
Iceberg のスキーマ進化がデータファイルの書き直しなしに成立するのは、列の同一性を名前ではなく field ID で管理しているからです。ここではその不変性を実際に確認します。
確かめたいこと
- 列を追加したとき、既存行の値がどうなるか
- 列を rename したとき field ID が変わるか
- 許可される型プロモーションと、許可されない変更の挙動
- 列を削除したときデータファイルがどうなるか
- 過去のスキーマがテーブルに残るか
背景
Iceberg 仕様は、データファイルの列の解決方法について次のように定めています(Table Spec, Schemas and Data Types の項)。
Columns in Iceberg data files are selected by field id. The table schema’s column names and order may change after a data file is written, and projection must be done using field ids.
改名については同じく次のとおりです。
Renaming an existing field must change the name, but not the field ID.
つまり Parquet ファイル側は field ID で列を持ち、読み手はテーブルスキーマの field ID を使って射影します。名前はスキーマ側のラベルにすぎません。これが、rename でデータファイルを書き換えなくてよい理由です。
追加した列を既存ファイルが持っていない場合、読み手はその field ID を見つけられず null を返します。列の削除も、スキーマから field ID を外すだけで、データファイル側には手を触れません。
型プロモーションは、既存のバイト列を再解釈できる方向にだけ許されます。int から long への拡大は v1/v2/v3 すべてで許可されますが、逆方向は値が失われる可能性があるため拒否されます。
なお PyIceberg の update_schema() は Iceberg 型を取ります。pa.string() のような PyArrow 型を渡すと NotImplementedError: Cannot visit non-type になります。
処理の流れ
lab.evolveをid(long, required)とname(string, optional)で作成し、2 行入れます。add_columnでemailを追加し、既存行を読み直します。
with tbl.update_schema() as us:
us.add_column("email", StringType(), doc="メールアドレス")rename_columnでnameをfull_nameに変え、rename 前後の field ID を突き合わせます。
before = {f.name: f.field_id for f in tbl.schema().fields}
with tbl.update_schema() as us:
us.rename_column("name", "full_name")
after = {f.name: f.field_id for f in tbl.schema().fields}
assert before["name"] == after["full_name"], "field ID が変わってしまいました"IntegerTypeのscore列を追加してからLongTypeに変更し、続けてIntegerTypeに戻そうとします。後者は失敗するはずなので例外を捕まえて表示します。delete_columnでscoreを削除します。tbl.metadata.schemasを列挙し、保持されているスキーマの数と内容を表示します。
実測結果
初期スキーマと列追加後のスキーマです。
--- 初期スキーマ
[initial]
field-id=1 id long required
field-id=2 name string optional
--- 列を追加します (add_column)
[after add_column]
field-id=1 id long required
field-id=2 name string optional
field-id=3 email string optional
追加された email には新しい field ID 3 が割り当てられ、既存の 1 と 2 は動きません。既存 2 行を読むと email は null になりました。
id name email
1 alice None
2 bob None
データファイルは追加時のまま(id と name の 2 列)で、読み手が field ID 3 を見つけられず null を埋めています。
rename の結果です。
[after rename_column]
field-id=1 id long required
field-id=2 full_name string optional
field-id=3 email string optional
rename 前の 'name' の field-id : 2
rename 後の 'full_name' の field-id: 2
field ID は 2 のまま変わっていません。rename 後も既存 2 行はそのまま読めました。
型プロモーションは int → long が成功しました。
[after int -> long promotion]
field-id=1 id long required
field-id=2 full_name string optional
field-id=3 email string optional
field-id=4 score long optional
逆方向は拒否されました。
--- 許可されていない型プロモーションを試します (long -> int)
期待どおり拒否されました: ValidationError
Cannot change column type: score: long -> int
エラーはコミット後にサーバ側から返るのではなく、PyIceberg の ValidationError としてクライアント側で発生しています。
列削除後は score がスキーマから消えました。
[after delete_column]
field-id=1 id long required
field-id=2 full_name string optional
field-id=3 email string optional
スキーマの履歴は次のとおりです。
--- スキーマの履歴
テーブルが保持するスキーマ数: 5
schema-id=0: ['id', 'name']
schema-id=1: ['id', 'name', 'email']
schema-id=2 (current): ['id', 'full_name', 'email']
schema-id=3: ['id', 'full_name', 'email', 'score']
schema-id=4: ['id', 'full_name', 'email', 'score']
5 回の変更に対して 5 個のスキーマが残っています。ここで 2 点、読み取れることがあります。
1 つは current が schema-id=2 になっている点です。score を削除した結果、列構成が rename 直後の schema-id=2 と一致したため、新しいスキーマを作らず既存のものが再利用されたとみられます(実装の詳細は未確認)。スキーマ ID は単調に進むとは限りません。
もう 1 つは schema-id=3 と 4 の列名が同一に見える点です。両者の違いは score の型(int と long)で、列名の一覧だけでは区別できません。型を含めて比較しないと同じスキーマに見えます。
ここから分かること
rename と列削除は、データファイルの書き直しを伴いません。TB 級のテーブルでも列名の変更はメタデータ操作で完結します。Hive テーブルで列名の変更が事実上できなかったのと対照的です。
一方、名前が単なるラベルであることには裏返しがあります。a を消して、あとから同じ名前 a の列を追加すると、新しい field ID が振られ、古いデータファイルの値は読めません。名前が同じでも別の列です。列の入れ替えを名前ベースで考えると認識を誤ります。
過去のスキーマがすべて保持されるのは、古いスナップショットを読むときに当時のスキーマが必要だからです。スキーマ変更を繰り返せばメタデータ JSON はその分大きくなります。頻繁なスキーマ変更は、スナップショットの蓄積と同じくメタデータの肥大要因になります。
型プロモーションはクライアント側で検証され、許可されない変更はコミットに至りません。ただし許可される範囲は format version によって拡張されており、どの変換が使えるかはテーブルの version に依存します。
前は 11. 基本的な CRUD と既定値、次は 13. パーティション進化と hidden partitioning です。