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

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


The following commit(s) were added to refs/heads/main by this push:
     new 2d4796baad [#13767] improve(core,jdbc): verify JDBC catalog backend 
reachable at create (#13769)
2d4796baad is described below

commit 2d4796baad1d63386751f18dff8df8a570f4462a
Author: geyanggang <[email protected]>
AuthorDate: Tue Oct 13 11:03:20 2026 +0800

    [#13767] improve(core,jdbc): verify JDBC catalog backend reachable at 
create (#13769)
    
    ### What changes were proposed in this pull request?
    
    Relational JDBC catalog creation now verifies the backend is reachable,
    consistently
    across JDBC providers, instead of silently succeeding and failing later
    on an unrelated
    operation. The check is bounded so it fails fast and reports the failure
    to the caller
    naming the host.
    
    Three coordinated changes:
    
    1. **Bounded JDBC connect/socket timeout (`catalog-jdbc-common`).**
    - Add `jdbc.pool.connect-timeout-ms` to `JdbcConfig` (default `30000`,
    positive-value
         check), plus a `getConnectTimeoutMs()` getter.
    - Add a pure, driver-aware utility `JdbcConnectionTimeouts` that
    translates this
    canonical milliseconds value into each driver family's connection
    parameters, since
    the parameter unit differs: MySQL Connector/J
    (`com.mysql.cj.jdbc.Driver` /
    legacy `com.mysql.jdbc.Driver`, also used by Doris/StarRocks) takes
    milliseconds;
    PostgreSQL (`org.postgresql.Driver`) takes seconds (ms rounded up,
    minimum 1). An
    unknown driver family yields no properties, so adding a new provider
    never breaks.
    Any value the operator already set — as a connection property or a
    `jdbc-url` query
    parameter — is preserved (defaults, never overrides; the URL check is
    case-sensitive
         and boundary-anchored).
    - `DataSourceUtils.createDBCPDataSource` applies the translated
    properties via
    `addConnectionProperty`, so they are per-connection bounds, do not
    collide with the
         unsafe-pool-property guard, and keep the pool lazy.
    
    2. **`gravitino.catalog.verify-on-create` control property (`core`).**
    Registered in `BaseCatalogPropertiesMetadata` as a hidden, non-reserved
    boolean with
    default `true`, with the key constant on `Catalog`. It is a registered
    property (so it
    is not silently dropped during create validation) that lets an
    administrator opt out of
    create-time verification to register a catalog whose backend is
    temporarily unavailable.
    
    3. **Create-time verification via a `validateOnCreate()` hook (`core` +
    `jdbc`).**
    - Add `BaseCatalog.validateOnCreate()`, whose default body eagerly
    initializes `ops()`,
    preserving current behavior for existing opt-ins (e.g. Iceberg REST).
    - `CatalogManager.initCatalogWrapper` now calls `validateOnCreate()`
    instead of `ops()`
    on the existing `shouldValidateOnCreate()` opt-in path (one-line
    change).
    - `JdbcCatalog` overrides `shouldValidateOnCreate()` (reads
    `verify-on-create`,
    defaulting to `true`) and `validateOnCreate()` to run a single
    `testConnection`
    (one `listDatabases` round trip). On failure a
    `ConnectionFailedException` propagates,
    and the existing create rollback removes the just-persisted catalog
    entity.
    - `JdbcCatalogOperations.testConnection` enriches the
    `ConnectionFailedException`
    message with the `host:port` parsed from the `jdbc-url` (same exception
    type).
    
    Docs: `docs/jdbc-catalog-connection-validation.md` documents the
    create-time verification,
    the override, and the bounded connect timeout.
    
    ### Why are the changes needed?
    
    Creating a relational JDBC catalog returned success without any check
    that the backend was
    reachable, and the behavior was not consistent across providers. An
    administrator who
    mistyped a host, port, or credential got a success response and a
    catalog that did not
    work; the failure then surfaced later on an unrelated operation.
    
    The cause is that `CatalogManager.createCatalog` performed no connection
    check — whether
    the backend was touched during create depended entirely on each
    connector's `initialize()`
    side effects. JDBC catalogs build a lazy DBCP2 connection pool
    (`DataSourceUtils.createDBCPDataSource`, with `initialSize` blocked), so
    create opened no
    connection and never reached the backend. The existing
    `BaseCatalog.shouldValidateOnCreate()`
    hook only eagerly called `ops()`, which for JDBC still opened no
    connection, so even
    opting in did not verify reachability.
    
    There was also no bounded JDBC connect/socket timeout: `JdbcConfig`
    exposed only pool
    settings and `DataSourceUtils` set only the pool `maxWait`, so a
    connection attempt against
    an unreachable host could block for the driver default (effectively
    unbounded for the
    MySQL-family default `connectTimeout=0`). Bounding it is a prerequisite
    for safe create-time
    verification (which runs inside the metalake write lock) and a useful
    hardening on its own.
    
    Fix: #13767
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Creating a relational JDBC catalog verifies the backend is
    reachable by default and returns a connection error naming the host on
    failure. Verification uses a dedicated, time-bounded probe connection
    (connect + read, plus login for PostgreSQL); the normal connection pool
    keeps only a connect timeout so long-running operations are not capped.
    New config jdbc.pool.connect-timeout-ms (default 30000, max 2147483647).
    Set gravitino.catalog.verify-on-create=false to skip the check. Iceberg
    REST / model / generic catalogs are unaffected. Tests: connect-timeout
    translation per driver (pool) and connect+read+login (probe); an
    accept-but-silent server proves the probe is bounded; testConnection
    still rejects reserved/missing-required properties; host extraction
    never leaks credentials; connect-timeout upper bound.
    ### How was this patch tested?
    
    New and extended unit tests:
    
    - `catalog-jdbc-common`:
    - `TestJdbcConnectionTimeouts` — MySQL family emits milliseconds;
    PostgreSQL converts
    ms→seconds (round up, minimum 1); unknown driver emits nothing; an
    operator-set
    `connectTimeout`/`socketTimeout` is preserved whether set as a config
    key or a `jdbc-url`
    query parameter (case-sensitive, boundary-anchored); custom-value
    propagation; null-URL
        safe.
      - `TestJdbcConfig` — the new setting's default and override.
    - `TestDataSourceValidation` — an unknown driver injects nothing and the
    pool still borrows
        a connection.
    - `core`:
    - `TestCatalogManager` — an opted-in catalog whose backend probe fails
    propagates
    `ConnectionFailedException` and rolls back the persisted entity (store
    no longer contains
    it, identifier is reusable); a non-opted-in catalog is not verified; the
    existing backend
        validation test is unchanged.
    - `TestCatalog` / `TestCatalogOperations` — simulate a failing
    `testConnection` without a
        real backend.
    
    Commands run (from the repo root):
    
    - `./gradlew spotlessApply` — SUCCESS.
    - `./gradlew :catalogs:catalog-jdbc-common:test :core:test -PskipITs` —
    BUILD SUCCESSFUL
    (core: 2699 tests, 1 skipped, 0 failed), modules compiled with
    checkstyle `-Werror`.
    
    ---------
    
    Co-authored-by: Qi Yu <[email protected]>
---
 .../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 ++++
 .../jdbc/utils/TestDataSourceValidation.java       |  26 ++
 .../catalog/jdbc/utils/TestJdbcConfig.java         |  43 +++
 .../jdbc/utils/TestJdbcConnectionTimeouts.java     | 326 +++++++++++++++++++++
 .../mysql/integration/test/CatalogMysqlIT.java     |   8 +
 .../apache/gravitino/catalog/CatalogManager.java   |  55 +++-
 .../apache/gravitino/connector/BaseCatalog.java    |  15 +
 .../java/org/apache/gravitino/TestCatalog.java     |  17 ++
 .../gravitino/catalog/TestCatalogManager.java      | 106 +++++++
 .../gravitino/connector/TestCatalogOperations.java |  19 ++
 docs/jdbc-catalog-connection-validation.md         |  50 ++++
 19 files changed, 1509 insertions(+), 14 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 461308cf54..8d7444acb8 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
@@ -3274,6 +3274,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 7ac02124f7..d289aa1e0b 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 7807b68134..7d771b7ac2 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;
@@ -105,6 +109,7 @@ public class JdbcCatalogOperations
 
   private DataSource dataSource;
   private String jdbcUrl;
+  private JdbcConfig jdbcConfig;
 
   private final JdbcColumnDefaultValueConverter columnDefaultValueConverter;
 
@@ -172,7 +177,7 @@ public class JdbcCatalogOperations
     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);
 
@@ -239,21 +244,159 @@ public class JdbcCatalogOperations
    *
    * @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 062dd43888..3f933b0975 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;
 
@@ -46,7 +47,9 @@ public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata
           JdbcConfig.POOL_MIN_SIZE.getKey(),
           JdbcConfig.POOL_MAX_SIZE.getKey(),
           JdbcConfig.POOL_MAX_IDLE.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 =
@@ -120,6 +123,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 753a9fac84..d6426e6f54 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
@@ -114,6 +114,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_2_0_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_2_0_0)
+          .booleanConf()
+          .createWithDefault(true);
+
   public String getJdbcUrl() {
     return get(JDBC_URL);
   }
@@ -159,6 +205,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 40f4fc90dd..c2d224b7a1 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
@@ -167,6 +167,12 @@ public class DataSourceUtils {
     // Validate connections on borrow when enabled.
     basicDataSource.setTestOnBorrow(jdbcConfig.getTestOnBorrow());
     basicDataSource.setMaxWait(Duration.ofMillis(jdbcConfig.getMaxWaitMs()));
+    // Apply the driver-aware connect/socket timeout as DBCP connection 
properties (not pool keys,
+    // so no collision with UNSAFE_POOL_PROPERTIES and the pool stays lazy). 
These bound a single
+    // connection and take 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 6aa9f1b04c..b6fd8a56bf 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
@@ -54,6 +54,44 @@ public class TestJdbcCatalogPropertiesMetadata {
     
Assertions.assertFalse(propertiesMetadata.isHiddenProperty(JdbcConfig.POOL_MAX_IDLE.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 =
@@ -96,6 +134,31 @@ public class TestJdbcCatalogPropertiesMetadata {
             .get(JdbcConfig.POOL_MAX_IDLE.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()));
+  }
+
   /** Verifies the actual catalog configuration path for drivers without 
isValid support. */
   @ParameterizedTest
   @CsvSource({
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceValidation.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceValidation.java
index 2636c8ba00..40db43de67 100644
--- 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceValidation.java
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceValidation.java
@@ -119,6 +119,32 @@ public class TestDataSourceValidation {
     }
   }
 
+  /**
+   * Verifies that for an unknown driver family (SQLite) no connect/socket 
timeout connection
+   * property is injected and the datasource still builds and borrows a 
connection. This guards the
+   * "unknown driver -> no-op" contract of {@link JdbcConnectionTimeouts}: 
SQLite does not
+   * understand {@code connectTimeout}/{@code socketTimeout}, so injecting 
them would make the
+   * connection fail.
+   */
+  @Test
+  public void testUnknownDriverInjectsNoConnectionTimeout() throws 
SQLException {
+    // JdbcConnectionTimeouts emits nothing for the SQLite driver family.
+    Map<String, String> props = new HashMap<>();
+    props.put(JdbcConfig.JDBC_DRIVER.getKey(), "org.sqlite.JDBC");
+    props.put(JdbcConfig.JDBC_URL.getKey(), "jdbc:sqlite::memory:");
+    Assertions.assertTrue(
+        JdbcConnectionTimeouts.connectionProperties(
+                new JdbcConfig(props), props.get(JdbcConfig.JDBC_URL.getKey()))
+            .isEmpty());
+
+    // And the datasource built from it still borrows a working connection (no 
leak, no failure).
+    try (BasicDataSource dataSource = createDataSource(Map.of())) {
+      try (Connection connection = dataSource.getConnection()) {
+        Assertions.assertTrue(connection.isValid(1));
+      }
+    }
+  }
+
   /** A driver fixture whose connections do not implement JDBC connection 
validation. */
   public static class UnsupportedValidationDriver extends JDBC {
     /**
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 6fef5372f9..8b920576e1 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,
@@ -2433,6 +2437,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 2a23d7e0fa..16b5d6a9e1 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
@@ -996,7 +996,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 -> {
@@ -1772,11 +1776,33 @@ 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();
 
     if (!classLoaderSharingEnabled) {
-      return createNonPooledCatalogWrapper(provider, conf, entity, 
propsToValidate);
+      return createNonPooledCatalogWrapper(
+          provider, conf, entity, propsToValidate, validateBackend);
     }
 
     ClassLoaderKey key = buildClassLoaderKey(provider, conf);
@@ -1787,7 +1813,8 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
           initCatalogWrapper(
               new CatalogWrapper(poolEntry.classLoader(), classLoaderPool, 
poolEntry),
               entity,
-              propsToValidate);
+              propsToValidate,
+              validateBackend);
       return wrapper;
     } catch (Exception e) {
       classLoaderPool.release(poolEntry);
@@ -1799,10 +1826,12 @@ public class CatalogManager implements 
CatalogDispatcher, Closeable {
       String provider,
       Map<String, String> conf,
       CatalogEntity entity,
-      @Nullable Map<String, String> propsToValidate) {
+      @Nullable Map<String, String> propsToValidate,
+      boolean validateBackend) {
     IsolatedClassLoader classLoader = createClassLoader(provider, conf);
     try {
-      return initCatalogWrapper(new CatalogWrapper(classLoader), entity, 
propsToValidate);
+      return initCatalogWrapper(
+          new CatalogWrapper(classLoader), entity, propsToValidate, 
validateBackend);
     } catch (Exception e) {
       ClassLoaderPool.cleanupClassLoader(classLoader);
       throw e;
@@ -1814,7 +1843,10 @@ public class CatalogManager implements 
CatalogDispatcher, Closeable {
    * into the given wrapper.
    */
   private CatalogWrapper initCatalogWrapper(
-      CatalogWrapper wrapper, CatalogEntity entity, @Nullable Map<String, 
String> propsToValidate) {
+      CatalogWrapper wrapper,
+      CatalogEntity entity,
+      @Nullable Map<String, String> propsToValidate,
+      boolean validateBackend) {
     BaseCatalog<?> catalog = createBaseCatalog(wrapper.classLoader, entity);
     wrapper.catalog = catalog;
     try {
@@ -1826,11 +1858,14 @@ public class CatalogManager implements 
CatalogDispatcher, Closeable {
             catalog.properties();
             catalog.capability();
 
-            // Eagerly initialize opted-in catalogs so a misconfiguration 
fails at create rather
-            // than on first use. The backend translates failures into the 
caller-error vs
+            // 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.
-            if (propsToValidate != null && catalog.shouldValidateOnCreate()) {
-              catalog.ops();
+            // 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;
           },
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 801fd3bf45..4b36da86e1 100644
--- a/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
+++ b/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
@@ -227,6 +227,21 @@ public abstract class BaseCatalog<T extends BaseCatalog>
     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/test/java/org/apache/gravitino/TestCatalog.java 
b/core/src/test/java/org/apache/gravitino/TestCatalog.java
index 4e8e8ada24..d3fb322182 100644
--- a/core/src/test/java/org/apache/gravitino/TestCatalog.java
+++ b/core/src/test/java/org/apache/gravitino/TestCatalog.java
@@ -67,6 +67,23 @@ public class TestCatalog extends BaseCatalog<TestCatalog> {
         && 
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 90dff3e5e0..6dbc3662f0 100644
--- a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
+++ b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
@@ -26,6 +26,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;
@@ -70,6 +71,7 @@ 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;
@@ -625,6 +627,110 @@ public class TestCatalogManager {
     
Assertions.assertNotNull(catalogManager.getCatalogCache().getIfPresent(skipIdent));
   }
 
+  @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 5fd405c62f..3ac659243c 100644
--- 
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
+++ 
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
@@ -130,6 +130,8 @@ 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";
@@ -149,6 +151,7 @@ public class TestCatalogOperations
     modelVersions = Maps.newHashMap();
     modelAliasToVersion = Maps.newHashMap();
     views = Maps.newHashMap();
+    this.config = config == null ? ImmutableMap.of() : 
ImmutableMap.copyOf(config);
   }
 
   @Override
@@ -886,6 +889,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
index b313982491..13cedaafd7 100644
--- a/docs/jdbc-catalog-connection-validation.md
+++ b/docs/jdbc-catalog-connection-validation.md
@@ -55,3 +55,53 @@ 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