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

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


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new f9f8695111 [Cherry-pick to branch-1.3] [#13767] improve(core,jdbc): 
verify JDBC catalog backend reachable at create (#13769) (#13780)
f9f8695111 is described below

commit f9f8695111166bc4dcbe610e4905b103c36e0c0a
Author: geyanggang <[email protected]>
AuthorDate: Tue Oct 13 14:13:08 2026 +0800

    [Cherry-pick to branch-1.3] [#13767] improve(core,jdbc): verify JDBC 
catalog backend reachable at create (#13769) (#13780)
    
    Cherry-pick Information:
    
    Original PR: #13769 ([#13767] improve(core,jdbc): verify JDBC catalog
    backend reachable at create)
    Tracking issue: #13767
    Target branch: branch-1.3
    Status: ✅ Clean cherry-pick (no conflicts)
---
 .../integration/test/CatalogClickHouseIT.java      |   4 +
 .../apache/gravitino/catalog/jdbc/JdbcCatalog.java |  46 +++
 .../catalog/jdbc/JdbcCatalogOperations.java        | 149 +++++++++-
 .../jdbc/JdbcCatalogPropertiesMetadata.java        |  19 +-
 .../gravitino/catalog/jdbc/config/JdbcConfig.java  |  64 ++++
 .../catalog/jdbc/utils/DataSourceUtils.java        |   6 +
 .../catalog/jdbc/utils/JdbcConnectionTimeouts.java | 303 +++++++++++++++++++
 .../jdbc/TestJdbcCatalogOperationsProbe.java       | 204 +++++++++++++
 .../jdbc/TestJdbcCatalogPropertiesMetadata.java    |  63 ++++
 .../catalog/jdbc/utils/TestJdbcConfig.java         |  43 +++
 .../jdbc/utils/TestJdbcConnectionTimeouts.java     | 326 +++++++++++++++++++++
 .../mysql/integration/test/CatalogMysqlIT.java     |   8 +
 .../apache/gravitino/catalog/CatalogManager.java   |  41 ++-
 .../apache/gravitino/connector/BaseCatalog.java    |  24 ++
 .../gravitino/utils/IsolatedClassLoader.java       |  25 ++
 .../java/org/apache/gravitino/TestCatalog.java     |  24 ++
 .../gravitino/catalog/TestCatalogManager.java      | 108 +++++++
 .../gravitino/connector/TestCatalogOperations.java |  21 ++
 docs/jdbc-catalog-connection-validation.md         | 107 +++++++
 19 files changed, 1579 insertions(+), 6 deletions(-)

diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
index 8da517460d..8891377e4b 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
@@ -2713,6 +2713,10 @@ public class CatalogClickHouseIT extends BaseIT {
     String password = CLICKHOUSE_CONTAINER.getPassword();
     String wrongPassword = password + "wrong";
     catalogProperties.put(JdbcConfig.PASSWORD.getKey(), wrongPassword);
+    // This test deliberately creates the catalog with a wrong password and 
then fixes it via
+    // alterCatalog, so it must opt out of create-time backend verification; 
otherwise the create
+    // itself would be rejected for the unreachable backend (the behavior 
added in #13767).
+    catalogProperties.put(JdbcConfig.VERIFY_ON_CREATE.getKey(), "false");
 
     metalake.createCatalog(
         testCatalogName, Catalog.Type.RELATIONAL, provider, "comment", 
catalogProperties);
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalog.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalog.java
index 65bf8eb897..2d8877f8c8 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalog.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalog.java
@@ -37,6 +37,8 @@ import org.apache.gravitino.connector.PropertiesMetadata;
 import org.apache.gravitino.connector.PropertyEntry;
 import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.credential.JdbcCredential;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.apache.gravitino.exceptions.GravitinoRuntimeException;
 
 /** Implementation of an Jdbc catalog in Gravitino. */
 public abstract class JdbcCatalog extends BaseCatalog<JdbcCatalog> {
@@ -79,6 +81,50 @@ public abstract class JdbcCatalog extends 
BaseCatalog<JdbcCatalog> {
     return new JdbcCatalogCapability();
   }
 
+  /**
+   * Returns the effective {@link JdbcConfig#VERIFY_ON_CREATE 
verify-on-create} setting for this
+   * catalog. A relational JDBC catalog verifies its backend at create time by 
default; an
+   * administrator opts out by setting {@code verify-on-create=false} to 
register a catalog whose
+   * backend is temporarily unavailable.
+   *
+   * @return {@code true} to verify the backend at create (default); {@code 
false} to skip the check
+   */
+  @Override
+  public boolean shouldValidateOnCreate() {
+    Map<String, String> properties = entity().getProperties();
+    if (properties == null) {
+      return true;
+    }
+    return new JdbcConfig(properties).getVerifyOnCreate();
+  }
+
+  /**
+   * Verifies the backend is reachable at create time with a dedicated, 
time-bounded one-off probe
+   * connection (see {@link JdbcCatalogOperations#probeConnection}). On 
failure the probe raises a
+   * {@link ConnectionFailedException}, which propagates so {@code 
CatalogManager} rolls back the
+   * just-persisted catalog entity.
+   *
+   * <p>The probe does not borrow from the lazy pool: it opens its own 
connection bounded by both
+   * the connect and the read/login timeout, so a backend that accepts the 
socket but never speaks
+   * the protocol fails fast instead of blocking the metalake write lock. The 
probe connection is
+   * opened, validated and closed within the call, so there is no pool or 
connection leak.
+   */
+  @Override
+  public void validateOnCreate() {
+    try {
+      ((JdbcCatalogOperations) 
ops()).probeConnection(entity().nameIdentifier());
+    } catch (RuntimeException e) {
+      // Includes ConnectionFailedException; rethrow unchanged so 
CatalogManager rolls back and the
+      // connection-failed taxonomy the caller relies on is preserved.
+      throw e;
+    } catch (Exception e) {
+      // Defensive: the probe only throws runtime exceptions, so wrap any 
unexpected checked
+      // exception without changing the connection-failed taxonomy the caller 
relies on.
+      throw new GravitinoRuntimeException(
+          e, "Failed to verify JDBC catalog backend at create: %s", 
e.getMessage());
+    }
+  }
+
   /**
    * @return The {@link JdbcExceptionConverter} to be used by the catalog.
    */
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
index cee2ae8533..6b5e99eb8e 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.catalog.jdbc;
 
 import static org.apache.gravitino.connector.BaseCatalog.CATALOG_BYPASS_PREFIX;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.Maps;
 import java.sql.Connection;
@@ -34,6 +35,8 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Properties;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import javax.sql.DataSource;
@@ -53,6 +56,7 @@ import 
org.apache.gravitino.catalog.jdbc.operation.JdbcTableOperations;
 import org.apache.gravitino.catalog.jdbc.operation.RequireDatabaseOperation;
 import org.apache.gravitino.catalog.jdbc.operation.TableOperation;
 import org.apache.gravitino.catalog.jdbc.utils.DataSourceUtils;
+import org.apache.gravitino.catalog.jdbc.utils.JdbcConnectionTimeouts;
 import org.apache.gravitino.connector.CatalogInfo;
 import org.apache.gravitino.connector.CatalogOperations;
 import org.apache.gravitino.connector.HasPropertyMetadata;
@@ -104,6 +108,7 @@ public class JdbcCatalogOperations implements 
CatalogOperations, SupportsSchemas
 
   private DataSource dataSource;
   private String jdbcUrl;
+  private JdbcConfig jdbcConfig;
 
   private final JdbcColumnDefaultValueConverter columnDefaultValueConverter;
 
@@ -171,7 +176,7 @@ public class JdbcCatalogOperations implements 
CatalogOperations, SupportsSchemas
     Map<String, String> resultConf = Maps.newHashMap(prefixMap);
     resultConf.putAll(gravitinoConfig);
 
-    JdbcConfig jdbcConfig = new JdbcConfig(resultConf);
+    this.jdbcConfig = new JdbcConfig(resultConf);
     this.jdbcUrl = jdbcConfig.getJdbcUrl();
     this.dataSource = DataSourceUtils.createDataSource(jdbcConfig);
 
@@ -246,21 +251,159 @@ public class JdbcCatalogOperations implements 
CatalogOperations, SupportsSchemas
    *
    * @param catalogIdent the name of the catalog.
    */
+  /**
+   * Verifies the backend is reachable using a dedicated, time-bounded one-off 
connection rather
+   * than a pooled one. Unlike {@link #testConnection(NameIdentifier)} (which 
borrows from the lazy
+   * pool, whose connections carry only a connect timeout so normal operations 
stay unbounded), this
+   * probe applies both the connect timeout and the socket-read (and, for 
PostgreSQL, login) timeout
+   * from {@link JdbcConnectionTimeouts#probeConnectionProperties}. That 
matters because the probe
+   * runs under the metalake write lock during catalog creation: a backend 
that accepts the TCP
+   * connection but never speaks the database protocol would otherwise block 
forever and starve the
+   * whole metalake. The connection is opened, validated and closed within 
this call.
+   *
+   * @param catalogIdent the identifier of the catalog being verified
+   * @throws ConnectionFailedException if the backend cannot be reached within 
the bounded timeout
+   */
+  /**
+   * Sets just the configuration the backend probe needs ({@link #jdbcConfig} 
and {@link #jdbcUrl}),
+   * without building the connection pool. For tests that exercise {@link 
#probeConnection} with a
+   * fake driver and no real database.
+   *
+   * @param config the resolved JDBC configuration
+   */
+  @VisibleForTesting
+  void setProbeConfigForTest(JdbcConfig config) {
+    this.jdbcConfig = config;
+    this.jdbcUrl = config.getJdbcUrl();
+  }
+
+  public void probeConnection(NameIdentifier catalogIdent) {
+    Driver driver;
+    try {
+      driver = getDriver();
+    } catch (SQLException e) {
+      throw new ConnectionFailedException(
+          e,
+          "Failed to connect to JDBC catalog at %s: %s",
+          hostPortFromJdbcUrl(jdbcUrl),
+          e.getMessage());
+    }
+
+    Properties properties = new Properties();
+    String user = jdbcConfig.getUsername();
+    if (user != null) {
+      properties.setProperty("user", user);
+    }
+    String password = jdbcConfig.getPassword();
+    if (password != null) {
+      properties.setProperty("password", password);
+    }
+    // Bound both the connect and the read/login timeout for this probe so an 
accept-but-silent
+    // backend fails fast instead of hanging under the metalake write lock.
+    JdbcConnectionTimeouts.probeConnectionProperties(jdbcConfig, jdbcUrl)
+        .forEach(properties::setProperty);
+
+    try (Connection connection = driver.connect(jdbcUrl, properties)) {
+      if (connection == null) {
+        throw new ConnectionFailedException(
+            "Failed to connect to JDBC catalog at %s: the driver did not 
accept the URL",
+            hostPortFromJdbcUrl(jdbcUrl));
+      }
+      // A lightweight round trip that confirms the connection is actually 
usable. isValid() is
+      // bounded by the same read timeout applied above.
+      connection.getMetaData().getDatabaseProductName();
+    } catch (ConnectionFailedException e) {
+      throw e;
+    } catch (SQLException e) {
+      throw new ConnectionFailedException(
+          e,
+          "Failed to connect to JDBC catalog at %s: %s",
+          hostPortFromJdbcUrl(jdbcUrl),
+          e.getMessage());
+    }
+  }
+
   @Override
   public void testConnection(NameIdentifier catalogIdent) {
     try {
       databaseOperation.listDatabases();
     } catch (ConnectionFailedException e) {
-      throw e;
+      // Already classified as a connection failure (for example a MySQL 
access-denied or a
+      // PostgreSQL SQLSTATE 08 error). Re-wrap it with the host context so 
every connection failure
+      // satisfies the documented "names the host" contract, preserving the 
original cause.
+      throw new ConnectionFailedException(
+          e.getCause() != null ? e.getCause() : e,
+          "Failed to connect to JDBC catalog at %s: %s",
+          hostPortFromJdbcUrl(jdbcUrl),
+          e.getMessage());
     } catch (GravitinoRuntimeException e) {
       if (e.getClass() == GravitinoRuntimeException.class && e.getCause() 
instanceof SQLException) {
         throw new ConnectionFailedException(
-            e.getCause(), "Failed to connect to JDBC catalog: %s", 
e.getMessage());
+            e.getCause(),
+            "Failed to connect to JDBC catalog at %s: %s",
+            hostPortFromJdbcUrl(jdbcUrl),
+            e.getMessage());
       }
       throw e;
     }
   }
 
+  /**
+   * A plain authority: an optional {@code user-info@}, then either a 
bracketed IPv6 literal or a
+   * host of letters/digits/dots/hyphens, then an optional {@code :port}. 
Deliberately strict so
+   * that credential-bearing forms the driver also accepts — MySQL's key-value 
authority {@code
+   * (host=..,password=..)} and its {@code address=(host=..)(password=..)} 
form — do NOT match and
+   * are reported as {@code "unknown"} instead of leaking the password.
+   */
+  private static final Pattern HOST_PORT_AUTHORITY =
+      
Pattern.compile("^(?:[^@/]*@)?(\\[[0-9A-Fa-f:]+\\]|[A-Za-z0-9.\\-]+)(?::\\d+)?$");
+
+  /**
+   * Extracts {@code host:port} from a {@code 
jdbc:subprotocol://host:port/...} URL for use in a
+   * connection-failure message. Returns {@code "unknown"} whenever the 
authority is not a plain
+   * {@code host[:port]} (including a URL with no {@code ://} authority, or 
MySQL key-value / {@code
+   * address=(...)} host syntaxes) rather than the raw URL, because the raw 
URL and those
+   * alternative syntaxes can carry credentials (for example {@code 
jdbc:postgresql:db?password=...}
+   * or {@code jdbc:mysql://(host=h,password=s)/db}) that must not leak into 
the exception, the REST
+   * response, or the server logs. Any user-info is stripped. Never throws, so 
message formatting
+   * cannot mask the original connection failure.
+   *
+   * @param url the JDBC URL stored for this catalog
+   * @return a {@code host:port} (or {@code host}) description, or {@code 
"unknown"} when it is not
+   *     a plain host authority
+   */
+  static String hostPortFromJdbcUrl(String url) {
+    if (StringUtils.isBlank(url)) {
+      return "unknown";
+    }
+    int schemeEnd = url.indexOf("://");
+    if (schemeEnd < 0) {
+      return "unknown";
+    }
+    int authorityStart = schemeEnd + "://".length();
+    // The authority ends at the first '/', '?', or ';' following it.
+    int authorityEnd = url.length();
+    for (int i = authorityStart; i < url.length(); i++) {
+      char c = url.charAt(i);
+      if (c == '/' || c == '?' || c == ';') {
+        authorityEnd = i;
+        break;
+      }
+    }
+    String authority = url.substring(authorityStart, authorityEnd);
+    // Only accept a plain host[:port] authority; anything else (blank, or a 
credential-bearing
+    // key-value / address=(...) form) is reported as unknown rather than 
risking a leak.
+    if (!HOST_PORT_AUTHORITY.matcher(authority).matches()) {
+      return "unknown";
+    }
+    // Strip any user-info ("user@host:port"); keep only the host:port part.
+    int at = authority.lastIndexOf('@');
+    if (at >= 0) {
+      authority = authority.substring(at + 1);
+    }
+    return StringUtils.isBlank(authority) ? "unknown" : authority;
+  }
+
   /**
    * Creates a new schema with the provided identifier, comment and metadata.
    *
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogPropertiesMetadata.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogPropertiesMetadata.java
index e505027907..5f6c84ba84 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogPropertiesMetadata.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogPropertiesMetadata.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.catalog.jdbc;
 
 import static 
org.apache.gravitino.connector.PropertyEntry.booleanPropertyEntry;
 import static 
org.apache.gravitino.connector.PropertyEntry.integerPropertyEntry;
+import static 
org.apache.gravitino.connector.PropertyEntry.longOptionalPropertyEntry;
 import static 
org.apache.gravitino.connector.PropertyEntry.stringOptionalPropertyEntry;
 import static org.apache.gravitino.connector.PropertyEntry.stringPropertyEntry;
 
@@ -45,7 +46,9 @@ public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata
           JdbcConfig.PASSWORD.getKey(),
           JdbcConfig.POOL_MIN_SIZE.getKey(),
           JdbcConfig.POOL_MAX_SIZE.getKey(),
-          JdbcConfig.TEST_ON_BORROW.getKey());
+          JdbcConfig.TEST_ON_BORROW.getKey(),
+          JdbcConfig.CONNECT_TIMEOUT_MS.getKey(),
+          JdbcConfig.VERIFY_ON_CREATE.getKey());
 
   static {
     List<PropertyEntry<?>> propertyEntries =
@@ -111,6 +114,20 @@ public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata
                 false /* immutable */,
                 JdbcConfig.TEST_ON_BORROW.getDefaultValue(),
                 true /* hidden */,
+                false /* reserved */),
+            longOptionalPropertyEntry(
+                JdbcConfig.CONNECT_TIMEOUT_MS.getKey(),
+                JdbcConfig.CONNECT_TIMEOUT_MS.getDoc(),
+                false /* immutable */,
+                JdbcConfig.CONNECT_TIMEOUT_MS.getDefaultValue(),
+                false /* hidden */),
+            booleanPropertyEntry(
+                JdbcConfig.VERIFY_ON_CREATE.getKey(),
+                JdbcConfig.VERIFY_ON_CREATE.getDoc(),
+                false /* required */,
+                false /* immutable */,
+                JdbcConfig.VERIFY_ON_CREATE.getDefaultValue(),
+                true /* hidden */,
                 false /* reserved */));
     PROPERTIES_METADATA =
         ImmutableMap.<String, PropertyEntry<?>>builder()
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/config/JdbcConfig.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/config/JdbcConfig.java
index e474bcda39..a6ff2aa7e9 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/config/JdbcConfig.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/config/JdbcConfig.java
@@ -102,6 +102,52 @@ public class JdbcConfig extends Config {
           .checkValue(value -> value > 0, 
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
           .createWithDefault(30000L);
 
+  /**
+   * The canonical connect/read timeout, in milliseconds, used to bound a JDBC 
connection so an
+   * unreachable or unresponsive backend fails fast instead of hanging. It is 
translated to each
+   * driver family's own parameters (and unit) by {@link
+   * org.apache.gravitino.catalog.jdbc.utils.JdbcConnectionTimeouts}.
+   *
+   * <p>It is applied differently to the two kinds of connection: the pooled 
connections used by
+   * normal operations get only the <em>connect</em> timeout (a read timeout 
there would abort
+   * legitimate long-running operations), while the one-off create-time 
verification probe gets both
+   * the <em>connect</em> and the <em>read/login</em> timeout, because that 
probe runs under the
+   * metalake write lock and a backend that accepts the socket but never 
speaks the protocol would
+   * otherwise block the connect-only bound forever. An operator value set 
directly on the
+   * connection or in the {@code jdbc-url} is never overridden.
+   *
+   * <p>The value is bounded by {@link Integer#MAX_VALUE} milliseconds because 
the MySQL and
+   * PostgreSQL driver timeouts are {@code int}s (and the PostgreSQL 
millisecond-to-seconds
+   * conversion stays within {@code int} range); a larger value would be 
accepted here and only fail
+   * later when the driver parses it.
+   */
+  public static final ConfigEntry<Long> CONNECT_TIMEOUT_MS =
+      new ConfigBuilder("jdbc.pool.connect-timeout-ms")
+          .doc(
+              "The connect/read timeout for a JDBC connection, in milliseconds 
(1.."
+                  + Integer.MAX_VALUE
+                  + "), translated per driver so an unreachable or silent 
backend fails fast")
+          .version(ConfigConstants.VERSION_1_3_0)
+          .longConf()
+          .checkValue(
+              value -> value > 0 && value <= Integer.MAX_VALUE,
+              "The value must be a positive number not greater than " + 
Integer.MAX_VALUE)
+          .createWithDefault(30000L);
+
+  /**
+   * Whether a relational JDBC catalog verifies that its backend is reachable 
at create time. When
+   * {@code true} (the default, and when absent) the catalog runs a bounded 
connection probe during
+   * create and the create fails if the backend is unreachable; set it to 
{@code false} to register
+   * a catalog whose backend is temporarily unavailable. It is a catalog 
property, not a connection
+   * setting, so it is not forwarded to the driver.
+   */
+  public static final ConfigEntry<Boolean> VERIFY_ON_CREATE =
+      new ConfigBuilder("verify-on-create")
+          .doc("Whether a relational JDBC catalog verifies its backend is 
reachable at create time")
+          .version(ConfigConstants.VERSION_1_3_0)
+          .booleanConf()
+          .createWithDefault(true);
+
   public String getJdbcUrl() {
     return get(JDBC_URL);
   }
@@ -138,6 +184,24 @@ public class JdbcConfig extends Config {
     return get(POOL_MAX_WAIT_MS);
   }
 
+  /**
+   * Returns the canonical connect/socket timeout for a single JDBC 
connection, in milliseconds.
+   *
+   * @return the connect/socket timeout in milliseconds
+   */
+  public long getConnectTimeoutMs() {
+    return get(CONNECT_TIMEOUT_MS);
+  }
+
+  /**
+   * Returns whether this catalog verifies that its backend is reachable at 
create time.
+   *
+   * @return {@code true} to verify the backend at create (default); {@code 
false} to skip the check
+   */
+  public boolean getVerifyOnCreate() {
+    return get(VERIFY_ON_CREATE);
+  }
+
   public JdbcConfig(Map<String, String> properties) {
     super(false);
     loadFromMap(properties, k -> true);
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
index d69055db03..1430cf4c8c 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
@@ -123,6 +123,12 @@ public class DataSourceUtils {
     basicDataSource.setTestOnBorrow(jdbcConfig.getTestOnBorrow());
     basicDataSource.setValidationQuery(POOL_TEST_QUERY);
     basicDataSource.setMaxWait(Duration.ofMillis(jdbcConfig.getMaxWaitMs()));
+    // Apply the driver-aware connect timeout as a DBCP connection property 
(not a pool key, so the
+    // pool stays lazy). It bounds a single connection and takes effect on the 
first real connection
+    // — i.e. create-time verification — so an unreachable backend fails fast 
instead of hanging.
+    // Operator-set values are preserved.
+    JdbcConnectionTimeouts.connectionProperties(jdbcConfig, jdbcUrl)
+        .forEach(basicDataSource::addConnectionProperty);
     return basicDataSource;
   }
 
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/JdbcConnectionTimeouts.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/JdbcConnectionTimeouts.java
new file mode 100644
index 0000000000..88188551f0
--- /dev/null
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/JdbcConnectionTimeouts.java
@@ -0,0 +1,303 @@
+/*
+ * 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.util.LinkedHashMap;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.catalog.jdbc.config.JdbcConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Translates the canonical {@link JdbcConfig#CONNECT_TIMEOUT_MS 
connect-timeout-ms} into the
+ * driver-family connection properties that bound a JDBC connection, so an 
unreachable or silent
+ * backend fails fast instead of hanging.
+ *
+ * <p>Two translations are provided, because a pooled connection and the 
create-time verification
+ * probe need different bounds:
+ *
+ * <ul>
+ *   <li>{@link #connectionProperties} — used for the <b>pooled</b> 
connections of normal
+ *       operations. It bounds only the <em>connection-establishment</em> 
timeout, deliberately not
+ *       the socket-read timeout: a read timeout on the pool would also abort 
legitimate
+ *       long-running operations on a healthy backend (a large {@code ALTER 
TABLE}, a slow metadata
+ *       query).
+ *   <li>{@link #probeConnectionProperties} — used for the one-off 
<b>create-time verification
+ *       probe</b>. It bounds the connect timeout <em>and</em> the socket-read 
(and, for PostgreSQL,
+ *       login) timeout, because that probe runs under the metalake write 
lock: a backend that
+ *       accepts the TCP connection but never speaks the database protocol (a 
load balancer with a
+ *       dead backend, a wrong port, a proxy that accepts and never forwards) 
would otherwise make
+ *       the connect-only bound wait on the driver's default read timeout, 
which is "wait forever",
+ *       and hold the lock for the whole metalake.
+ * </ul>
+ *
+ * <p>DBCP has no portable timeout; the real bound lives in the driver, and 
both the parameter name
+ * and its unit differ per driver family:
+ *
+ * <ul>
+ *   <li><b>MySQL Connector/J</b> ({@code com.mysql.cj.jdbc.Driver}, the 
legacy {@code
+ *       com.mysql.jdbc.Driver} — also used by Doris and StarRocks — and the 
MySQL-compatible
+ *       OceanBase Connector/J {@code com.oceanbase.jdbc.Driver}): {@code 
connectTimeout} and {@code
+ *       socketTimeout} in <b>milliseconds</b>.
+ *   <li><b>PostgreSQL</b> ({@code org.postgresql.Driver}, also used by the 
PostgreSQL-compatible
+ *       Hologres catalog): {@code connectTimeout}, {@code socketTimeout} and 
{@code loginTimeout}
+ *       in <b>seconds</b>.
+ *   <li><b>ClickHouse</b> ({@code com.clickhouse.jdbc.ClickHouseDriver}): 
{@code connect_timeout}
+ *       and {@code socket_timeout} in <b>milliseconds</b> (note the 
underscore names).
+ * </ul>
+ *
+ * <p>An unknown driver family yields no properties (logged at debug), so 
adding a new JDBC provider
+ * never breaks; that provider opts in by registering its family here.
+ *
+ * <p><b>Default, never override.</b> A property is omitted when the operator 
has already set it in
+ * one of the two places that actually reach the driver: a query parameter in 
the {@code jdbc-url},
+ * or inside the DBCP {@code connectionProperties} value (a {@code 
name=value;...} string), so the
+ * operator's value always wins. Both checks are case-sensitive and anchored 
at a boundary ({@code
+ * ?} or {@code &} for the URL, {@code ;} for connectionProperties), so a 
differently-cased or
+ * substring variant does not suppress injection of the canonical key. A bare 
top-level config key
+ * of the same name is deliberately not treated as operator-set, because DBCP 
does not honor such a
+ * key.
+ *
+ * <p>The pooled properties are applied as DBCP <em>connection</em> properties 
(via {@code
+ * BasicDataSource.addConnectionProperty}), not raw pool keys, so they neither 
collide with the
+ * unsafe pool properties nor make the pool eager.
+ */
+public final class JdbcConnectionTimeouts {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(JdbcConnectionTimeouts.class);
+
+  /** The MySQL Connector/J driver class (modern). */
+  static final String MYSQL_DRIVER = "com.mysql.cj.jdbc.Driver";
+
+  /** The MySQL Connector/J driver class (legacy, pre-8.0). */
+  static final String MYSQL_LEGACY_DRIVER = "com.mysql.jdbc.Driver";
+
+  /** The OceanBase Connector/J driver class (MySQL-compatible, milliseconds). 
*/
+  static final String OCEANBASE_DRIVER = "com.oceanbase.jdbc.Driver";
+
+  /** The PostgreSQL driver class (also used by the Hologres catalog). */
+  static final String POSTGRESQL_DRIVER = "org.postgresql.Driver";
+
+  /** The ClickHouse JDBC driver class. */
+  static final String CLICKHOUSE_DRIVER = 
"com.clickhouse.jdbc.ClickHouseDriver";
+
+  /** The connect-timeout parameter name used by the MySQL and PostgreSQL 
families (camelCase). */
+  static final String CONNECT_TIMEOUT_PARAM = "connectTimeout";
+
+  /** The socket-read-timeout parameter name used by the MySQL and PostgreSQL 
families. */
+  static final String SOCKET_TIMEOUT_PARAM = "socketTimeout";
+
+  /** The login-timeout parameter name used by the PostgreSQL family. */
+  static final String LOGIN_TIMEOUT_PARAM = "loginTimeout";
+
+  /** The connect-timeout parameter name used by ClickHouse (snake_case). */
+  static final String CLICKHOUSE_CONNECT_TIMEOUT_PARAM = "connect_timeout";
+
+  /** The socket-read-timeout parameter name used by ClickHouse (snake_case). 
*/
+  static final String CLICKHOUSE_SOCKET_TIMEOUT_PARAM = "socket_timeout";
+
+  private JdbcConnectionTimeouts() {
+    // Utility class, prevent instantiation.
+  }
+
+  /**
+   * Returns the driver-family <b>connect-timeout</b> connection property that 
bounds a pooled
+   * connection to the canonical {@link JdbcConfig#getConnectTimeoutMs()} 
value, or an empty map
+   * when the driver family is unknown or the operator has already set the 
connect timeout. Only the
+   * connect timeout is returned here; see {@link #probeConnectionProperties} 
for the create-time
+   * probe, which also bounds the read timeout.
+   *
+   * @param config the catalog JDBC configuration; must not be null
+   * @param jdbcUrl the JDBC URL whose query parameters are inspected for 
operator-set timeouts; may
+   *     be null or blank
+   * @return a map of connect-timeout property name to value, or an empty map
+   */
+  public static Map<String, String> connectionProperties(JdbcConfig config, 
String jdbcUrl) {
+    return timeoutProperties(config, jdbcUrl, false /* includeReadTimeout */);
+  }
+
+  /**
+   * Returns the driver-family <b>connect and read (and, for PostgreSQL, 
login)</b> timeout
+   * connection properties that bound the one-off create-time verification 
probe to the canonical
+   * {@link JdbcConfig#getConnectTimeoutMs()} value, or an empty map when the 
driver family is
+   * unknown or the operator has already set the properties. Bounding the read 
timeout here (but not
+   * on the pool, see {@link #connectionProperties}) ensures a backend that 
accepts the socket but
+   * never speaks the protocol fails the probe instead of blocking the 
metalake write lock forever.
+   *
+   * @param config the catalog JDBC configuration; must not be null
+   * @param jdbcUrl the JDBC URL whose query parameters are inspected for 
operator-set timeouts; may
+   *     be null or blank
+   * @return a map of timeout property name to value, or an empty map
+   */
+  public static Map<String, String> probeConnectionProperties(JdbcConfig 
config, String jdbcUrl) {
+    return timeoutProperties(config, jdbcUrl, true /* includeReadTimeout */);
+  }
+
+  private static Map<String, String> timeoutProperties(
+      JdbcConfig config, String jdbcUrl, boolean includeReadTimeout) {
+    Map<String, String> properties = new LinkedHashMap<>();
+    String driver = config.getJdbcDriver();
+    long millis = config.getConnectTimeoutMs();
+
+    String connectParam;
+    String readParam;
+    String loginParam;
+    boolean seconds;
+    if (MYSQL_DRIVER.equals(driver)
+        || MYSQL_LEGACY_DRIVER.equals(driver)
+        || OCEANBASE_DRIVER.equals(driver)) {
+      // MySQL Connector/J and the MySQL-compatible OceanBase driver expect 
milliseconds.
+      connectParam = CONNECT_TIMEOUT_PARAM;
+      readParam = SOCKET_TIMEOUT_PARAM;
+      loginParam = null;
+      seconds = false;
+    } else if (POSTGRESQL_DRIVER.equals(driver)) {
+      // PostgreSQL (and Hologres via the PostgreSQL driver) expect seconds. 
loginTimeout bounds the
+      // authentication handshake, which socketTimeout alone does not fully 
cover.
+      connectParam = CONNECT_TIMEOUT_PARAM;
+      readParam = SOCKET_TIMEOUT_PARAM;
+      loginParam = LOGIN_TIMEOUT_PARAM;
+      seconds = true;
+    } else if (CLICKHOUSE_DRIVER.equals(driver)) {
+      // ClickHouse uses snake_case parameter names and expects milliseconds.
+      connectParam = CLICKHOUSE_CONNECT_TIMEOUT_PARAM;
+      readParam = CLICKHOUSE_SOCKET_TIMEOUT_PARAM;
+      loginParam = null;
+      seconds = false;
+    } else {
+      LOG.debug(
+          "JDBC driver '{}' has no known timeout translation; leaving the 
connection timeout "
+              + "unbounded by Gravitino. Set it in the jdbc-url if needed.",
+          driver);
+      return properties;
+    }
+
+    String value = Long.toString(seconds ? millisToSeconds(millis) : millis);
+    addIfAbsent(properties, connectParam, value, config, jdbcUrl);
+    if (includeReadTimeout) {
+      addIfAbsent(properties, readParam, value, config, jdbcUrl);
+      if (loginParam != null) {
+        addIfAbsent(properties, loginParam, value, config, jdbcUrl);
+      }
+    }
+    return properties;
+  }
+
+  /**
+   * Converts milliseconds to seconds, rounded up with a minimum of 1 so a 
sub-second bound never
+   * collapses to 0 ("no timeout"). The arithmetic is overflow-safe for the 
whole positive {@code
+   * long} range (the input is validated positive and {@code <= 
Integer.MAX_VALUE} by {@link
+   * JdbcConfig}).
+   *
+   * @param millis the timeout in milliseconds (positive)
+   * @return the timeout in whole seconds, at least 1
+   */
+  private static long millisToSeconds(long millis) {
+    // (millis - 1) / 1000 + 1 is ceil(millis/1000) for positive millis 
without the overflow that
+    // (millis + 999) would hit near Long.MAX_VALUE.
+    return (millis - 1L) / 1000L + 1L;
+  }
+
+  /**
+   * The DBCP2 pool key whose value is a {@code name=value;name2=value2} 
string of connection
+   * properties. {@code DataSourceUtils} forwards it to {@code 
BasicDataSourceFactory}, which parses
+   * it into the pool's connection properties, and the create-time probe reads 
the same catalog
+   * configuration. An operator-set timeout lives inside this value, not as a 
top-level config key.
+   */
+  static final String DBCP_CONNECTION_PROPERTIES_KEY = "connectionProperties";
+
+  /**
+   * Adds a timeout property unless the operator has already set it, 
preserving the operator's value
+   * in that case. The operator can set it in two places that actually reach 
the driver: a {@code
+   * jdbc-url} query parameter, or inside the DBCP {@code 
connectionProperties} value (a {@code
+   * name=value;...} string). A bare top-level config key of the same name is 
deliberately NOT
+   * treated as operator-set, because DBCP does not recognize such a key — 
honoring it would
+   * suppress the bound while the driver still saw no timeout.
+   *
+   * @param properties the map being built
+   * @param name the timeout property name
+   * @param value the Gravitino default value
+   * @param config the catalog configuration
+   * @param jdbcUrl the JDBC URL
+   */
+  private static void addIfAbsent(
+      Map<String, String> properties,
+      String name,
+      String value,
+      JdbcConfig config,
+      String jdbcUrl) {
+    if (hasUrlParameter(jdbcUrl, name)
+        || 
hasConnectionProperty(config.getAllConfig().get(DBCP_CONNECTION_PROPERTIES_KEY),
 name)) {
+      return;
+    }
+    properties.put(name, value);
+  }
+
+  /**
+   * Determines whether the DBCP {@code connectionProperties} value already 
defines the given
+   * property. The value is a {@code name=value} list separated by {@code ;} 
(the format DBCP's
+   * {@code BasicDataSourceFactory} parses), so the name is matched 
case-sensitively at a {@code ;}
+   * boundary, mirroring the URL check; a differently-cased or substring 
variant does not count.
+   *
+   * @param connectionProperties the DBCP connectionProperties string; may be 
null or blank
+   * @param name the exact property name to look for
+   * @return true if the property is present with exactly this name
+   */
+  private static boolean hasConnectionProperty(String connectionProperties, 
String name) {
+    if (StringUtils.isBlank(connectionProperties)) {
+      return false;
+    }
+    for (String pair : connectionProperties.split(";")) {
+      int eq = pair.indexOf('=');
+      String key = (eq >= 0 ? pair.substring(0, eq) : pair).trim();
+      if (key.equals(name)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Determines whether the URL already carries the given query parameter.
+   *
+   * <p>The match is case-sensitive and anchored at a parameter boundary 
({@code ?} or {@code &}),
+   * so a differently-cased variant is treated as a different parameter and 
{@code connectTimeout}
+   * is not considered present merely because {@code tunnelConnectTimeout} is.
+   *
+   * @param url the JDBC URL; may be null or blank
+   * @param parameter the exact query parameter name to look for
+   * @return true if the parameter is already present with exactly this key
+   */
+  private static boolean hasUrlParameter(String url, String parameter) {
+    if (StringUtils.isBlank(url)) {
+      return false;
+    }
+    String needle = parameter + "=";
+    int index = url.indexOf(needle);
+    while (index >= 0) {
+      char preceding = index == 0 ? '\0' : url.charAt(index - 1);
+      if (preceding == '?' || preceding == '&') {
+        return true;
+      }
+      index = url.indexOf(needle, index + 1);
+    }
+    return false;
+  }
+}
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogOperationsProbe.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogOperationsProbe.java
new file mode 100644
index 0000000000..68cde50c87
--- /dev/null
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogOperationsProbe.java
@@ -0,0 +1,204 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.sql.Connection;
+import java.sql.Driver;
+import java.sql.DriverPropertyInfo;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.util.Properties;
+import org.apache.gravitino.NameIdentifier;
+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.operation.SqliteDatabaseOperations;
+import org.apache.gravitino.catalog.jdbc.operation.SqliteTableOperations;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for the create-time backend probe and the credential-safe host 
extraction in {@link
+ * JdbcCatalogOperations}, exercised without a real database.
+ */
+public class TestJdbcCatalogOperationsProbe {
+
+  /**
+   * A probe against a server that accepts the TCP connection but never 
replies must fail within the
+   * configured read timeout, not hang. This is the 
metalake-write-lock-starvation case: before the
+   * read timeout was injected on the probe, the driver's handshake read 
waited forever.
+   */
+  @Test
+  public void testProbeAgainstAcceptButSilentServerIsBounded() throws 
Exception {
+    try (ServerSocket server = new ServerSocket(0)) {
+      server.setSoTimeout(0);
+      // Accept and then stay silent on a background thread, mimicking a 
dead-backend load balancer.
+      Thread acceptor =
+          new Thread(
+              () -> {
+                try {
+                  Socket socket = server.accept();
+                  // Hold the socket open without ever writing a protocol 
handshake.
+                  Thread.sleep(60_000L);
+                  socket.close();
+                } catch (Exception ignored) {
+                  // Test is finishing; ignore.
+                }
+              });
+      acceptor.setDaemon(true);
+      acceptor.start();
+
+      int port = server.getLocalPort();
+      // Driver string is a MySQL family name so JdbcConnectionTimeouts 
injects connectTimeout +
+      // socketTimeout (ms); the actual driver is the socket-reading fake 
below.
+      JdbcCatalogOperations ops = new ProbeOnlyJdbcCatalogOperations(new 
SocketReadingDriver());
+      java.util.Map<String, String> config = new java.util.HashMap<>();
+      config.put(JdbcConfig.JDBC_DRIVER.getKey(), "com.mysql.cj.jdbc.Driver");
+      config.put(JdbcConfig.JDBC_URL.getKey(), "jdbc:faketimeout://127.0.0.1:" 
+ port + "/db");
+      config.put(JdbcConfig.USERNAME.getKey(), "u");
+      config.put(JdbcConfig.PASSWORD.getKey(), "p");
+      // 1 second probe budget; the test asserts the probe returns well within 
that order.
+      config.put(JdbcConfig.CONNECT_TIMEOUT_MS.getKey(), "1000");
+      ops.setProbeConfigForTest(new JdbcConfig(config));
+
+      // The probe must fail fast (bounded by the injected socketTimeout), not 
hang on the read.
+      Assertions.assertTimeoutPreemptively(
+          java.time.Duration.ofSeconds(15),
+          () ->
+              Assertions.assertThrows(
+                  ConnectionFailedException.class,
+                  () -> ops.probeConnection(NameIdentifier.of("m", "c"))));
+    }
+  }
+
+  @Test
+  public void testHostPortFromJdbcUrlNeverLeaksCredentials() {
+    // Plain host[:port] forms are returned.
+    Assertions.assertEquals(
+        "db:3306", 
JdbcCatalogOperations.hostPortFromJdbcUrl("jdbc:mysql://db:3306/test"));
+    Assertions.assertEquals(
+        "db", 
JdbcCatalogOperations.hostPortFromJdbcUrl("jdbc:mysql://db/test"));
+    Assertions.assertEquals(
+        "db:3306",
+        
JdbcCatalogOperations.hostPortFromJdbcUrl("jdbc:mysql://root:s3cret@db:3306/test"));
+    Assertions.assertEquals(
+        "[::1]:5432",
+        
JdbcCatalogOperations.hostPortFromJdbcUrl("jdbc:postgresql://[::1]:5432/test"));
+
+    // Credential-bearing or non-plain authorities must be reported as 
unknown, never echoed.
+    Assertions.assertEquals(
+        "unknown",
+        JdbcCatalogOperations.hostPortFromJdbcUrl(
+            
"jdbc:mysql://(host=db1,port=3306,user=root,password=s3cret)/test"));
+    Assertions.assertEquals(
+        "unknown",
+        JdbcCatalogOperations.hostPortFromJdbcUrl(
+            
"jdbc:mysql://address=(host=db1)(port=3306)(password=s3cret)/test"));
+    // No "://" authority (the PostgreSQL short form can carry a password 
query parameter).
+    Assertions.assertEquals(
+        "unknown", 
JdbcCatalogOperations.hostPortFromJdbcUrl("jdbc:postgresql:db?password=s3cret"));
+    Assertions.assertEquals("unknown", 
JdbcCatalogOperations.hostPortFromJdbcUrl(""));
+    Assertions.assertEquals("unknown", 
JdbcCatalogOperations.hostPortFromJdbcUrl(null));
+  }
+
+  /** A JdbcCatalogOperations that only needs enough wiring to run the probe 
in a unit test. */
+  private static class ProbeOnlyJdbcCatalogOperations extends 
JdbcCatalogOperations {
+    private final Driver driver;
+
+    ProbeOnlyJdbcCatalogOperations(Driver driver) {
+      super(
+          new SqliteExceptionConverter(),
+          new SqliteTypeConverter(),
+          new SqliteDatabaseOperations(":memory:"),
+          new SqliteTableOperations(),
+          new SqliteColumnDefaultValueConverter());
+      this.driver = driver;
+    }
+
+    @Override
+    protected Driver getDriver() {
+      return driver;
+    }
+  }
+
+  /**
+   * A fake driver that performs a real blocking socket read against the URL's 
host:port, honoring a
+   * {@code socketTimeout} connection property (milliseconds) exactly as a 
real driver would. With
+   * the property set the read is bounded and surfaces as a SQLException; 
without it the read
+   * blocks.
+   */
+  private static class SocketReadingDriver implements Driver {
+    @Override
+    public Connection connect(String url, Properties info) throws SQLException 
{
+      String hostPort = url.substring("jdbc:faketimeout://".length());
+      String host = hostPort.substring(0, hostPort.indexOf(':'));
+      String rest = hostPort.substring(hostPort.indexOf(':') + 1);
+      int port = Integer.parseInt(rest.substring(0, rest.indexOf('/')));
+      int socketTimeoutMs =
+          info.getProperty("socketTimeout") == null
+              ? 0
+              : Integer.parseInt(info.getProperty("socketTimeout"));
+      try (Socket socket = new Socket(host, port)) {
+        socket.setSoTimeout(socketTimeoutMs);
+        // Mimic a protocol handshake read; a silent server makes this block 
until socketTimeout.
+        socket.getInputStream().read();
+      } catch (IOException e) {
+        throw new SQLException("handshake read failed: " + e.getMessage(), e);
+      }
+      throw new SQLException("unexpected: silent server returned data");
+    }
+
+    @Override
+    public boolean acceptsURL(String url) {
+      return url != null && url.startsWith("jdbc:faketimeout://");
+    }
+
+    @Override
+    public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) {
+      return new DriverPropertyInfo[0];
+    }
+
+    @Override
+    public int getMajorVersion() {
+      return 1;
+    }
+
+    @Override
+    public int getMinorVersion() {
+      return 0;
+    }
+
+    @Override
+    public boolean jdbcCompliant() {
+      return false;
+    }
+
+    @Override
+    public java.util.logging.Logger getParentLogger() throws 
SQLFeatureNotSupportedException {
+      // This fake driver does not use java.util.logging. FQN is used because 
java.sql.Driver
+      // mandates java.util.logging.Logger, whose simple name would clash with 
org.slf4j.Logger.
+      throw new SQLFeatureNotSupportedException();
+    }
+  }
+}
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogPropertiesMetadata.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogPropertiesMetadata.java
index 84099abd0c..d810dd1d72 100644
--- 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogPropertiesMetadata.java
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogPropertiesMetadata.java
@@ -47,6 +47,44 @@ public class TestJdbcCatalogPropertiesMetadata {
     
Assertions.assertFalse(propertiesMetadata.isHiddenProperty(JdbcConfig.POOL_MAX_SIZE.getKey()));
   }
 
+  @Test
+  public void testShouldValidateOnCreateHonorsVerifyOnCreate() {
+    // Default (property absent): verify on create.
+    
Assertions.assertTrue(buildCatalog(ImmutableMap.of()).shouldValidateOnCreate());
+    // Explicit true: verify.
+    Assertions.assertTrue(
+        buildCatalog(ImmutableMap.of(JdbcConfig.VERIFY_ON_CREATE.getKey(), 
"true"))
+            .shouldValidateOnCreate());
+    // Explicit false: skip.
+    Assertions.assertFalse(
+        buildCatalog(ImmutableMap.of(JdbcConfig.VERIFY_ON_CREATE.getKey(), 
"false"))
+            .shouldValidateOnCreate());
+  }
+
+  private static JdbcCatalog buildCatalog(Map<String, String> extraProperties) 
{
+    Map<String, String> properties =
+        ImmutableMap.<String, String>builder()
+            .put(JdbcConfig.JDBC_URL.getKey(), "jdbc:sqlite::memory:")
+            .put(JdbcConfig.JDBC_DRIVER.getKey(), "org.sqlite.JDBC")
+            .put(JdbcConfig.USERNAME.getKey(), "user")
+            .put(JdbcConfig.PASSWORD.getKey(), "password")
+            .putAll(extraProperties)
+            .build();
+    CatalogEntity entity =
+        CatalogEntity.builder()
+            .withId(1L)
+            .withName("jdbc_catalog")
+            .withNamespace(Namespace.of("metalake"))
+            .withType(Catalog.Type.RELATIONAL)
+            .withProvider("jdbc-test")
+            .withComment("test")
+            .withProperties(properties)
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+            .build();
+    return new 
TestJdbcCatalog().withCatalogConf(entity.getProperties()).withCatalogEntity(entity);
+  }
+
   @Test
   public void testCatalogPropertiesKeepPoolSizeAfterLoad() {
     Map<String, String> createProperties =
@@ -82,6 +120,31 @@ public class TestJdbcCatalogPropertiesMetadata {
     Assertions.assertEquals("20", 
loadedProperties.get(JdbcConfig.POOL_MAX_SIZE.getKey()));
   }
 
+  @Test
+  public void testConnectTimeoutSurvivesTransformProperties() {
+    // transformProperties only retains keys in the JDBC allowlist; the 
connect-timeout key must be
+    // on it, otherwise an operator-configured value is silently dropped and 
the catalog always uses
+    // the 30000 ms default (the production-path bug behind the create-time 
verification feature).
+    Map<String, String> createProperties =
+        ImmutableMap.<String, String>builder()
+            .put(JdbcConfig.JDBC_URL.getKey(), "jdbc:sqlite::memory:")
+            .put(JdbcConfig.JDBC_DRIVER.getKey(), "org.sqlite.JDBC")
+            .put(JdbcConfig.USERNAME.getKey(), "user")
+            .put(JdbcConfig.PASSWORD.getKey(), "password")
+            .put(JdbcConfig.CONNECT_TIMEOUT_MS.getKey(), "7500")
+            .build();
+
+    Map<String, String> transformed =
+        new 
JdbcCatalogPropertiesMetadata().transformProperties(createProperties);
+
+    Assertions.assertEquals("7500", 
transformed.get(JdbcConfig.CONNECT_TIMEOUT_MS.getKey()));
+    Assertions.assertEquals(
+        7500L, new JdbcConfig(transformed).getConnectTimeoutMs(), "value must 
reach JdbcConfig");
+    Assertions.assertFalse(
+        new JdbcCatalogPropertiesMetadata()
+            .isHiddenProperty(JdbcConfig.CONNECT_TIMEOUT_MS.getKey()));
+  }
+
   private static class TestJdbcCatalog extends JdbcCatalog {
     @Override
     public String shortName() {
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestJdbcConfig.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestJdbcConfig.java
index 9eebf08ff7..2b558ff22a 100644
--- 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestJdbcConfig.java
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestJdbcConfig.java
@@ -46,4 +46,47 @@ public class TestJdbcConfig {
     jdbcConfig = new JdbcConfig(immutableMap);
     Assertions.assertEquals(60000L, jdbcConfig.getMaxWaitMs());
   }
+
+  @Test
+  void testConnectTimeoutMs() {
+    JdbcConfig jdbcConfig = new JdbcConfig(Maps.newHashMap());
+    Assertions.assertEquals(30000L, jdbcConfig.getConnectTimeoutMs());
+
+    ImmutableMap immutableMap = 
ImmutableMap.of("jdbc.pool.connect-timeout-ms", "5000");
+    jdbcConfig = new JdbcConfig(immutableMap);
+    Assertions.assertEquals(5000L, jdbcConfig.getConnectTimeoutMs());
+  }
+
+  @Test
+  void testConnectTimeoutMsRejectsNonPositiveAndOverIntMax() {
+    // Config validation is lazy (checkValue runs on get()), so assert via the 
getter.
+    // Zero / negative are rejected (must be positive).
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            new JdbcConfig(ImmutableMap.of("jdbc.pool.connect-timeout-ms", 
"0"))
+                .getConnectTimeoutMs());
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            new JdbcConfig(ImmutableMap.of("jdbc.pool.connect-timeout-ms", 
"-1"))
+                .getConnectTimeoutMs());
+
+    // Integer.MAX_VALUE is the inclusive upper bound (the MySQL/PostgreSQL 
driver timeouts are
+    // ints), so it is accepted and one more is rejected.
+    Assertions.assertEquals(
+        (long) Integer.MAX_VALUE,
+        new JdbcConfig(
+                ImmutableMap.of(
+                    "jdbc.pool.connect-timeout-ms", Long.toString((long) 
Integer.MAX_VALUE)))
+            .getConnectTimeoutMs());
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            new JdbcConfig(
+                    ImmutableMap.of(
+                        "jdbc.pool.connect-timeout-ms",
+                        Long.toString((long) Integer.MAX_VALUE + 1L)))
+                .getConnectTimeoutMs());
+  }
 }
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestJdbcConnectionTimeouts.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestJdbcConnectionTimeouts.java
new file mode 100644
index 0000000000..da3426ff26
--- /dev/null
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestJdbcConnectionTimeouts.java
@@ -0,0 +1,326 @@
+/*
+ * 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 com.google.common.collect.Maps;
+import java.util.Map;
+import org.apache.gravitino.catalog.jdbc.config.JdbcConfig;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestJdbcConnectionTimeouts {
+
+  private static final String DRIVER_KEY = "jdbc-driver";
+  private static final String URL_KEY = "jdbc-url";
+  private static final String CONNECT_TIMEOUT_MS_KEY = 
"jdbc.pool.connect-timeout-ms";
+  private static final String CONNECT_TIMEOUT = "connectTimeout";
+  private static final String SOCKET_TIMEOUT = "socketTimeout";
+  private static final String CLICKHOUSE_CONNECT_TIMEOUT = "connect_timeout";
+  private static final String CLICKHOUSE_SOCKET_TIMEOUT = "socket_timeout";
+
+  private static JdbcConfig config(Map<String, String> props) {
+    return new JdbcConfig(props);
+  }
+
+  @Test
+  void testMysqlFamilyEmitsConnectTimeoutMillisOnly() {
+    for (String driver : new String[] {"com.mysql.cj.jdbc.Driver", 
"com.mysql.jdbc.Driver"}) {
+      Map<String, String> props = Maps.newHashMap();
+      props.put(DRIVER_KEY, driver);
+      props.put(URL_KEY, "jdbc:mysql://host:3306/db");
+
+      Map<String, String> result =
+          JdbcConnectionTimeouts.connectionProperties(config(props), 
props.get(URL_KEY));
+
+      // Only the connect timeout is emitted (never a socket-read timeout), as 
milliseconds.
+      Assertions.assertEquals("30000", result.get(CONNECT_TIMEOUT), driver);
+      Assertions.assertFalse(result.containsKey(SOCKET_TIMEOUT), driver);
+      Assertions.assertEquals(1, result.size(), driver);
+    }
+  }
+
+  @Test
+  void testOceanBaseDriverEmitsConnectTimeoutMillis() {
+    // OceanBase Connector/J is MySQL-compatible and uses the same camelCase 
param in milliseconds.
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "com.oceanbase.jdbc.Driver");
+    props.put(URL_KEY, "jdbc:oceanbase://host:2881/db");
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.connectionProperties(config(props), 
props.get(URL_KEY));
+
+    Assertions.assertEquals("30000", result.get(CONNECT_TIMEOUT));
+    Assertions.assertFalse(result.containsKey(SOCKET_TIMEOUT));
+    Assertions.assertEquals(1, result.size());
+  }
+
+  @Test
+  void testClickHouseDriverEmitsSnakeCaseConnectTimeoutMillis() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "com.clickhouse.jdbc.ClickHouseDriver");
+    props.put(URL_KEY, "jdbc:clickhouse://host:8443/default");
+    props.put(CONNECT_TIMEOUT_MS_KEY, "7500");
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.connectionProperties(config(props), 
props.get(URL_KEY));
+
+    // ClickHouse uses the snake_case name and milliseconds; the camelCase 
name must NOT be emitted,
+    // and no socket-read timeout is emitted.
+    Assertions.assertEquals("7500", result.get(CLICKHOUSE_CONNECT_TIMEOUT));
+    Assertions.assertFalse(result.containsKey(CLICKHOUSE_SOCKET_TIMEOUT));
+    Assertions.assertFalse(result.containsKey(CONNECT_TIMEOUT));
+    Assertions.assertEquals(1, result.size());
+  }
+
+  @Test
+  void testClickHouseOperatorConnectTimeoutInJdbcUrlIsPreserved() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "com.clickhouse.jdbc.ClickHouseDriver");
+    String url = "jdbc:clickhouse://host:8443/default?connect_timeout=5000";
+    props.put(URL_KEY, url);
+
+    Map<String, String> result = 
JdbcConnectionTimeouts.connectionProperties(config(props), url);
+
+    // The operator's connect_timeout wins, so nothing is injected.
+    Assertions.assertTrue(result.isEmpty());
+  }
+
+  @Test
+  void testHologresUsesPostgresqlDriverSeconds() {
+    // Hologres connects through the PostgreSQL driver, so it inherits the 
seconds translation.
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "org.postgresql.Driver");
+    props.put(URL_KEY, "jdbc:postgresql://host:80/db");
+    props.put(CONNECT_TIMEOUT_MS_KEY, "5000");
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.connectionProperties(config(props), 
props.get(URL_KEY));
+
+    Assertions.assertEquals("5", result.get(CONNECT_TIMEOUT));
+    Assertions.assertFalse(result.containsKey(SOCKET_TIMEOUT));
+    Assertions.assertEquals(1, result.size());
+  }
+
+  @Test
+  void testPostgresRoundsUpToSecondsWithMinimumOne() {
+    assertPostgresConnectSeconds("30000", "30");
+    assertPostgresConnectSeconds("1", "1");
+    assertPostgresConnectSeconds("999", "1");
+    assertPostgresConnectSeconds("1000", "1");
+    assertPostgresConnectSeconds("1001", "2");
+    assertPostgresConnectSeconds("1999", "2");
+  }
+
+  @Test
+  void testPostgresSecondsConversionAtMaxAllowedValue() {
+    // The config caps the value at Integer.MAX_VALUE ms; the ceiling 
conversion stays correct and
+    // within int range at that bound (and is overflow-safe for the whole 
positive long range).
+    long millis = Integer.MAX_VALUE;
+    long expectedSeconds = (millis - 1L) / 1000L + 1L;
+    assertPostgresConnectSeconds(Long.toString(millis), 
Long.toString(expectedSeconds));
+    Assertions.assertTrue(expectedSeconds > 1L, "conversion must not collapse 
to 1 second");
+    Assertions.assertTrue(
+        expectedSeconds <= Integer.MAX_VALUE, "converted seconds must fit the 
driver int range");
+  }
+
+  private static void assertPostgresConnectSeconds(String millis, String 
expectedSeconds) {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "org.postgresql.Driver");
+    props.put(URL_KEY, "jdbc:postgresql://host:5432/db");
+    props.put(CONNECT_TIMEOUT_MS_KEY, millis);
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.connectionProperties(config(props), 
props.get(URL_KEY));
+
+    Assertions.assertEquals(expectedSeconds, result.get(CONNECT_TIMEOUT), 
millis);
+    Assertions.assertFalse(result.containsKey(SOCKET_TIMEOUT), millis);
+  }
+
+  @Test
+  void testUnknownDriverEmitsNothing() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "org.sqlite.JDBC");
+    props.put(URL_KEY, "jdbc:sqlite:/tmp/test.db");
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.connectionProperties(config(props), 
props.get(URL_KEY));
+
+    Assertions.assertTrue(result.isEmpty());
+  }
+
+  @Test
+  void testOperatorConnectTimeoutInConnectionPropertiesIsPreserved() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "com.mysql.cj.jdbc.Driver");
+    props.put(URL_KEY, "jdbc:mysql://host:3306/db");
+    // The operator set connectTimeout inside the DBCP connectionProperties 
value (name=value;...).
+    props.put("connectionProperties", "connectTimeout=5000;useSSL=false");
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.connectionProperties(config(props), 
props.get(URL_KEY));
+
+    // The operator's connectTimeout wins, so nothing is injected (no 
overwrite).
+    Assertions.assertTrue(result.isEmpty());
+  }
+
+  @Test
+  void testBareTopLevelConnectTimeoutKeyDoesNotSuppressInjection() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "com.mysql.cj.jdbc.Driver");
+    props.put(URL_KEY, "jdbc:mysql://host:3306/db");
+    // A bare top-level connectTimeout key is NOT a DBCP connection property 
(DBCP ignores it), so
+    // it must NOT suppress injection — otherwise the connection would end up 
with no timeout.
+    props.put(CONNECT_TIMEOUT, "5000");
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.connectionProperties(config(props), 
props.get(URL_KEY));
+
+    Assertions.assertEquals("30000", result.get(CONNECT_TIMEOUT));
+    Assertions.assertEquals(1, result.size());
+  }
+
+  @Test
+  void testOperatorConnectTimeoutInJdbcUrlIsPreserved() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "com.mysql.cj.jdbc.Driver");
+    String url = "jdbc:mysql://host:3306/db?connectTimeout=5000";
+    props.put(URL_KEY, url);
+
+    Map<String, String> result = 
JdbcConnectionTimeouts.connectionProperties(config(props), url);
+
+    Assertions.assertTrue(result.isEmpty());
+  }
+
+  @Test
+  void testUrlParameterMatchIsCaseSensitiveAndBoundaryAnchored() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "com.mysql.cj.jdbc.Driver");
+    // "ConnectTimeout" (wrong case) and "tunnelConnectTimeout" (substring) 
must NOT suppress the
+    // canonical key, so the canonical connect timeout is still injected.
+    String url = 
"jdbc:mysql://host:3306/db?ConnectTimeout=5000&tunnelConnectTimeout=6000";
+    props.put(URL_KEY, url);
+
+    Map<String, String> result = 
JdbcConnectionTimeouts.connectionProperties(config(props), url);
+
+    Assertions.assertEquals("30000", result.get(CONNECT_TIMEOUT));
+    Assertions.assertEquals(1, result.size());
+  }
+
+  @Test
+  void testCustomConnectTimeoutPropagates() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "com.mysql.cj.jdbc.Driver");
+    props.put(URL_KEY, "jdbc:mysql://host:3306/db");
+    props.put(CONNECT_TIMEOUT_MS_KEY, "7500");
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.connectionProperties(config(props), 
props.get(URL_KEY));
+
+    Assertions.assertEquals("7500", result.get(CONNECT_TIMEOUT));
+    Assertions.assertEquals(1, result.size());
+  }
+
+  @Test
+  void testNullJdbcUrlIsSafe() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "org.postgresql.Driver");
+
+    Map<String, String> result = 
JdbcConnectionTimeouts.connectionProperties(config(props), null);
+
+    Assertions.assertEquals("30", result.get(CONNECT_TIMEOUT));
+    Assertions.assertEquals(1, result.size());
+  }
+
+  @Test
+  void testMysqlProbeEmitsConnectAndSocketTimeoutMillis() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "com.mysql.cj.jdbc.Driver");
+    props.put(URL_KEY, "jdbc:mysql://host:3306/db");
+    props.put(CONNECT_TIMEOUT_MS_KEY, "2000");
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.probeConnectionProperties(config(props), 
props.get(URL_KEY));
+
+    // The probe bounds both the connect and the read timeout (milliseconds 
for MySQL).
+    Assertions.assertEquals("2000", result.get(CONNECT_TIMEOUT));
+    Assertions.assertEquals("2000", result.get(SOCKET_TIMEOUT));
+    Assertions.assertEquals(2, result.size());
+  }
+
+  @Test
+  void testPostgresProbeEmitsConnectSocketAndLoginTimeoutSeconds() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "org.postgresql.Driver");
+    props.put(URL_KEY, "jdbc:postgresql://host:5432/db");
+    props.put(CONNECT_TIMEOUT_MS_KEY, "2000");
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.probeConnectionProperties(config(props), 
props.get(URL_KEY));
+
+    // The probe bounds connect, socket and login timeouts, converted to 
seconds.
+    Assertions.assertEquals("2", result.get(CONNECT_TIMEOUT));
+    Assertions.assertEquals("2", result.get(SOCKET_TIMEOUT));
+    Assertions.assertEquals("2", result.get("loginTimeout"));
+    Assertions.assertEquals(3, result.size());
+  }
+
+  @Test
+  void testClickHouseProbeEmitsSnakeCaseConnectAndSocketTimeoutMillis() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "com.clickhouse.jdbc.ClickHouseDriver");
+    props.put(URL_KEY, "jdbc:clickhouse://host:8443/default");
+    props.put(CONNECT_TIMEOUT_MS_KEY, "2000");
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.probeConnectionProperties(config(props), 
props.get(URL_KEY));
+
+    Assertions.assertEquals("2000", result.get(CLICKHOUSE_CONNECT_TIMEOUT));
+    Assertions.assertEquals("2000", result.get(CLICKHOUSE_SOCKET_TIMEOUT));
+    Assertions.assertFalse(result.containsKey(CONNECT_TIMEOUT));
+    Assertions.assertEquals(2, result.size());
+  }
+
+  @Test
+  void testProbeUnknownDriverEmitsNothing() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "org.sqlite.JDBC");
+    props.put(URL_KEY, "jdbc:sqlite::memory:");
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.probeConnectionProperties(config(props), 
props.get(URL_KEY));
+
+    Assertions.assertTrue(result.isEmpty());
+  }
+
+  @Test
+  void testProbeOperatorSocketTimeoutIsPreserved() {
+    Map<String, String> props = Maps.newHashMap();
+    props.put(DRIVER_KEY, "com.mysql.cj.jdbc.Driver");
+    String url = "jdbc:mysql://host:3306/db?socketTimeout=9000";
+    props.put(URL_KEY, url);
+
+    Map<String, String> result =
+        JdbcConnectionTimeouts.probeConnectionProperties(config(props), url);
+
+    // The operator's socketTimeout wins; only the connect timeout is injected.
+    Assertions.assertFalse(result.containsKey(SOCKET_TIMEOUT));
+    Assertions.assertEquals("30000", result.get(CONNECT_TIMEOUT));
+  }
+}
diff --git 
a/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/integration/test/CatalogMysqlIT.java
 
b/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/integration/test/CatalogMysqlIT.java
index d496c5f2dd..01638df1b9 100644
--- 
a/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/integration/test/CatalogMysqlIT.java
+++ 
b/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/integration/test/CatalogMysqlIT.java
@@ -216,6 +216,10 @@ public class CatalogMysqlIT extends BaseIT {
         JdbcConfig.JDBC_DRIVER.getKey(), 
MYSQL_CONTAINER.getDriverClassName(TEST_DB_NAME));
     catalogProperties.put(JdbcConfig.USERNAME.getKey(), 
MYSQL_CONTAINER.getUsername());
     catalogProperties.put(JdbcConfig.PASSWORD.getKey(), 
MYSQL_CONTAINER.getPassword());
+    // This test registers a catalog pointing at an unreachable endpoint on 
purpose, so it can then
+    // assert testConnection fails on the EXISTING catalog. It must opt out of 
create-time
+    // verification; the rejection by the default create-time verification is 
covered separately.
+    catalogProperties.put(JdbcConfig.VERIFY_ON_CREATE.getKey(), "false");
 
     metalake.createCatalog(
         unreachableCatalogName,
@@ -2333,6 +2337,10 @@ public class CatalogMysqlIT extends BaseIT {
     String password = MYSQL_CONTAINER.getPassword();
     String wrongPassword = password + "wrong";
     catalogProperties.put(JdbcConfig.PASSWORD.getKey(), wrongPassword);
+    // This test deliberately creates the catalog with a wrong password and 
then fixes it via
+    // alterCatalog, so it must opt out of create-time backend verification; 
otherwise the create
+    // itself would be rejected for the unreachable backend (the behavior 
added in #13767).
+    catalogProperties.put(JdbcConfig.VERIFY_ON_CREATE.getKey(), "false");
 
     metalake.createCatalog(
         testCatalogName, Catalog.Type.RELATIONAL, provider, "comment", 
catalogProperties);
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java 
b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
index e0d97232dc..5c25e4521e 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
@@ -91,6 +91,7 @@ import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.exceptions.CatalogAlreadyExistsException;
 import org.apache.gravitino.exceptions.CatalogInUseException;
 import org.apache.gravitino.exceptions.CatalogNotInUseException;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
 import org.apache.gravitino.exceptions.NoSuchCatalogException;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
@@ -931,7 +932,11 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
                       .build())
               .build();
 
-      CatalogWrapper wrapper = createCatalogWrapper(dummyEntity, mergedConfig);
+      // Validate the properties (reserved/required/hidden-mask checks) but do 
NOT run the
+      // create-time backend probe here: this standalone path performs its own 
explicit probe
+      // below, and letting the wrapper validate as well would probe the 
backend twice.
+      CatalogWrapper wrapper =
+          createCatalogWrapper(dummyEntity, mergedConfig, false /* 
validateBackend */);
       try {
         wrapper.doWithCatalogOps(
             c -> {
@@ -1633,6 +1638,27 @@ public class CatalogManager implements 
CatalogDispatcher, Closeable {
    */
   CatalogWrapper createCatalogWrapper(
       CatalogEntity entity, @Nullable Map<String, String> propsToValidate) {
+    // Default: when properties are validated at create, also verify the 
backend. The standalone
+    // testConnection path uses the overload below to keep property validation 
but skip the probe.
+    return createCatalogWrapper(entity, propsToValidate, true /* 
validateBackend */);
+  }
+
+  /**
+   * Create a catalog wrapper, optionally running the create-time backend 
probe. {@code
+   * propsToValidate} always drives property validation when non-null; {@code 
validateBackend}
+   * additionally controls whether {@link BaseCatalog#validateOnCreate()} 
runs. The standalone
+   * {@code testConnection} path passes {@code validateBackend = false} so it 
still rejects invalid
+   * properties while performing its own single explicit probe.
+   *
+   * @param entity The catalog entity.
+   * @param propsToValidate The properties to validate; may be null to skip 
property validation.
+   * @param validateBackend Whether to run the create-time backend probe.
+   * @return The created catalog wrapper.
+   */
+  CatalogWrapper createCatalogWrapper(
+      CatalogEntity entity,
+      @Nullable Map<String, String> propsToValidate,
+      boolean validateBackend) {
     Map<String, String> conf = entity.getProperties();
     String provider = entity.getProvider();
 
@@ -1652,9 +1678,20 @@ public class CatalogManager implements 
CatalogDispatcher, Closeable {
           // so that AppClassLoader can get the value of properties.
           wrapper.catalog.properties();
           wrapper.catalog.capability();
+
+          // Validate opted-in catalogs so a misconfiguration fails at create 
rather than on
+          // first use. The default validation eagerly initializes ops(); JDBC 
overrides it to
+          // run a real backend probe. The backend translates failures into 
the caller-error vs
+          // dependency-unavailable taxonomy; both types are forwarded by the 
passthrough below.
+          // The backend probe is gated by validateBackend so the standalone 
testConnection path
+          // can keep property validation while running its own explicit probe 
instead.
+          if (validateBackend && propsToValidate != null && 
catalog.shouldValidateOnCreate()) {
+            catalog.validateOnCreate();
+          }
           return null;
         },
-        IllegalArgumentException.class);
+        IllegalArgumentException.class,
+        ConnectionFailedException.class);
 
     return wrapper;
   }
diff --git a/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java 
b/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
index 04fc8a58f6..2bc1a07a9f 100644
--- a/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
+++ b/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
@@ -213,6 +213,30 @@ public abstract class BaseCatalog<T extends BaseCatalog>
     return ops;
   }
 
+  /**
+   * Whether to validate this catalog against its backend at create time 
rather than on first use.
+   *
+   * @return {@code true} to validate at create; {@code false} (default) to 
defer
+   */
+  public boolean shouldValidateOnCreate() {
+    return false;
+  }
+
+  /**
+   * Validates this catalog at create time when {@link 
#shouldValidateOnCreate()} returns true. The
+   * default eagerly initializes {@link #ops()}, which preserves the current 
behavior for every
+   * existing opt-in (for example Iceberg REST, which only needs eager 
initialization). Subclasses
+   * that can cheaply probe their backend — such as JDBC — override this to 
run a real connection
+   * check and throw a connection-failed error when the backend is unreachable.
+   *
+   * <p>This is {@code public} rather than {@code protected} because {@code 
CatalogManager}, in a
+   * different package, invokes it on the create-time validation path 
(mirroring {@link #ops()} and
+   * {@link #shouldValidateOnCreate()}).
+   */
+  public void validateOnCreate() {
+    ops();
+  }
+
   public void checkMetalakeAndCatalogInUse() {
     Map<String, String> catalogProperties = entity.getProperties();
     if (catalogProperties == null) {
diff --git 
a/core/src/main/java/org/apache/gravitino/utils/IsolatedClassLoader.java 
b/core/src/main/java/org/apache/gravitino/utils/IsolatedClassLoader.java
index 6c80a6caba..bc68a70f04 100644
--- a/core/src/main/java/org/apache/gravitino/utils/IsolatedClassLoader.java
+++ b/core/src/main/java/org/apache/gravitino/utils/IsolatedClassLoader.java
@@ -111,6 +111,31 @@ public class IsolatedClassLoader implements Closeable {
     }
   }
 
+  /**
+   * Executes the provided function within the isolated class loading context 
and rethrows any
+   * exception matching one of the given classes unchanged, wrapping the rest; 
for more, please
+   * refer to {@link #withClassLoader(ThrowableFunction)}.
+   *
+   * @param fn The function to be executed within the isolated class loading 
context.
+   * @param exceptionClass1 The first exception class to be rethrown unchanged.
+   * @param exceptionClass2 The second exception class to be rethrown 
unchanged.
+   * @param <T> The type of value return by this method
+   * @return The return value of the fn that executed within the classloader.
+   */
+  public <T> T withClassLoader(
+      ThrowableFunction<ClassLoader, T> fn,
+      Class<? extends RuntimeException> exceptionClass1,
+      Class<? extends RuntimeException> exceptionClass2) {
+    try {
+      return withClassLoader(fn);
+    } catch (Exception e) {
+      if (exceptionClass1.isInstance(e) || exceptionClass2.isInstance(e)) {
+        throw (RuntimeException) e;
+      }
+      throw new RuntimeException(e);
+    }
+  }
+
   public static IsolatedClassLoader buildClassLoader(List<String> 
libAndResourcesPaths) {
     // Listing all the classPath under the package path and build the isolated 
class loader.
     List<URL> classPathContents = Lists.newArrayList();
diff --git a/core/src/test/java/org/apache/gravitino/TestCatalog.java 
b/core/src/test/java/org/apache/gravitino/TestCatalog.java
index fbea6b5e59..d3fb322182 100644
--- a/core/src/test/java/org/apache/gravitino/TestCatalog.java
+++ b/core/src/test/java/org/apache/gravitino/TestCatalog.java
@@ -60,6 +60,30 @@ public class TestCatalog extends BaseCatalog<TestCatalog> {
     return new TestCatalogOperations(config);
   }
 
+  @Override
+  public boolean shouldValidateOnCreate() {
+    Map<String, String> properties = entity().getProperties();
+    return properties != null
+        && 
Boolean.parseBoolean(properties.get(TestCatalogOperations.VALIDATE_ON_CREATE));
+  }
+
+  /**
+   * Mirrors the JDBC override: runs a real {@code testConnection} probe at 
create time instead of
+   * only eagerly initializing {@code ops()}. {@code ops()} is still invoked 
(so a failing {@code
+   * initialize()} still surfaces here), and the probe additionally throws 
when the catalog was
+   * created with {@code need-fail=true}, exercising the create-time 
verification/rollback path.
+   */
+  @Override
+  public void validateOnCreate() {
+    try {
+      ops().testConnection(entity().nameIdentifier());
+    } catch (RuntimeException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
   @Override
   protected Capability newCapability() {
     return new TestCatalogCapabilities();
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java 
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
index 613e3f64ce..b415f35bd7 100644
--- a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
+++ b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
@@ -25,6 +25,7 @@ import static org.apache.gravitino.TestCatalog.PROPERTY_KEY3;
 import static org.apache.gravitino.TestCatalog.PROPERTY_KEY4;
 import static org.apache.gravitino.TestCatalog.PROPERTY_KEY5_PREFIX;
 import static org.apache.gravitino.TestCatalog.PROPERTY_KEY6_PREFIX;
+import static org.apache.gravitino.TestCatalog.PROPERTY_RESERVED_KEY;
 import static org.awaitility.Awaitility.await;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
@@ -59,11 +60,14 @@ import org.apache.gravitino.Schema;
 import org.apache.gravitino.connector.BaseCatalog;
 import org.apache.gravitino.connector.CatalogDropAware;
 import org.apache.gravitino.connector.CatalogOperations;
+import org.apache.gravitino.connector.TestCatalogOperations;
 import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.connector.capability.CapabilityResult;
 import org.apache.gravitino.exceptions.CatalogAlreadyExistsException;
 import org.apache.gravitino.exceptions.CatalogNotInUseException;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
 import org.apache.gravitino.exceptions.NoSuchCatalogException;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.lock.LockManager;
@@ -402,6 +406,110 @@ public class TestCatalogManager {
     
Assertions.assertNull(catalogManager.getCatalogCache().getIfPresent(failedIdent));
   }
 
+  @Test
+  public void testCreateCatalogVerifiesBackendViaTestConnection() throws 
Exception {
+    // Opted in via validateOnCreate() force-probe, and the probe reports the 
backend unreachable:
+    // creation must propagate ConnectionFailedException and roll back both 
the cache and the store.
+    NameIdentifier failIdent = NameIdentifier.of("metalake", 
"verify-testconn-fail");
+    Map<String, String> failProps =
+        Maps.newHashMap(
+            ImmutableMap.of(
+                PROPERTY_KEY1,
+                "value1",
+                PROPERTY_KEY2,
+                "value2",
+                PROPERTY_KEY5_PREFIX + "1",
+                "value3",
+                TestCatalogOperations.VALIDATE_ON_CREATE,
+                "true",
+                TestCatalogOperations.FAIL_TEST,
+                "true"));
+
+    Assertions.assertThrows(
+        ConnectionFailedException.class,
+        () ->
+            catalogManager.createCatalog(
+                failIdent, Catalog.Type.RELATIONAL, provider, "comment", 
failProps));
+    
Assertions.assertNull(catalogManager.getCatalogCache().getIfPresent(failIdent));
+    // Store rollback: the just-persisted entity is deleted.
+    Assertions.assertThrows(
+        NoSuchEntityException.class,
+        () -> entityStore.get(failIdent, EntityType.CATALOG, 
CatalogEntity.class));
+
+    // Rolled back, so the identifier is reusable with a reachable backend.
+    Map<String, String> okProps =
+        Maps.newHashMap(
+            ImmutableMap.of(
+                PROPERTY_KEY1,
+                "value1",
+                PROPERTY_KEY2,
+                "value2",
+                PROPERTY_KEY5_PREFIX + "1",
+                "value3",
+                TestCatalogOperations.VALIDATE_ON_CREATE,
+                "true"));
+    Assertions.assertDoesNotThrow(
+        () ->
+            catalogManager.createCatalog(
+                failIdent, Catalog.Type.RELATIONAL, provider, "comment", 
okProps));
+    
Assertions.assertNotNull(catalogManager.getCatalogCache().getIfPresent(failIdent));
+
+    // Not opted in (no VALIDATE_ON_CREATE): the backend probe is never 
forced, so a FAIL_TEST
+    // catalog is still created. This mirrors model/generic/non-JDBC catalogs 
that are never
+    // force-verified.
+    NameIdentifier noOptInIdent = NameIdentifier.of("metalake", 
"verify-no-optin");
+    Map<String, String> noOptInProps =
+        Maps.newHashMap(
+            ImmutableMap.of(
+                PROPERTY_KEY1,
+                "value1",
+                PROPERTY_KEY2,
+                "value2",
+                PROPERTY_KEY5_PREFIX + "1",
+                "value3",
+                TestCatalogOperations.FAIL_TEST,
+                "true"));
+    Assertions.assertDoesNotThrow(
+        () ->
+            catalogManager.createCatalog(
+                noOptInIdent, Catalog.Type.RELATIONAL, provider, "comment", 
noOptInProps));
+    
Assertions.assertNotNull(catalogManager.getCatalogCache().getIfPresent(noOptInIdent));
+  }
+
+  @Test
+  public void testTestConnectionStillValidatesProperties() {
+    // Regression: the standalone testConnection path skips only the backend 
probe, NOT property
+    // validation. A reserved property, or a missing required property, must 
be rejected exactly as
+    // createCatalog would reject it (otherwise testConnection could report 
success for a
+    // configuration createCatalog then refuses).
+    NameIdentifier ident = NameIdentifier.of("metalake", 
"testconn-prop-validation");
+
+    Map<String, String> reservedProps = Maps.newHashMap();
+    reservedProps.put(PROPERTY_KEY1, "value1");
+    reservedProps.put(PROPERTY_KEY2, "value2");
+    reservedProps.put(PROPERTY_KEY5_PREFIX + "1", "value3");
+    reservedProps.put(PROPERTY_RESERVED_KEY, "value");
+    IllegalArgumentException reserved =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                catalogManager.testConnection(
+                    ident, Catalog.Type.RELATIONAL, provider, "comment", 
reservedProps));
+    Assertions.assertTrue(reserved.getMessage().contains("reserved"), 
reserved.getMessage());
+
+    Map<String, String> missingRequiredProps = Maps.newHashMap();
+    // key1 is required; omit it.
+    missingRequiredProps.put(PROPERTY_KEY2, "value2");
+    missingRequiredProps.put(PROPERTY_KEY5_PREFIX + "1", "value3");
+    IllegalArgumentException missing =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                catalogManager.testConnection(
+                    ident, Catalog.Type.RELATIONAL, provider, "comment", 
missingRequiredProps));
+    Assertions.assertTrue(missing.getMessage().contains("required"), 
missing.getMessage());
+  }
+
   @Test
   public void testListCatalogs() {
     NameIdentifier ident = NameIdentifier.of("metalake", "test11");
diff --git 
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java 
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
index b6324652ca..40aa8caabe 100644
--- 
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
+++ 
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
@@ -130,10 +130,14 @@ public class TestCatalogOperations
 
   public final Map<NameIdentifier, View> views;
 
+  private final Map<String, String> config;
+
   public static final String FAIL_CREATE = "fail-create";
 
   public static final String FAIL_TEST = "need-fail";
 
+  public static final String VALIDATE_ON_CREATE = "validate-on-create";
+
   private static final String SLASH = "/";
 
   public TestCatalogOperations(Map<String, String> config) {
@@ -145,6 +149,7 @@ public class TestCatalogOperations
     modelVersions = Maps.newHashMap();
     modelAliasToVersion = Maps.newHashMap();
     views = Maps.newHashMap();
+    this.config = config == null ? ImmutableMap.of() : 
ImmutableMap.copyOf(config);
   }
 
   @Override
@@ -878,6 +883,22 @@ public class TestCatalogOperations
     }
   }
 
+  @Override
+  public void testConnection(NameIdentifier catalogIdent) {
+    // Mirror a JDBC-style backend probe: fail when the catalog was created 
with FAIL_TEST=true.
+    if ("true".equals(config.get(FAIL_TEST))) {
+      throw new ConnectionFailedException("Connection failed");
+    }
+    // A catalog that opts into create-time verification mirrors a reachable 
JDBC backend: the probe
+    // succeeds. Otherwise keep the interface default (unsupported) so the 
standalone testConnection
+    // REST path still reports that this test catalog does not support 
connection testing.
+    if ("true".equals(config.get(VALIDATE_ON_CREATE))) {
+      return;
+    }
+    throw new UnsupportedOperationException(
+        String.format("Catalog %s does not support connection testing", 
catalogIdent));
+  }
+
   @Override
   public void testConnection(
       NameIdentifier name,
diff --git a/docs/jdbc-catalog-connection-validation.md 
b/docs/jdbc-catalog-connection-validation.md
new file mode 100644
index 0000000000..13cedaafd7
--- /dev/null
+++ b/docs/jdbc-catalog-connection-validation.md
@@ -0,0 +1,107 @@
+---
+title: "JDBC Catalog Connection Validation"
+slug: "/jdbc-catalog-connection-validation"
+keywords:
+- jdbc
+- catalog
+- connection-pool
+license: "This software is licensed under the Apache License version 2."
+---
+
+This page describes connection validation for JDBC catalogs' datasource pools. 
The settings apply
+to the MySQL, PostgreSQL, Doris, StarRocks, ClickHouse, Hologres, and 
OceanBase catalogs.
+
+## Connection Validation
+
+JDBC catalogs validate connections on borrow by default using the JDBC driver's
+`Connection.isValid()` method. Gravitino does not force a validation SQL 
query, so the driver
+can validate the connection after catalog or schema changes.
+
+The default avoids a connection-reuse problem with MySQL URLs that have no 
default database.
+DBCP caches SQL validation as a prepared statement. MySQL Connector/J retains 
the database used
+when that statement was prepared and tries to switch back to it when executing 
the statement.
+After an application calls `setCatalog()`, switching back to an empty original 
database can fail
+even though the connection is still healthy. DBCP then discards that 
connection and opens another.
+Connector/J's `Connection.isValid()` uses a ping instead of the cached 
statement, so validation
+does not need to restore the statement's original database.
+
+The benefit is preserving healthy pooled connections and avoiding repeated 
connection setup and
+authentication caused by failed validation. Validation remains enabled and 
disconnected connections
+are still replaced. This does not establish a general throughput improvement: 
other drivers may
+implement `isValid()` using SQL, and validation can still require a network 
round trip.
+
+For a driver that does not support `Connection.isValid()`, set the catalog 
property
+`gravitino.bypass.validationQuery` to a SQL SELECT statement that returns at 
least one row.
+Explicit validation queries are passed to DBCP without being overwritten. The
+`jdbc.pool.test-on-borrow` property controls validation on borrow. See the
+[DBCP configuration 
reference](https://commons.apache.org/proper/commons-dbcp/configuration.html)
+for other pool validation settings. DBCP-specific properties require the 
`gravitino.bypass.`
+prefix in catalog configuration; the prefix is removed before passing them to 
DBCP.
+
+For example, to use SQL validation with a driver that does not support 
`Connection.isValid()`:
+
+```json
+{
+  "jdbc.pool.test-on-borrow": "true",
+  "gravitino.bypass.validationQuery": "SELECT 1",
+  "gravitino.bypass.validationQueryTimeout": "5"
+}
+```
+
+Choose a validation query supported by the driver and database. Disabling 
validation on borrow
+alone does not make a driver without `Connection.isValid()` support 
compatible: DBCP also validates
+a connection when initializing its connection factory.
+
+For MySQL drivers affected by the catalog-switching problem, explicitly 
setting `SELECT 1` restores
+the cached-statement validation path and can reproduce connection churn. Leave 
the query unset when
+the driver supports `Connection.isValid()`.
+
+## Backend verification at create time
+
+A relational JDBC catalog verifies that its backend is reachable when the 
catalog is created,
+rather than deferring the first connection to a later operation. The 
verification opens a dedicated,
+time-bounded one-off probe connection (separate from the pool used by normal 
operations), confirms
+it, and closes it. If it fails, the create is rejected and rolled back (the 
catalog entity is not
+persisted), and the caller receives a connection error that names the backend 
`host:port` and the
+underlying driver message.
+
+To register a catalog whose backend is temporarily unavailable, set the hidden 
catalog property
+`verify-on-create` to `false`, which skips the create-time check:
+
+```json
+{
+  "verify-on-create": "false"
+}
+```
+
+The default is `true` (verify on create). The connection is still opened 
lazily on first use when
+the check is skipped.
+
+### Bounded timeouts
+
+So that create-time verification fails fast instead of hanging, the timeout
+`jdbc.pool.connect-timeout-ms` (default `30000` ms, maximum `2147483647`) 
bounds a JDBC connection.
+Gravitino translates this canonical millisecond value into each driver 
family's own parameters and
+unit:
+
+- MySQL Connector/J — also used by the Doris, StarRocks, and OceanBase 
catalogs: `connectTimeout`
+  and `socketTimeout` in milliseconds.
+- PostgreSQL — also used by the Hologres catalog: `connectTimeout`, 
`socketTimeout` and
+  `loginTimeout` in seconds (rounded up, minimum 1).
+- ClickHouse: `connect_timeout` and `socket_timeout` in milliseconds (note the 
underscore names).
+
+It is applied differently to the two kinds of connection:
+
+- **Pooled connections** (normal operations) get only the **connect** timeout. 
A socket-read
+  timeout is deliberately not imposed there, because it would also abort 
legitimate long-running
+  operations on a healthy backend (a large `ALTER TABLE`, a slow metadata 
query).
+- **The create-time verification probe** gets both the **connect** and the 
**read** (and, for
+  PostgreSQL, **login**) timeout. The probe runs under the metalake write 
lock, so it must be fully
+  bounded: a backend that accepts the TCP connection but never speaks the 
database protocol (a load
+  balancer with a dead backend, a wrong port, a proxy that accepts and never 
forwards) would
+  otherwise block a connect-only bound forever and stall the whole metalake.
+
+A timeout value you set directly on the connection or in the `jdbc-url` query 
string is never
+overridden; drivers whose family is not recognized are left unbounded by 
Gravitino. The pooled
+timeout is applied as a per-connection bound, so the pool stays lazy and no 
connection is opened
+until the first real round trip.

Reply via email to