jerryshao commented on code in PR #14508:
URL: https://github.com/apache/gravitino/pull/14508#discussion_r4464289703


##########
core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java:
##########
@@ -485,31 +496,72 @@ private List<SchemaPO> listSchemaPOs(Namespace namespace) 
{
         mapper -> POStorageReadRouting.listPOs(mapper, namespace, ops, 
Entity.EntityType.SCHEMA));
   }
 
+  private boolean prepareSchemaOverwrite(
+      NameIdentifier identifier, SchemaPO incoming, SchemaMetaMapper mapper) {
+    // Reject an active foreign identity before taking its row lock. The 
locking read below
+    // must still revalidate identity because the first check is only a 
fail-fast optimization.
+    SchemaPO registeredId = 
mapper.selectSchemaMetaById(incoming.getSchemaId());
+    rejectForeignSchemaIdentity(identifier, incoming, registeredId);
+    // Lock even a retired ID before reviving it. Otherwise two catalogs could 
both revive the
+    // same marker and the slower upsert would move the winner's newly live 
registration.
+    registeredId = 
mapper.selectSchemaMetaByIdIncludingDeletedForUpdate(incoming.getSchemaId());
+    rejectForeignSchemaIdentity(identifier, incoming, registeredId);
+    SchemaPO stored =
+        ops.listPOs(mapper, identifier.namespace(), 
List.of(identifier.name())).stream()
+            .findFirst()
+            .orElse(null);
+    if (stored != null && 
!stored.getSchemaId().equals(incoming.getSchemaId())) {

Review Comment:
   **Major: an overwrite can cascade-delete metadata for a schema that is still 
live in the source.**
   
   When the stored row for this name has a different id, 
`prepareSchemaOverwrite` treats it as stale and cascade-deletes it 
(`deleteSchemaAndGet` at line 523). It never checks whether that stored 
identity still exists in the source. `importSchema` does make this check 
(`SchemaOperationDispatcher.java:687`) and refuses to move a live schema, but 
`createSchema` always calls `store.put(..., true)` 
(`SchemaOperationDispatcher.java:247`), so a new id is the normal case there.
   
   Scenario: the schema is renamed externally from `s` to `s2`, and nobody has 
loaded `s2` yet. `createSchema(s)` succeeds in the source, then drops `s`'s 
tags, privileges and child registrations, even though that schema is still live 
as `s2`.
   
   Suggestion: before the cascade, resolve the stored id in the source the same 
way `importSchema` does, and skip the delete (or fail) if that object still 
exists.



##########
core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java:
##########
@@ -229,12 +247,11 @@ public Schema createSchema(
               store.put(schemaEntity, true /* overwrite */);
             } catch (Exception e) {
               LOG.error(FormattedErrorMessages.STORE_OP_FAILURE, "put", ident, 
e);
-              return EntityCombinedSchema.of(schema)
-                  .withHiddenProperties(
-                      getMaskAndOmitKeys(
-                          catalogIdent,
-                          HasPropertyMetadata::schemaPropertiesMetadata,
-                          schema.properties()));
+              throw new GravitinoRuntimeException(

Review Comment:
   **Major: there's no real recovery path after the source create succeeds but 
registration fails.**
   
   `GravitinoRuntimeException` maps to HTTP 500 
(`ExceptionHandlers.java:1283`). The message suggests retrying, but a retry 
hits `SchemaAlreadyExistsException` and registers nothing. The external schema 
and its secrets (line 268) stay unregistered until a later load happens to 
import them. The same applies to tables at `TableOperationDispatcher.java:802`.
   
   Suggestion: either make the message say that recovery is a load/import, or, 
on already-exists after this kind of failure, register the existing source 
object instead of failing again. A test that does fail-then-retry would help, 
since `TestSchemaOperationDispatcher` currently only checks that the first 
failure keeps the secret.



##########
core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java:
##########
@@ -175,10 +180,10 @@ public void insertSchema(SchemaEntity schemaEntity, 
boolean overwrite) throws IO
 
       // Everything below runs in one transaction, and it starts by locking 
the parent catalog row.
       // That lock is what stops a catalog drop from running at the same time 
as this insert. A
-      // plain name is enough with a shared lock; a nested name needs an 
exclusive one, see
-      // lockCatalogForSchemaCreate.
+      // plain insert can use a shared lock; nested ancestors or an overwrite 
need an exclusive
+      // lock because replacement can retire a stale schema and its 
descendants.
       SessionUtils.doMultipleWithCommit(
-          () -> lockCatalogForSchemaCreate(catalogPO, rowsToInsert.size() > 1),
+          () -> lockCatalogForSchemaCreate(catalogPO, rowsToInsert.size() > 1 
|| overwrite),

Review Comment:
   Minor: every overwrite takes the exclusive parent-catalog lock, and external 
`createSchema` always overwrites. So even creating a new name with no stale row 
serializes every schema create and drop in the catalog. Could we keep the 
shared lock when the name lookup finds no row with a different id, and only 
roll back and rerun the transaction under the exclusive lock when it does?



##########
core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java:
##########
@@ -165,18 +165,37 @@ public NameIdentifier[] listTables(Namespace namespace) 
throws NoSuchSchemaExcep
   @Override
   public Table loadTable(NameIdentifier ident) throws NoSuchTableException {
     EntityCombinedTable entityCombinedTable =
-        TreeLockUtils.doWithTreeLock(ident, LockType.READ, () -> 
internalLoadTable(ident));
-
-    if (!entityCombinedTable.imported()) {
+        TreeLockUtils.doWithTreeLock(
+            ident,
+            LockType.READ,
+            () -> {
+              // A cold external table will be loaded under its own WRITE lock 
below. Do not
+              // fetch it here only to discard the snapshot and fetch it again 
during import.
+              // Managed catalogs do not need the external import path, even 
without a store row.
+              if (getEntity(ident, TABLE, TableEntity.class) == null
+                  && !isManagedEntity(getCatalogIdentifier(ident), 
Capability.Scope.TABLE)) {
+                return null;
+              }
+              return internalLoadTable(ident);
+            });
+
+    if (entityCombinedTable == null || !entityCombinedTable.imported()) {
       // Load the schema to make sure the schema is imported.
       SchemaDispatcher schemaDispatcher = getSchemaDispatcher();
       NameIdentifier schemaIdent = 
NameIdentifier.of(ident.namespace().levels());
-      schemaDispatcher.loadSchema(schemaIdent);
+      try {
+        schemaDispatcher.loadSchema(schemaIdent);
+      } catch (NoSuchSchemaException e) {

Review Comment:
   Minor: on table load, a missing schema used to surface as 
`NoSuchSchemaException`; with this change it comes back as 
`NoSuchTableException`. REST uses the outer exception class name 
(`server-common/.../web/Utils.java:163`), so clients lose the schema-missing 
type, while #14499 asks to preserve missing-schema errors. Could we propagate 
`NoSuchSchemaException` here?



##########
core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java:
##########
@@ -778,7 +799,11 @@ private Table internalCreateTable(
       store.put(tableEntity, true /* overwrite */);
     } catch (Exception e) {
       LOG.error(FormattedErrorMessages.STORE_OP_FAILURE, "put", ident, e);
-      return 
EntityCombinedTable.of(table).withHiddenProperties(catalogResult.hiddenProperties);
+      throw new GravitinoRuntimeException(

Review Comment:
   Same issue as the schema case in `SchemaOperationDispatcher`: retrying after 
this failure returns `TableAlreadyExistsException`, and the table stays 
unregistered until a later load imports it.



##########
core/src/main/java/org/apache/gravitino/utils/BoundedConnectionDriver.java:
##########
@@ -0,0 +1,295 @@
+/*
+ * 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.utils;
+
+import java.security.AccessControlContext;
+import java.security.AccessController;
+import java.security.PrivilegedAction;
+import java.security.ProtectionDomain;
+import java.sql.Connection;
+import java.sql.Driver;
+import java.sql.DriverPropertyInfo;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.sql.SQLTimeoutException;
+import java.sql.SQLTransientConnectionException;
+import java.util.Properties;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Bounds physical connection creation for drivers that cannot enforce a login 
timeout.
+ *
+ * <p>Only {@link Driver#connect} runs on a worker. Validation, metadata 
queries and DDL stay on the
+ * caller. A connection that arrives after timeout or interruption is closed 
by its worker and is
+ * never handed to a pool. Cancellation is best effort: a driver ignoring 
interruption retains a
+ * worker until it returns. A JVM-wide limit of 64 workers, with no queue, 
bounds that retention;
+ * excess attempts fail with {@link SQLTransientConnectionException}. Workers 
are daemon threads and
+ * release their catalog context class loader after each attempt. Each 
attempt, including late
+ * connection cleanup, runs with the submitting caller's access-control 
context and JAAS subject;
+ * shared workers do not inherit the identity of the request that creates them.
+ *
+ * <p>After a timeout, further attempts through this instance fail immediately 
until the abandoned
+ * attempt finishes cleanup. This prevents a lazy pool's initialization lock 
from serializing a
+ * backlog of new attempts against the same unreachable backend. Normal pool 
waits and SQL execution
+ * have their own budgets and are not bounded by this adapter.
+ */
+@SuppressWarnings("removal") // JDK 17 associates JAAS subjects with 
access-control contexts.
+public final class BoundedConnectionDriver implements Driver {
+  private static final Logger LOG = 
LoggerFactory.getLogger(BoundedConnectionDriver.class);
+
+  private static final AccessControlContext WORKER_CONTEXT =
+      new AccessControlContext(new ProtectionDomain[0]);
+
+  private static final ExecutorService WORKERS =
+      new ThreadPoolExecutor(
+          0,
+          64,
+          60,
+          TimeUnit.SECONDS,
+          new SynchronousQueue<>(),
+          task ->
+              AccessController.doPrivileged(
+                  (PrivilegedAction<Thread>)
+                      () -> {
+                        Thread thread = new Thread(task, 
"jdbc-connect-timeout");
+                        thread.setDaemon(true);
+                        thread.setContextClassLoader(
+                            BoundedConnectionDriver.class.getClassLoader());
+                        return thread;
+                      },
+                  WORKER_CONTEXT));
+
+  private final Driver delegate;
+  private final long timeoutMs;
+  private final ExecutorService workers;
+  private final AtomicInteger abandonedAttempts = new AtomicInteger();
+
+  /**
+   * Wraps a driver with a physical connection deadline.
+   *
+   * @param delegate the driver to call, without registering the wrapper with 
DriverManager
+   * @param timeoutMs a positive timeout in milliseconds
+   * @throws IllegalArgumentException if timeoutMs is not positive
+   */
+  public BoundedConnectionDriver(Driver delegate, long timeoutMs) {
+    this(delegate, timeoutMs, WORKERS);
+  }
+
+  BoundedConnectionDriver(Driver delegate, long timeoutMs, ExecutorService 
workers) {
+    if (timeoutMs <= 0) {
+      throw new IllegalArgumentException("JDBC connect timeout must be 
positive");
+    }
+    this.delegate = delegate;
+    this.timeoutMs = timeoutMs;
+    this.workers = workers;
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * @throws SQLTimeoutException if the connection deadline expires or the 
caller is interrupted
+   * @throws SQLTransientConnectionException if all workers are busy or a 
previous timed-out attempt
+   *     through this driver is still running
+   */
+  @Override
+  public Connection connect(String url, Properties info) throws SQLException {
+    if (abandonedAttempts.get() != 0) {
+      throw new SQLTransientConnectionException(
+          "A previous timed-out JDBC connection attempt is still running. Try 
again after it finishes.",
+          "08001");
+    }
+    Attempt attempt = new Attempt();
+    ClassLoader context = Thread.currentThread().getContextClassLoader();
+    AccessControlContext callerContext = AccessController.getContext();
+    Properties properties = (Properties) info.clone();
+    try {
+      workers.execute(
+          () ->
+              AccessController.doPrivileged(
+                  (PrivilegedAction<Void>)
+                      () -> {
+                        attempt.run(url, properties, context);
+                        return null;
+                      },
+                  callerContext));
+    } catch (RejectedExecutionException e) {
+      throw new SQLTransientConnectionException(
+          "The JDBC connection attempt limit has been reached. Try again after 
an attempt finishes.",
+          "08001",
+          e);
+    }
+    try {
+      Connection connection = attempt.result.get(timeoutMs, 
TimeUnit.MILLISECONDS);
+      attempt.accept();
+      return connection;
+    } catch (TimeoutException e) {
+      attempt.abandon();
+      throw new SQLTimeoutException(
+          "JDBC connection attempt exceeded "
+              + timeoutMs
+              + " ms. Check backend and proxy connectivity.",
+          "08001",
+          e);
+    } catch (InterruptedException e) {
+      attempt.abandon();
+      Thread.currentThread().interrupt();
+      throw new SQLTimeoutException("Interrupted while opening a JDBC 
connection", "08001", e);
+    } catch (ExecutionException e) {
+      Throwable cause = e.getCause();
+      if (cause instanceof SQLException) {
+        throw (SQLException) cause;
+      }
+      if (cause instanceof RuntimeException) {
+        throw (RuntimeException) cause;
+      }
+      if (cause instanceof Error) {
+        throw (Error) cause;
+      }
+      throw new SQLException("Failed to open JDBC connection", "08001", cause);
+    } finally {
+      attempt.decision.countDown();
+    }
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public boolean acceptsURL(String url) throws SQLException {
+    return delegate.acceptsURL(url);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) 
throws SQLException {
+    return delegate.getPropertyInfo(url, info);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public int getMajorVersion() {
+    return delegate.getMajorVersion();
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public int getMinorVersion() {
+    return delegate.getMinorVersion();
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public boolean jdbcCompliant() {
+    return delegate.jdbcCompliant();
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public java.util.logging.Logger getParentLogger() throws 
SQLFeatureNotSupportedException {
+    return delegate.getParentLogger();
+  }
+
+  private final class Attempt {
+    private final CompletableFuture<Connection> result = new 
CompletableFuture<>();
+    private final CountDownLatch decision = new CountDownLatch(1);
+    private boolean accepted;
+    private boolean abandoned;
+    private boolean finished;
+    @Nullable private Thread runner;
+
+    synchronized void accept() {
+      accepted = true;
+    }
+
+    synchronized void abandon() {
+      if (!finished) {
+        abandoned = true;
+        abandonedAttempts.incrementAndGet();
+        if (runner != null) {
+          runner.interrupt();
+        }
+      }
+    }
+
+    void run(String url, Properties properties, @Nullable ClassLoader context) 
{
+      Thread current = Thread.currentThread();
+      ClassLoader previous = current.getContextClassLoader();
+      current.setContextClassLoader(context);
+      Connection connection = null;
+      try {
+        synchronized (this) {
+          runner = current;
+          if (abandoned) {
+            return;
+          }
+        }
+        connection = delegate.connect(url, properties);
+        result.complete(connection);
+        boolean interrupted = false;
+        while (true) {
+          try {
+            decision.await();
+            break;
+          } catch (InterruptedException e) {
+            // Cancellation must not bypass the handoff decision or discard a 
late connection.
+            interrupted = true;
+          }
+        }
+        if (interrupted) {
+          current.interrupt();

Review Comment:
   nit: re-interrupting the pooled worker isn't needed, because 
`ThreadPoolExecutor` clears the flag in `getTask`, so the next task isn't 
interrupted anyway. You can drop this; the pool already ends the task.



##########
core/src/main/java/org/apache/gravitino/utils/BoundedConnectionDriver.java:
##########
@@ -0,0 +1,295 @@
+/*
+ * 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.utils;
+
+import java.security.AccessControlContext;
+import java.security.AccessController;
+import java.security.PrivilegedAction;
+import java.security.ProtectionDomain;
+import java.sql.Connection;
+import java.sql.Driver;
+import java.sql.DriverPropertyInfo;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.sql.SQLTimeoutException;
+import java.sql.SQLTransientConnectionException;
+import java.util.Properties;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Bounds physical connection creation for drivers that cannot enforce a login 
timeout.
+ *
+ * <p>Only {@link Driver#connect} runs on a worker. Validation, metadata 
queries and DDL stay on the
+ * caller. A connection that arrives after timeout or interruption is closed 
by its worker and is
+ * never handed to a pool. Cancellation is best effort: a driver ignoring 
interruption retains a
+ * worker until it returns. A JVM-wide limit of 64 workers, with no queue, 
bounds that retention;
+ * excess attempts fail with {@link SQLTransientConnectionException}. Workers 
are daemon threads and
+ * release their catalog context class loader after each attempt. Each 
attempt, including late
+ * connection cleanup, runs with the submitting caller's access-control 
context and JAAS subject;
+ * shared workers do not inherit the identity of the request that creates them.
+ *
+ * <p>After a timeout, further attempts through this instance fail immediately 
until the abandoned
+ * attempt finishes cleanup. This prevents a lazy pool's initialization lock 
from serializing a
+ * backlog of new attempts against the same unreachable backend. Normal pool 
waits and SQL execution
+ * have their own budgets and are not bounded by this adapter.
+ */
+@SuppressWarnings("removal") // JDK 17 associates JAAS subjects with 
access-control contexts.
+public final class BoundedConnectionDriver implements Driver {
+  private static final Logger LOG = 
LoggerFactory.getLogger(BoundedConnectionDriver.class);
+
+  private static final AccessControlContext WORKER_CONTEXT =
+      new AccessControlContext(new ProtectionDomain[0]);
+
+  private static final ExecutorService WORKERS =
+      new ThreadPoolExecutor(
+          0,
+          64,
+          60,
+          TimeUnit.SECONDS,
+          new SynchronousQueue<>(),
+          task ->
+              AccessController.doPrivileged(
+                  (PrivilegedAction<Thread>)
+                      () -> {
+                        Thread thread = new Thread(task, 
"jdbc-connect-timeout");
+                        thread.setDaemon(true);
+                        thread.setContextClassLoader(
+                            BoundedConnectionDriver.class.getClassLoader());
+                        return thread;
+                      },
+                  WORKER_CONTEXT));
+
+  private final Driver delegate;
+  private final long timeoutMs;
+  private final ExecutorService workers;
+  private final AtomicInteger abandonedAttempts = new AtomicInteger();
+
+  /**
+   * Wraps a driver with a physical connection deadline.
+   *
+   * @param delegate the driver to call, without registering the wrapper with 
DriverManager
+   * @param timeoutMs a positive timeout in milliseconds
+   * @throws IllegalArgumentException if timeoutMs is not positive
+   */
+  public BoundedConnectionDriver(Driver delegate, long timeoutMs) {
+    this(delegate, timeoutMs, WORKERS);
+  }
+
+  BoundedConnectionDriver(Driver delegate, long timeoutMs, ExecutorService 
workers) {
+    if (timeoutMs <= 0) {
+      throw new IllegalArgumentException("JDBC connect timeout must be 
positive");
+    }
+    this.delegate = delegate;
+    this.timeoutMs = timeoutMs;
+    this.workers = workers;
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * @throws SQLTimeoutException if the connection deadline expires or the 
caller is interrupted
+   * @throws SQLTransientConnectionException if all workers are busy or a 
previous timed-out attempt
+   *     through this driver is still running
+   */
+  @Override
+  public Connection connect(String url, Properties info) throws SQLException {
+    if (abandonedAttempts.get() != 0) {
+      throw new SQLTransientConnectionException(
+          "A previous timed-out JDBC connection attempt is still running. Try 
again after it finishes.",
+          "08001");
+    }
+    Attempt attempt = new Attempt();
+    ClassLoader context = Thread.currentThread().getContextClassLoader();
+    AccessControlContext callerContext = AccessController.getContext();
+    Properties properties = (Properties) info.clone();
+    try {
+      workers.execute(
+          () ->
+              AccessController.doPrivileged(
+                  (PrivilegedAction<Void>)
+                      () -> {
+                        attempt.run(url, properties, context);
+                        return null;
+                      },
+                  callerContext));
+    } catch (RejectedExecutionException e) {
+      throw new SQLTransientConnectionException(
+          "The JDBC connection attempt limit has been reached. Try again after 
an attempt finishes.",
+          "08001",
+          e);
+    }
+    try {
+      Connection connection = attempt.result.get(timeoutMs, 
TimeUnit.MILLISECONDS);
+      attempt.accept();
+      return connection;
+    } catch (TimeoutException e) {
+      attempt.abandon();
+      throw new SQLTimeoutException(
+          "JDBC connection attempt exceeded "
+              + timeoutMs
+              + " ms. Check backend and proxy connectivity.",
+          "08001",
+          e);
+    } catch (InterruptedException e) {
+      attempt.abandon();
+      Thread.currentThread().interrupt();
+      throw new SQLTimeoutException("Interrupted while opening a JDBC 
connection", "08001", e);
+    } catch (ExecutionException e) {
+      Throwable cause = e.getCause();
+      if (cause instanceof SQLException) {
+        throw (SQLException) cause;
+      }
+      if (cause instanceof RuntimeException) {
+        throw (RuntimeException) cause;
+      }
+      if (cause instanceof Error) {
+        throw (Error) cause;
+      }
+      throw new SQLException("Failed to open JDBC connection", "08001", cause);
+    } finally {
+      attempt.decision.countDown();
+    }
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public boolean acceptsURL(String url) throws SQLException {
+    return delegate.acceptsURL(url);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) 
throws SQLException {
+    return delegate.getPropertyInfo(url, info);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public int getMajorVersion() {
+    return delegate.getMajorVersion();
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public int getMinorVersion() {
+    return delegate.getMinorVersion();
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public boolean jdbcCompliant() {
+    return delegate.jdbcCompliant();
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public java.util.logging.Logger getParentLogger() throws 
SQLFeatureNotSupportedException {
+    return delegate.getParentLogger();
+  }
+
+  private final class Attempt {
+    private final CompletableFuture<Connection> result = new 
CompletableFuture<>();
+    private final CountDownLatch decision = new CountDownLatch(1);
+    private boolean accepted;
+    private boolean abandoned;
+    private boolean finished;
+    @Nullable private Thread runner;
+
+    synchronized void accept() {
+      accepted = true;
+    }
+
+    synchronized void abandon() {
+      if (!finished) {
+        abandoned = true;
+        abandonedAttempts.incrementAndGet();
+        if (runner != null) {
+          runner.interrupt();
+        }
+      }
+    }
+
+    void run(String url, Properties properties, @Nullable ClassLoader context) 
{
+      Thread current = Thread.currentThread();
+      ClassLoader previous = current.getContextClassLoader();
+      current.setContextClassLoader(context);
+      Connection connection = null;
+      try {
+        synchronized (this) {
+          runner = current;
+          if (abandoned) {
+            return;
+          }
+        }
+        connection = delegate.connect(url, properties);
+        result.complete(connection);
+        boolean interrupted = false;
+        while (true) {
+          try {
+            decision.await();
+            break;
+          } catch (InterruptedException e) {
+            // Cancellation must not bypass the handoff decision or discard a 
late connection.
+            interrupted = true;
+          }
+        }
+        if (interrupted) {
+          current.interrupt();
+        }
+      } catch (Throwable e) {
+        result.completeExceptionally(e);
+      } finally {
+        boolean delivered;
+        synchronized (this) {
+          delivered = accepted;
+        }
+        try {
+          if (!delivered && connection != null) {
+            try {
+              connection.close();

Review Comment:
   Minor: after a timeout, the worker has its interrupt flag set when it gets 
here, and a `close()` that honors the interrupt can fail. That failure is only 
logged, the quarantine is cleared anyway (line 287), and the physical 
connection can leak. Consider clearing the interrupt for the close and 
restoring it afterwards.
   
   Also, the `finally` only restores the context class loader (line 290). Any 
`ThreadLocal`s the delegate driver sets, such as a Kerberos or Hadoop user, 
stay on the shared worker, and the JVM-wide 64-thread pool (line 71) then runs 
the next catalog's connect on that same thread. Could you document this, or 
isolate workers per catalog?



##########
core/src/main/java/org/apache/gravitino/utils/BoundedConnectionDriver.java:
##########
@@ -0,0 +1,295 @@
+/*
+ * 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.utils;
+
+import java.security.AccessControlContext;
+import java.security.AccessController;
+import java.security.PrivilegedAction;
+import java.security.ProtectionDomain;
+import java.sql.Connection;
+import java.sql.Driver;
+import java.sql.DriverPropertyInfo;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.sql.SQLTimeoutException;
+import java.sql.SQLTransientConnectionException;
+import java.util.Properties;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Bounds physical connection creation for drivers that cannot enforce a login 
timeout.
+ *
+ * <p>Only {@link Driver#connect} runs on a worker. Validation, metadata 
queries and DDL stay on the
+ * caller. A connection that arrives after timeout or interruption is closed 
by its worker and is
+ * never handed to a pool. Cancellation is best effort: a driver ignoring 
interruption retains a
+ * worker until it returns. A JVM-wide limit of 64 workers, with no queue, 
bounds that retention;
+ * excess attempts fail with {@link SQLTransientConnectionException}. Workers 
are daemon threads and
+ * release their catalog context class loader after each attempt. Each 
attempt, including late
+ * connection cleanup, runs with the submitting caller's access-control 
context and JAAS subject;
+ * shared workers do not inherit the identity of the request that creates them.
+ *
+ * <p>After a timeout, further attempts through this instance fail immediately 
until the abandoned
+ * attempt finishes cleanup. This prevents a lazy pool's initialization lock 
from serializing a
+ * backlog of new attempts against the same unreachable backend. Normal pool 
waits and SQL execution
+ * have their own budgets and are not bounded by this adapter.
+ */
+@SuppressWarnings("removal") // JDK 17 associates JAAS subjects with 
access-control contexts.
+public final class BoundedConnectionDriver implements Driver {
+  private static final Logger LOG = 
LoggerFactory.getLogger(BoundedConnectionDriver.class);
+
+  private static final AccessControlContext WORKER_CONTEXT =
+      new AccessControlContext(new ProtectionDomain[0]);
+
+  private static final ExecutorService WORKERS =
+      new ThreadPoolExecutor(
+          0,
+          64,
+          60,
+          TimeUnit.SECONDS,
+          new SynchronousQueue<>(),
+          task ->
+              AccessController.doPrivileged(
+                  (PrivilegedAction<Thread>)
+                      () -> {
+                        Thread thread = new Thread(task, 
"jdbc-connect-timeout");
+                        thread.setDaemon(true);
+                        thread.setContextClassLoader(
+                            BoundedConnectionDriver.class.getClassLoader());
+                        return thread;
+                      },
+                  WORKER_CONTEXT));
+
+  private final Driver delegate;
+  private final long timeoutMs;
+  private final ExecutorService workers;
+  private final AtomicInteger abandonedAttempts = new AtomicInteger();
+
+  /**
+   * Wraps a driver with a physical connection deadline.
+   *
+   * @param delegate the driver to call, without registering the wrapper with 
DriverManager
+   * @param timeoutMs a positive timeout in milliseconds
+   * @throws IllegalArgumentException if timeoutMs is not positive
+   */
+  public BoundedConnectionDriver(Driver delegate, long timeoutMs) {
+    this(delegate, timeoutMs, WORKERS);
+  }
+
+  BoundedConnectionDriver(Driver delegate, long timeoutMs, ExecutorService 
workers) {
+    if (timeoutMs <= 0) {
+      throw new IllegalArgumentException("JDBC connect timeout must be 
positive");
+    }
+    this.delegate = delegate;
+    this.timeoutMs = timeoutMs;
+    this.workers = workers;
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * @throws SQLTimeoutException if the connection deadline expires or the 
caller is interrupted
+   * @throws SQLTransientConnectionException if all workers are busy or a 
previous timed-out attempt
+   *     through this driver is still running
+   */
+  @Override
+  public Connection connect(String url, Properties info) throws SQLException {
+    if (abandonedAttempts.get() != 0) {
+      throw new SQLTransientConnectionException(
+          "A previous timed-out JDBC connection attempt is still running. Try 
again after it finishes.",
+          "08001");
+    }
+    Attempt attempt = new Attempt();
+    ClassLoader context = Thread.currentThread().getContextClassLoader();
+    AccessControlContext callerContext = AccessController.getContext();
+    Properties properties = (Properties) info.clone();

Review Comment:
   Minor: `info.clone()` throws an NPE when `Driver.connect` is called with 
null `Properties`, which JDBC allows. Please treat null as an empty 
`Properties`.



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

To unsubscribe, e-mail: [email protected]

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

Reply via email to