8181from pyiceberg .table import (
8282 CommitTableRequest ,
8383 CommitTableResponse ,
84+ CommitTransactionRequest ,
8485 CreateTableTransaction ,
8586 FileScanTask ,
8687 StagedTable ,
8788 Table ,
8889 TableIdentifier ,
8990 TableProperties ,
91+ Transaction ,
9092)
9193from pyiceberg .table .metadata import TableMetadata
9294from pyiceberg .table .sorting import UNSORTED_SORT_ORDER , SortOrder , assign_fresh_sort_order_ids
@@ -181,6 +183,7 @@ class Endpoints:
181183 fetch_planning_result : str = "namespaces/{namespace}/tables/{table}/plan/{plan_id}"
182184 cancel_planning : str = "namespaces/{namespace}/tables/{table}/plan/{plan_id}"
183185 fetch_scan_tasks : str = "namespaces/{namespace}/tables/{table}/tasks"
186+ commit_transaction : str = "transactions/commit"
184187
185188
186189API_PREFIX = "/v1/{prefix}"
@@ -220,6 +223,7 @@ class Capability:
220223 http_method = HttpMethod .DELETE , path = f"{ API_PREFIX } /namespaces/{{namespace}}/tables/{{table}}/plan/{{plan-id}}"
221224 )
222225 V1_TABLE_SCAN_PLAN_TASKS = Endpoint (http_method = HttpMethod .POST , path = f"{ API_PREFIX } /{ Endpoints .fetch_scan_tasks } " )
226+ V1_COMMIT_TRANSACTION = Endpoint (http_method = HttpMethod .POST , path = f"{ API_PREFIX } /{ Endpoints .commit_transaction } " )
223227
224228
225229# Default endpoints for backwards compatibility with legacy servers that don't return endpoints
@@ -238,6 +242,7 @@ class Capability:
238242 Capability .V1_DELETE_TABLE ,
239243 Capability .V1_RENAME_TABLE ,
240244 Capability .V1_REGISTER_TABLE ,
245+ Capability .V1_COMMIT_TRANSACTION ,
241246 )
242247)
243248
@@ -1634,6 +1639,97 @@ def commit_table(
16341639 )
16351640 return CommitTableResponse .model_validate_json (response .text )
16361641
1642+ @override
1643+ def commit_transactions (self , * transactions : Transaction ) -> None :
1644+ """Commit the changes of several table transactions atomically.
1645+
1646+ Sends the staged changes of every transaction in one request to the
1647+ ``transactions/commit`` endpoint: either all of them are applied, or none. Each
1648+ table is refreshed after a successful commit. Unlike ``Transaction.commit_transaction``,
1649+ a conflict is not retried: re-stage the changes and commit again.
1650+
1651+ Args:
1652+ transactions: The transactions to commit, at most one per table. Transactions
1653+ without staged changes are skipped.
1654+
1655+ Raises:
1656+ ValueError: If no transaction was given, a transaction is given twice, two
1657+ transactions change the same table, a transaction belongs to another catalog,
1658+ or a transaction creates a table.
1659+ NotImplementedError: If the server does not support multi-table commits.
1660+ NoSuchTableError: If one of the tables does not exist.
1661+ CommitFailedException: A requirement wasn't met, or a concurrent commit conflicted.
1662+ Nothing was applied.
1663+ CommitStateUnknownException: The catalog failed with an internal error; the commit
1664+ may or may not have been applied.
1665+ """
1666+ if not transactions :
1667+ raise ValueError ("At least one transaction is required" )
1668+ if len ({id (transaction ) for transaction in transactions }) != len (transactions ):
1669+ raise ValueError ("A transaction can be committed only once per call" )
1670+ identifiers : set [Identifier ] = set ()
1671+ for transaction in transactions :
1672+ if isinstance (transaction , CreateTableTransaction ):
1673+ raise ValueError ("Creating a table in a multi-table commit is not supported" )
1674+ table = transaction ._table # pylint: disable=W0212
1675+ if table .catalog is not self :
1676+ raise ValueError (f"Table { table .name ()} belongs to a different catalog" )
1677+ if table .name () in identifiers :
1678+ raise ValueError (f"More than one transaction changes table { table .name ()} " )
1679+ identifiers .add (table .name ())
1680+
1681+ staged = [transaction for transaction in transactions if transaction ._updates ] # pylint: disable=W0212
1682+ if not staged :
1683+ return
1684+ self ._check_endpoint (Capability .V1_COMMIT_TRANSACTION )
1685+
1686+ table_changes = []
1687+ for transaction in staged :
1688+ requirements , updates = transaction ._changes_for_multi_table_commit () # pylint: disable=W0212
1689+ identifier = transaction ._table .name () # pylint: disable=W0212
1690+ table_changes .append (
1691+ CommitTableRequest (
1692+ identifier = TableIdentifier (namespace = identifier [:- 1 ], name = identifier [- 1 ]),
1693+ requirements = requirements ,
1694+ updates = updates ,
1695+ )
1696+ )
1697+ request = CommitTransactionRequest (table_changes = table_changes )
1698+
1699+ try :
1700+ self ._post_commit_transaction (request )
1701+ except (CommitFailedException , NoSuchTableError ):
1702+ for transaction in staged :
1703+ transaction ._multi_table_commit_failed (outcome_known = True ) # pylint: disable=W0212
1704+ raise
1705+ except Exception :
1706+ for transaction in staged :
1707+ transaction ._multi_table_commit_failed (outcome_known = False ) # pylint: disable=W0212
1708+ raise
1709+
1710+ for transaction in staged :
1711+ transaction ._multi_table_commit_succeeded () # pylint: disable=W0212
1712+
1713+ @retry (** _RETRY_ARGS )
1714+ def _post_commit_transaction (self , request : CommitTransactionRequest ) -> None :
1715+ response = self ._session .post (
1716+ self .url (Endpoints .commit_transaction , prefixed = True ),
1717+ data = request .model_dump_json ().encode (UTF8 ),
1718+ )
1719+ try :
1720+ response .raise_for_status ()
1721+ except HTTPError as exc :
1722+ _handle_non_200_response (
1723+ exc ,
1724+ {
1725+ 404 : NoSuchTableError ,
1726+ 409 : CommitFailedException ,
1727+ 500 : CommitStateUnknownException ,
1728+ 502 : CommitStateUnknownException ,
1729+ 504 : CommitStateUnknownException ,
1730+ },
1731+ )
1732+
16371733 @retry (** _RETRY_ARGS )
16381734 @override
16391735 def create_namespace (self , namespace : str | Identifier , properties : Properties = EMPTY_DICT ) -> None :
0 commit comments