Skip to content
Published on

Iceberg v3 ロウリネージ — 行IDはファイルに保存されない

シェア
Authors

はじめに — v3ではロウリネージをオフにできない

Apache Icebergスペック原文の「Format Versioning」節はこう始まります — バージョン1、2、3は完了しコミュニティに採用済みであり、バージョン4は活発に開発中でまだ正式採用されていない。つまりv3はもはや未来形ではありません。Javaライブラリはすでにv4まで読み書きできるよう定数が設定されています(SUPPORTED_TABLE_FORMAT_VERSION = 4)。

v3が追加したものをリストにするとこうなります — ナノ秒タイムスタンプ、unknownvariantgeometrygeography型、カラムのデフォルト値、多引数パーティション変換、ロウリネージ追跡、バイナリ削除ベクター、テーブル暗号化キー。このうち削除ベクターはよく語られてきました。本稿は比較的語られてこなかった、そしてはるかに静かにパイプラインを噛む可能性があるロウリネージを見ていきます。

まず知っておくべき事実が一つ。ロウリネージはオン・オフを切り替えるテーブルプロパティではありません。Icebergコアの判定ロジックはこれがすべてです。

public static boolean supportsRowLineage(Table table) {
  Preconditions.checkArgument(null != table, "Invalid table: null");
  if (table instanceof BaseMetadataTable) {
    return false;
  }

  return formatVersion(table) >= TableMetadata.MIN_FORMAT_VERSION_ROW_LINEAGE;
}

MIN_FORMAT_VERSION_ROW_LINEAGEは3です。つまりフォーマットバージョンが3以上であれば、ロウリネージはオンになっています。初期設計にはこれをオンにするメタデータフィールドがありましたが削除され("Core: Enable row lineage for all v3 tables"、PR #12593)、スペックは断定的にこう書いています — v3以上において、Icebergテーブルは新しく作成されるすべての行についてロウリネージフィールドを必ず追跡しなければならない。v3に上げた瞬間、そのテーブルに書き込むすべてのエンジンがリネージ維持の義務を負います。その機能を望んだかどうかにかかわらず。

逆に安心できる事実も一つ。同じファイルの中でDEFAULT_TABLE_FORMAT_VERSIONは依然として2です。2026年7月時点のmainブランチでもそうです。新しいテーブルを作ってもv3にはなりません — v3は明示的に選ばないと入れない扉です。

ロウリネージが与えるもの

v3は2つの予約済みメタデータカラムを定義します。

フィールドID名前意味
2147483540_row_idlongテーブル内で行ごとに一意な識別子
2147483539_last_updated_sequence_numberlongその行を最後に更新したコミットのシーケンス番号

核心は_row_id行のアイデンティティだという点です。行がUPDATEされても、コンパクションで別のファイルに移されても、パーティションが変わっても、同じ_row_idを維持しなければなりません。物理的な位置と論理的なアイデンティティを分離すること — これがなければ「この行がいつ初めて入り、その間に何回変わったか」を、テーブルの外に別の台帳を持たずには答えられません。

ここに_last_updated_sequence_numberが加わると、「シーケンス番号N以降に変わった行だけをくれ」がテーブルメタデータだけで表現できます。増分処理とCDCを外部ツールなしに構築する土台がこれです。

核心の設計 — 値を書かずに継承する

ここがこの機能のすべてです。_row_idはデータファイルに保存されません。

新しい行を書くとき、writerは_row_id_last_updated_sequence_numbernullのままにします。カラム自体を省略してもかまいません — スペックはカラムがなければreaderが「すべての行でnullであるカラムが存在するかのように」扱えと定めています。実際の値は読み取り時に計算されます。

なぜこうするのかについては、スペック自身が答えを書いています — コミットのシーケンス番号と開始行IDは、スナップショットが正常にコミットされるまで確定しないからです。Icebergのコミットは楽観的並行性制御です。2つのwriterが同時にコミットを試みると片方は負け、負けた側は最新のスナップショットの上で再試行します。もし行IDをデータファイルの中に埋め込んでいたら、コミットが再試行されるたびに、すでに書き終えたデータファイルとマニフェストファイルを全部書き直す必要があります。書き込みが大きいほど、衝突1回のコストがそのまま膨らむ構造です。スペックの表現どおり、継承は値が確定する前にファイルを書けるようにし、楽観的コミットが再試行されてもファイルを書き直さなくて済むようにするためにあります。

代償ははっきりしています。値がないのだから、値を作り出すチェーンが正確でなければなりません。 チェーンはこう下っていきます。

テーブルメタデータ   next-row-id            (次に配るIDの起点)
   └─ スナップショット first-row-id         = コミット時点のテーブルのnext-row-id
        └─ マニフェスト  first_row_id       = 先行するマニフェストの行数ぶんずれた値
             └─ データファイル first_row_id = 先行するファイルのrecord_countぶんずれた値
                  └─ 行       _row_id       = データファイルのfirst_row_id + _pos

一番下の一行が結論です — 行のIDは、その行が入っているデータファイルのfirst_row_idにファイル内の行位置(_pos)を足した値です。IDを保存する代わりに、ID空間を区間として予約して配り、位置でオフセットを取る構造です。

スペックの例をたどってみる

スペックに載っている例がこのチェーンをよく示しています。next-row-idが1000のテーブルから始めます。appendスナップショットはfirst-row-idとして、テーブルの現在のnext-row-idである1000を受け取ります。

マニフェストリストを書くとき、各マニフェストにfirst_row_idが割り当てられます。

manifest_pathadded_rows_countexisting_rows_countfirst_row_id
existing750925
added1100251000
added201001125
added3125251225

added1はスナップショットと同じ1000を受け取り、それ以降は先行するマニフェストの行数(追加行 + 既存行)ぶんずれていきます。1000 + 125 = 1125、1125 + 100 = 1225。

ここでスペックが指摘する落とし穴が一つ — added2は追加されたデータファイルが一つもないにもかかわらず、次のマニフェストのfirst_row_idを100ぶんずらしています。first_row_idを持たないデータファイルであれば、たとえEXISTING状態でもIDを割り当てられ得るため、その分の空間をあらかじめ空けているのです。

最初のマニフェストadded1の中のデータファイルはこうなります。

statusfile_pathrecord_countfirst_row_id
EXISTINGdata125800
ADDEDdata250null → 1000
ADDEDdata350null → 1050

data1はすでに以前800を割り当てられているので、その値がそのままコピーされます。data2data3nullのまま書かれており、読み取り時にマニフェストのfirst_row_id(1000)を起点として先行ファイルのrecord_countぶんずれ、それぞれ1000と1050を受け取ります。したがってdata3の3行目の_row_idは1050 + 2 = 1052です。

コミットが終わると、テーブルのnext-row-idが更新されます。スペックの義務条項は「そのスナップショットで新たに割り当てられた行ID数以上」ずらすことであり、推奨される計算はfirst_row_idを割り当てられたマニフェストすべてのadded_rows_countexisting_rows_countを合計することです。例ではその合計が375(added1の125 + added2の100 + added3の150)なので、next-row-idは1000 + 375 = 1375になります。

ここでEXISTINGの行もID空間を消費する点に注目してください。すでに自分のIDを持っていて新しいIDを受け取らない行なのに、範囲は予約されます。義務は下限しか定めず、推奨計算はそれより余裕をもって取るため、ID空間には穴が空くのが正常です。_row_idは一意ではあっても密ではありません — 行数を数えたり連続性を仮定したりする用途には使えません。

読み取り時のルール

reader側のルールはスペック付録Eに明記されています。

  • データファイルのfirst_row_idがnon-nullなら、readerはnullまたは存在しない_row_idfirst_row_id + _posで埋める。
  • 同様に、nullまたは存在しない_last_updated_sequence_numberを、そのデータファイルのdata_sequence_numberで埋める。
  • すでにnon-nullな値は手を触れずそのまま読む
  • データファイルのfirst_row_idがnullなら、readerは_row_id_last_updated_sequence_numberをnullとして出力する。

3番目のルールが重要です。値がすでにあるならそれは「移されてきた行」だという意味で、そのアイデンティティを上書きしてはいけません。

ここからが本題 — リネージが途切れる場所

コンパクションはリネージを殺す

行が別のデータファイルへ移されるとき(コンパクション、リライト、パーティション変更など理由は問わず)、writerが守るべきルールはこうです。

  1. 行の既存のnon-nullな_row_idは新しいデータファイルへコピーされなければならない
  2. その書き込みが行を修正したなら、_last_updated_sequence_numbernullのままにする(修正時点のシーケンス番号が継承されるように)。
  3. 修正していないなら、既存のnon-nullな_last_updated_sequence_numberをそのままコピーする。

言葉にすれば単純ですが、実装の立場からすると厄介です。コンパクションはもともと「データを読んで大きなファイルに書き直す」だけの単純な作業でした。ところがv3では、コンパクションジョブが隠れたメタデータカラム2つを一緒に読み、一緒に書かなければなりません。単純に書き直すと_row_idが全部新しく発行され、テーブルのすべての行が「たった今初めて生まれた行」になってしまいます。リネージの上に構築されたCDCパイプラインは全件再処理をすることになります。静かに、エラー一つ出さずに。

これが理論上の懸念ではないという証拠は、コミットログにすべて残っています。後ろで見ていきます。

equality deleteはリネージを追跡しない

スペックは明示的に例外を設けています — equality deleteで修正された行についてはロウリネージを追跡しません。理由もスペックに書かれています。equality deleteを使うエンジンはそもそも既存データを読まずに変更を書き込むため、新しい行に元のrow IDを入れてやることができません。そのためこうした更新は、既存の行が完全に削除され、唯一無二の新しい行が追加されたものとして扱われます。

これは大きな話です。Flinkのupsertストリーミングのようにequality deleteに依存するワークロードなら、v3に上げても行のアイデンティティはUPDATEを越えられません。ロウリネージをCDCの基盤として使う計画は、まさにここで半分ほど崩れることになります。

アップグレードしたテーブルの過去は永遠にnull

v2テーブルをv3に上げると、next-row-idは0に初期化され、既存のスナップショットは変更されません。first-row-idを持たないそれらのスナップショットでは、データファイルとマニフェストのfirst_row_idがnullであり、したがってすべての行の_row_idはnullとして読み取られます。スペックの表現どおり — アップグレード前に作られたスナップショットには行IDがありません。

アップグレード後の最初のコミットで、既存ファイルにもIDは割り当てられます。しかしそれは「そのファイルがたった今IDを受け取った」という意味であり、過去の履歴が復元されたという意味ではありません。ロウリネージは遡及しません。

ブランチごとにID範囲が分かれる

スペックが静かに残した脚注が一つあります。アップグレード後、異なるブランチの新しいスナップショットは、コミット時点のnext-row-idに応じて、既存のデータファイルに互いに重ならないID範囲を割り当てます。複数のブランチにまたがるデータファイルに対して、writerは別ブランチのfirst_row_idを再利用することもあれば、大規模なメタデータ書き換えを避けるために新しいfirst_row_idを割り当てることもあります。

つまりブランチを使うテーブルでは、_row_idがブランチ間で同じ行を指すとは保証されません。_row_idを結合キーとして使おうとしていたなら、その計画はここで止めるべきです。

エンジン現実チェック — スペックの「must」と実装の距離

ここが本稿を書いた理由です。ベンダーブログは「v3対応」をチェックボックスとして描きます。実際のコードは違う話をします。以下は2026年7月16日時点で、各リポジトリのmain/masterブランチとマージ済みPRを直接確認した内容です。

Spark 4.0が事実上のリファレンスです。 ロウリネージ対応はPR #13310として2025年7月14日に入りました。PR説明によれば、Spark 4.0で新しく登場した条件付きnullification機構を使いますが、これはSpark 3.5には存在せず、Icebergは独自のrewriteルールを別途実装する必要がありました(PR #12736、2025年4月28日)。そのPRが整理したルールは、先に見たスペックそのままです — 既存のrow IDは操作の種類を問わず常に引き継がれ、INSERTではnullであり、修正されていない行のシーケンス番号は保存され、修正された行はnullになる。

コンパクションの保存は別作業でした。 先に「コンパクションはリネージを殺す」と述べたのは、まさにこれが理由です。Spark 4.0のコンパクションにおけるリネージ保存は、PR #13555として2025年7月22日になってようやく入り、3.5と3.4へは翌日にバックポートされました(PR #13637#13641)。実装方法が興味深く、PR作者本人の表現では、rewriteの場合SparkTableのスキーマを「乗っ取って(hijacking)」メタデータカラムを追加する方式です。優雅ではないと作者自身も自覚しています。

Trinoは思っていたより先を行っています。 ここは検索結果に危うく騙されるところでした — 複数の二次情報源が「Trinoは2026年6月時点でv3対応ができていない」「ロウリネージはまだ」と書いていますが、リポジトリを直接見るとそれは事実ではありません。削除ベクターはPR #27788として2026年1月16日に、ロウリネージはPR #27836として2026年3月19日にmasterへマージされています。後者は読み取りだけでなく、UPDATE/MERGEで元のrow IDを保存すること、そしてv3テーブルでOPTIMIZEを再び許可しながらリネージを守ることまで含んでいます。

Trinoがその前にしたことも見る価値があります。PR #27786(2026年1月10日)はv3テーブルの作成を解禁しつつ、まだ実装していないv3機能はすべてNOT_SUPPORTEDとして明示的に拒否するようにしました。v3テーブルに対するDELETE/UPDATE/MERGE、OPTIMIZE、add_files、削除ベクター、カラムのデフォルト値、暗号化がすべてここに引っかかります。PR説明の表現どおり「スペック違反を避けるため」、静かに間違った答えを返す代わりに、速く予測可能に失敗する道を選んだわけです。部分実装の段階ではこれは正しい選択で、他のエンジンも見習うべきところです。

カラム名はエンジンごとに異なります。Icebergコアとsparkは_row_id / _last_updated_sequence_numberを使い、Trinoは自身のメタデータカラム慣習に従って$row_id / $last_updated_sequence_numberとして公開します。フィールドIDは同じなのでデータは互換ですが、クエリはそのままでは移せません。

Flinkは二手に分かれています。ここが一番わかりにくい部分です。 Iceberg mainブランチのFlinkコネクタにはコンパクション経路が2つあり、ロウリネージについて正反対に振る舞います。

旧式のactions API(org.apache.iceberg.flink.actions.RewriteDataFilesAction)は、今もv3テーブルを単純に拒否します。mainブランチのv1.20、v2.0、v2.1のいずれにもこのコードがそのまま残っています。

Preconditions.checkArgument(
    !TableUtil.supportsRowLineage(table),
    "Flink does not support compaction on row lineage enabled tables (V3+)");

一方、新式のmaintenance API(org.apache.iceberg.flink.maintenance.operator.DataFileRewriteRunner)はリネージを保存します。PR #14149として2025年11月6日に入り、コードはこう分岐します。

boolean preserveRowId = TableUtil.supportsRowLineage(value.table());

try (TaskWriter<RowData> writer = writerFor(value, preserveRowId)) {
  try (DataIterator<RowData> iterator = readerFor(value, preserveRowId)) {

まとめると — Flinkでv3テーブルをコンパクションするならmaintenance APIを使う必要があり、旧式のactions APIを使っているなら、v3に上げた瞬間コンパクションが例外を投げて止まります。悪い知らせのようですが、実はよい設計です。静かにリネージを壊すより、うるさく止まるほうがましです。ちなみにこの拒否ロジック自体は、PR #13646("Flink: fail file rewrite for V3 tables as row lineage not supported"、2025年7月23日)によって意図的に追加されたものです。

PyIcebergはまだv3テーブルを書けません。 mainブランチのpyiceberg/table/metadata.pyには、まだこう書かれています。

SUPPORTED_TABLE_FORMAT_VERSION = 2

v3マニフェスト/マニフェストリストの書き込みとロウリネージのスナップショットコミットを入れようとしたPR #3070はマージされずクローズされ、後続のPR #3551("Writing v3 Table Metadata")は2026年7月8日を最後に更新されたまま、まだオープンです。v3アップグレード対応PR #3623は、#3551がマージされるのを待つdraft状態です。Pythonでicebergに書き込むパイプラインがあるなら、v3計画からは一旦外しておくのが正確です。

そして継承ロジックはいまも修正され続けています。 本稿を書く1週間前、2026年7月9日にPR #17039("Core: Fix row lineage last updated sequence inheritance")がマージされました。バグはこうです — Javaが_last_updated_sequence_numberメタデータカラムを埋める際、data_sequence_numberの代わりにfileSequenceNumber()を使っていました。

両者の違いは普段は見えません。しかしv2時代のコンパクションが、より古いdata sequence numberを持つ代替ファイルを作っておき、そのテーブルを後でv3に上げると差が現れます。data_sequence_numberはファイルの内容に紐づく値なのでリライトを越えて保存されますが、file_sequence_numberはその物理ファイルが追加された時点です。そのためバグのあるバージョンは、「この行が最後に修正されたのはいつか」と問われたとき、本来の更新時点ではなくコンパクションが走った時点を答えていました。

これが継承設計の代償を正確に示しています。値がファイルにないため、継承ルールが一つ間違っていても、データは無傷なのに答えが間違います。そしてその間違いは例外を投げません。スペックが2024年10月(PR #11130)にロウリネージを初めて入れてから21か月が経った今もこうしたバグが出るという事実は、この機能の難度がスペック文書の分量に比べてはるかに高いことを物語っています。Avro reader側でも2026年2月と3月に似た性質の修正がありました(PR #15187#15508)。

いつ使うべきでないか

正直に整理するとこうなります。

まだv3に上げないでください、こういう場合は

  • パイプラインにPyIceberg writerが一つでもある。今はv3テーブルを書けません。
  • Flinkの旧式actions APIでコンパクションを回しており、maintenance APIへ移す計画がない。
  • ワークロードがequality delete中心である(典型的にはFlinkのupsertストリーミング)。リネージを得ようとv3に上げても、肝心のUPDATEで行のアイデンティティが引き継がれません。
  • 複数のエンジンが同じテーブルに書き込んでおり、そのうち一つでもリネージ保存が検証されていない。リネージを守らないwriterが一つでもあれば、テーブル全体のリネージが壊れます。

上げてもよい場合

  • writerがSpark 4.0(またはバックポートされた3.5/3.4)か、最近のTrinoに限定されている。
  • コンパクション経路がリネージを保存するか実際に確認した — ドキュメントではなく、コンパクション前後の_row_idを自分で直接比較して。
  • 削除ベクターの利益だけですでに元が取れている。ロウリネージはv3のおまけとしてついてくるものであり、実のところ大半の組織がv3へ移る本当の理由は削除ベクターです。

そしてロウリネージをCDCとして使う前に必ず確認すべきこと — _row_idは一意ではあっても密ではなく、ブランチ間で安定しておらず、アップグレード以前の履歴には存在せず、equality deleteを越えられません。この4点があなたの設計と衝突しないか、まず確かめてください。

おわりに

ロウリネージの設計は賢いです。行IDを保存する代わりにID空間を予約して配り、位置でオフセットを取ることで、楽観的コミットが再試行されてもデータファイルを書き直さずに済みます。Icebergのコミットモデルを知っている人なら、なぜこういう形になっているのかすぐに理解できるでしょう。

代償は責任の移動です。値がファイルにないため、その値を作り出し守り抜くことは、完全にwriterとreaderの正確さにかかっています。スペックは「must」と書きますが、スペックはコードを実行しません。だから2026年7月時点の図はこうなります — Spark 4.0はできていて、Trinoは3月からできていて、Flinkはどちらのapiを使うかで分かれ、PyIcebergはまだで、コアの継承ロジックは先週も修正されていました。

これはv3を使うなという話ではありません。削除ベクターだけでもv3は行く価値のある場所です。ただし、v3に上げた瞬間ロウリネージはオフにできない形でオンになり、そのテーブルを触るすべてのツールが暗黙の契約に署名することになる、と知ったうえで進むべきです。その契約を守れないツールが一つでも混ざっていれば、あなたが得るのはリネージではなく、リネージがあるという錯覚です。そちらのほうがはるかに悪い。

Icebergの全体的な構造と運用は、Apache Icebergデータレイクハウステーブルフォーマットガイド編で別途扱います。

参考資料