16. REST API を直接叩く

Iceberg REST Catalog は HTTP の API で、その仕様は OpenAPI(API の形を機械可読な定義ファイルで記述する書式)で公開されています。PyIceberg は内部でこの API を呼んでいますが、クライアントライブラリを通すと、コミットが何を送って何が返るのかが見えなくなります。

ここでは samples/06_rest_api_raw.py を使い、httpx で直接エンドポイントを叩きます。トークン取得、GET /v1/config、loadTable、updateTable のコミットプロトコル、エラー形式の順に確認します。特に updateTable の requirements / updates の構造と、409 が返る条件はこのラボの中核です。

確かめたいこと

  • GET /v1/config の defaults / overrides / endpoints をどう解釈するか
  • loadTable のレスポンスに何が含まれるか(credential vending を含む)
  • updateTable の requirements(表明)と updates(変更)の構造
  • 意図的に古い snapshot ID を表明したとき 409 が返ること
  • 409 と 500 の意味の違い
  • エラーレスポンスの形式

そもそもカタログは何をしているのか

先に全体像を整理します。

Iceberg のテーブルの実体は、オブジェクトストレージ上のファイル群です(15. メタデータ三層構造を覗く で見たとおり)。データも、スキーマも、スナップショットの履歴も、すべて S3 上のファイルに書かれています。

ではカタログは何を持っているのかというと、「このテーブルの最新の metadata.json はどれか」という1本のポインタだけです。

flowchart TD
    C["カタログ<br/>持つのはポインタ1本"] -->|"指す"| M["metadata.json<br/>S3 上のファイル"]
    M --> S["スナップショット<br/>スキーマ・統計"]
    S --> D["data file<br/>実データ"]

この構造から、カタログに必要な機能が決まります。

  • ポインタを読む — テーブルを開く(loadTable)
  • ポインタを差し替える — コミットする(updateTable)
  • ポインタの一覧を管理する — namespace やテーブルの作成・一覧・削除

つまり Iceberg のコミットとは、カタログが持つポインタを新しい metadata.json に差し替えることです。REST Catalog は、この操作を HTTP で行うための取り決めにすぎません。

なぜ HTTP なのか

以前は、カタログを Hive Metastore や JDBC で直接触るのが一般的でした。この方式では、各エンジン(Spark、Trino、Flink…)がカタログの実装ライブラリを抱え込むことになります。

REST にすると、サーバ側が仕様どおりの HTTP を話しさえすれば、クライアントは実装を知らずに済みます。カタログを差し替えても、クライアント側のコードは変わりません。この「差し替え可能性」が REST カタログの主眼です。

全体の流れ

このサンプルが叩く順序です。

sequenceDiagram
    participant C as クライアント
    participant P as Polaris
    C->>P: 1. POST /v1/oauth/tokens
    P-->>C: アクセストークン
    C->>P: 2. GET /v1/config?warehouse=...
    P-->>C: defaults / overrides / endpoints
    Note over C: prefix を受け取り<br/>以降の URL を組み立てる
    C->>P: 3. GET .../tables/rest_demo
    P-->>C: metadata + 一時的な S3 資格情報
    C->>P: 4. POST .../tables/rest_demo(嘘の版を表明)
    P-->>C: 409 CommitFailedException
    C->>P: 5. POST .../tables/rest_demo(正しい版)
    P-->>C: 200 + 新しい metadata-location

1 から 3 までは準備で、4 と 5 がこの実験の核心です。同じリクエストで、表明する「版」だけを変えて成否が分かれます。

背景

認証: なぜ最初にトークンを取るのか

HTTP には状態がないため、リクエストごとに「自分が誰か」を示す必要があります。毎回パスワードを送るのは危険なので、OAuth2 では次の2段階を踏みます。

  1. 一度だけ、client ID と client secret を送ってトークンを受け取る
  2. 以降は Authorization: Bearer <トークン> ヘッダで、そのトークンを提示する

トークンには有効期限があり、漏れても影響が期限内に限られます。ここで使っている client_credentials は「人間ではなくプログラムが自分自身として認証する」ための方式です。

設定のネゴシエーション

仕様は GET /v1/config について次のように述べています。

All REST clients should first call this route

なぜ最初に呼ぶ必要があるのかというと、接続先ごとに設定が違い、その一部はサーバが決めるからです。クライアントは自分の設定とサーバの設定を合成してから動き出します。

レスポンスには defaults と overrides が含まれます。名前のとおりの役割です。

フィールド 意味 クライアントは上書きできるか
defaults サーバからの提案。クライアントが何も指定しなければこれを使う できる
overrides サーバの指定。サーバ側の都合で強制したい値 できない

適用順は defaults → クライアント設定 → overrides で、後に来たものが勝ちます。つまりクライアントは defaults を上書きできますが、overrides には逆らえません。

prefix とは何か

overrides でよく配られるのが prefix です。これは以降のリクエストのパスに差し込む文字列です。

prefix なし: /v1/namespaces/lab/tables/trips
prefix あり: /v1/lab_catalog/namespaces/lab/tables/trips
                 ~~~~~~~~~~~

1台のサーバが複数のカタログを提供するとき、どのカタログ宛てかをパスで区別するために使われます。クライアントが勝手に決める値ではなく、サーバから配られる値である点が重要です。GET /v1/config を呼ばずに URL を組み立てると、以降のリクエストが全部パスを外します。

「フィールドがあること」自体が意味を持つ

endpoints と idempotency-key-lifetime は、少し変わった使われ方をします。値ではなく、フィールドが存在するかどうかがサーバの対応可否を表します。

endpoints はサーバが対応しているエンドポイントの一覧です。このフィールドが無い場合、クライアントは仕様が定めるデフォルトセット(13個)だけを仮定します。view やスキャンプランニング、credential vending はそこに含まれません。つまり「宣言が無い=古い最小構成とみなす」という約束です。

idempotency-key-lifetime も同じ形です。仕様はこう述べています。

If absent, clients MUST assume idempotency is not supported.

冪等(べきとう)とは

同じ操作を何回繰り返しても、1回だけ実行したのと同じ結果になる性質です。

これがコミットで問題になるのは、リクエストを送ったあと応答が返ってこないときです。ネットワークが切れた場合、クライアントには次の区別が付きません。

  • サーバに届かなかった(コミットされていない)
  • サーバは処理したが、応答が返る途中で切れた(コミット済み)

前者ならリトライすべきで、後者でリトライすると同じデータをもう一度追加してしまいます。

Idempotency-Key ヘッダは、この曖昧さを解消するための仕組みです。クライアントがリクエストに一意な鍵を付けておくと、サーバは同じ鍵のリクエストを2回目以降は処理せず、1回目の結果を返します。これでリトライが安全になります。

idempotency-key-lifetime は、サーバがその鍵を覚えておく期間を表します。このフィールドが無いサーバは鍵を覚えてくれないので、クライアントはリトライの安全性を自前で確保しなければなりません。

コミットプロトコル

POST /v1/{prefix}/namespaces/{namespace}/tables/{table}(updateTable)のボディは、大きく2つの配列でできています。

  • requirements — コミット前に成り立っていなければならない条件の表明。例: assert-table-uuid、assert-ref-snapshot-id
  • updates — 適用したい変更。例: set-properties、add-snapshot、set-snapshot-ref

サーバは requirements を現在のテーブル状態と突き合わせ、すべて満たされる場合にだけ updates を適用します。requirements は「自分が読んだ版はこれだ」という宣言であり、その版が変わっていればコミットは拒否されます。

なぜこの形なのか

複数のクライアントが同じテーブルに同時に書くと、素朴な実装ではあとから書いたほうが前の変更を消してしまいます。

sequenceDiagram
    participant A as クライアントA
    participant S as カタログ
    participant B as クライアントB
    A->>S: 読む(版100)
    B->>S: 読む(版100)
    A->>S: 版101 を書く
    Note over S: A の変更が入った
    B->>S: 版101 を書く
    Note over S: B は A の変更を<br/>知らずに上書き

これを防ぐ方法は2つあります。ひとつはロックで、書く前にテーブルを占有します。確実ですが、ロックの管理が必要になり、持ったまま落ちたクライアントの後始末も要ります。

Iceberg が採るのはもうひとつの方法、楽観的並行制御です。ロックを取らずに書きに行き、書く瞬間に「自分が読んだときから変わっていないこと」を条件として付けます。変わっていれば失敗するので、読み直してやり直します。

これが compare-and-swap(CAS)です。requirements が「比較」、updates が「交換」にあたります。

sequenceDiagram
    participant A as クライアントA
    participant S as カタログ
    participant B as クライアントB
    A->>S: 読む(版100)
    B->>S: 読む(版100)
    A->>S: 「版100のはず」+ 変更
    S-->>A: 200(版101 になった)
    B->>S: 「版100のはず」+ 変更
    S-->>B: 409(実際は版101)
    Note over B: 読み直して<br/>やり直す

衝突がまれであれば、ロックの管理コストを払わずに済みます。「楽観的」と呼ばれるのは、衝突しない前提で進み、衝突したときだけ対処するからです。

requirements と updates の例

主なものを挙げます。

requirements(表明) 意味
assert-table-uuid 相手が同じテーブルであること。削除して同名で作り直された場合に検出できる
assert-ref-snapshot-id 指定したブランチが、自分の見た版を指していること
assert-current-schema-id スキーマが変わっていないこと
updates(変更) 意味
set-properties テーブルプロパティを設定する
add-snapshot 新しいスナップショットを追加する
set-snapshot-ref ブランチやタグの指す先を変える

データを追加するコミットは、実際には add-snapshot(スナップショットを足す)と set-snapshot-ref(main をそこに向ける)の組み合わせになります。

安全性の要は、未知の要素を無視しないことです。仕様は次のように述べています。

Server implementations are required to fail with a 400 status code if any unknown updates or requirements are received.

未知の requirement を黙って読み飛ばす実装があると、クライアントは検証されたつもりでコミットが通ってしまいます。400 での失敗が必須とされているのはこのためです。

コミットが失敗したときの2種類

コミットの失敗には、意味がまったく違う2種類があります。この区別を誤ると、データを壊します。

ステータス 型 意味 リトライ
409 CommitFailedException requirements が満たされなかった。コミットは確実に適用されていない 安全。読み直して再送する
500 CommitStateUnknownException サーバが結果を確定できなかった。適用されたか不明 危険。そのまま再送してはいけない

409 は「あなたの見ていた版は古い」という明確な拒否なので、状態は変わっていません。読み直して requirements を作り直し、送り直せば済みます。

500 が厄介なのは、成功したかもしれないことです。たとえばカタログが metadata.json のポインタを書き換えたあと、応答を返す前に落ちた場合がこれにあたります。ここで単純にリトライすると、同じスナップショットをもう一度追加してしまいます。データが二重に入るということです。

flowchart TD
    E["コミットが失敗した"] --> C{"ステータスは?"}
    C -->|"409"| R1["読み直して再送<br/>安全"]
    C -->|"500"| U{"Idempotency-Key<br/>は使えるか?"}
    U -->|"使える"| R2["同じ鍵で再送<br/>安全"]
    U -->|"使えない"| R3["読み直して<br/>適用済みか確認<br/>してから判断"]

先ほどの idempotency-key-lifetime が効いてくるのがここです。サーバが対応していれば同じ鍵で送り直せば済みますが、対応していなければ、クライアントが自分でテーブルを読み直し、自分のコミットが入っているかどうかを確かめる必要があります。

409 には2つの意味がある

紛らわしいのですが、同じ 409 が別の場面でも使われます。

場面 型 意味 リトライ
namespace / テーブルの作成 AlreadyExistsException すでに存在する 無意味(何度送っても同じ)
updateTable CommitFailedException コミット競合 有効(読み直せば通る)

ステータスコードだけで分岐すると、「すでに存在する」に対して延々とリトライする実装になります。エラーボディの type を見て判断する必要があります。

処理の流れ

  1. POST /v1/oauth/tokens に client_credentials でトークンを要求します。このエンドポイントは仕様上 DEPRECATED for REMOVAL です(Iceberg Java 1.6.0 以降で非推奨、2.0 で仕様から削除予定)。本番では外部 IdP を使い oauth2-server-uri を明示的に設定します。

  2. GET /v1/config に warehouse を付けて呼び、overrides.prefix を取り出して以降のベース URL を組み立てます。

  3. namespace を作成し、既存なら 409 を確認します。

  4. PyIceberg でテーブル lab.rest_demo を用意したうえで、loadTable を叩きます。credential vending を要求するヘッダを付けます。

r = client.get(
    f"{api}/namespaces/{c.NAMESPACE}/tables/{table_name}",
    headers={**auth, "X-Iceberg-Access-Delegation": "vended-credentials,remote-signing"},
)

仕様は次のように述べており、このヘッダは要求であって保証ではありません。

The server may choose to supply access via any or none of the requested mechanisms

  1. わざと存在しない snapshot ID を表明して updateTable を投げ、409 を起こします。
req = {
    "identifier": {"namespace": [c.NAMESPACE], "name": table_name},
    "requirements": [
        {"type": "assert-table-uuid", "uuid": table_uuid},
        {"type": "assert-ref-snapshot-id", "ref": "main", "snapshot-id": bogus_snapshot},
    ],
    "updates": [
        {"action": "set-properties", "updates": {"lab.marker": "should-not-apply"}}
    ],
}
  1. requirements の snapshot-id を正しい値に直して再送し、成功を確認します。

  2. 存在しないテーブルを loadTable してエラー形式を確認します。

実測結果

GET /v1/config のレスポンスは次のとおりでした(endpoints は抜粋)。

{
  "defaults": {
    "default-base-location": "s3://warehouse/lab_catalog"
  },
  "overrides": {
    "namespace-separator": "%1F",
    "prefix": "lab_catalog"
  },
  "endpoints": [
    "GET /v1/{prefix}/namespaces",
    "POST /v1/{prefix}/namespaces/{namespace}/tables/{table}",
    "POST /v1/{prefix}/transactions/commit",
    "GET /polaris/v1/{prefix}/applicable-policies"
  ]
}

endpoints は36個宣言されており、Iceberg 仕様のエンドポイントに加えて Polaris 固有の polaris/v1/...(generic-tables、policies)が混在しています。仕様は endpoints を文字列として扱うため、実装固有のエンドポイントもこの形で宣言できます。

idempotency-key-lifetime は返りませんでした。仕様の「無ければ非対応とみなせ」という規定により、このサーバでは Idempotency-Key による安全なリトライが使えないと判断します。500 を受け取ったときは、クライアント側でテーブルを読み直して自分のコミットが入っているか確認する必要があります。

overrides.prefix として lab_catalog が配られ、以降のベース URL は次になりました。

http://localhost:8181/api/catalog/v1/lab_catalog

このように prefix でカタログを分ける実装は一般的ですが、仕様は prefix の意味論を定義していません(description は “An optional prefix in the path” のみ)。解釈はサーバ実装に委ねられます。

namespace 作成は 409 を返しました。これは AlreadyExistsException であり、リトライ不可の 409 です。

--- POST /v1/{prefix}/namespaces — namespace 作成
    409: 既に存在します (AlreadyExistsException)

loadTable が返すもの

loadTable のレスポンスには、テーブルの状態そのものが入っています。ポインタ(metadata-location)だけでなく、metadata.json の中身(metadata)も展開されて返るため、クライアントは S3 から metadata.json を読み直さずに済みます。

さらに storage-credentials が付くことがあります。これが credential vending です。

普通に考えると、クライアントが S3 のデータを読むには S3 の資格情報が必要です。しかしそれを全クライアントに配ると、テーブル単位の権限管理ができません。カタログ側で「このユーザはこのテーブルだけ」と決めても、S3 の鍵を持っていれば直接バケット全体を触れてしまうからです。

credential vending は、この矛盾を解きます。クライアントは S3 の鍵を持たず、カタログにテーブルを要求します。カタログは権限を確認したうえで、そのテーブルのパスにだけ有効な、期限付きの鍵を発行します。

今回のレスポンスは次のとおりでした。トップレベルキーは4つで、credential vending が有効でした。

    レスポンスのトップレベルキー: ['metadata-location', 'metadata', 'config', 'storage-credentials']

    storage-credentials が 1 個返りました:
      prefix: s3://warehouse/lab_catalog/lab/rest_demo
        s3.access-key-id = ***(本文では伏せています)
        s3.secret-access-key = ***
        s3.session-token = ***
        expiration-time = 1784386178000
        s3.session-token-expires-at-ms = ***

注目すべきは prefix です。払い出された資格情報はテーブル 1 つ分のパスにスコープされています。カタログ全体でも namespace 単位でもありません。クライアントはこのテーブル配下しか触れない鍵を受け取ります。

s3.session-token が付いていることから、静的な鍵ではなく一時的な資格情報だと分かります。expiration-time は有効期限で、1970-01-01 からの経過ミリ秒(エポックミリ秒)で表されています。

storage-credentials は prefix 付きで返ります。クライアントは config より先に storage-credentials を参照し、複数返ってきた場合は、対象パスに最も長く一致するものを使います(longest-prefix-wins)。カタログ全体向けの鍵とテーブル個別の鍵が両方返るような場合に、より限定された後者が選ばれるということです。

嘘の snapshot ID を表明した updateTable のリクエストとレスポンスは次のとおりです。

{
  "identifier": { "namespace": ["lab"], "name": "rest_demo" },
  "requirements": [
    { "type": "assert-table-uuid", "uuid": "260da375-3cb1-4ea7-825e-911487d00cf7" },
    { "type": "assert-ref-snapshot-id", "ref": "main", "snapshot-id": 1234567890123456789 }
  ],
  "updates": [
    { "action": "set-properties", "updates": { "lab.marker": "should-not-apply" } }
  ]
}
    ステータス: 409

    レスポンス:
      {
        "error": {
          "message": "Requirement failed: branch main has changed: expected id 1234567890123456789 != 1472481652836649450",
          "type": "CommitFailedException",
          "code": 409
        }
      }

エラーメッセージが、期待値と実際の値の両方を含んでいる点に注目してください。実際の current-snapshot-id は 1472481652836649450 で、これは loadTable が返した値と一致します。updates 側の set-properties は適用されていません。requirements が1つでも失敗すれば updates は一切適用されません。一部だけ適用されて中途半端な状態が残ることはない、ということです。

requirements を正しい値に直すと成功し、metadata-location が差し替わりました。

    正しい requirements でコミットします:
    ステータス: 200
    新しい metadata-location: s3://warehouse/lab_catalog/lab/rest_demo/metadata/00002-02cf940b-6a03-4c23-850d-3776e25e8efc.metadata.json

loadTable 時点では 00001-f856842a-...metadata.json でしたので、コミットによって 00002-... へ移ったことになります。コミット成功時のレスポンスには metadata-location と metadata が必ず含まれます(仕様上 required)。クライアントは差し替え後の状態を、読み直さずにそのまま受け取れます。

存在しないテーブルへの loadTable は 404 を返しました。

{
  "error": {
    "message": "Table does not exist: lab.does_not_exist",
    "type": "NoSuchTableException",
    "code": 404
  }
}

非 2xx はすべて {"error": {message, type, code}} という形でラップされます。中身(message / type / code)を error で包まずそのまま返す実装は仕様違反です。クライアントは type を見て処理を分けるため、この形が保たれていることが前提になります。

ここから分かること

コミットの実体は、requirements で読んだ版を表明し、サーバ側でその版が変わっていないことを確認したうえで、metadata.json のポインタを差し替える操作です。メタデータの構造そのものは 15. メタデータ三層構造を覗く で見たとおりで、REST カタログはその最上位のポインタだけを管理します。

クライアントを自作する場合、409 に対する CAS リトライループは必須です。テーブルを再ロードして最新の snapshot ID を取り、requirements を作り直して再送します。一方で 500 CommitStateUnknownException を 409 と同じ扱いにしてはいけません。結果が不明な状態でリトライすると、同じスナップショットが二重に適用されます。この実験で使ったサーバは idempotency-key-lifetime を返していないため、Idempotency-Key による保護は期待できません。500 を受けたら、再ロードして自分のコミットが適用済みかどうかを確認する経路が必要になります。

409 の二義性も実装上の注意点です。ステータスコードだけで分岐すると、AlreadyExistsException に対して無限にリトライする実装になりかねません。エラーボディの type を見て判断します。

GET /v1/config を最初に呼ぶ理由もここで具体化しました。prefix が overrides で配られる以上、config を呼ばずに URL を組み立てると全リクエストがパスを外します。endpoints と idempotency-key-lifetime も同様に、config を読まなければサーバの能力を判定できません。

REST 仕様側の整理は姉妹リポジトリの調査報告書(https://dobachi.github.io/iceberg-research/ )にまとめてあります。