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

yuqi1129 pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new 8433c5671c [#13949]fix(core): Preserve table metadata on load repair 
conflicts (#13930)
8433c5671c is described below

commit 8433c5671c9f4393f9699869599b4cd41eab14ba
Author: Qi Yu <[email protected]>
AuthorDate: Tue Oct 20 21:38:27 2026 +0800

    [#13949]fix(core): Preserve table metadata on load repair conflicts (#13930)
    
    ### What changes were proposed in this pull request?
    
    - Skip table-version writes when the existing conditional table-meta
    update affects no rows, matching the existing guard for column writes.
    - During load-time column repair only, recognize the existing
    table-update conflict message, log a warning, and retain the metadata
    snapshot already read by that load. Other failures keep their existing
    handling.
    
    ### Why are the changes needed?
    
    A concurrent metadata update can win after a table load reads its
    snapshot. On branch-1.3, the losing update still writes the version
    payload before reporting a conflict, potentially overwriting the winner.
    The load then drops its Gravitino metadata because the best-effort
    repair failed.
    
    This is a limited branch-1.3 fix. It preserves the existing
    conditional-update mechanism without backporting the OCC framework or
    replaying external DDL. The returned metadata snapshot can temporarily
    be stale; broader HA read consistency and stale catalog snapshots remain
    outside this patch.
    
    Fixed: #13949
    
    ### Does this PR introduce _any_ user-facing change?
    
    Table loads retain their already-read Gravitino metadata when column
    repair loses a concurrent update. No API, configuration, or schema
    changes.
    
    ### How was this patch tested?
    
    - Focused `TestTableMetaService` and `TestTableOperationDispatcher`
    suites: 34 passed.
    - Full core non-Docker unit tests: 1,656 passed, 2 skipped on the final
    rerun.
    - `TestTableMetaService` with H2, MySQL, and PostgreSQL backends: all 21
    passed.
    - Mutation verification: reverting the production fixes makes both new
    regression tests fail (lost load metadata and overwritten winning
    version).
    - Core Spotless checks.
    
    An initial full-core run hit three metrics initialization failures. A
    separate same-JVM probe using unmodified branch-1.3 service code/tests
    reproduced the existing fixture ordering dependency (JDBC tests leave a
    mock global Config without the metrics parameter). The final full-core
    rerun passed.
---
 .../catalog/TableOperationDispatcher.java          | 24 +++++++-
 .../relational/service/TableMetaService.java       |  9 ++-
 .../catalog/TestTableOperationDispatcher.java      | 51 +++++++++++++++
 .../relational/service/TestTableMetaService.java   | 72 ++++++++++++++++++++++
 4 files changed, 151 insertions(+), 5 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java 
b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
index 43cb76ff7c..725a688467 100644
--- 
a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
@@ -23,6 +23,7 @@ import static org.apache.gravitino.Entity.EntityType.TABLE;
 import static org.apache.gravitino.catalog.CapabilityHelpers.applyCapabilities;
 import static 
org.apache.gravitino.catalog.PropertiesMetadataHelpers.validatePropertyForCreate;
 import static 
org.apache.gravitino.rel.expressions.transforms.Transforms.EMPTY_TRANSFORM;
+import static 
org.apache.gravitino.storage.relational.service.TableMetaService.UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX;
 import static 
org.apache.gravitino.utils.NameIdentifierUtil.getCatalogIdentifier;
 import static 
org.apache.gravitino.utils.NameIdentifierUtil.getSchemaIdentifier;
 
@@ -30,6 +31,7 @@ import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Objects;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.Lists;
+import java.io.IOException;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -984,8 +986,9 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
         () ->
             operateOnEntity(
                 tableIdent,
-                id ->
-                    store.update(
+                id -> {
+                  try {
+                    return store.update(
                         id,
                         TableEntity.class,
                         TABLE,
@@ -1012,7 +1015,22 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
                                             
PrincipalUtils.getCurrentPrincipal().getName())
                                         .withLastModifiedTime(Instant.now())
                                         .build())
-                                .build()),
+                                .build());
+                  } catch (IOException e) {
+                    if (!(UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX + 
id).equals(e.getMessage())) {
+                      throw e;
+                    }
+                    // Column repair is best-effort. Retain this load's 
existing snapshot rather
+                    // than lose its metadata or replay a write against a 
concurrent change.
+                    // This snapshot can be stale; this does not provide HA 
read consistency.
+                    LOG.warn(
+                        "Skipping column repair for table {} after a 
concurrent metadata update; "
+                            + "retaining the metadata snapshot read by this 
load",
+                        id,
+                        e);
+                    return combinedTable.tableFromGravitino();
+                  }
+                },
                 "UPDATE",
                 combinedTable.tableFromGravitino().id()));
   }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
index 17ee0d5c3b..5a01a10015 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
@@ -210,14 +210,19 @@ public class TableMetaService {
                   SessionUtils.getWithoutCommit(
                       TableMetaMapper.class,
                       mapper -> ops.updatePO(mapper, newTablePO, oldTablePO))),
-          () ->
+          () -> {
+            // A concurrent update may have won. Its version payload must not 
be overwritten by
+            // this stale update when the conditional table-meta update 
affected no rows.
+            if (updateResult.get() > 0) {
               SessionUtils.doWithoutCommit(
                   TableVersionMapper.class,
                   mapper -> {
                     mapper.softDeleteTableVersionByTableIdAndVersion(
                         oldTablePO.getTableId(), 
oldTablePO.getCurrentVersion());
                     mapper.insertTableVersionOnDuplicateKeyUpdate(newTablePO);
-                  }),
+                  });
+            }
+          },
           () -> {
             if (updateResult.get() > 0) {
               TableColumnMetaService.getInstance()
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
 
b/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
index 0a819235bc..598163c506 100644
--- 
a/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
+++ 
b/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
@@ -33,6 +33,8 @@ import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 import com.google.common.collect.ImmutableMap;
 import java.io.IOException;
@@ -75,6 +77,7 @@ import org.apache.gravitino.rel.TableChange;
 import org.apache.gravitino.rel.expressions.literals.Literals;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.storage.relational.service.TableMetaService;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
@@ -1060,6 +1063,54 @@ public class TestTableOperationDispatcher extends 
TestOperationDispatcher {
     Assertions.assertEquals(ids.get("b"), afterDropAndAdd.get("c"));
   }
 
+  @Test
+  void testLoadRetainsSnapshotOnlyForTableUpdateConflict() throws IOException {
+    reset(entityStore);
+    Namespace namespace = Namespace.of(metalake, catalog, 
"schema_load_conflict");
+    NameIdentifier ident = NameIdentifier.of(namespace, "table_load_conflict");
+    schemaOperationDispatcher.createSchema(
+        NameIdentifier.of(namespace.levels()), "comment", 
ImmutableMap.of("k1", "v1", "k2", "v2"));
+    Column[] columns = {
+      
TestColumn.builder().withName("c1").withPosition(0).withType(Types.IntegerType.get()).build()
+    };
+    tableOperationDispatcher.createTable(
+        ident, columns, "comment", ImmutableMap.of("k1", "v1", "k2", "v2"), 
new Transform[0]);
+    TableEntity snapshot = entityStore.get(ident, TABLE, TableEntity.class);
+    TestCatalog testCatalog =
+        (TestCatalog)
+            catalogManager.loadCatalogAndWrap(NameIdentifier.of(metalake, 
catalog)).catalog();
+    ((TestCatalogOperations) testCatalog.ops())
+        .alterTable(ident, TableChange.renameColumn(new String[] {"c1"}, 
"c1_new"));
+    try {
+      doThrow(new 
IOException(TableMetaService.UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX + ident))
+          .when(entityStore)
+          .update(any(), any(), any(), any());
+      EntityCombinedTable loaded = (EntityCombinedTable) 
tableOperationDispatcher.loadTable(ident);
+      Assertions.assertEquals(snapshot, loaded.tableFromGravitino());
+      Assertions.assertEquals("c1_new", loaded.columns()[0].name());
+      verify(entityStore, times(1)).update(any(), any(), any(), any());
+      Assertions.assertEquals(snapshot, entityStore.get(ident, TABLE, 
TableEntity.class));
+
+      reset(entityStore);
+      doThrow(new IOException("Storage unavailable"))
+          .when(entityStore)
+          .update(any(), any(), any(), any());
+      Assertions.assertNull(
+          ((EntityCombinedTable) 
tableOperationDispatcher.loadTable(ident)).tableFromGravitino());
+
+      reset(entityStore);
+      doThrow(
+              new IOException(
+                  TableMetaService.UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX + 
"another_table"))
+          .when(entityStore)
+          .update(any(), any(), any(), any());
+      Assertions.assertNull(
+          ((EntityCombinedTable) 
tableOperationDispatcher.loadTable(ident)).tableFromGravitino());
+    } finally {
+      reset(entityStore);
+    }
+  }
+
   @Test
   public void testLoadDoesNotUndoConcurrentColumnRename() throws IOException {
     Namespace tableNs = Namespace.of(metalake, catalog, "schema_load_race");
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTableMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTableMetaService.java
index 08d874f295..5399f3c0f9 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTableMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTableMetaService.java
@@ -177,6 +177,78 @@ public class TestTableMetaService extends TestJDBCBackend {
     assertFalse(legacyRecordExistsInDB(table.id(), Entity.EntityType.TABLE));
   }
 
+  @TestTemplate
+  void testConflictingUpdatePreservesWinningVersion() throws IOException {
+    createParentEntities(metalakeName, catalogName, schemaName, AUDIT_INFO);
+    Namespace namespace = Namespace.of(metalakeName, catalogName, schemaName);
+    long tableId = RandomIdGenerator.INSTANCE.nextId();
+    ColumnEntity column =
+        ColumnEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("column1")
+            .withPosition(0)
+            .withDataType(Types.IntegerType.get())
+            .withNullable(true)
+            .withAutoIncrement(false)
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableEntity original =
+        TableEntity.builder()
+            .withId(tableId)
+            .withName("conflicting_table")
+            .withNamespace(namespace)
+            .withColumns(List.of(column))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService service = TableMetaService.getInstance();
+    service.insertTable(original, false);
+    TableEntity winner =
+        TableEntity.builder()
+            .withId(tableId)
+            .withName(original.name())
+            .withNamespace(namespace)
+            .withComment("winner")
+            .withProperties(ImmutableMap.of("key", "winner"))
+            .withColumns(List.of(column))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableEntity loser =
+        TableEntity.builder()
+            .withId(tableId)
+            .withName(original.name())
+            .withNamespace(namespace)
+            .withComment("loser")
+            .withProperties(ImmutableMap.of("key", "loser"))
+            .withColumns(List.of())
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+
+    IOException conflict =
+        assertThrows(
+            IOException.class,
+            () ->
+                service.updateTable(
+                    original.nameIdentifier(),
+                    (TableEntity old) -> {
+                      // The outer update has read its old snapshot, but has 
not begun its write
+                      // transaction.
+                      try {
+                        service.updateTable(
+                            original.nameIdentifier(), (TableEntity ignored) 
-> winner);
+                      } catch (IOException e) {
+                        throw new AssertionError(e);
+                      }
+                      return loser;
+                    }));
+    Assertions.assertEquals(
+        TableMetaService.UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX + 
original.nameIdentifier(),
+        conflict.getMessage());
+    TableEntity stored = 
service.getTableByIdentifier(original.nameIdentifier());
+    Assertions.assertEquals(winner.comment(), stored.comment());
+    Assertions.assertEquals(winner.properties(), stored.properties());
+    compareTwoColumns(winner.columns(), stored.columns());
+  }
+
   @TestTemplate
   public void testUpdateTable() throws IOException {
     String catalogName = "catalog1";

Reply via email to