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

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


The following commit(s) were added to refs/heads/main by this push:
     new 28a4ac73c8 [#11891] fix(lance): retry repair-on-load metadata update 
on optimistic-lock conflict (#11892)
28a4ac73c8 is described below

commit 28a4ac73c8f73188ae022f1fa65d9f124b0cac80
Author: Qi Yu <[email protected]>
AuthorDate: Tue Jul 7 20:41:07 2026 +0800

    [#11891] fix(lance): retry repair-on-load metadata update on 
optimistic-lock conflict (#11892)
    
    ### What changes were proposed in this pull request?
    
    `LanceTableOperations.repairTableMetadata` and
    `recordCheckedEmptyVersion` run an optimistic-locked
    `EntityStore.update` on every `loadTable`. When two loads repair the
    same table concurrently, the slower CAS matches zero rows and surfaces
    as `IOException("Failed to update the entity")`, which was rethrown as a
    fatal `RuntimeException("Failed to repair table")` (HTTP 500).
    
    This wraps that update in a bounded CAS retry
    (`updateTableWithCasRetry`, 5 attempts): on conflict it re-reads the
    latest (already-repaired) entity and re-applies the idempotent updater,
    returning a usable table instead of failing.
    
    ### Why are the changes needed?
    
    Concurrent repair-on-load races (e.g. Spark parallel `LOAD` during
    planning + execution) intermittently fail table loads with HTTP 500.
    Seen as flaky
    `LanceSparkRESTServiceIT.testSelectFromEmptyTableViaSpark`.
    
    Fix: #11891
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    Added
    `TestLanceTableOperations.testLoadTableSurvivesConcurrentRepairVersionRace`:
    it models a lost CAS (first `store.update` throws the conflict
    `IOException`, the retry re-reads the winner's already-repaired entity)
    and asserts `loadTable` returns the repaired table. It fails before the
    fix and passes after. The full `TestLanceTableOperations` suite (23
    tests) passes locally.
    
    ---------
    
    Signed-off-by: yuqi <[email protected]>
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
---
 .../lakehouse/lance/LanceTableOperations.java      |  88 ++++++-
 .../lance/TestLanceConcurrentRepairStress.java     | 283 +++++++++++++++++++++
 .../lakehouse/lance/TestLanceTableOperations.java  |  85 +++++++
 .../relational/service/TableMetaService.java       |  12 +-
 4 files changed, 461 insertions(+), 7 deletions(-)

diff --git 
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
 
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
index 38dbf0e90d..7edfc468d5 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
@@ -31,6 +31,8 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import org.apache.arrow.vector.types.pojo.Field;
@@ -63,6 +65,7 @@ import org.apache.gravitino.rel.expressions.sorts.SortOrder;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.storage.relational.service.TableMetaService;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.lance.Dataset;
 import org.lance.ReadOptions;
@@ -79,6 +82,18 @@ import org.slf4j.LoggerFactory;
 public class LanceTableOperations extends ManagedTableOperations {
   private static final Logger LOG = 
LoggerFactory.getLogger(LanceTableOperations.class);
 
+  /**
+   * Max attempts for the optimistic-locked repair-on-load {@code 
store.update}. Concurrent loads
+   * can repair the same table at once; the loser of the version CAS re-reads 
and retries.
+   */
+  private static final int REPAIR_UPDATE_MAX_ATTEMPTS = 5;
+
+  /** Lower bound (inclusive) of the randomized backoff slept between lost-CAS 
retries. */
+  private static final long REPAIR_RETRY_MIN_BACKOFF_MS = 10;
+
+  /** Upper bound (inclusive) of the randomized backoff slept between lost-CAS 
retries. */
+  private static final long REPAIR_RETRY_MAX_BACKOFF_MS = 100;
+
   public enum CreationMode {
     CREATE,
     EXIST_OK,
@@ -527,10 +542,8 @@ public class LanceTableOperations extends 
ManagedTableOperations {
   private Table repairTableMetadata(NameIdentifier ident, Column[] columns, 
long datasetVersion) {
     try {
       TableEntity tableEntity =
-          store.update(
+          updateTableWithCasRetry(
               ident,
-              TableEntity.class,
-              Entity.EntityType.TABLE,
               current -> {
                 if (!needsSchemaRefresh(current, datasetVersion)) {
                   return current;
@@ -547,13 +560,76 @@ public class LanceTableOperations extends 
ManagedTableOperations {
     }
   }
 
+  /**
+   * Applies an idempotent update to the stored table, retrying when the 
optimistic-lock CAS is lost
+   * to a concurrent update. The repair-on-load path runs on every {@code 
loadTable}, so concurrent
+   * loads of the same table race on the version CAS; {@code store.update} 
surfaces the lost race as
+   * an {@link IOException} whose message starts with {@link
+   * TableMetaService#UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX}. Because the 
updater is idempotent, the
+   * loser sleeps a short randomized backoff (to avoid re-colliding), re-reads 
the latest (already
+   * repaired) entity, and retries instead of failing the whole load with a 
fatal error. Other IO
+   * failures (DB outage, serialization errors, etc.) are not conflicts and 
fail fast.
+   */
+  private TableEntity updateTableWithCasRetry(
+      NameIdentifier ident, Function<TableEntity, TableEntity> updater) throws 
IOException {
+    IOException lastConflict = null;
+    for (int attempt = 1; attempt <= REPAIR_UPDATE_MAX_ATTEMPTS; attempt++) {
+      try {
+        return store.update(ident, TableEntity.class, Entity.EntityType.TABLE, 
updater);
+      } catch (IOException e) {
+        // Only retry when the update matched 0 rows (lost optimistic-lock 
CAS). Other IO failures
+        // (DB outage, serialization errors, etc.) should fail fast.
+        String message = e.getMessage();
+        if (message == null
+            || 
!message.startsWith(TableMetaService.UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX)) {
+          throw e;
+        }
+
+        lastConflict = e;
+        LOG.debug(
+            "Optimistic-lock conflict updating table {} metadata (attempt 
{}/{}), {}",
+            ident,
+            attempt,
+            REPAIR_UPDATE_MAX_ATTEMPTS,
+            attempt < REPAIR_UPDATE_MAX_ATTEMPTS ? "retrying" : "retries 
exhausted",
+            e);
+
+        if (attempt < REPAIR_UPDATE_MAX_ATTEMPTS) {
+          backoffBeforeRetry(ident);
+        }
+      }
+    }
+    throw new IOException(
+        String.format(
+            "Failed to update table %s after %d optimistic-lock retries",
+            ident, REPAIR_UPDATE_MAX_ATTEMPTS),
+        lastConflict);
+  }
+
+  /**
+   * Sleeps a short randomized backoff between two lost-CAS retries so that 
racing loads de-sync
+   * instead of immediately colliding again. Package-private so tests can 
neutralize the sleep.
+   *
+   * @param ident the table being updated, used only for the interrupt error 
message
+   * @throws IOException if the thread is interrupted while backing off
+   */
+  void backoffBeforeRetry(NameIdentifier ident) throws IOException {
+    long backoffMillis =
+        ThreadLocalRandom.current()
+            .nextLong(REPAIR_RETRY_MIN_BACKOFF_MS, REPAIR_RETRY_MAX_BACKOFF_MS 
+ 1);
+    try {
+      Thread.sleep(backoffMillis);
+    } catch (InterruptedException ie) {
+      Thread.currentThread().interrupt();
+      throw new IOException("Interrupted while retrying metadata update for " 
+ ident, ie);
+    }
+  }
+
   private Table recordCheckedEmptyVersion(NameIdentifier ident, long 
datasetVersion) {
     try {
       TableEntity tableEntity =
-          store.update(
+          updateTableWithCasRetry(
               ident,
-              TableEntity.class,
-              Entity.EntityType.TABLE,
               current -> {
                 if (!isDatasetVersionChanged(current.properties(), 
datasetVersion)) {
                   return current;
diff --git 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceConcurrentRepairStress.java
 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceConcurrentRepairStress.java
new file mode 100644
index 0000000000..4a67be530b
--- /dev/null
+++ 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceConcurrentRepairStress.java
@@ -0,0 +1,283 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.gravitino.catalog.lakehouse.lance;
+
+import static 
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_STORAGE_OPTIONS_PREFIX;
+import static 
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_DECLARED;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.Entity.EntityType;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.HasIdentifier;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.catalog.ManagedSchemaOperations;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.utils.Executable;
+import org.apache.gravitino.utils.PrincipalUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.TempDir;
+import org.lance.Dataset;
+import org.mockito.Mockito;
+
+/**
+ * Real multi-threaded reproduction of the repair-on-load optimistic-lock race 
behind #11891. Unlike
+ * {@code 
TestLanceTableOperations#testLoadTableSurvivesConcurrentRepairVersionRace} (a
+ * deterministic mock that throws one scripted {@code IOException}), this test 
drives {@link
+ * LanceTableOperations#loadTable} from several threads at once against a 
{@link CasEntityStore}
+ * that models the production relational store's compare-and-set semantics 
faithfully: every {@code
+ * update} bumps a version guarded by the base version, so concurrent updates 
from the same base
+ * conflict and exactly one wins per generation — just like {@code 
TableMetaService.updateTable}
+ * ({@code UPDATE ... WHERE current_version = old}).
+ *
+ * <p>Before the CAS retry, the loser of the race got {@code 
IOException("Failed to update the
+ * entity")}, rethrown as a fatal {@code RuntimeException} (HTTP 500). This 
test asserts every
+ * concurrent load returns the repaired table instead.
+ */
+public class TestLanceConcurrentRepairStress {
+
+  @TempDir private java.nio.file.Path tempDir;
+
+  private static final NameIdentifier IDENT = NameIdentifier.of("schema", 
"table");
+
+  @Test
+  @Timeout(60)
+  public void testConcurrentRepairLoadsSurviveCasContention() throws Exception 
{
+    // The reported race is "two loads repair the same table at once". Also 
drive a small herd to
+    // confirm the bounded CAS retry stays robust beyond the minimal two-load 
case. Iterations are
+    // kept modest (and the retry backoff is neutralized in runStress) so this 
stays a fast,
+    // non-flaky unit test rather than a soak test — the pre-fix bug already 
fails on iteration 0.
+    runStress(2, 500);
+    runStress(8, 300);
+  }
+
+  private void runStress(int concurrency, int iterations) throws Exception {
+    String location = tempDir.resolve("cas-race-" + concurrency).toString();
+    Map<String, String> storageOptions = Map.of("endpoint", "http://endpoint";);
+    Schema datasetSchema =
+        new Schema(
+            List.of(
+                Field.nullable("id", new ArrowType.Int(32, true)),
+                Field.nullable("name", new ArrowType.Utf8())));
+
+    ManagedSchemaOperations schemaOps = mock(ManagedSchemaOperations.class);
+    IdGenerator idGenerator = mock(IdGenerator.class);
+    AtomicLong idSeq = new AtomicLong(1000);
+    when(idGenerator.nextId()).thenAnswer(invocation -> 
idSeq.incrementAndGet());
+
+    ExecutorService pool = Executors.newFixedThreadPool(concurrency);
+    UserPrincipal user = new UserPrincipal("tester");
+    int failures = 0;
+    try {
+      for (int iter = 0; iter < iterations; iter++) {
+        // Fresh stale (declared, empty-schema) entity every iteration so each 
round starts from the
+        // pre-repair state that triggers repairTableMetadata on every 
concurrent load.
+        TableEntity stale =
+            tableEntity(
+                List.of(),
+                Map.of(
+                    Table.PROPERTY_LOCATION,
+                    location,
+                    LANCE_TABLE_DECLARED,
+                    "true",
+                    LANCE_STORAGE_OPTIONS_PREFIX + "endpoint",
+                    "http://endpoint";));
+        CasEntityStore store = new CasEntityStore(stale);
+        LanceTableOperations ops = spy(new LanceTableOperations(store, 
schemaOps, idGenerator));
+        // Neutralize the inter-retry backoff: this test validates the CAS 
re-read/retry recovery,
+        // not the sleep duration, and real sleeps would make it slow and 
timing-flaky.
+        Mockito.doNothing().when(ops).backoffBeforeRetry(Mockito.any());
+        Dataset dataset = mock(Dataset.class);
+        when(dataset.version()).thenReturn(8L);
+        when(dataset.getSchema()).thenReturn(datasetSchema);
+        Mockito.doReturn(dataset).when(ops).openDataset(location, 
storageOptions);
+
+        CyclicBarrier barrier = new CyclicBarrier(concurrency);
+        List<Future<Table>> futures = new java.util.ArrayList<>(concurrency);
+        for (int t = 0; t < concurrency; t++) {
+          Callable<Table> task =
+              () -> {
+                barrier.await();
+                return PrincipalUtils.doAs(user, () -> ops.loadTable(IDENT));
+              };
+          futures.add(pool.submit(task));
+        }
+
+        for (Future<Table> future : futures) {
+          try {
+            Table loaded = future.get(30, TimeUnit.SECONDS);
+            Assertions.assertEquals(
+                2, loaded.columns().length, "repaired table must expose both 
columns");
+          } catch (ExecutionException e) {
+            failures++;
+            // Surface the first real failure with its cause for debugging.
+            if (failures == 1) {
+              throw new AssertionError(
+                  "Concurrent repair-on-load failed at iteration "
+                      + iter
+                      + " with concurrency="
+                      + concurrency,
+                  e.getCause());
+            }
+          }
+        }
+      }
+    } finally {
+      pool.shutdownNow();
+    }
+    Assertions.assertEquals(
+        0, failures, "no concurrent load should fail (concurrency=" + 
concurrency + ")");
+  }
+
+  private static TableEntity tableEntity(
+      List<org.apache.gravitino.meta.ColumnEntity> columns, Map<String, 
String> properties) {
+    return TableEntity.builder()
+        .withId(1L)
+        .withName(IDENT.name())
+        .withNamespace(IDENT.namespace())
+        .withComment("comment")
+        .withColumns(columns)
+        .withProperties(properties)
+        .withAuditInfo(
+            
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.EPOCH).build())
+        .build();
+  }
+
+  /**
+   * In-memory {@link EntityStore} that reproduces the relational store's 
optimistic-lock CAS:
+   * {@code update} reads a versioned snapshot, applies the (idempotent) 
updater, and commits only
+   * if the version has not advanced since the read — otherwise it throws 
{@code IOException("Failed
+   * to update the entity")}, exactly as {@code TableMetaService.updateTable} 
does when {@code
+   * UPDATE ... WHERE current_version = old} matches zero rows. Every commit 
bumps the version, so
+   * even a no-op update invalidates a concurrent update from the same base, 
matching production.
+   */
+  private static final class CasEntityStore implements EntityStore {
+
+    private static final class Versioned {
+      private final long version;
+      private final TableEntity entity;
+
+      Versioned(long version, TableEntity entity) {
+        this.version = version;
+        this.entity = entity;
+      }
+    }
+
+    private final AtomicReference<Versioned> ref;
+
+    CasEntityStore(TableEntity initial) {
+      this.ref = new AtomicReference<>(new Versioned(0L, initial));
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <E extends Entity & HasIdentifier> E get(
+        NameIdentifier ident, EntityType entityType, Class<E> type) {
+      return (E) ref.get().entity;
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <E extends Entity & HasIdentifier> E update(
+        NameIdentifier ident, Class<E> type, EntityType entityType, 
Function<E, E> updater)
+        throws IOException {
+      Versioned base = ref.get();
+      E updated = updater.apply((E) base.entity);
+      Versioned next = new Versioned(base.version + 1, (TableEntity) updated);
+      if (ref.compareAndSet(base, next)) {
+        return updated;
+      }
+      throw new IOException("Failed to update the entity: " + ident);
+    }
+
+    // --- unused surface ---------------------------------------------------
+
+    @Override
+    public void initialize(Config config) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean exists(NameIdentifier ident, EntityType entityType) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public <E extends Entity & HasIdentifier> void put(E e, boolean 
overwritten) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public <E extends Entity & HasIdentifier> List<E> batchGet(
+        List<NameIdentifier> idents, EntityType entityType, Class<E> clazz) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean delete(NameIdentifier ident, EntityType entityType, boolean 
cascade) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public int batchDelete(
+        List<Pair<NameIdentifier, EntityType>> entitiesToDelete, boolean 
cascade) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public <E extends Entity & HasIdentifier> void batchPut(List<E> entities, 
boolean overwritten) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public <R, E extends Exception> R executeInTransaction(Executable<R, E> 
executable) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void close() {}
+  }
+}
diff --git 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
index 59b2a41ff9..bf99a64814 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
@@ -34,6 +34,7 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.Maps;
+import java.io.IOException;
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
@@ -170,6 +171,90 @@ public class TestLanceTableOperations {
     
Assertions.assertFalse(loadedTable.properties().containsKey(LANCE_TABLE_DECLARED));
   }
 
+  /**
+   * Reproduces the concurrent repair-on-load race seen in {@code 
LanceSparkRESTServiceIT}. When two
+   * loads repair the same table at once, the optimistic-locked {@code 
store.update} of the slower
+   * one matches zero rows and {@code TableMetaService} surfaces it as {@code 
IOException("Failed to
+   * update the entity")}. Before the CAS retry, {@code repairTableMetadata} 
rethrew it as a fatal
+   * {@code RuntimeException} (HTTP 500) instead of tolerating the concurrent 
update. This test
+   * asserts that the lost race is benign and load returns a usable table.
+   */
+  @Test
+  public void testLoadTableSurvivesConcurrentRepairVersionRace() throws 
Exception {
+    NameIdentifier ident = NameIdentifier.of("schema", "table");
+    String location = tempDir.resolve("concurrent-repair-table").toString();
+    TableEntity tableEntity =
+        tableEntity(
+            ident,
+            List.of(),
+            Map.of(
+                Table.PROPERTY_LOCATION,
+                location,
+                LANCE_TABLE_DECLARED,
+                "true",
+                LANCE_STORAGE_OPTIONS_PREFIX + "endpoint",
+                "http://endpoint";));
+    // The winner of the race already repaired the table to the dataset schema 
and version.
+    TableEntity alreadyRepairedTableEntity =
+        tableEntity(
+            ident,
+            List.of(
+                ColumnEntity.builder()
+                    .withId(11L)
+                    .withName("id")
+                    .withDataType(Types.IntegerType.get())
+                    .withPosition(0)
+                    .withAuditInfo(AuditInfo.EMPTY)
+                    .build(),
+                ColumnEntity.builder()
+                    .withId(12L)
+                    .withName("name")
+                    .withDataType(Types.StringType.get())
+                    .withPosition(1)
+                    .withAuditInfo(AuditInfo.EMPTY)
+                    .build()),
+            Map.of(Table.PROPERTY_LOCATION, location, LANCE_TABLE_VERSION, 
"8"));
+    when(store.get(eq(ident), eq(Entity.EntityType.TABLE), 
eq(TableEntity.class)))
+        .thenReturn(tableEntity);
+    when(idGenerator.nextId()).thenReturn(10L, 11L);
+
+    // First repair attempt loses the optimistic-lock CAS (a concurrent load 
already bumped the
+    // version): TableMetaService surfaces exactly this IOException. The retry 
re-reads the winner's
+    // already-repaired entity, against which the idempotent updater succeeds.
+    when(store.update(eq(ident), eq(TableEntity.class), 
eq(Entity.EntityType.TABLE), any()))
+        .thenThrow(new IOException("Failed to update the entity: " + ident))
+        .thenAnswer(
+            invocation -> {
+              @SuppressWarnings("unchecked")
+              Function<TableEntity, TableEntity> updater = 
invocation.getArgument(3);
+              return updater.apply(alreadyRepairedTableEntity);
+            });
+
+    Dataset dataset = mock(Dataset.class);
+    when(dataset.getSchema())
+        .thenReturn(
+            new Schema(
+                List.of(
+                    Field.nullable("id", new ArrowType.Int(32, true)),
+                    Field.nullable("name", new ArrowType.Utf8()))));
+    when(dataset.version()).thenReturn(8L);
+    Mockito.doReturn(dataset)
+        .when(lanceTableOps)
+        .openDataset(location, Map.of("endpoint", "http://endpoint";));
+
+    // A lost repair race must not fail the load: the bounded CAS retry 
recovers and returns the
+    // repaired table instead of surfacing the first conflict as 
RuntimeException("Failed to repair
+    // table").
+    Table loadedTable =
+        Assertions.assertDoesNotThrow(
+            () ->
+                PrincipalUtils.doAs(
+                    new UserPrincipal("tester"), () -> 
lanceTableOps.loadTable(ident)));
+    Assertions.assertEquals(2, loadedTable.columns().length);
+    Assertions.assertEquals("id", loadedTable.columns()[0].name());
+    Assertions.assertEquals("name", loadedTable.columns()[1].name());
+  }
+
   @Test
   public void testLoadTableWithStoredColumnsDoesNotReadLocation() throws 
Exception {
     NameIdentifier ident = NameIdentifier.of("schema", "table");
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 72391bc31b..17ee0d5c3b 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
@@ -56,6 +56,16 @@ import org.apache.gravitino.utils.NamespaceUtil;
 
 /** The service class for table metadata. It provides the basic database 
operations for table. */
 public class TableMetaService {
+
+  /**
+   * Message prefix of the {@link java.io.IOException} thrown by {@link 
#updateTable} when the
+   * optimistic-lock CAS matches zero rows (the stored version advanced under 
a concurrent update).
+   * Exposed so callers that retry the lost race (e.g. the Lance 
repair-on-load path) can recognize
+   * the conflict without re-declaring the literal.
+   */
+  public static final String UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX =
+      "Failed to update the entity: ";
+
   private static final TableMetaService INSTANCE = new TableMetaService();
   private BasePOStorageOps<TablePO, TableMetaMapper> ops;
 
@@ -236,7 +246,7 @@ public class TableMetaService {
     if (updateResult.get() > 0) {
       return newTableEntity;
     } else {
-      throw new IOException("Failed to update the entity: " + identifier);
+      throw new IOException(UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX + 
identifier);
     }
   }
 

Reply via email to