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 9014793d4c [#13591] fix(jdbc): Avoid nested connection borrows in JDBC 
catalog operations (#13729)
9014793d4c is described below

commit 9014793d4c7393483a6afc3e0c35b840a6a94268
Author: Qi Yu <[email protected]>
AuthorDate: Sat Oct 10 18:45:07 2026 +0800

    [#13591] fix(jdbc): Avoid nested connection borrows in JDBC catalog 
operations (#13729)
    
    ### What changes were proposed in this pull request?
    
    Stop JDBC catalog operations from holding one pooled connection while
    borrowing another from the same data source.
    
    - Table create/alter and database drop generate SQL before borrowing the
    execution connection. Generators may load the table, query Doris
    backends/version, or check that a database is empty.
    - ClickHouse validates alter settings and delegates to the base method.
    OceanBase still generates and applies each change in order, borrowing
    separately for each non-empty statement.
    - Base and ClickHouse `load` prefetch the driver version on their held
    connection. PostgreSQL and Hologres inherit base `load`. Successful
    reads, including null, are cached; failed reads use the existing
    null-version precision fallback and retry on the next load's held
    connection. Initialization clears the cache for the new data source.
    - SQL-generator Javadoc documents the connection-borrowing contract.
    
    ### Why are the changes needed?
    
    With the default pool size of 10, concurrent alters on distinct MySQL
    tables could fill the pool with outer connections and then wait for
    another inside SQL generation, failing with `Timeout waiting for idle
    object` after 30 seconds. Loading datetime columns had the same nested
    borrow, including when driver-version metadata returned null or failed.
    
    Fix: #13591
    
    ### Does this PR introduce _any_ user-facing change?
    
    No API or configuration changes. The changed operation paths avoid
    nested borrows. Side effects:
    
    - Generator validation and Doris backend/version queries precede a
    missing-database error from the execution connection.
    - Empty generated SQL skips the execution connection, so that step no
    longer reports a missing database.
    - OceanBase borrows per non-empty statement. Each statement remains
    standalone DDL with the catalog set on every borrow; sequential
    application and partial-failure behavior are preserved.
    - Driver-version metadata is read once per data source after success,
    including a null result. A failed read is logged at debug level and
    retried by a later load; it does not borrow again during column parsing.
    
    ### How was this patch tested?
    
    - All non-Docker unit tests in JDBC common, ClickHouse, OceanBase,
    MySQL, Doris, PostgreSQL, Hologres and StarRocks: 538 tests passed.
    - JDBC common connection-usage coverage now has 12 cases, including two
    real single-connection SQLite/DBCP reproductions and counted base-load
    coverage for null metadata, failed-read recovery, uncached getter
    fallback, and reinitialization. ClickHouse's actual load test verifies
    driver-version prefetch; OceanBase verifies generation after the
    preceding DDL.
    - Four targeted mutations independently remove the failed-read guard,
    base-load prefetch, initialization reset, and ClickHouse-load prefetch;
    each is detected by its regression test.
    - Spotless and Javadoc pass for the changed modules. Docker catalog
    tests and a real 32-client MySQL reproduction were not run locally.
---
 .../operations/ClickHouseTableOperations.java      |  14 +-
 ...stClickHouseTableOperationsConnectionUsage.java |  67 +++
 .../TestClickHouseTableOperationsUnit.java         |   1 +
 .../operation/OceanBaseTableOperations.java        |  23 +-
 ...estOceanBaseTableOperationsConnectionUsage.java |  80 ++++
 .../jdbc/operation/JdbcDatabaseOperations.java     |   8 +-
 .../jdbc/operation/JdbcTableOperations.java        | 134 +++++-
 .../TestJdbcOperationsConnectionUsage.java         | 480 +++++++++++++++++++++
 .../jdbc/utils/ConnectionCountingDataSource.java   | 168 ++++++++
 9 files changed, 929 insertions(+), 46 deletions(-)

diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableOperations.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableOperations.java
index dac8cd44ed..bb07965db0 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableOperations.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableOperations.java
@@ -761,18 +761,7 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
   public void alterTable(String databaseName, String tableName, TableChange... 
changes)
       throws NoSuchTableException {
     validateTableSettingChanges(changes);
-    LOG.info("Attempting to alter table {} from database {}", tableName, 
databaseName);
-    try (Connection connection = getConnection(databaseName)) {
-      String sql = generateAlterTableSql(databaseName, tableName, changes);
-      if (StringUtils.isEmpty(sql)) {
-        LOG.info("No changes to alter table {} from database {}", tableName, 
databaseName);
-        return;
-      }
-      JdbcConnectorUtils.executeUpdate(connection, sql);
-      LOG.info("Alter table {} from database {}", tableName, databaseName);
-    } catch (final SQLException se) {
-      throw this.exceptionMapper.toGravitinoException(se);
-    }
+    super.alterTable(databaseName, tableName, changes);
   }
 
   @Override
@@ -892,6 +881,7 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
   @Override
   public JdbcTable load(String databaseName, String tableName) throws 
NoSuchTableException {
     try (Connection connection = getConnection(databaseName)) {
+      cacheDriverVersion(connection);
       ResultSet tables = getTable(connection, databaseName, tableName);
       JdbcTable.Builder jdbcTableBuilder = getTableBuilder(tables, 
databaseName, tableName);
 
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsConnectionUsage.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsConnectionUsage.java
new file mode 100644
index 0000000000..d9ce7a3188
--- /dev/null
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsConnectionUsage.java
@@ -0,0 +1,67 @@
+/*
+ * 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.clickhouse.operations;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import 
org.apache.gravitino.catalog.clickhouse.converter.ClickHouseExceptionConverter;
+import org.apache.gravitino.catalog.jdbc.utils.ConnectionCountingDataSource;
+import org.apache.gravitino.rel.TableChange;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests that altering a table never holds one pooled connection while 
borrowing another. */
+public class TestClickHouseTableOperationsConnectionUsage {
+
+  private static final String ALTER_SQL = "ALTER TABLE `orders` MODIFY COMMENT 
'comment'";
+
+  @Test
+  public void testAlterTableGeneratesSqlWithoutHoldingAConnection() {
+    ConnectionCountingDataSource connections = new 
ConnectionCountingDataSource();
+    List<Integer> borrowedDuringGeneration = new ArrayList<>();
+    ClickHouseTableOperations operations =
+        new ClickHouseTableOperations() {
+          {
+            dataSource = connections.dataSource();
+            exceptionMapper = new ClickHouseExceptionConverter();
+          }
+
+          @Override
+          protected String generateAlterTableSql(
+              String databaseName, String tableName, TableChange... changes) {
+            borrowedDuringGeneration.add(connections.borrowed());
+            // Like loading the original table, borrow a connection while 
generating.
+            try (Connection ignored = dataSource.getConnection()) {
+              return ALTER_SQL;
+            } catch (SQLException e) {
+              throw new IllegalStateException(e);
+            }
+          }
+        };
+
+    operations.alterTable("db", "orders", 
TableChange.updateComment("comment"));
+
+    Assertions.assertEquals(List.of(0), borrowedDuringGeneration);
+    Assertions.assertEquals(1, connections.peakBorrowed());
+    Assertions.assertEquals(List.of(ALTER_SQL), connections.executedSql());
+    Assertions.assertEquals(0, connections.borrowed());
+  }
+}
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsUnit.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsUnit.java
index cc8c1f4c85..1a77ef0a1a 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsUnit.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsUnit.java
@@ -822,6 +822,7 @@ public class TestClickHouseTableOperationsUnit {
 
     JdbcTable table = ops.load("db", "t");
 
+    Mockito.verify(metadata).getDriverVersion();
     Assertions.assertEquals(1, table.columns().length);
     Assertions.assertEquals("MergeTree", 
table.properties().get(TableConstants.ENGINE));
     Assertions.assertEquals("", 
table.properties().get(TableConstants.PARTITION_KEY));
diff --git 
a/catalogs-contrib/catalog-jdbc-oceanbase/src/main/java/org/apache/gravitino/catalog/oceanbase/operation/OceanBaseTableOperations.java
 
b/catalogs-contrib/catalog-jdbc-oceanbase/src/main/java/org/apache/gravitino/catalog/oceanbase/operation/OceanBaseTableOperations.java
index bd09770adc..fd1aceeec5 100644
--- 
a/catalogs-contrib/catalog-jdbc-oceanbase/src/main/java/org/apache/gravitino/catalog/oceanbase/operation/OceanBaseTableOperations.java
+++ 
b/catalogs-contrib/catalog-jdbc-oceanbase/src/main/java/org/apache/gravitino/catalog/oceanbase/operation/OceanBaseTableOperations.java
@@ -211,19 +211,22 @@ public class OceanBaseTableOperations extends 
JdbcTableOperations {
   public void alterTable(String databaseName, String tableName, TableChange... 
changes)
       throws NoSuchTableException {
     LOG.info("Attempting to alter table {} from database {}", tableName, 
databaseName);
-    try (Connection connection = getConnection(databaseName)) {
-      for (TableChange change : changes) {
-        String sql = generateAlterTableSql(databaseName, tableName, change);
-        if (StringUtils.isEmpty(sql)) {
-          LOG.info("No changes to alter table {} from database {}", tableName, 
databaseName);
-          continue;
-        }
+    for (TableChange change : changes) {
+      // Generate each statement before borrowing the connection that executes 
it, as the
+      // generator may load the table through another connection. Each change 
is generated after
+      // the previous one is applied, so it sees the updated table.
+      String sql = generateAlterTableSql(databaseName, tableName, change);
+      if (StringUtils.isEmpty(sql)) {
+        LOG.info("No changes to alter table {} from database {}", tableName, 
databaseName);
+        continue;
+      }
+      try (Connection connection = getConnection(databaseName)) {
         JdbcConnectorUtils.executeUpdate(connection, sql);
+      } catch (final SQLException se) {
+        throw this.exceptionMapper.toGravitinoException(se);
       }
-      LOG.info("Alter table {} from database {}", tableName, databaseName);
-    } catch (final SQLException se) {
-      throw this.exceptionMapper.toGravitinoException(se);
     }
+    LOG.info("Alter table {} from database {}", tableName, databaseName);
   }
 
   @Override
diff --git 
a/catalogs-contrib/catalog-jdbc-oceanbase/src/test/java/org/apache/gravitino/catalog/oceanbase/operation/TestOceanBaseTableOperationsConnectionUsage.java
 
b/catalogs-contrib/catalog-jdbc-oceanbase/src/test/java/org/apache/gravitino/catalog/oceanbase/operation/TestOceanBaseTableOperationsConnectionUsage.java
new file mode 100644
index 0000000000..839422ba42
--- /dev/null
+++ 
b/catalogs-contrib/catalog-jdbc-oceanbase/src/test/java/org/apache/gravitino/catalog/oceanbase/operation/TestOceanBaseTableOperationsConnectionUsage.java
@@ -0,0 +1,80 @@
+/*
+ * 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.oceanbase.operation;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.gravitino.catalog.jdbc.converter.JdbcExceptionConverter;
+import org.apache.gravitino.catalog.jdbc.utils.ConnectionCountingDataSource;
+import org.apache.gravitino.rel.TableChange;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests that altering a table never holds one pooled connection while 
borrowing another. */
+public class TestOceanBaseTableOperationsConnectionUsage {
+
+  @Test
+  public void 
testEachChangeIsGeneratedWithoutHoldingAConnectionAfterThePreviousOneIsApplied()
 {
+    ConnectionCountingDataSource connections = new 
ConnectionCountingDataSource();
+    List<Integer> borrowedDuringGeneration = new ArrayList<>();
+    List<Integer> executedBeforeGeneration = new ArrayList<>();
+    OceanBaseTableOperations operations =
+        new OceanBaseTableOperations() {
+          {
+            dataSource = connections.dataSource();
+            exceptionMapper = new JdbcExceptionConverter();
+          }
+
+          @Override
+          protected String generateAlterTableSql(
+              String databaseName, String tableName, TableChange... changes) {
+            borrowedDuringGeneration.add(connections.borrowed());
+            executedBeforeGeneration.add(connections.executedSql().size());
+            if (changes[0] instanceof TableChange.SetProperty) {
+              return "";
+            }
+            // Like loading the original table, borrow a connection while 
generating.
+            try (Connection ignored = dataSource.getConnection()) {
+              return "ALTER TABLE `orders` "
+                  + ((TableChange.UpdateComment) changes[0]).getNewComment();
+            } catch (SQLException e) {
+              throw new IllegalStateException(e);
+            }
+          }
+        };
+
+    operations.alterTable(
+        "db",
+        "orders",
+        TableChange.updateComment("first"),
+        TableChange.setProperty("key", "value"),
+        TableChange.updateComment("second"));
+
+    Assertions.assertEquals(List.of(0, 0, 0), borrowedDuringGeneration);
+    // Each change is generated after the previous statement ran, so it sees 
the updated table.
+    Assertions.assertEquals(List.of(0, 1, 1), executedBeforeGeneration);
+    Assertions.assertEquals(1, connections.peakBorrowed());
+    Assertions.assertEquals(
+        List.of("ALTER TABLE `orders` first", "ALTER TABLE `orders` second"),
+        connections.executedSql());
+    Assertions.assertEquals(0, connections.borrowed());
+  }
+}
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/operation/JdbcDatabaseOperations.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/operation/JdbcDatabaseOperations.java
index 7dd96aa444..cf6f89d95a 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/operation/JdbcDatabaseOperations.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/operation/JdbcDatabaseOperations.java
@@ -131,8 +131,11 @@ public abstract class JdbcDatabaseOperations implements 
DatabaseOperation {
   }
 
   protected void dropDatabase(String databaseName, boolean cascade) {
+    // Generate the SQL before borrowing the connection that executes it, as a 
non-cascading drop
+    // checks for tables through another connection.
+    String sql = generateDropDatabaseSql(databaseName, cascade);
     try (final Connection connection = getConnection()) {
-      JdbcConnectorUtils.executeUpdate(connection, 
generateDropDatabaseSql(databaseName, cascade));
+      JdbcConnectorUtils.executeUpdate(connection, sql);
     } catch (final SQLException se) {
       throw this.exceptionMapper.toGravitinoException(se);
     }
@@ -161,6 +164,9 @@ public abstract class JdbcDatabaseOperations implements 
DatabaseOperation {
    * The default implementation of this method is based on MySQL syntax, and 
if the catalog does not
    * support MySQL syntax, this method needs to be rewritten.
    *
+   * <p>A non-cascading drop checks for tables through a connection from 
{@link #dataSource}, so
+   * callers must not hold one while calling this method.
+   *
    * @param databaseName The name of the database.
    * @param cascade cascade If set to true, drops all the tables in the schema 
as well.
    * @return the SQL statement to drop a database with the given name.
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/operation/JdbcTableOperations.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/operation/JdbcTableOperations.java
index 516b3c01b9..00cf4a9111 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/operation/JdbcTableOperations.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/operation/JdbcTableOperations.java
@@ -85,6 +85,16 @@ public abstract class JdbcTableOperations implements 
TableOperation {
 
   protected JdbcColumnDefaultValueConverter columnDefaultValueConverter;
 
+  // The driver version is fixed for a data source. It is cached so that 
column parsing in load,
+  // which holds a connection, does not borrow another one to read it. A 
driver may report a null
+  // version, so a separate flag records that it was read; written after the 
version, it publishes
+  // it.
+  private String driverVersion;
+  private volatile boolean driverVersionCached;
+  // Even a failed attempt must prevent a fallback borrow while load holds its 
connection.
+  // cacheDriverVersion retries failures using the connection held by a later 
load.
+  private volatile boolean driverVersionAttempted;
+
   @Override
   public void initialize(
       DataSource dataSource,
@@ -93,6 +103,9 @@ public abstract class JdbcTableOperations implements 
TableOperation {
       JdbcColumnDefaultValueConverter jdbcColumnDefaultValueConverter,
       Map<String, String> conf) {
     this.dataSource = dataSource;
+    driverVersion = null;
+    driverVersionCached = false;
+    driverVersionAttempted = false;
     this.exceptionMapper = exceptionMapper;
     this.typeConverter = jdbcTypeConverter;
     this.columnDefaultValueConverter = jdbcColumnDefaultValueConverter;
@@ -142,18 +155,19 @@ public abstract class JdbcTableOperations implements 
TableOperation {
       SortOrder[] sortOrders)
       throws TableAlreadyExistsException {
     LOG.info("Attempting to create table {} in database {}", tableName, 
databaseName);
+    // Generate the SQL before borrowing the connection that executes it, as 
generators may query
+    // the server through another connection (for example, Doris checks its 
backends and version).
+    String sql =
+        generateCreateTableSql(
+            tableName,
+            columns,
+            comment,
+            properties,
+            partitioning,
+            distribution,
+            indexes,
+            sortOrders);
     try (Connection connection = getConnection(databaseName)) {
-      String sql =
-          generateCreateTableSql(
-              tableName,
-              columns,
-              comment,
-              properties,
-              partitioning,
-              distribution,
-              indexes,
-              sortOrders);
-
       JdbcConnectorUtils.executeUpdate(connection, sql);
       LOG.info("Created table {} in database {} with SQL:\n{}", tableName, 
databaseName, sql);
     } catch (final SQLException se) {
@@ -248,6 +262,7 @@ public abstract class JdbcTableOperations implements 
TableOperation {
     // 2. MySQL treats 'a_b' as a wildcard, matching any table name that 
begins with 'a', followed
     // by any character, and ending with 'b'.
     try (Connection connection = getConnection(databaseName)) {
+      cacheDriverVersion(connection);
       // 1. Get table information, The result of tables may be more than one 
due to the reason
       // above, so we need to check the result.
       ResultSet tables = getTable(connection, databaseName, tableName);
@@ -353,12 +368,15 @@ public abstract class JdbcTableOperations implements 
TableOperation {
   public void alterTable(String databaseName, String tableName, TableChange... 
changes)
       throws NoSuchTableException {
     LOG.info("Attempting to alter table {} from database {}", tableName, 
databaseName);
+    // Generate the SQL before borrowing the connection that executes it. 
Generators may load the
+    // table, which borrows another connection; holding both lets concurrent 
alters on distinct
+    // tables exhaust the pool and wait for each other until the borrow 
timeout.
+    String sql = generateAlterTableSql(databaseName, tableName, changes);
+    if (StringUtils.isEmpty(sql)) {
+      LOG.info("No changes to alter table {} from database {}", tableName, 
databaseName);
+      return;
+    }
     try (Connection connection = getConnection(databaseName)) {
-      String sql = generateAlterTableSql(databaseName, tableName, changes);
-      if (StringUtils.isEmpty(sql)) {
-        LOG.info("No changes to alter table {} from database {}", tableName, 
databaseName);
-        return;
-      }
       JdbcConnectorUtils.executeUpdate(connection, sql);
       LOG.info("Alter table {} from database {}", tableName, databaseName);
     } catch (final SQLException se) {
@@ -511,6 +529,21 @@ public abstract class JdbcTableOperations implements 
TableOperation {
     return fieldNames.stream().map(colName -> new String[] 
{colName}).toArray(String[][]::new);
   }
 
+  /**
+   * Generates the SQL statement that creates a table without sort orders.
+   *
+   * <p>Implementations may borrow connections from {@link #dataSource}, so 
callers must not hold
+   * one while calling this method.
+   *
+   * @param tableName the name of the table
+   * @param columns the columns of the table
+   * @param comment the comment of the table
+   * @param properties the properties of the table
+   * @param partitioning the partitioning of the table
+   * @param distribution the distribution of the table
+   * @param indexes the indexes of the table
+   * @return the SQL statement to create the table
+   */
   protected abstract String generateCreateTableSql(
       String tableName,
       JdbcColumn[] columns,
@@ -520,6 +553,22 @@ public abstract class JdbcTableOperations implements 
TableOperation {
       Distribution distribution,
       Index[] indexes);
 
+  /**
+   * Generates the SQL statement that creates a table.
+   *
+   * <p>Implementations may borrow connections from {@link #dataSource}, so 
callers must not hold
+   * one while calling this method.
+   *
+   * @param tableName the name of the table
+   * @param columns the columns of the table
+   * @param comment the comment of the table
+   * @param properties the properties of the table
+   * @param partitioning the partitioning of the table
+   * @param distribution the distribution of the table
+   * @param indexes the indexes of the table
+   * @param sortOrders the sort orders of the table
+   * @return the SQL statement to create the table
+   */
   protected String generateCreateTableSql(
       String tableName,
       JdbcColumn[] columns,
@@ -567,6 +616,17 @@ public abstract class JdbcTableOperations implements 
TableOperation {
     return generatePurgeTableSql(tableName);
   }
 
+  /**
+   * Generates the SQL statement that applies the given changes to a table.
+   *
+   * <p>Implementations may load the table, which borrows a connection from 
{@link #dataSource}, so
+   * callers must not hold one while calling this method.
+   *
+   * @param databaseName the name of the database
+   * @param tableName the name of the table
+   * @param changes the changes to apply
+   * @return the SQL statement, or null or an empty string if nothing needs to 
be executed
+   */
   protected abstract String generateAlterTableSql(
       String databaseName, String tableName, TableChange... changes);
 
@@ -754,21 +814,49 @@ public abstract class JdbcTableOperations implements 
TableOperation {
   }
 
   /**
-   * Get MySQL driver version from DatabaseMetaData
+   * Get MySQL driver version from DatabaseMetaData.
+   *
+   * <p>The version is cached once read. {@link #load} caches it from the 
connection it holds before
+   * parsing columns (see {@link #cacheDriverVersion}), so this method borrows 
a connection only
+   * before the first version-read attempt. Failed reads return null until a 
later load retries
+   * using its own connection, avoiding a nested borrow during column parsing.
    *
    * @return the driver version string, or null if not available
    */
   protected String getMySQLDriverVersion() {
+    if (driverVersionCached || driverVersionAttempted || dataSource == null) {
+      return driverVersion;
+    }
+    try (Connection connection = dataSource.getConnection()) {
+      cacheDriverVersion(connection);
+    } catch (SQLException e) {
+      LOG.debug("Failed to get driver version", e);
+    }
+    return driverVersion;
+  }
+
+  /**
+   * Caches the driver version from a connection the caller already holds.
+   *
+   * <p>Overrides of {@link #load} that parse columns must call this first, so 
that {@link
+   * #getMySQLDriverVersion()} does not borrow another connection while theirs 
is held.
+   *
+   * @param connection the connection held by the caller
+   */
+  protected void cacheDriverVersion(Connection connection) {
+    if (driverVersionCached) {
+      return;
+    }
     try {
-      if (dataSource != null) {
-        try (Connection connection = dataSource.getConnection()) {
-          return connection.getMetaData().getDriverVersion();
-        }
-      }
+      driverVersion = connection.getMetaData().getDriverVersion();
+      driverVersionCached = true;
     } catch (SQLException e) {
+      // A later load retries on its held connection; column parsing must not 
retry by borrowing
+      // another connection. Until recovery, preserve the existing 
null-version precision fallback.
       LOG.debug("Failed to get driver version", e);
+    } finally {
+      driverVersionAttempted = true;
     }
-    return null;
   }
 
   /**
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/operation/TestJdbcOperationsConnectionUsage.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/operation/TestJdbcOperationsConnectionUsage.java
new file mode 100644
index 0000000000..f8d70176c0
--- /dev/null
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/operation/TestJdbcOperationsConnectionUsage.java
@@ -0,0 +1,480 @@
+/*
+ * 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.jdbc.operation;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Proxy;
+import java.nio.file.Files;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.sql.DataSource;
+import org.apache.commons.dbcp2.BasicDataSource;
+import org.apache.commons.io.FileUtils;
+import org.apache.gravitino.catalog.jdbc.JdbcColumn;
+import org.apache.gravitino.catalog.jdbc.JdbcTable;
+import org.apache.gravitino.catalog.jdbc.config.JdbcConfig;
+import 
org.apache.gravitino.catalog.jdbc.converter.SqliteColumnDefaultValueConverter;
+import org.apache.gravitino.catalog.jdbc.converter.SqliteExceptionConverter;
+import org.apache.gravitino.catalog.jdbc.converter.SqliteTypeConverter;
+import org.apache.gravitino.catalog.jdbc.utils.ConnectionCountingDataSource;
+import org.apache.gravitino.catalog.jdbc.utils.DataSourceUtils;
+import org.apache.gravitino.exceptions.NoSuchTableException;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.TableChange;
+import org.apache.gravitino.rel.expressions.distributions.Distribution;
+import org.apache.gravitino.rel.expressions.distributions.Distributions;
+import org.apache.gravitino.rel.expressions.sorts.SortOrder;
+import org.apache.gravitino.rel.expressions.transforms.Transform;
+import org.apache.gravitino.rel.expressions.transforms.Transforms;
+import org.apache.gravitino.rel.indexes.Index;
+import org.apache.gravitino.rel.indexes.Indexes;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests that JDBC operations never hold one pooled connection while 
borrowing another. */
+public class TestJdbcOperationsConnectionUsage {
+
+  private static final String DATABASE = "test";
+  private static final String TABLE = "orders";
+  private static final String ALTER_SQL = "ALTER TABLE orders ADD COLUMN note 
INTEGER";
+
+  @Test
+  public void testAlterTableGeneratesSqlWithoutHoldingAConnection() {
+    ConnectionCountingDataSource connections = new 
ConnectionCountingDataSource();
+    List<Integer> borrowedDuringGeneration = new ArrayList<>();
+    JdbcTableOperations operations =
+        borrowingGenerator(connections, borrowedDuringGeneration, ALTER_SQL);
+
+    operations.alterTable(DATABASE, TABLE, 
TableChange.updateComment("comment"));
+
+    Assertions.assertEquals(List.of(0), borrowedDuringGeneration);
+    Assertions.assertEquals(1, connections.peakBorrowed());
+    Assertions.assertEquals(List.of(ALTER_SQL), connections.executedSql());
+    Assertions.assertEquals(0, connections.borrowed());
+  }
+
+  @Test
+  public void testAlterTableWithoutChangesExecutesNothing() {
+    ConnectionCountingDataSource connections = new 
ConnectionCountingDataSource();
+    List<Integer> borrowedDuringGeneration = new ArrayList<>();
+    JdbcTableOperations operations = borrowingGenerator(connections, 
borrowedDuringGeneration, "");
+
+    operations.alterTable(DATABASE, TABLE, 
TableChange.updateComment("comment"));
+
+    Assertions.assertEquals(List.of(0), borrowedDuringGeneration);
+    Assertions.assertTrue(connections.executedSql().isEmpty());
+    Assertions.assertEquals(0, connections.borrowed());
+  }
+
+  @Test
+  public void testAlterTableGeneratorFailureBorrowsNoExecutionConnection() {
+    ConnectionCountingDataSource connections = new 
ConnectionCountingDataSource();
+    JdbcTableOperations operations =
+        new SqliteTableOperations() {
+          @Override
+          protected String generateAlterTableSql(
+              String databaseName, String tableName, TableChange... changes) {
+            throw new NoSuchTableException("Table %s does not exist", 
tableName);
+          }
+        };
+    initialize(operations, connections.dataSource());
+
+    NoSuchTableException exception =
+        Assertions.assertThrows(
+            NoSuchTableException.class,
+            () -> operations.alterTable(DATABASE, TABLE, 
TableChange.updateComment("comment")));
+
+    Assertions.assertTrue(exception.getMessage().contains(TABLE));
+    Assertions.assertEquals(0, connections.peakBorrowed());
+    Assertions.assertTrue(connections.executedSql().isEmpty());
+  }
+
+  @Test
+  public void testCreateTableGeneratesSqlWithoutHoldingAConnection() {
+    ConnectionCountingDataSource connections = new 
ConnectionCountingDataSource();
+    List<Integer> borrowedDuringGeneration = new ArrayList<>();
+    String createSql = "CREATE TABLE orders (id INTEGER)";
+    JdbcTableOperations operations =
+        new SqliteTableOperations() {
+          @Override
+          protected String generateCreateTableSql(
+              String tableName,
+              JdbcColumn[] columns,
+              String comment,
+              Map<String, String> properties,
+              Transform[] partitioning,
+              Distribution distribution,
+              Index[] indexes,
+              SortOrder[] sortOrders) {
+            borrowedDuringGeneration.add(connections.borrowed());
+            // Like Doris checking its backends and version, query the server 
while generating.
+            borrowAndRelease(this.dataSource);
+            return createSql;
+          }
+        };
+    initialize(operations, connections.dataSource());
+
+    operations.create(
+        DATABASE,
+        TABLE,
+        new JdbcColumn[0],
+        null,
+        Collections.emptyMap(),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        Indexes.EMPTY_INDEXES);
+
+    Assertions.assertEquals(List.of(0), borrowedDuringGeneration);
+    Assertions.assertEquals(1, connections.peakBorrowed());
+    Assertions.assertEquals(List.of(createSql), connections.executedSql());
+    Assertions.assertEquals(0, connections.borrowed());
+  }
+
+  @Test
+  public void testDropDatabaseGeneratesSqlWithoutHoldingAConnection() {
+    ConnectionCountingDataSource connections = new 
ConnectionCountingDataSource();
+    List<Integer> borrowedDuringGeneration = new ArrayList<>();
+    String dropSql = "DROP DATABASE `test`";
+    JdbcDatabaseOperations operations =
+        new SqliteDatabaseOperations("unused") {
+          @Override
+          public String generateDropDatabaseSql(String databaseName, boolean 
cascade) {
+            borrowedDuringGeneration.add(connections.borrowed());
+            // Like the default non-cascading drop checking for tables, borrow 
while generating.
+            borrowAndRelease(this.dataSource);
+            return dropSql;
+          }
+        };
+    operations.initialize(
+        connections.dataSource(), new SqliteExceptionConverter(), 
Collections.emptyMap());
+
+    operations.dropDatabase(DATABASE, false);
+
+    Assertions.assertEquals(List.of(0), borrowedDuringGeneration);
+    Assertions.assertEquals(1, connections.peakBorrowed());
+    Assertions.assertEquals(List.of(dropSql), connections.executedSql());
+    Assertions.assertEquals(0, connections.borrowed());
+  }
+
+  @Test
+  public void 
testAlterTableSucceedsWhenGeneratorLoadsTableFromSingleConnectionPool()
+      throws IOException, SQLException {
+    // The pool size reproduces the starvation deterministically: with the 
execution connection
+    // borrowed first, the generator's load would wait for a connection that 
can never be returned.
+    File directory = 
Files.createTempDirectory("gravitino-jdbc-alter").toFile();
+    BasicDataSource dataSource = null;
+    try {
+      dataSource = singleConnectionPool(new File(directory, 
DATABASE).getPath());
+      try (Connection connection = dataSource.getConnection();
+          Statement statement = connection.createStatement()) {
+        statement.executeUpdate("CREATE TABLE orders (id INTEGER)");
+      }
+      JdbcTableOperations operations = loadingGenerator(dataSource);
+
+      operations.alterTable(DATABASE, TABLE, 
TableChange.updateComment("comment"));
+
+      JdbcTable table = operations.load(DATABASE, TABLE);
+      Assertions.assertArrayEquals(
+          new String[] {"id", "note"},
+          
Arrays.stream(table.columns()).map(Column::name).toArray(String[]::new));
+    } finally {
+      if (dataSource != null) {
+        dataSource.close();
+      }
+      FileUtils.deleteQuietly(directory);
+    }
+  }
+
+  @Test
+  public void testLoadReadsDriverVersionWithoutBorrowingAnotherConnection()
+      throws IOException, SQLException {
+    // MySQL, OceanBase and Doris read the driver version while parsing each 
datetime column, which
+    // happens while load holds its connection.
+    File directory = Files.createTempDirectory("gravitino-jdbc-load").toFile();
+    BasicDataSource dataSource = null;
+    try {
+      dataSource = singleConnectionPool(new File(directory, 
DATABASE).getPath());
+      try (Connection connection = dataSource.getConnection();
+          Statement statement = connection.createStatement()) {
+        statement.executeUpdate("CREATE TABLE orders (id INTEGER, created 
TEXT)");
+      }
+      List<String> driverVersions = new ArrayList<>();
+      JdbcTableOperations operations =
+          new SqliteTableOperations() {
+            @Override
+            public Integer calculateDatetimePrecision(String typeName, int 
columnSize, int scale) {
+              driverVersions.add(getMySQLDriverVersion());
+              return null;
+            }
+          };
+      initialize(operations, dataSource);
+
+      operations.load(DATABASE, TABLE);
+
+      Assertions.assertEquals(2, driverVersions.size());
+      Assertions.assertNotNull(driverVersions.get(0));
+      Assertions.assertEquals(driverVersions.get(0), driverVersions.get(1));
+    } finally {
+      if (dataSource != null) {
+        dataSource.close();
+      }
+      FileUtils.deleteQuietly(directory);
+    }
+  }
+
+  @Test
+  public void testNullDriverVersionIsCachedFromTheHeldConnection() throws 
SQLException {
+    ConnectionCountingDataSource connections = new 
ConnectionCountingDataSource();
+    JdbcTableOperations operations = new SqliteTableOperations();
+    initialize(operations, connections.dataSource());
+
+    try (Connection held = connections.dataSource().getConnection()) {
+      operations.cacheDriverVersion(held);
+      // A null version is still cached, so later lookups never borrow while 
one is held.
+      Assertions.assertNull(operations.getMySQLDriverVersion());
+      Assertions.assertNull(operations.getMySQLDriverVersion());
+    }
+
+    Assertions.assertEquals(1, connections.totalBorrows());
+    Assertions.assertEquals(0, connections.borrowed());
+  }
+
+  @Test
+  public void testLoadWithNullVersionUsesOneCountedConnection() throws 
SQLException {
+    ConnectionCountingDataSource connections = new 
ConnectionCountingDataSource();
+    List<String> versions = new ArrayList<>();
+    JdbcTableOperations operations = countedLoadOperations(connections, 
versions);
+
+    JdbcTable table = operations.load(DATABASE, TABLE);
+
+    Assertions.assertEquals(2, table.columns().length);
+    Assertions.assertEquals(Arrays.asList(null, null), versions);
+    Assertions.assertEquals(1, connections.totalBorrows());
+    Assertions.assertEquals(1, connections.peakBorrowed());
+    Assertions.assertEquals(0, connections.borrowed());
+  }
+
+  @Test
+  public void 
testFailedVersionReadDoesNotBorrowDuringLoadAndRetriesOnNextLoad()
+      throws SQLException {
+    AtomicInteger reads = new AtomicInteger();
+    DatabaseMetaData metadata =
+        metadata(
+            () -> {
+              if (reads.incrementAndGet() == 1) {
+                throw new SQLException("version unavailable");
+              }
+              return "driver-1";
+            });
+    ConnectionCountingDataSource connections = new 
ConnectionCountingDataSource(metadata);
+    List<String> versions = new ArrayList<>();
+    JdbcTableOperations operations = countedLoadOperations(connections, 
versions);
+
+    Assertions.assertEquals(2, operations.load(DATABASE, 
TABLE).columns().length);
+    Assertions.assertEquals(Arrays.asList(null, null), versions);
+    Assertions.assertEquals(1, connections.totalBorrows());
+    Assertions.assertEquals(2, operations.load(DATABASE, 
TABLE).columns().length);
+
+    Assertions.assertEquals(Arrays.asList(null, null, "driver-1", "driver-1"), 
versions);
+    Assertions.assertEquals(2, reads.get());
+    Assertions.assertEquals(2, connections.totalBorrows());
+    Assertions.assertEquals(1, connections.peakBorrowed());
+    Assertions.assertEquals(0, connections.borrowed());
+  }
+
+  @Test
+  public void testDriverVersionFallbackBorrowsOnceAndCachesNull() {
+    ConnectionCountingDataSource connections = new 
ConnectionCountingDataSource();
+    JdbcTableOperations operations = new SqliteTableOperations();
+    initialize(operations, connections.dataSource());
+
+    Assertions.assertNull(operations.getMySQLDriverVersion());
+    Assertions.assertNull(operations.getMySQLDriverVersion());
+
+    Assertions.assertEquals(1, connections.totalBorrows());
+    Assertions.assertEquals(1, connections.peakBorrowed());
+    Assertions.assertEquals(0, connections.borrowed());
+  }
+
+  @Test
+  public void testInitializeClearsPreviouslyCachedVersion() throws 
SQLException {
+    JdbcTableOperations operations = new SqliteTableOperations();
+    initialize(operations, new ConnectionCountingDataSource().dataSource());
+    Assertions.assertNull(operations.getMySQLDriverVersion());
+    DatabaseMetaData metadata = metadata(() -> "new-driver");
+    ConnectionCountingDataSource replacement = new 
ConnectionCountingDataSource(metadata);
+
+    initialize(operations, replacement.dataSource());
+
+    Assertions.assertEquals("new-driver", operations.getMySQLDriverVersion());
+    Assertions.assertEquals(1, replacement.totalBorrows());
+    Assertions.assertEquals(0, replacement.borrowed());
+  }
+
+  private static JdbcTableOperations countedLoadOperations(
+      ConnectionCountingDataSource connections, List<String> versions) {
+    JdbcTableOperations operations =
+        new SqliteTableOperations() {
+          @Override
+          protected ResultSet getTable(Connection connection, String 
databaseName, String tableName)
+              throws SQLException {
+            return resultSet(Collections.singletonList(Map.of("TABLE_NAME", 
tableName)));
+          }
+
+          @Override
+          protected ResultSet getColumns(
+              Connection connection, String databaseName, String tableName) 
throws SQLException {
+            return resultSet(
+                Arrays.asList(
+                    Map.of("TABLE_NAME", tableName, "COLUMN_NAME", "id", 
"TYPE_NAME", "TEXT"),
+                    Map.of(
+                        "TABLE_NAME", tableName, "COLUMN_NAME", "created", 
"TYPE_NAME", "TEXT")));
+          }
+
+          @Override
+          protected List<Index> getIndexes(
+              Connection connection, String databaseName, String tableName) {
+            return Collections.emptyList();
+          }
+
+          @Override
+          public Integer calculateDatetimePrecision(String typeName, int 
columnSize, int scale) {
+            Assertions.assertEquals(1, connections.borrowed());
+            versions.add(getMySQLDriverVersion());
+            return null;
+          }
+        };
+    initialize(operations, connections.dataSource());
+    return operations;
+  }
+
+  private interface VersionReader {
+    String read() throws SQLException;
+  }
+
+  private static DatabaseMetaData metadata(VersionReader reader) {
+    return (DatabaseMetaData)
+        Proxy.newProxyInstance(
+            DatabaseMetaData.class.getClassLoader(),
+            new Class<?>[] {DatabaseMetaData.class},
+            (proxy, method, args) -> {
+              if ("getDriverVersion".equals(method.getName())) {
+                return reader.read();
+              }
+              throw new UnsupportedOperationException(method.getName());
+            });
+  }
+
+  private static ResultSet resultSet(List<Map<String, String>> rows) {
+    AtomicInteger position = new AtomicInteger(-1);
+    return (ResultSet)
+        Proxy.newProxyInstance(
+            ResultSet.class.getClassLoader(),
+            new Class<?>[] {ResultSet.class},
+            (proxy, method, args) -> {
+              switch (method.getName()) {
+                case "next":
+                  return position.incrementAndGet() < rows.size();
+                case "close":
+                  return null;
+                case "getString":
+                  return rows.get(position.get()).get(args[0]);
+                case "getInt":
+                  return 0;
+                case "getBoolean":
+                  return false;
+                default:
+                  throw new UnsupportedOperationException(method.getName());
+              }
+            });
+  }
+
+  // Like the MySQL generator loading the original table, borrows a connection 
while generating.
+  private static JdbcTableOperations borrowingGenerator(
+      ConnectionCountingDataSource connections,
+      List<Integer> borrowedDuringGeneration,
+      String sql) {
+    JdbcTableOperations operations =
+        new SqliteTableOperations() {
+          @Override
+          protected String generateAlterTableSql(
+              String databaseName, String tableName, TableChange... changes) {
+            borrowedDuringGeneration.add(connections.borrowed());
+            borrowAndRelease(this.dataSource);
+            return sql;
+          }
+        };
+    initialize(operations, connections.dataSource());
+    return operations;
+  }
+
+  private static JdbcTableOperations loadingGenerator(DataSource dataSource) {
+    JdbcTableOperations operations =
+        new SqliteTableOperations() {
+          @Override
+          protected String generateAlterTableSql(
+              String databaseName, String tableName, TableChange... changes) {
+            load(databaseName, tableName);
+            return ALTER_SQL;
+          }
+        };
+    initialize(operations, dataSource);
+    return operations;
+  }
+
+  private static void borrowAndRelease(DataSource dataSource) {
+    try (Connection ignored = dataSource.getConnection()) {
+      // Only the borrow matters.
+    } catch (SQLException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  private static void initialize(JdbcTableOperations operations, DataSource 
dataSource) {
+    operations.initialize(
+        dataSource,
+        new SqliteExceptionConverter(),
+        new SqliteTypeConverter(),
+        new SqliteColumnDefaultValueConverter(),
+        Collections.emptyMap());
+  }
+
+  private static BasicDataSource singleConnectionPool(String path) {
+    Map<String, String> properties = new HashMap<>();
+    properties.put(JdbcConfig.JDBC_DRIVER.getKey(), "org.sqlite.JDBC");
+    properties.put(JdbcConfig.JDBC_URL.getKey(), "jdbc:sqlite:" + path);
+    properties.put(JdbcConfig.USERNAME.getKey(), "test");
+    properties.put(JdbcConfig.PASSWORD.getKey(), "test");
+    properties.put(JdbcConfig.POOL_MIN_SIZE.getKey(), "1");
+    properties.put(JdbcConfig.POOL_MAX_SIZE.getKey(), "1");
+    properties.put(JdbcConfig.POOL_MAX_WAIT_MS.getKey(), "1000");
+    return (BasicDataSource) DataSourceUtils.createDataSource(properties);
+  }
+}
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/ConnectionCountingDataSource.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/ConnectionCountingDataSource.java
new file mode 100644
index 0000000000..e2a947745b
--- /dev/null
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/ConnectionCountingDataSource.java
@@ -0,0 +1,168 @@
+/*
+ * 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.jdbc.utils;
+
+import java.lang.reflect.Proxy;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.Statement;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.sql.DataSource;
+
+/**
+ * A test {@link DataSource} whose connections only record the SQL they 
execute. It tracks how many
+ * connections are borrowed at once, so tests can assert that an operation 
never holds one
+ * connection while borrowing another.
+ */
+public class ConnectionCountingDataSource {
+
+  private final DatabaseMetaData metadata;
+
+  private final AtomicInteger borrowed = new AtomicInteger();
+  private final AtomicInteger peakBorrowed = new AtomicInteger();
+  private final AtomicInteger totalBorrows = new AtomicInteger();
+  private final List<String> executedSql = new CopyOnWriteArrayList<>();
+  private final DataSource dataSource =
+      (DataSource)
+          Proxy.newProxyInstance(
+              DataSource.class.getClassLoader(),
+              new Class<?>[] {DataSource.class},
+              (proxy, method, args) -> {
+                if ("getConnection".equals(method.getName())) {
+                  return borrow();
+                }
+                return objectMethod(proxy, method.getName(), args, 
"DataSource");
+              });
+
+  /** Creates a counting data source whose driver reports a null version. */
+  public ConnectionCountingDataSource() {
+    this(metaData());
+  }
+
+  /**
+   * Creates a counting data source with the supplied JDBC metadata.
+   *
+   * @param metadata the metadata returned by each borrowed connection
+   */
+  public ConnectionCountingDataSource(DatabaseMetaData metadata) {
+    this.metadata = metadata;
+  }
+
+  /** Returns the data source to pass to the operations under test. */
+  public DataSource dataSource() {
+    return dataSource;
+  }
+
+  /** Returns the number of connections currently borrowed and not yet closed. 
*/
+  public int borrowed() {
+    return borrowed.get();
+  }
+
+  /** Returns the largest number of connections that were borrowed at the same 
time. */
+  public int peakBorrowed() {
+    return peakBorrowed.get();
+  }
+
+  /** Returns how many connections were borrowed in total. */
+  public int totalBorrows() {
+    return totalBorrows.get();
+  }
+
+  /** Returns the update statements executed so far, in order. */
+  public List<String> executedSql() {
+    return executedSql;
+  }
+
+  private Connection borrow() {
+    totalBorrows.incrementAndGet();
+    peakBorrowed.accumulateAndGet(borrowed.incrementAndGet(), Math::max);
+    AtomicBoolean closed = new AtomicBoolean();
+    return (Connection)
+        Proxy.newProxyInstance(
+            Connection.class.getClassLoader(),
+            new Class<?>[] {Connection.class},
+            (proxy, method, args) -> {
+              switch (method.getName()) {
+                case "close":
+                  if (closed.compareAndSet(false, true)) {
+                    borrowed.decrementAndGet();
+                  }
+                  return null;
+                case "setCatalog":
+                case "setSchema":
+                  return null;
+                case "createStatement":
+                  return statement();
+                case "getMetaData":
+                  return metadata;
+                default:
+                  return objectMethod(proxy, method.getName(), args, 
"Connection");
+              }
+            });
+  }
+
+  // Answers Object methods so the proxies can be printed or compared, and 
rejects anything else.
+  private static Object objectMethod(Object proxy, String name, Object[] args, 
String type) {
+    switch (name) {
+      case "toString":
+        return "Counting" + type + "@" + 
Integer.toHexString(System.identityHashCode(proxy));
+      case "hashCode":
+        return System.identityHashCode(proxy);
+      case "equals":
+        return proxy == args[0];
+      default:
+        throw new UnsupportedOperationException(type + "." + name);
+    }
+  }
+
+  // Reports a null driver version, which the JDBC contract allows.
+  private static DatabaseMetaData metaData() {
+    return (DatabaseMetaData)
+        Proxy.newProxyInstance(
+            DatabaseMetaData.class.getClassLoader(),
+            new Class<?>[] {DatabaseMetaData.class},
+            (proxy, method, args) -> {
+              if ("getDriverVersion".equals(method.getName())) {
+                return null;
+              }
+              return objectMethod(proxy, method.getName(), args, 
"DatabaseMetaData");
+            });
+  }
+
+  private Statement statement() {
+    return (Statement)
+        Proxy.newProxyInstance(
+            Statement.class.getClassLoader(),
+            new Class<?>[] {Statement.class},
+            (proxy, method, args) -> {
+              switch (method.getName()) {
+                case "executeUpdate":
+                  executedSql.add((String) args[0]);
+                  return 0;
+                case "close":
+                  return null;
+                default:
+                  return objectMethod(proxy, method.getName(), args, 
"Statement");
+              }
+            });
+  }
+}

Reply via email to