14. タイムトラベルと branch / tag

Iceberg のテーブルは、コミットのたびにスナップショットを積み上げます。古いスナップショットはコミット後も残り続けるため、過去の任意の時点の状態をそのまま読めます。この仕組みの上に、名前付き参照である branch と tag が乗っています。

ここでは samples/04_time_travel.py を使い、3世代のスナップショットを作ってから、ID 指定の読み取り、tag と branch の作成、ロールバックまでを一通り実行します。あわせて、tag を打つとスナップショットの寿命が延びるという運用上の副作用も確認します。

確かめたいこと

  • スナップショット ID を指定すると過去の状態が読めること
  • tag と branch がテーブルの refs にどう現れるか
  • tag / branch から参照されたスナップショットは expire の対象外になること
  • ロールバック後、捨てられたコミットが履歴上どう見えるか

背景

Iceberg のテーブルメタデータは refs というマップを持ち、名前を snapshot ID に対応づけます。branch は移動する参照、tag は固定された参照です。仕様には次の記述があります。

There is always a main branch reference pointing to the current-snapshot-id even if the refs map is null.

The main branch never expires.

つまり main は特別扱いで、明示的に refs へ書かれていなくても存在し、期限切れにもなりません。

もうひとつ、branch はデータの分岐であってスキーマの分岐ではありません。仕様は次のように述べています。

the schema tracked for a table is valid across all branches

スキーマはテーブル全体で1つです。ブランチごとに異なるスキーマを持たせることはできません。

保持の観点では、branch や tag から参照されているスナップショットは expire されません。tag は「消えてほしくないスナップショットに錨を打つ」操作であり、同時に「そのスナップショットが参照するデータファイルを解放できなくする」操作でもあります。

処理の流れ

  1. lab.versioned テーブルを作り直し、3回に分けて append します。各 append の間に time.sleep(1) を挟み、コミットのタイムスタンプを分離します。

  2. 各世代の snapshot ID を控えたうえで、inspect.snapshots() で履歴を表示します。

  3. スナップショット ID を指定してスキャンします。

for label, sid in (("世代1", snap1), ("世代2", snap2), ("世代3", snap3)):
    n = tbl.scan(snapshot_id=sid).to_arrow().num_rows
  1. 世代2 に tag を打ち、inspect.refs() で refs の中身を確認します。
with tbl.manage_snapshots() as ms:
    ms.create_tag(snapshot_id=snap2, tag_name="release-2026-07")
  1. 同じく世代2 を指す audit ブランチを作ります。Write-Audit-Publish は、この監査用ブランチに書いて検証してから main へ fast-forward する運用です。ただし fast-forward は Spark の procedure であり、PyIceberg には該当機能がありません。

  2. 世代1 へロールバックし、inspect.history()is_current_ancestor を確認します。

tbl.manage_snapshots().rollback_to_snapshot(snap1).commit()

実測結果

3世代のスナップショットは次のようになりました。

--- 3回に分けてデータを書き、3つのスナップショットを作ります
    世代1: snapshot_id=7742509384658575965, 行数=1
    世代2: snapshot_id=3695037040879739491, 行数=3
    世代3: snapshot_id=1545959734487094455, 行数=4

履歴では parent_id が1つ前の snapshot ID を指しており、コミットが線形に連なっていることが読み取れます。

--- スナップショット履歴
           committed_at         snapshot_id    parent_id operation
2026-07-18 13:49:32.679 7742509384658575965          NaN    append
2026-07-18 13:49:33.789 3695037040879739491 7.742509e+18    append
2026-07-18 13:49:34.894 1545959734487094455 3.695037e+18    append

スナップショット ID を指定した読み取りは、それぞれの時点の行数を返します。

--- スナップショット ID でタイムトラベル
    世代1 (id=7742509384658575965): 1 行
    世代2 (id=3695037040879739491): 3 行
    世代3 (id=1545959734487094455): 4 行

tag と branch を作ったあとの refs は次のとおりです。main は最新の世代3 を、audit ブランチと release-2026-07 タグは世代2 を指しています。

           name   type         snapshot_id  max_reference_age_in_ms  min_snapshots_to_keep  max_snapshot_age_in_ms
           main BRANCH 1545959734487094455                      NaN                    NaN                     NaN
          audit BRANCH 3695037040879739491                      NaN                    NaN                     NaN
release-2026-07    TAG 3695037040879739491                      NaN                    NaN                     NaN

保持設定の列がすべて NaN であることに注意してください。参照ごとの保持ポリシー(max-reference-age-ms など)は指定していないため、これらの参照は既定で無期限に残ります。

ロールバックは行数を4行から1行に戻しました。

--- 世代1 にロールバックします
    ロールバック前: 4 行
    ロールバック後: 1 行

ロールバック後の履歴には、捨てられたコミットが is_current_ancestor=False として残っています。

        made_current_at         snapshot_id    parent_id  is_current_ancestor
2026-07-18 13:49:32.679 7742509384658575965          NaN                 True
2026-07-18 13:49:33.789 3695037040879739491 7.742509e+18                False
2026-07-18 13:49:34.894 1545959734487094455 3.695037e+18                False
2026-07-18 13:49:35.140 7742509384658575965          NaN                 True

最終行は「世代1 を再び current にした」というエントリです。ロールバックはスナップショットの削除ではなく、参照の付け替えとして記録されます。世代2・世代3 のスナップショットは消えていません。しかもこの実行では世代2 に tag と branch が付いているため、仮に expire を回しても世代2 は残ります。

ここから分かること

タイムトラベルが成立するのは、古いデータファイルがスナップショットから参照され続けているからです。裏返すと、expire_snapshots を実行しない限りストレージは解放されません。ロールバックしても容量は減りません。

tag の運用コストは見落とされやすい点です。tag を1つ打つと、そのスナップショットと、それが参照するデータファイル一式が保持対象になります。参照ごとの保持ポリシーを設定しなければ、これは無期限です。

マネージドサービスではさらに影響が広がる場合があります。Amazon S3 Tables の公式ドキュメントは、テーブルにユーザー定義の tag や branch が存在すると、そのテーブル全体で snapshot management が失敗すると述べています。tag や branch 側の retention 期間が短くても同様とされています。この場合、自動メンテナンスの停止は例外としては現れず、課金の増加としてしか観測できません。

WAP を PyIceberg だけで完結させることはできません。branch の作成と書き込みまではできますが、main への fast-forward は Spark procedure です。ブランチ運用を前提にするなら、Spark を含む構成を最初から想定しておく必要があります。PyIceberg 側の機能範囲については 17. PyIceberg の制約を実証する を参照してください。メタデータ上で snapshot と refs がどう格納されているかは 15. メタデータ三層構造を覗く で扱います。