Copilot commented on code in PR #13792:
URL: https://github.com/apache/gravitino/pull/13792#discussion_r4248609802


##########
core/src/main/java/org/apache/gravitino/job/JobManager.java:
##########
@@ -600,6 +600,27 @@ public JobEntity runJob(String metalake, String 
jobTemplateName, Map<String, Str
           "Job template with name %s under metalake %s does not exist",
           jobTemplateName,
           metalake);
+    } catch (OptimisticLockException e) {
+      // The registration conflicted with a concurrent writer, e.g. a lock 
wait timeout, and the
+      // conflict tells the caller to retry. A retry submits the job again, so 
cancel this
+      // execution rather than leave it running untracked.
+      LOG.error(
+          "Job {} was submitted as execution {} but could not be registered 
because of a "
+              + "concurrent operation, cancelling the execution",
+          jobEntity.name(),
+          jobExecutionId,
+          e);

Review Comment:
   This logs an expected retryable conflict (HTTP 409 semantics) at ERROR 
level, which can create noisy error logs during normal concurrent activity and 
obscure real failures. Consider logging this as WARN (or INFO if appropriate 
for your operational posture), reserving ERROR for non-retryable or unexpected 
job-registration failures.



##########
core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreLockWaitTimeout.java:
##########
@@ -0,0 +1,494 @@
+/*
+ * 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.storage.relational;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.time.Instant;
+import java.util.List;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.RelationEdgeTarget;
+import org.apache.gravitino.RelationUpdate;
+import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.cache.EntityCache;
+import org.apache.gravitino.exceptions.OptimisticLockException;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.BaseMetalake;
+import org.apache.gravitino.meta.CatalogEntity;
+import org.apache.gravitino.meta.SchemaEntity;
+import org.apache.gravitino.meta.SchemaVersion;
+import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.meta.TagEntity;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.apache.ibatis.exceptions.PersistenceException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+import org.mockito.Mockito;
+
+/**
+ * Verifies that {@link RelationalEntityStore} reports a write that fails on a 
lock wait timeout as
+ * an {@link OptimisticLockException}, and leaves every other failure 
unchanged.
+ */
+public class TestRelationalEntityStoreLockWaitTimeout {
+
+  private static final String METALAKE = "metalake_lwt";
+  private static final String CATALOG = "catalog_lwt";
+  private static final String SCHEMA = "schema_lwt";
+  private static final String USER = "root";
+  private static final String PASSWORD = "123456";
+
+  private static final AuditInfo AUDIT_INFO =
+      
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+
+  private static final NameIdentifier TABLE_IDENT =
+      NameIdentifier.of(METALAKE, CATALOG, SCHEMA, "t1");
+
+  private RelationalEntityStore store;
+  private String dbPath;
+  private Object previousConfig;
+  private Object previousIdGenerator;
+
+  @AfterEach
+  void tearDown() throws Exception {
+    // Only the H2 store owns resources; a store over a failing mock backend 
is just dropped.
+    if (dbPath != null) {
+      store.close();
+      FileUtils.deleteQuietly(new File(dbPath));

Review Comment:
   The test cleanup deletes `dbPath` (a temp directory), but the H2 URL uses 
`jdbc:h2:file:%s` where `%s` is the directory path. H2 typically creates 
database files adjacent to that path (e.g., `<dbPath>.mv.db`, 
`<dbPath>.trace.db`) rather than inside the directory, so `deleteQuietly(new 
File(dbPath))` may leave the actual DB files behind. To avoid leaking temp 
files across test runs, consider either (a) using a file base name inside the 
directory (e.g., `...file:%s/db...`) so deleting the directory removes all 
artifacts, or (b) explicitly deleting the known H2 file suffixes for the chosen 
base path.



##########
core/src/main/java/org/apache/gravitino/storage/relational/utils/ExceptionUtils.java:
##########
@@ -69,4 +94,103 @@ public static OptimisticLockException 
concurrentChildModification(
         parentType.name().toLowerCase(Locale.ROOT),
         parentIdentifier);
   }
+
+  /**
+   * Throws a write that failed on a lock wait timeout as a conflict, and 
returns otherwise. A
+   * service calls this before {@link #checkSQLException} when the type and 
name it reports for
+   * other SQL errors do not describe what was written, for example the 
metadata object of a tag
+   * association.
+   *
+   * @param re The failure of the write.
+   * @param target What was being written, as the subject of the message.
+   * @throws OptimisticLockException If the failure is a lock wait timeout.
+   * @see #lockWaitTimeoutAsConflict(RuntimeException, String)
+   */
+  public static void throwIfLockWaitTimeout(RuntimeException re, String 
target) {
+    RuntimeException conflict = lockWaitTimeoutAsConflict(re, target);
+    if (conflict != re) {
+      throw conflict;
+    }
+  }
+
+  /**
+   * Returns whether a failure was caused by a statement that waited on a row 
lock longer than the
+   * backend's lock wait timeout. MySQL reports error code 1205, H2 error code 
50200 (SQLState
+   * HYT00), and PostgreSQL SQLState 55P03 when lock_timeout is set. The MySQL 
and H2 vendor codes
+   * are matched rather than their SQLState, because the MySQL driver reports 
a lock wait timeout as
+   * SQLState 40001 or HY000 depending on its version, and 40001 is also used 
for deadlocks.
+   *
+   * <p>Deadlocks are deliberately not included: a deadlock on a write path 
points at an
+   * inconsistent lock order, which should be fixed on that path rather than 
reported as a conflict.
+   *
+   * @param failure The failure to inspect, including its causes.
+   * @return {@code true} if a lock wait timeout is found in the cause chain.
+   */
+  public static boolean isLockWaitTimeout(Throwable failure) {
+    return hasSqlException(
+        failure,
+        Set.of(POSTGRESQL_LOCK_NOT_AVAILABLE),
+        Set.of(MYSQL_LOCK_WAIT_TIMEOUT, H2_LOCK_TIMEOUT));
+  }
+
+  /**
+   * Returns whether the cause chain of a failure contains a {@link 
SQLException} with one of the
+   * given SQLStates or vendor error codes.
+   *
+   * @param failure The failure to inspect, including its causes.
+   * @param sqlStates The SQLStates to match.
+   * @param vendorErrorCodes The vendor error codes to match.
+   * @return {@code true} if a matching {@link SQLException} is found.
+   */
+  public static boolean hasSqlException(
+      Throwable failure, Set<String> sqlStates, Set<Integer> vendorErrorCodes) 
{
+    for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
+      if (cause instanceof SQLException) {
+        SQLException sql = (SQLException) cause;
+        if (sqlStates.contains(sql.getSQLState())
+            || vendorErrorCodes.contains(sql.getErrorCode())) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }

Review Comment:
   `hasSqlException` only walks the `Throwable.getCause()` chain. JDBC drivers 
frequently attach additional `SQLException`s via 
`SQLException#getNextException()` (especially for batch/statement failures), 
which this implementation will miss—causing lock-wait timeouts (or other 
classified SQL errors) to be misreported. Consider iterating `SQLException`'s 
`getNextException()` chain for each encountered `SQLException` (while still 
walking the outer cause chain) so classification works reliably across drivers 
and batch scenarios.



##########
core/src/main/java/org/apache/gravitino/storage/relational/utils/ExceptionUtils.java:
##########
@@ -69,4 +94,103 @@ public static OptimisticLockException 
concurrentChildModification(
         parentType.name().toLowerCase(Locale.ROOT),
         parentIdentifier);
   }
+
+  /**
+   * Throws a write that failed on a lock wait timeout as a conflict, and 
returns otherwise. A
+   * service calls this before {@link #checkSQLException} when the type and 
name it reports for
+   * other SQL errors do not describe what was written, for example the 
metadata object of a tag
+   * association.
+   *
+   * @param re The failure of the write.
+   * @param target What was being written, as the subject of the message.
+   * @throws OptimisticLockException If the failure is a lock wait timeout.
+   * @see #lockWaitTimeoutAsConflict(RuntimeException, String)
+   */
+  public static void throwIfLockWaitTimeout(RuntimeException re, String 
target) {
+    RuntimeException conflict = lockWaitTimeoutAsConflict(re, target);
+    if (conflict != re) {
+      throw conflict;
+    }
+  }
+
+  /**
+   * Returns whether a failure was caused by a statement that waited on a row 
lock longer than the
+   * backend's lock wait timeout. MySQL reports error code 1205, H2 error code 
50200 (SQLState
+   * HYT00), and PostgreSQL SQLState 55P03 when lock_timeout is set. The MySQL 
and H2 vendor codes
+   * are matched rather than their SQLState, because the MySQL driver reports 
a lock wait timeout as
+   * SQLState 40001 or HY000 depending on its version, and 40001 is also used 
for deadlocks.
+   *
+   * <p>Deadlocks are deliberately not included: a deadlock on a write path 
points at an
+   * inconsistent lock order, which should be fixed on that path rather than 
reported as a conflict.
+   *
+   * @param failure The failure to inspect, including its causes.
+   * @return {@code true} if a lock wait timeout is found in the cause chain.
+   */
+  public static boolean isLockWaitTimeout(Throwable failure) {
+    return hasSqlException(
+        failure,
+        Set.of(POSTGRESQL_LOCK_NOT_AVAILABLE),
+        Set.of(MYSQL_LOCK_WAIT_TIMEOUT, H2_LOCK_TIMEOUT));
+  }

Review Comment:
   `isLockWaitTimeout` allocates new `Set` instances on every call 
(`Set.of(...)`). If this method is called on hot paths (multiple write 
failures, retries), these allocations are avoidable. Consider hoisting these 
into `private static final` sets (e.g., `LOCK_WAIT_TIMEOUT_SQLSTATES` / 
`LOCK_WAIT_TIMEOUT_VENDOR_CODES`) and reuse them.



##########
core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreLockWaitTimeout.java:
##########
@@ -0,0 +1,494 @@
+/*
+ * 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.storage.relational;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.time.Instant;
+import java.util.List;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.RelationEdgeTarget;
+import org.apache.gravitino.RelationUpdate;
+import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.cache.EntityCache;
+import org.apache.gravitino.exceptions.OptimisticLockException;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.BaseMetalake;
+import org.apache.gravitino.meta.CatalogEntity;
+import org.apache.gravitino.meta.SchemaEntity;
+import org.apache.gravitino.meta.SchemaVersion;
+import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.meta.TagEntity;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.apache.ibatis.exceptions.PersistenceException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+import org.mockito.Mockito;
+
+/**
+ * Verifies that {@link RelationalEntityStore} reports a write that fails on a 
lock wait timeout as
+ * an {@link OptimisticLockException}, and leaves every other failure 
unchanged.
+ */
+public class TestRelationalEntityStoreLockWaitTimeout {
+
+  private static final String METALAKE = "metalake_lwt";
+  private static final String CATALOG = "catalog_lwt";
+  private static final String SCHEMA = "schema_lwt";
+  private static final String USER = "root";
+  private static final String PASSWORD = "123456";
+
+  private static final AuditInfo AUDIT_INFO =
+      
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+
+  private static final NameIdentifier TABLE_IDENT =
+      NameIdentifier.of(METALAKE, CATALOG, SCHEMA, "t1");
+
+  private RelationalEntityStore store;
+  private String dbPath;
+  private Object previousConfig;
+  private Object previousIdGenerator;
+
+  @AfterEach
+  void tearDown() throws Exception {
+    // Only the H2 store owns resources; a store over a failing mock backend 
is just dropped.
+    if (dbPath != null) {
+      store.close();
+      FileUtils.deleteQuietly(new File(dbPath));
+      dbPath = null;
+      FieldUtils.writeField(GravitinoEnv.getInstance(), "config", 
previousConfig, true);
+      FieldUtils.writeField(GravitinoEnv.getInstance(), "idGenerator", 
previousIdGenerator, true);
+    }
+    store = null;
+  }
+
+  /**
+   * Holds the row of a table from another session, as a concurrent 
table-scoped write does, so that
+   * a drop of the table waits on it until H2 aborts the statement.
+   */
+  @Test
+  void testDropWaitingOnHeldTableRowIsConflict() throws Exception {
+    String jdbcUrl = initH2Store();
+    TableEntity table = createTable();
+
+    OptimisticLockException conflict =
+        writeWhileRowHeld(
+            jdbcUrl,
+            "table_meta",
+            "table_id",
+            table.id(),
+            () -> store.delete(TABLE_IDENT, Entity.EntityType.TABLE));
+    Assertions.assertTrue(
+        conflict.getMessage().startsWith("The table " + TABLE_IDENT + " could 
not be changed"),
+        conflict.getMessage());
+
+    // The failed drop was rolled back, so the table is still there and a 
retry succeeds.
+    Assertions.assertTrue(store.exists(TABLE_IDENT, Entity.EntityType.TABLE));
+    Assertions.assertTrue(store.delete(TABLE_IDENT, Entity.EntityType.TABLE));
+    Assertions.assertFalse(store.exists(TABLE_IDENT, Entity.EntityType.TABLE));
+  }
+
+  /** An alter goes through the service's own SQL error conversion, which must 
keep the conflict. */
+  @Test
+  void testAlterWaitingOnHeldTableRowIsConflict() throws Exception {
+    String jdbcUrl = initH2Store();
+    TableEntity table = createTable();
+    NameIdentifier renamed = NameIdentifier.of(TABLE_IDENT.namespace(), 
"t1_renamed");
+
+    OptimisticLockException conflict =
+        writeWhileRowHeld(
+            jdbcUrl,
+            "table_meta",
+            "table_id",
+            table.id(),
+            () ->
+                store.update(
+                    TABLE_IDENT, TableEntity.class, Entity.EntityType.TABLE, 
this::rename));
+    // The service names the entity it writes, which is the new name of a 
rename.
+    Assertions.assertTrue(
+        conflict.getMessage().startsWith("The table " + renamed + " could not 
be changed"),
+        conflict.getMessage());
+
+    Assertions.assertTrue(store.exists(TABLE_IDENT, Entity.EntityType.TABLE));
+    Assertions.assertFalse(store.exists(renamed, Entity.EntityType.TABLE));
+    store.update(TABLE_IDENT, TableEntity.class, Entity.EntityType.TABLE, 
this::rename);
+    Assertions.assertTrue(store.exists(renamed, Entity.EntityType.TABLE));
+  }
+
+  /** A create locks its parent row first, so it waits behind a write that 
holds the schema. */
+  @Test
+  void testCreateWaitingOnHeldSchemaRowIsConflict() throws Exception {
+    String jdbcUrl = initH2Store();
+    store.put(metalake(), false);
+    store.put(catalog(), false);
+    SchemaEntity schema = schema();
+    store.put(schema, false);
+    TableEntity table = table();
+
+    OptimisticLockException conflict =
+        writeWhileRowHeld(
+            jdbcUrl, "schema_meta", "schema_id", schema.id(), () -> 
store.put(table, false));
+    Assertions.assertTrue(
+        conflict.getMessage().startsWith("The table " + TABLE_IDENT + " could 
not be changed"),
+        conflict.getMessage());
+
+    Assertions.assertFalse(store.exists(TABLE_IDENT, Entity.EntityType.TABLE));
+    store.put(table, false);
+    Assertions.assertTrue(store.exists(TABLE_IDENT, Entity.EntityType.TABLE));
+  }
+
+  /**
+   * A tag association locks the table row first, as the issue's example of a 
table-scoped write.
+   */
+  @Test
+  void testTagAssociationWaitingOnHeldTableRowIsConflict() throws Exception {
+    String jdbcUrl = initH2Store();
+    TableEntity table = createTable();
+    TagEntity tag =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("tag1")
+            .withNamespace(NamespaceUtil.ofTag(METALAKE))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    store.put(tag, false);
+    NameIdentifier[] tags = {tag.nameIdentifier()};
+
+    OptimisticLockException conflict =
+        writeWhileRowHeld(jdbcUrl, "table_meta", "table_id", table.id(), () -> 
associateTags(tags));
+    Assertions.assertTrue(
+        conflict.getMessage().startsWith("The tags of table " + TABLE_IDENT + 
" could not"),
+        conflict.getMessage());
+
+    Assertions.assertTrue(
+        store
+            .listEntitiesByRelation(
+                SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+                TABLE_IDENT,
+                Entity.EntityType.TABLE)
+            .isEmpty());
+    Assertions.assertEquals(1, associateTags(tags).size());
+  }
+
+  /** Verifies every write of the store maps a lock wait timeout of the 
backend to a conflict. */
+  @Test
+  void testEveryWriteMapsLockWaitTimeout() throws Exception {
+    for (SQLException timeout :
+        List.of(
+            new SQLException("MySQL lock wait timeout", "40001", 1205),
+            new SQLException("MySQL lock wait timeout", "HY000", 1205),
+            new SQLException("H2 lock wait timeout", "HYT00", 50200),
+            new SQLException("PostgreSQL lock timeout", "55P03"))) {
+      PersistenceException failure = new PersistenceException(timeout);
+      initFailingStore(failure);
+      for (Pair<String, Executable> write : writes()) {
+        OptimisticLockException conflict =
+            Assertions.assertThrows(
+                OptimisticLockException.class, write.getRight(), 
write.getLeft());
+        Assertions.assertSame(failure, conflict.getCause(), write.getLeft());
+        Assertions.assertTrue(
+            conflict.getMessage().contains("lock wait timeout"), 
conflict.getMessage());
+      }
+    }
+
+    // The message names what was written.
+    initFailingStore(new PersistenceException(new SQLException("timeout", 
"HYT00", 50200)));
+    OptimisticLockException conflict =
+        Assertions.assertThrows(
+            OptimisticLockException.class,
+            () -> store.delete(TABLE_IDENT, Entity.EntityType.TABLE));
+    Assertions.assertTrue(
+        conflict.getMessage().startsWith("The table " + TABLE_IDENT + " could 
not be changed"),
+        conflict.getMessage());
+  }
+
+  /** Verifies a failure that is not a lock wait timeout reaches the caller 
unchanged. */
+  @Test
+  void testOtherFailuresAreUnchanged() throws Exception {
+    for (RuntimeException failure :
+        List.of(
+            // A deadlock points at an inconsistent lock order and is not 
reported as a conflict.
+            new PersistenceException(new SQLException("MySQL deadlock", 
"40001", 1213)),
+            new PersistenceException(new SQLException("PostgreSQL deadlock", 
"40P01")),
+            new PersistenceException(new SQLException("connection lost", 
"08006")),
+            new IllegalStateException("not a SQL failure"),
+            // A conflict reported by the backend keeps its own message and is 
not wrapped again.
+            new OptimisticLockException(
+                new PersistenceException(new SQLException("timeout", "HYT00", 
50200)),
+                "already a conflict"))) {
+      initFailingStore(failure);
+      for (Pair<String, Executable> write : writes()) {
+        Throwable thrown = Assertions.assertThrows(Throwable.class, 
write.getRight());
+        Assertions.assertSame(failure, thrown, write.getLeft());
+      }
+    }
+  }
+
+  private List<Pair<String, Executable>> writes() {
+    TableEntity table = table();
+    NameIdentifier tagIdent = NameIdentifier.of(METALAKE, "tag1");
+    return List.of(
+        Pair.of("put", () -> store.put(table, false)),
+        Pair.of(
+            "update",
+            () ->
+                store.update(
+                    TABLE_IDENT, TableEntity.class, Entity.EntityType.TABLE, 
entity -> entity)),
+        Pair.of("delete", () -> store.delete(TABLE_IDENT, 
Entity.EntityType.TABLE)),
+        Pair.of(
+            "deleteAndGet",
+            () ->
+                store.deleteAndGet(
+                    TABLE_IDENT, Entity.EntityType.TABLE, TableEntity.class, 
entity -> {})),
+        Pair.of(
+            "insertRelation",
+            () ->
+                store.insertRelation(
+                    SupportsRelationOperations.Type.OWNER_REL,
+                    TABLE_IDENT,
+                    Entity.EntityType.TABLE,
+                    NameIdentifier.of(METALAKE, "user1"),
+                    Entity.EntityType.USER,
+                    true)),
+        Pair.of(
+            "batchInsertRelations",
+            () ->
+                store.batchInsertRelations(
+                    SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+                    List.of(TABLE_IDENT),
+                    Entity.EntityType.TABLE,
+                    tagIdent,
+                    Entity.EntityType.TAG,
+                    false)),
+        Pair.of(
+            "updateEntityRelations",
+            () ->
+                store.updateEntityRelations(
+                    SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+                    TABLE_IDENT,
+                    Entity.EntityType.TABLE,
+                    new NameIdentifier[] {tagIdent},
+                    new NameIdentifier[0])),
+        Pair.of(
+            "updateEntityRelations(RelationUpdate)",
+            () ->
+                store.updateEntityRelations(
+                    RelationUpdate.of(
+                        SupportsRelationOperations.Type.POLICY_TAG_REL,
+                        tagIdent,
+                        Entity.EntityType.TAG,
+                        new RelationEdgeTarget[] {
+                          RelationEdgeTarget.of(
+                              NameIdentifier.of(METALAKE, "policy1"),
+                              Entity.EntityType.POLICY,
+                              null)
+                        },
+                        new RelationEdgeTarget[0]))),
+        Pair.of(
+            "batchDelete",
+            () -> store.batchDelete(List.of(Pair.of(TABLE_IDENT, 
Entity.EntityType.TABLE)), false)),
+        Pair.of("batchPut", () -> store.batchPut(List.of(table), true)));
+  }
+
+  private List<TagEntity> associateTags(NameIdentifier[] tags) throws 
IOException {
+    return store.updateEntityRelations(
+        SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+        TABLE_IDENT,
+        Entity.EntityType.TABLE,
+        tags,
+        new NameIdentifier[0]);
+  }
+
+  private TableEntity rename(TableEntity table) {
+    return TableEntity.builder()
+        .withId(table.id())
+        .withName("t1_renamed")
+        .withNamespace(table.namespace())
+        .withColumns(table.columns())
+        .withAuditInfo(AUDIT_INFO)
+        .build();
+  }
+
+  private TableEntity createTable() throws IOException {
+    store.put(metalake(), false);
+    store.put(catalog(), false);
+    store.put(schema(), false);
+    TableEntity table = table();
+    store.put(table, false);
+    return table;
+  }
+
+  /**
+   * Runs a write while another session holds a row with {@code SELECT ... FOR 
UPDATE}, and checks
+   * that the write fails as a conflict caused by H2's lock wait timeout.
+   */
+  private OptimisticLockException writeWhileRowHeld(
+      String jdbcUrl, String table, String idColumn, long id, Executable write)
+      throws SQLException {
+    try (Connection holder = DriverManager.getConnection(jdbcUrl, USER, 
PASSWORD)) {
+      holder.setAutoCommit(false);
+      try {
+        try (PreparedStatement statement =
+            holder.prepareStatement(
+                String.format(
+                    "SELECT %s FROM %s WHERE %s = ? FOR UPDATE", idColumn, 
table, idColumn))) {
+          statement.setLong(1, id);
+          try (ResultSet rs = statement.executeQuery()) {
+            Assertions.assertTrue(rs.next(), "the row must exist to be held");
+          }
+        }
+
+        OptimisticLockException conflict =
+            Assertions.assertThrows(OptimisticLockException.class, write);
+        Assertions.assertTrue(
+            conflict.getMessage().contains("lock wait timeout; retry the 
operation"),
+            conflict.getMessage());
+        SQLException timeout = findSqlException(conflict);
+        Assertions.assertNotNull(timeout, "the lock wait timeout stays 
attached for diagnosis");
+        Assertions.assertEquals(50200, timeout.getErrorCode());
+        return conflict;
+      } finally {
+        holder.rollback();
+      }
+    }
+  }
+
+  /** Creates a store whose backend fails every call with the given failure. */
+  private void initFailingStore(RuntimeException failure) throws 
IllegalAccessException {
+    RelationalBackend backend =
+        Mockito.mock(
+            RelationalBackend.class,
+            invocation -> {
+              throw failure;
+            });
+    store = new RelationalEntityStore();
+    FieldUtils.writeField(store, "backend", backend, true);
+    FieldUtils.writeField(store, "cache", Mockito.mock(EntityCache.class), 
true);
+  }
+
+  private String initH2Store() throws IOException, IllegalAccessException {
+    dbPath = 
Files.createTempDirectory("gravitino_lock_wait_timeout_").toString();
+    String jdbcUrl = 
String.format("jdbc:h2:file:%s;DB_CLOSE_DELAY=-1;MODE=MYSQL", dbPath);

Review Comment:
   The test cleanup deletes `dbPath` (a temp directory), but the H2 URL uses 
`jdbc:h2:file:%s` where `%s` is the directory path. H2 typically creates 
database files adjacent to that path (e.g., `<dbPath>.mv.db`, 
`<dbPath>.trace.db`) rather than inside the directory, so `deleteQuietly(new 
File(dbPath))` may leave the actual DB files behind. To avoid leaking temp 
files across test runs, consider either (a) using a file base name inside the 
directory (e.g., `...file:%s/db...`) so deleting the directory removes all 
artifacts, or (b) explicitly deleting the known H2 file suffixes for the chosen 
base path.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to