This is an automated email from the ASF dual-hosted git repository.

singhpk234 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git


The following commit(s) were added to refs/heads/main by this push:
     new ae2efce58e  Fix: Group transaction changes by table (#3360)
ae2efce58e is described below

commit ae2efce58ea1103c3a28d988ce67ee3f9062e9e4
Author: Prashant Singh <[email protected]>
AuthorDate: Fri Mar 6 11:10:59 2026 -0800

     Fix: Group transaction changes by table (#3360)
    
    Co-authored-by: Prashant Kumar Singh <[email protected]>
---
 .../it/test/PolarisRestCatalogIntegrationBase.java |  95 ++++++++++++++++
 .../catalog/iceberg/IcebergCatalogHandler.java     | 123 ++++++++++++---------
 2 files changed, 168 insertions(+), 50 deletions(-)

diff --git 
a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
 
b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
index 765c22b5b3..ab209302ab 100644
--- 
a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
+++ 
b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
@@ -1189,6 +1189,101 @@ public abstract class PolarisRestCatalogIntegrationBase 
extends CatalogTests<RES
     
assertThat(latestCommittedSchema.asStruct()).isEqualTo(originalSchema.asStruct());
   }
 
+  @Test
+  public void testCoalescedConflictOnOneTableRollsBackEntireTransaction() {
+    Namespace namespace = Namespace.of("coalescingAtomicNs");
+    TableIdentifier goodId = TableIdentifier.of(namespace, "goodTable");
+    TableIdentifier conflictId = TableIdentifier.of(namespace, 
"conflictTable");
+
+    if (requiresNamespaceCreate()) {
+      catalog().createNamespace(namespace);
+    }
+
+    catalog().createTable(goodId, SCHEMA);
+    catalog().createTable(conflictId, SCHEMA);
+
+    Schema originalGoodSchema = catalog().loadTable(goodId).schema();
+    Schema originalConflictSchema = catalog().loadTable(conflictId).schema();
+
+    // goodTable: a single non-conflicting schema change
+    Transaction goodTx = catalog().loadTable(goodId).newTransaction();
+    goodTx.updateSchema().addColumn("new_col", Types.LongType.get()).commit();
+
+    // conflictTable: two independent transactions that both rename the same 
column,
+    // producing two TableCommits for the same table. The first rename 
succeeds,
+    // but the second conflicts because the schema has already changed.
+    Table conflictTable = catalog().loadTable(conflictId);
+    Transaction conflictTx1 = conflictTable.newTransaction();
+    Transaction conflictTx2 = conflictTable.newTransaction();
+    conflictTx1.updateSchema().renameColumn("data", "renamed-col1").commit();
+    conflictTx2.updateSchema().renameColumn("data", "renamed-col2").commit();
+
+    TableCommit goodCommit =
+        TableCommit.create(
+            goodId,
+            ((BaseTransaction) goodTx).startMetadata(),
+            ((BaseTransaction) goodTx).currentMetadata());
+    TableCommit conflictCommit1 =
+        TableCommit.create(
+            conflictId,
+            ((BaseTransaction) conflictTx1).startMetadata(),
+            ((BaseTransaction) conflictTx1).currentMetadata());
+    TableCommit conflictCommit2 =
+        TableCommit.create(
+            conflictId,
+            ((BaseTransaction) conflictTx2).startMetadata(),
+            ((BaseTransaction) conflictTx2).currentMetadata());
+
+    // The coalescing logic groups conflictCommit1 and conflictCommit2 
together.
+    // The first rename succeeds, but the second fails requirement validation
+    // (schema ID changed). This should fail the entire transaction atomically.
+    assertThatThrownBy(
+            () -> restCatalog.commitTransaction(goodCommit, conflictCommit1, 
conflictCommit2))
+        .isInstanceOf(CommitFailedException.class);
+
+    // Verify atomicity: neither table should have changed.
+    assertThat(catalog().loadTable(goodId).schema().asStruct())
+        .isEqualTo(originalGoodSchema.asStruct());
+    assertThat(catalog().loadTable(conflictId).schema().asStruct())
+        .isEqualTo(originalConflictSchema.asStruct());
+  }
+
+  @Test
+  public void 
testMultipleNonConflictingUpdatesToSameTableWithSchemaAndProperties() {
+    Namespace namespace = Namespace.of("coalescingMixedNs");
+    TableIdentifier identifier = TableIdentifier.of(namespace, 
"coalescingMixedTable");
+
+    if (requiresNamespaceCreate()) {
+      catalog().createNamespace(namespace);
+    }
+
+    // Use a single Iceberg transaction that performs both a schema change and 
a property update.
+    // This produces a single TableCommit containing multiple metadata 
updates, verifying
+    // that the server correctly handles multiple update types within a single 
commit request.
+    Table table = catalog().buildTable(identifier, SCHEMA).create();
+    Transaction transaction = table.newTransaction();
+
+    UpdateSchema updateSchema =
+        transaction.updateSchema().addColumn("new_col", Types.LongType.get());
+    Schema expectedSchema = updateSchema.apply();
+    updateSchema.commit();
+
+    transaction.updateProperties().set("prop-key", "prop-val").commit();
+
+    TableCommit tableCommit =
+        TableCommit.create(
+            identifier,
+            ((BaseTransaction) transaction).startMetadata(),
+            ((BaseTransaction) transaction).currentMetadata());
+
+    restCatalog.commitTransaction(tableCommit);
+
+    // Verify both schema and property updates were applied.
+    Table loaded = catalog().loadTable(identifier);
+    
assertThat(loaded.schema().asStruct()).isEqualTo(expectedSchema.asStruct());
+    assertThat(loaded.properties()).containsEntry("prop-key", "prop-val");
+  }
+
   @Test
   public void testTableExistsStatus() {
     String tableName = "tbl1";
diff --git 
a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
 
b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
index 88bcb595b0..2d8664f204 100644
--- 
a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
+++ 
b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
@@ -41,6 +41,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -1044,60 +1045,82 @@ public abstract class IcebergCatalogHandler extends 
CatalogHandler implements Au
         new TransactionWorkspaceMetaStoreManager(diagnostics(), 
metaStoreManager());
     ((IcebergCatalog) 
baseCatalog).setMetaStoreManager(transactionMetaStoreManager);
 
+    // Group all changes by table identifier to handle them atomically.
+    // This prevents conflicts when multiple changes target the same table 
entity.
+    // LinkedHashMap preserves insertion order for deterministic processing.
+    Map<TableIdentifier, List<UpdateTableRequest>> changesByTable = new 
LinkedHashMap<>();
+    for (UpdateTableRequest change : commitTransactionRequest.tableChanges()) {
+      if (CatalogHandlerUtils.isCreate(change)) {
+        throw new BadRequestException(
+            "Unsupported operation: commitTranaction with 
updateForStagedCreate: %s", change);
+      }
+      changesByTable.computeIfAbsent(change.identifier(), k -> new 
ArrayList<>()).add(change);
+    }
+
+    // Process each table's changes in order.
+    // Note: All UpdateTableRequests for a given table are coalesced into a 
single metadata
+    // update and a single tableOps.commit(), which results in one Polaris 
entity update per
+    // table. This is subtly different from applying each UpdateTableRequest 
as an independent
+    // commit (as if each were under a lock). Requirements are still validated 
sequentially
+    // against the evolving metadata, so conflicts are detected correctly.
+    // See also the TODO in TransactionWorkspaceMetaStoreManager for a more 
general (but more
+    // complex) alternative that would intercept at the MetaStoreManager layer.
     List<TableMetadata> tableMetadataObjs = new ArrayList<>();
-    commitTransactionRequest.tableChanges().stream()
-        .forEach(
-            change -> {
-              Table table = baseCatalog.loadTable(change.identifier());
-              if (!(table instanceof BaseTable baseTable)) {
-                throw new IllegalStateException(
-                    "Cannot wrap catalog that does not produce BaseTable");
-              }
-              if (CatalogHandlerUtils.isCreate(change)) {
-                throw new BadRequestException(
-                    "Unsupported operation: commitTranaction with 
updateForStagedCreate: %s",
-                    change);
+    changesByTable.forEach(
+        (tableIdentifier, changes) -> {
+          Table table = baseCatalog.loadTable(tableIdentifier);
+          if (!(table instanceof BaseTable baseTable)) {
+            throw new IllegalStateException("Cannot wrap catalog that does not 
produce BaseTable");
+          }
+
+          TableOperations tableOps = baseTable.operations();
+          TableMetadata baseMetadata = tableOps.current();
+
+          // Apply each change sequentially: validate requirements against 
current state,
+          // then apply updates. This ensures conflicts are detected (e.g., if 
two changes
+          // both expect schema ID 0, the second will fail after the first 
increments it).
+          TableMetadata currentMetadata = baseMetadata;
+          for (UpdateTableRequest change : changes) {
+            // Validate requirements against the current metadata state
+            final TableMetadata metadataForValidation = currentMetadata;
+            change
+                .requirements()
+                .forEach(requirement -> 
requirement.validate(metadataForValidation));
+
+            // TODO: Refactor to share/reconcile the update-application logic 
below with
+            // CatalogHandlerUtils to avoid divergence as complexity grows.
+            TableMetadata.Builder metadataBuilder = 
TableMetadata.buildFrom(currentMetadata);
+            for (MetadataUpdate singleUpdate : change.updates()) {
+              // Note: If location-overlap checking is refactored to be 
atomic, we could
+              // support validation within a single multi-table transaction as 
well, but
+              // will need to update the TransactionWorkspaceMetaStoreManager 
to better
+              // expose the concept of being able to read uncommitted updates.
+              if (singleUpdate instanceof MetadataUpdate.SetLocation 
setLocation) {
+                if (!currentMetadata.location().equals(setLocation.location())
+                    && !realmConfig()
+                        
.getConfig(FeatureConfiguration.ALLOW_NAMESPACE_LOCATION_OVERLAP)) {
+                  throw new BadRequestException(
+                      "Unsupported operation: commitTransaction containing 
SetLocation"
+                          + " for table '%s' and new location '%s'",
+                      change.identifier(), ((MetadataUpdate.SetLocation) 
singleUpdate).location());
+                }
               }
 
-              TableOperations tableOps = baseTable.operations();
-              TableMetadata currentMetadata = tableOps.current();
-
-              // Validate requirements; any CommitFailedExceptions will fail 
the overall request
-              change.requirements().forEach(requirement -> 
requirement.validate(currentMetadata));
-
-              // Apply changes
-              TableMetadata.Builder metadataBuilder = 
TableMetadata.buildFrom(currentMetadata);
-              change.updates().stream()
-                  .forEach(
-                      singleUpdate -> {
-                        // Note: If location-overlap checking is refactored to 
be atomic, we could
-                        // support validation within a single multi-table 
transaction as well, but
-                        // will need to update the 
TransactionWorkspaceMetaStoreManager to better
-                        // expose the concept of being able to read 
uncommitted updates.
-                        if (singleUpdate instanceof MetadataUpdate.SetLocation 
setLocation) {
-                          if 
(!currentMetadata.location().equals(setLocation.location())
-                              && !realmConfig()
-                                  .getConfig(
-                                      
FeatureConfiguration.ALLOW_NAMESPACE_LOCATION_OVERLAP)) {
-                            throw new BadRequestException(
-                                "Unsupported operation: commitTransaction 
containing SetLocation"
-                                    + " for table '%s' and new location '%s'",
-                                change.identifier(), setLocation.location());
-                          }
-                        }
-
-                        // Apply updates to builder
-                        singleUpdate.applyTo(metadataBuilder);
-                      });
-
-              // Commit into transaction workspace we swapped the baseCatalog 
to use
-              TableMetadata updatedMetadata = metadataBuilder.build();
-              if (!updatedMetadata.changes().isEmpty()) {
-                tableOps.commit(currentMetadata, updatedMetadata);
-              }
+              // Apply updates to builder
+              singleUpdate.applyTo(metadataBuilder);
+            }
+
+            // Update currentMetadata to reflect this change for subsequent 
requirement validation
+            currentMetadata = metadataBuilder.build();
+          }
+
+          // Commit all accumulated changes for this table in a single atomic 
operation
+          if (!currentMetadata.changes().isEmpty()) {
+            tableOps.commit(baseMetadata, currentMetadata);
+          }
 
-              tableMetadataObjs.add(updatedMetadata);
-            });
+          tableMetadataObjs.add(currentMetadata);
+        });
 
     // Commit the collected updates in a single atomic operation
     List<EntityWithPath> pendingUpdates = 
transactionMetaStoreManager.getPendingUpdates();

Reply via email to