Support staged table creates in commitTransaction#4939
Conversation
There was a problem hiding this comment.
Pull request overview
This pull request adds support for “staged” table creates (i.e., UpdateTableRequest with AssertTableDoesNotExist) within commitTransaction by buffering creates/updates in a transaction workspace, deferring location-overlap validation, and then committing both creates and updates atomically in the metastore.
Changes:
- Add staged-create detection + namespace-based authorization for
commitTransaction, and execute staged creates within the transaction workspace. - Extend the transaction workspace metastore wrapper to buffer entity creations (in addition to updates) and defer certain validations until commit time.
- Introduce
commitTransactionBatchin the metastore interface/implementation and add new tests for staged creates + mixed transactions + authz.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java | Implements staged-create handling in commitTransaction, including new authz flow, deferred validation, and atomic batch commit. |
| polaris-core/src/main/java/org/apache/polaris/core/persistence/TransactionWorkspaceMetaStoreManager.java | Buffers entity creations and defers list/overlap validation behavior during the transaction loop. |
| polaris-core/src/main/java/org/apache/polaris/core/persistence/PolarisMetaStoreManager.java | Adds commitTransactionBatch API (default implementation) to support atomic create+update commits. |
| polaris-core/src/main/java/org/apache/polaris/core/persistence/transactional/TransactionalMetaStoreManagerImpl.java | Implements transactional commitTransactionBatch to commit creates first, then CAS updates in one DB transaction. |
| runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/CommitTransactionEventTest.java | Adds integration-style tests for staged-create success and failure cases via REST API. |
| runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogHandlerAuthzTest.java | Updates/extends authorization tests to cover staged-create privilege requirements and mixed transactions. |
Comments suppressed due to low confidence (2)
runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java:1430
commitTransactionBatchfailures due toENTITY_ALREADY_EXISTSwill currently be surfaced asCommitFailedException, which makes staged-create-on-existing-table return the wrong Iceberg exception type (tests expectAlreadyExistsException, and clients generally treat this as a conflict). Consider mappingresult.alreadyExists()toAlreadyExistsExceptionbefore falling back toCommitFailedExceptionfor other statuses.
if (!result.isSuccess()) {
// TODO: Retries and server-side cleanup on failure, review possible exceptions
throw new CommitFailedException(
"Transaction commit failed with status: %s, extraInfo: %s",
result.getReturnStatus(), result.getExtraInformation());
}
runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java:1294
baseCatalog's metastore manager is swapped toTransactionWorkspaceMetaStoreManagerbut only restored on the success path. If any exception is thrown while processingchangesByTable(or while building metadata/committing),LocalIcebergCatalogwill remain wired to the workspace manager for the remainder of the handler lifecycle, which can lead to confusing follow-on failures. Please wrap the workspace section in atry/finallythat always restoressetMetaStoreManager(metaStoreManager()).
// Swap in TransactionWorkspaceMetaStoreManager so all mutations (both creates and updates)
// go into an in-memory buffer that we commit as a single atomic unit at the end.
// Location validation is deferred: the workspace returns no-op results for validation
// calls during the loop, and real validation happens after the loop with the real metastore.
TransactionWorkspaceMetaStoreManager transactionMetaStoreManager =
new TransactionWorkspaceMetaStoreManager(diagnostics(), metaStoreManager());
((LocalIcebergCatalog) baseCatalog).setMetaStoreManager(transactionMetaStoreManager);
|
@ayushtkn : Thanks for the contribution! I only briefly looked at the PR, so I might have missed something 😅 I wonder what end-to-end flow is envisioned? Is it 1) do a staged create N times, 2) write table files, 3) commit all staged tables via the It's a pretty big change. I think it deserved a |
|
Thanx @dimas-b for taking a look. yep, it is the same
The server only sees step 3. This PR implements the server-side handling for that request when it includes staged creates alongside regular updates. Sure, I will shoot a mail to dev@ for more feedback |
snazy
left a comment
There was a problem hiding this comment.
I think we should separate two concepts here:
stage-create=trueis the staging step oncreateTable. It initializes metadata and returns it without creating the table.commitTransactionis a commit step. It should commit table changes that the client already constructed.
This PR seems to implement part of the staged-create flow inside commitTransaction by detecting AssertTableDoesNotExist and building metadata from empty/newTableOps.
I’m not sure that is the right responsibility for this endpoint.
If commitTransaction supports creates, I would expect it to commit an already-formed create commit entry, not perform the staging behavior itself.
Can we point to the IRC flow/client behavior this is matching?
In particular, does an Iceberg REST client send assert-create entries in CommitTransactionRequest after a prior stage-create call, and are we only completing that commit here?
If so, the PR should make that explicit and test that flow. If not, I think we should clarify whether this is actually supported by IRC before adding this path.
| "Unsupported operation: commitTransaction containing SetLocation" | ||
| + " for table '%s' and new location '%s'", | ||
| change.identifier(), ((MetadataUpdate.SetLocation) singleUpdate).location()); | ||
| // Track staged-creates for deferred location validation after the real metastore is restored. |
There was a problem hiding this comment.
I’m concerned about the location-check deferral here.
During the workspace phase, listEntities returns empty and hasOverlappingSiblings returns "no overlap", so LocalIcebergCatalog’s normal overlap validation is effectively bypassed.
The PR then revalidates staged creates after restoring the real metastore, but that validation happens before commitTransactionBatch persists the entities.
Unless the final persistence step also enforces the no-overlap invariant in the same transaction/lock, there is a TOCTOU window where another request can create or move a table into the same location after validation passes.
There is also a non-concurrent gap: the workspace bypass applies to regular updates too.
A normal update can change write.data.path/write.metadata.path through table properties, but only stagedCreateEntries are revalidated after restore.
So location-changing updates in the same commitTransaction may skip overlap validation entirely.
Can we keep overlap validation inside the final atomic persistence path, or at least revalidate every pending create/update whose effective locations changed immediately inside the transaction that writes them?
There was a problem hiding this comment.
You make a great point on the location overlap validations. I’ve updated the PR to completely close the non-concurrent gap (Issue 2).
Regular updates that mutate their locations (write.data.path, write.metadata.path, or table location) via commitTransaction are now explicitly tracked during the workspace phase. After the real metastore is restored, they are piped through validateTableLocationUpdate alongside the new staged creations. This ensures users cannot bypass location validation by changing properties inside a multi-table commit. (I've also added testUpdateChangingWriteDataPathFailsOnOverlap to explicitly test this).
Regarding the TOCTOU concurrency window between that deferred validation and the final persistence (Issue 1): you are absolutely right that a race exists here. However, while looking into how to safely push that validation down into commitTransactionBatch under the database lock, I realized that the standard single-table doCommit path in BasePolarisTableOperations actually suffers from this exact same TOCTOU race today.
In the existing updateTable path:
Validation (Read Tx): validateNoLocationOverlap runs a standalone read transaction via listEntities/hasOverlappingSiblings.
Cloud I/O: The thread then performs network I/O to write the new metadata file.
Persist (Write Tx): updateEntityPropertiesIfNotChanged runs in its own separate ms.runInTransaction() block.
Because these are completely separate metastore transactions separated by network I/O, concurrent requests can easily slip in and claim the location. Unless I misunderstood something here.
See LocalIcebergCatalog$BasePolarisTableOperations.doCommit() — validateNoLocationOverlap at L1766 runs in a read transaction, then updateEntityPropertiesIfNotChanged at L2677 persists in a separate write transaction
Fixing this systemic race requires pushing catalog-aware location extraction down into the generic persistence layer, and ensuring all Metastore implementations gracefully handle nested transactions or provide a pre-commit validation hook. Since this is a broader architectural limitation of Polaris affecting all location-mutating operations, I think we should track the TOCTOU fix as a separate issue rather than expanding the scope of this PR. Does that sound reasonable?
There was a problem hiding this comment.
The TOCTOU problem wrt location checks exists in the per-table create/update flows too, does it not?
There was a problem hiding this comment.
yes, what I could decode
There was a problem hiding this comment.
Thanks for confirming. I think it deserves a separate discussion. I think it's way beyond the scope of current PR 😅
|
Thanx @snazy for the review & feedback, To answer the questions The Iceberg REST client explicitly sends an From the Iceberg Code Because of this, the server is forced to reconstruct the staging behavior by building the metadata from empty and applying the client's update log. We can see this exact behavior codified in the Apache Iceberg reference REST server implementation 1. Detecting the Create Iceberg ref: 2. Building from Empty Iceberg ref: Some Test refernce in Iceberg: Ref from RestTableOPerarations, that it handles create Regarding adding a Test, I added |
|
@snazy / @dimas-b any feedback / suggestions around this.
It is here, seems quiet though |
| try { | ||
| changesByTable.forEach( | ||
| (tableIdentifier, changes) -> { | ||
| boolean isStagedCreate = changes.stream().anyMatch(CatalogHandlerUtils::isCreate); |
There was a problem hiding this comment.
Why are we calling it "staged"? How is it different from a normal create? As far as I can tell, there is no difference 🤔
| // does not exist, ALL requests for that table must be staged-creates. | ||
| if (isStagedCreate | ||
| && changes.stream().anyMatch(change -> !CatalogHandlerUtils.isCreate(change))) { | ||
| throw new BadRequestException( |
There was a problem hiding this comment.
Is it normal for a create request to have more than one UpdateTableRequest element?
Should we simply check that there is one and it satisfies isCreate()?
| UpdateTableRequest filteredChange = applyUpdateFilters(change); | ||
| filteredChange.requirements().forEach(req -> req.validate((TableMetadata) null)); | ||
| for (MetadataUpdate singleUpdate : filteredChange.updates()) { | ||
| singleUpdate.applyTo(metadataBuilder); |
There was a problem hiding this comment.
Should we delegate to CatalogHandlerUtils.create() perhaps? 🤔
| "Unsupported operation: commitTransaction containing SetLocation" | ||
| + " for table '%s' and new location '%s'", | ||
| change.identifier(), ((MetadataUpdate.SetLocation) singleUpdate).location()); | ||
| // Track staged-creates for deferred location validation after the real metastore is restored. |
There was a problem hiding this comment.
The TOCTOU problem wrt location checks exists in the per-table create/update flows too, does it not?
| @NonNull List<EntityWithPath> updates) { | ||
| TransactionalPersistence ms = ((TransactionalPersistence) callCtx.getMetaStore()); | ||
|
|
||
| return ms.runInTransaction( |
There was a problem hiding this comment.
@ayushtkn : I'm not sure whether you're aware of this, but this code will NOT be used for JDBC Persistence... Just FYI 🤷 Check usage paths to confirm, if you prefer.
| @NonNull List<EntityWithPath> creates, | ||
| @NonNull List<EntityWithPath> updates) { | ||
| for (EntityWithPath create : creates) { | ||
| EntityResult result = createEntityIfNotExists(callCtx, create.catalogPath(), create.entity()); |
There was a problem hiding this comment.
In current JDBC persistence code each of those changes will run in a separate RDBMS Tx, AFAIK. It is based on AtomicOperationMetaStoreManager 🤷
There was a problem hiding this comment.
Thanx. Have added AtomicOperationMetaStoreManager.commitTransactionBatch which should take care, I have added tests around that as well in the AtomicOperationMetaStoreManagerTest
| "Transaction contains multiple creates pointing to the same location: %s", location); | ||
| } | ||
| } | ||
| ((LocalIcebergCatalog) baseCatalog) |
There was a problem hiding this comment.
nit: this cast is done multiple times. It might be worth introducing a strongly typed variable for this.
| */ | ||
| void validateTableLocationUpdate(TableIdentifier tableIdentifier, TableMetadata tableMetadata) { | ||
| PolarisResolvedPathWrapper resolvedTableEntities = | ||
| resolvedEntityView.getPassthroughResolvedPath( |
There was a problem hiding this comment.
This is called before changes are committed. Can it detect overlaps within the multi-table commit batch?
There was a problem hiding this comment.
Added validateNoOverlapWithinBatch that runs first with a pairwise
StorageLocation.isChildOf check across all location-affecting entries (creates + location-changing updates) in the batch
Implement staged creates in the transaction workspace. Polaris previously rejected these and authorized
the whole batch with
TABLE_WRITE_PROPERTIES+TABLE_CREATEon everytable identifier.
Checklist
CHANGELOG.md(if needed)site/content/in-dev/unreleased(if needed)