Skip to content

Support atomic multi-table commits through the REST catalog - #4123

Open
kotwal-itpro wants to merge 3 commits into
apache:mainfrom
kotwal-itpro:rest-multi-table-commit
Open

kotwal-itpro wants to merge 3 commits into
apache:mainfrom
kotwal-itpro:rest-multi-table-commit

Conversation

@kotwal-itpro

@kotwal-itpro kotwal-itpro commented Oct 19, 2026 •

Copy link
Copy Markdown

Closes #4095

Rationale for this change

The REST spec's POST /v1/{prefix}/transactions/commit applies changes to several tables in one atomic operation, and the Java client exposes it as RESTCatalog.commitTransaction(...). PyIceberg had no way to call it, so write-audit-publish across tables or a backfill of related tables couldn't be published atomically from Python.

This adds Catalog.commit_transactions(*transactions):

orders_tx = orders.transaction()
orders_tx.append(new_orders)
items_tx = items.transaction()
items_tx.append(new_items)

catalog.commit_transactions(orders_tx, items_tx)  # both tables, or neither

What it does

  • REST catalog: sends each transaction's requirements and updates as one CommitTransactionRequest, adding the same AssertTableUUID that commit_transaction adds. The endpoint returns 204, so each table is refreshed afterwards and the transactions are cleared.
  • Other catalogs: the base Catalog raises NotImplementedError, so they can add support later.
  • Errors map like commit_table: 409 → CommitFailedException, 404 → NoSuchTableError, 500/502/504 → CommitStateUnknownException.
  • Files: on a definite failure (409, 404) the files written for the transactions are deleted. On an unknown outcome they're kept, because the catalog may already reference them. That's the same rule commit_transaction follows. A failed transaction can't be reused.
  • Rejected before anything is sent: no transactions, the same transaction twice, two transactions on one table, a transaction from another catalog, and CreateTableTransaction. Transactions without staged changes are skipped, and a call with only empty ones sends nothing. The exception is a snapshot operation that staged nothing, such as a delete whose filter matched no rows: it is sent with assert-ref-snapshot-id on its starting snapshot and no updates, so a concurrent change to that table fails the whole commit. commit_transaction validates the same case on the client; here the catalog checks it atomically with the other tables (the same requirement-only pattern as Multi-table commit does not isolate requirements on tables without updates: concurrent transactions both succeed polaris#5807).
  • Endpoint check: uses the server's advertised endpoints. For servers that don't list endpoints, transactions/commit is assumed supported, as in the Java client's DEFAULT_ENDPOINTS.

Not in this PR (open questions)

  • No retry on conflict. A 409 doesn't say which table conflicted, and replaying snapshot producers across tables is a bigger design. The caller re-stages and commits again, as with the Java client. Happy to follow up if you'd like retries.
  • Creating tables in a multi-table commit (with assert-create) could come later.
  • Entry point: I went with Catalog.commit_transactions(*transactions) as proposed in Support atomic multi-table commits through the REST catalog (transactions/commit) #4095. Glad to change it, for example to a context manager.

Are these changes tested?

  • tests/catalog/test_rest.py: 13 tests with a mocked server. They cover the request body, the prefix, each error status, file cleanup only when nothing was applied, skipping empty transactions, the requirement sent for a delete that matched nothing, the input checks and an unsupported endpoint.
  • tests/catalog/test_base.py: the base catalog raises NotImplementedError.
  • tests/integration/test_rest_catalog.py, against the apache/iceberg-rest-fixture with S3-compatible storage:
    • a delete that matched nothing on one table plus an append on another: committed when nothing else changed, rejected with neither table changed when another writer appended in between (the conflict case fails without the second commit);
    • two tables each get a real append (and a property change) in one call, and both have the rows afterwards;
    • another writer appends to one table after the transactions were staged: the commit fails with CommitFailedException, and neither table gets the staged rows.

make test: 4273 passed. make lint passes on the changed files.

Are there any user-facing changes?

Yes: the new Catalog.commit_transactions method, and a "Multi-table commits" section in mkdocs/docs/api.md.

I used an AI coding assistant while writing this change; I reviewed and tested all of it.

🤖 Generated with Claude Code

https://claude.ai/code/session_011ZBQVsXRmKT8WXDr2qSfbd

@kotwal-itpro
kotwal-itpro force-pushed the rest-multi-table-commit branch from 8c59067 to 920d5a9 Compare October 20, 2026 21:46
@kotwal-itpro
kotwal-itpro marked this pull request as ready for review October 27, 2026 21:13
@kotwal-itpro

Copy link
Copy Markdown
Author

This is ready for review. CI is green, and the integration tests run against apache/iceberg-rest-fixture.

There are three open questions under "Not in this PR" in the description, and I'd welcome a maintainer's view on them before polishing further: retry on conflict, creating tables in the same commit, and the Catalog.commit_transactions(*transactions) entry point.

@kevinjqliu @Fokko, would one of you have time to take a look?

Comment thread mkdocs/docs/api.md
Comment on lines +1418 to +1423
orders_tx = orders.transaction()
orders_tx.append(new_orders)
items_tx = items.transaction()
items_tx.append(new_items)

catalog.commit_transactions(orders_tx, items_tx)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a big fan of the context-manager, should we use that in this example as well?

@kotwal-itpro kotwal-itpro Oct 28, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. with orders.transaction() as tx: can't be used here as it is, since leaving that block commits the table on its own. So I added Catalog.transaction(), which commits every table in one commit_transactions call when the block exits, and nothing if it raises:

with catalog.transaction() as tx:
    tx.table(orders).append(new_orders)
    tx.table(items).append(new_items)

The docs now lead with this and keep commit_transactions(...) as the explicit form. It's in 6a29452, with unit tests for the commit on exit, no commit on an exception, and a table from another catalog, plus an integration test. Happy to rename it (multi_table_transaction()?) or drop it if you'd rather keep this PR to the explicit call.

@kotwal-itpro
kotwal-itpro force-pushed the rest-multi-table-commit branch from 920d5a9 to 68697df Compare October 28, 2026 20:25
kotwal-itpro and others added 3 commits October 28, 2026 13:25
Add Catalog.commit_transactions(*transactions), which commits the staged
changes of several table transactions in one request to the REST
catalog's transactions/commit endpoint: either every table is updated,
or none of them. Each table is refreshed after a successful commit.

Other catalogs raise NotImplementedError. A transaction can be given
only once, each table at most once, and creating a table is not
supported. Conflicts are not retried. On a definite failure (409, 404)
the files written for the transactions are deleted; on an unknown
outcome (500, 502, 504) they are kept, as in commit_transaction.

The endpoint is assumed supported by servers that don't list their
endpoints, as in the Java client.

Closes apache#4095

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZBQVsXRmKT8WXDr2qSfbd
A delete whose filter matched no rows stages no updates, so commit_transactions
skipped it and a concurrent change to that table went unnoticed. Send it as a
table change that asserts the branch still points at its starting snapshot, so
the catalog checks it in the same atomic commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZBQVsXRmKT8WXDr2qSfbd
Stage the changes of each table through tx.table(table) and commit them
atomically with commit_transactions when the block exits; nothing is
committed if it raises. The per-table Transaction context manager can't
be used for this, because its exit commits that table alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZBQVsXRmKT8WXDr2qSfbd
@kotwal-itpro
kotwal-itpro force-pushed the rest-multi-table-commit branch from 68697df to 6a29452 Compare October 28, 2026 20:26

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support atomic multi-table commits through the REST catalog (transactions/commit)

2 participants