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

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


The following commit(s) were added to refs/heads/main by this push:
     new ea1d960a0e [#12986] fix(catalog): Release the ClassLoader of a dropped 
catalog (#12987)
ea1d960a0e is described below

commit ea1d960a0edd086e19363a47596176970ed5c9d7
Author: Qi Yu <[email protected]>
AuthorDate: Wed Sep 9 14:10:41 2026 +0800

    [#12986] fix(catalog): Release the ClassLoader of a dropped catalog (#12987)
    
    ### What changes were proposed in this pull request?
    
    All in `ClassLoaderResourceCleanerUtils`, so every provider and every
    caller of the cleaner benefits:
    
    - `clearThreadLocalMap` looks **through** a `java.lang.ref.Reference`
    value to its referent when deciding whether a thread-local entry belongs
    to the dying loader, also checks the entry's key, and no longer skips
    threads that are not named `Gravitino-webserver-*`.
    - `runningWithClassLoader` matches a thread by the class of the thread
    and of its runnable, not only by its context ClassLoader.
    - New step `removeLoggerContextListeners`: removes listeners the loader
    registered on the shared Log4j `LoggerContext`.
    - New step `deregisterJdbcDrivers`: defines `JdbcDriverDeregisterer`
    inside the catalog's loader and calls it there, because `DriverManager`
    filters both `getDrivers()` and `deregisterDriver()` by the caller's
    ClassLoader.
    - New step `shutdownMysqlConnectionCleanup`: calls Connector/J's
    `uncheckedShutdown()`.
    - New step `removeSecurityProviders`: removes JCA providers the loader
    installed.
    - New step `clearResourceBundleCache`:
    `ResourceBundle.clearCache(loader)`, since bundles are cached JVM-wide
    behind soft references.
    - `HiveClientFactory.close()` runs the cleaner against the nested
    `HiveClientClassLoader` before closing it.
    - `ClassLoaderPool.deregisterAllDrivers` is removed: it ran from the
    server's ClassLoader, where the catalog's drivers are invisible, so it
    never deregistered anything. The cleaner now covers it.
    
    ### Why are the changes needed?
    
    Dropping or altering a catalog leaked its ClassLoader, so Metaspace grew
    until the JVM could no longer load new classes and the server degraded
    into per-feature 500s while already-warm paths kept returning 200. Since
    an `alter` rebuilds the ClassLoader, the loss accumulated: five alters
    of a Hive catalog cost ~48 MB that was never returned.
    
    Every retention path was traced from a heap dump back to a GC root. They
    are unrelated to each other, which is why the fix has several parts:
    
    | pinned by | affected |
    |---|---|
    | commons-logging listener on the shared Log4j `LoggerContext` | hive |
    | `SoftReference` in a `ThreadLocal` (Jackson `BufferRecycler`) |
    paimon, iceberg, cloud filesets |
    | nested `HiveClientClassLoader` never cleaned | hive |
    | `DriverManager.registeredDrivers` | every JDBC catalog |
    | PostgreSQL `LazyCleaner` thread | jdbc-postgresql |
    | MySQL `AbandonedConnectionCleanupThread` executor | jdbc-mysql,
    iceberg on a jdbc backend |
    | JCA provider (`OpenSSLProvider` from the AWS bundle) | fileset on
    abfss |
    | `ResourceBundle` cache (soft) | Oracle's `ErrorMessages`, any
    localized driver |
    | a task on a shared executor (AWS SDK idle-connection reaper) | cloud
    clients |
    
    Soft references deserve a note: they are cleared under **heap**
    pressure, and Metaspace pressure never triggers that, so on a server
    with a roomy heap and a small `MaxMetaspaceSize` a soft-referenced
    loader is permanent in practice.
    
    Fix: #12986
    
    ### Does this PR introduce _any_ user-facing change?
    
    No new configuration or API. Dropped catalogs release their Metaspace,
    so a long-running server no longer grows without bound.
    
    ### How was this patch tested?
    
    Unit tests: 7 new cases in `TestClassLoaderResourceCleanerUtils`
    (looking through a reference, cleared references, clearing a
    soft-referenced thread-local, leaving unrelated entries alone, matching
    a thread by its runnable, leaving unrelated security providers alone).
    `:catalogs:catalog-common`, `:catalogs:hive-metastore-common`,
    `:catalogs:catalog-hive`, `:catalogs:catalog-fileset`,
    `:catalogs:catalog-jdbc-common` and the `:core` ClassLoader tests pass.
    
    End-to-end on a packaged server with `-Xms1024m -Xmx1024m
    -XX:MaxMetaspaceSize=512m`, one catalog at a time: create, exercise
    (schema plus a table, fileset, topic or model version), drop, force a
    full GC, then count loaders with `jcmd VM.classloader_stats` and read
    Metaspace with `jcmd GC.heap_info`. Backends were a real Hive metastore,
    MySQL, PostgreSQL, Kafka and MinIO in containers.
    
    | provider | classes loaded | loaders after drop, before | after |
    |---|---|---|---|
    | `model` | 9 | 0 | 0 |
    | `fileset` (file://) | 803 | 0 | 0 |
    | `fileset` (s3a, MinIO) | 3042 | retained | **0** |
    | `fileset` (gs://) | 919 | retained | **0** |
    | `fileset` (abfss://) | 1090 | retained | **0** |
    | `jdbc-mysql` | 463 | retained | **0** |
    | `jdbc-postgresql` | 311 | retained | **0** |
    | `kafka` | 1072 | 0 | 0 |
    | `hive` | 1263 (3 loaders) | retained | **0** |
    | `lakehouse-paimon` | 1165 | retained | **0** |
    | `lakehouse-iceberg` (jdbc backend) | 1433 | retained | **0** |
    
    Repeated churn, the case that exhausts Metaspace in practice — five
    alters of a Hive catalog: before, 7 loaders and 70.6 → 119.3 MB that
    never came back; after, back to baseline at +0.8 MB.
    
    `glue` is the one provider still not released, verified against
    LocalStack. Its root is different in kind: the AWS SDK's
    `IdleConnectionReaper` is a singleton per ClassLoader that only stops
    once every connection manager is deregistered, and it ignores
    interrupts, so some AWS client the catalog builds is not being closed.
    That is a client-lifecycle bug in the catalog rather than a cleanup gap,
    and papering over it by reflecting into SDK internals seemed worse than
    reporting it; I will file it separately.
    
    Not covered locally, for lack of a backend: `jdbc-doris`,
    `jdbc-starrocks`, `lakehouse-hudi`, `lakehouse-generic`. Doris and
    StarRocks use the MySQL driver, so the DriverManager and Connector/J
    fixes apply to them unchanged.
    
    ```bash
    ./gradlew :catalogs:catalog-common:test 
:catalogs:hive-metastore-common:test \
      :catalogs:catalog-hive:test :catalogs:catalog-fileset:test \
      :catalogs:catalog-jdbc-common:test -PskipITs -PskipWeb=true
    ```
    
    https://claude.ai/code/session_013xVSteM2ZUjXRHFbHtayVK
---
 .../utils/ClassLoaderResourceCleanerUtils.java     | 229 ++++++++++++++++++++-
 .../gravitino/utils/JdbcDriverDeregisterer.java    |  65 ++++++
 .../utils/TestClassLoaderResourceCleanerUtils.java | 154 ++++++++++++++
 .../gravitino/hive/client/HiveClientFactory.java   |   7 +
 .../apache/gravitino/utils/ClassLoaderPool.java    |  37 +---
 5 files changed, 454 insertions(+), 38 deletions(-)

diff --git 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/utils/ClassLoaderResourceCleanerUtils.java
 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/utils/ClassLoaderResourceCleanerUtils.java
index 6a91f4ac48..b4c33ffa8a 100644
--- 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/utils/ClassLoaderResourceCleanerUtils.java
+++ 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/utils/ClassLoaderResourceCleanerUtils.java
@@ -20,10 +20,20 @@
 package org.apache.gravitino.utils;
 
 import com.google.common.annotations.VisibleForTesting;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.lang.ref.Reference;
 import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.security.Provider;
+import java.security.Security;
+import java.util.Collection;
 import java.util.IdentityHashMap;
+import java.util.ResourceBundle;
 import java.util.Timer;
 import java.util.concurrent.ScheduledExecutorService;
+import javax.annotation.Nullable;
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.commons.lang3.reflect.MethodUtils;
 import org.slf4j.Logger;
@@ -70,6 +80,16 @@ public class ClassLoaderResourceCleanerUtils {
     // instance.
     
executeAndCatch(ClassLoaderResourceCleanerUtils::releaseLogFactoryInCommonLogging,
 classLoader);
 
+    
executeAndCatch(ClassLoaderResourceCleanerUtils::removeLoggerContextListeners, 
classLoader);
+
+    executeAndCatch(ClassLoaderResourceCleanerUtils::deregisterJdbcDrivers, 
classLoader);
+
+    
executeAndCatch(ClassLoaderResourceCleanerUtils::shutdownMysqlConnectionCleanup,
 classLoader);
+
+    executeAndCatch(ClassLoaderResourceCleanerUtils::removeSecurityProviders, 
classLoader);
+
+    executeAndCatch(ClassLoaderResourceCleanerUtils::clearResourceBundleCache, 
classLoader);
+
     executeAndCatch(ClassLoaderResourceCleanerUtils::closeResourceInAWS, 
classLoader);
 
     executeAndCatch(ClassLoaderResourceCleanerUtils::closeResourceInGCP, 
classLoader);
@@ -160,8 +180,37 @@ public class ClassLoaderResourceCleanerUtils {
     }
   }
 
-  private static boolean runningWithClassLoader(Thread thread, ClassLoader 
targetClassLoader) {
-    return thread != null && thread.getContextClassLoader() == 
targetClassLoader;
+  /**
+   * Whether the thread belongs to the class loader being released.
+   *
+   * <p>The context ClassLoader is only one of the ways a thread can carry a 
catalog. A driver that
+   * starts its own housekeeping thread, such as PostgreSQL's {@code 
LazyCleaner}, is running code
+   * defined by the catalog's loader: the thread is a GC root, so its class 
alone keeps the loader
+   * alive no matter what its context ClassLoader says.
+   *
+   * <p>Ownership has to be read from the thread itself, never from what it 
happens to be running: a
+   * request thread executing an operation on this very catalog is not the 
catalog's to stop, and
+   * interrupting it fails the request with "Thread was interrupted while 
waiting for lock".
+   */
+  @VisibleForTesting
+  static boolean runningWithClassLoader(Thread thread, ClassLoader 
targetClassLoader) {
+    if (thread == null) {
+      return false;
+    }
+    if (thread.getContextClassLoader() == targetClassLoader
+        || thread.getClass().getClassLoader() == targetClassLoader) {
+      return true;
+    }
+    try {
+      Object runnable = FieldUtils.readField(thread, "target", true);
+      if (runnable != null && runnable.getClass().getClassLoader() == 
targetClassLoader) {
+        return true;
+      }
+    } catch (Exception e) {
+      LOG.debug("Cannot read the runnable of thread {}", thread.getName(), e);
+    }
+
+    return false;
   }
 
   private static Thread[] getAllThreads() {
@@ -178,8 +227,9 @@ public class ClassLoaderResourceCleanerUtils {
     return threads;
   }
 
-  private static void clearThreadLocalMap(Thread thread, ClassLoader 
targetClassLoader) {
-    if (thread == null || 
!thread.getName().startsWith("Gravitino-webserver-")) {
+  @VisibleForTesting
+  static void clearThreadLocalMap(Thread thread, ClassLoader 
targetClassLoader) {
+    if (thread == null) {
       return;
     }
 
@@ -197,9 +247,10 @@ public class ClassLoaderResourceCleanerUtils {
         for (Object entry : table) {
           if (entry != null) {
             Object value = FieldUtils.readField(entry, "value", true);
-            if (value != null
-                && value.getClass().getClassLoader() != null
-                && value.getClass().getClassLoader() == targetClassLoader) {
+            // The entry is a WeakReference to the ThreadLocal itself, which 
can be the leaking
+            // side when the ThreadLocal was declared by a class of the dying 
catalog.
+            Object key = entry instanceof Reference ? ((Reference<?>) 
entry).get() : null;
+            if (definedBy(value, targetClassLoader) || definedBy(key, 
targetClassLoader)) {
               LOG.debug(
                   "Cleaning up thread local {} for thread {} with custom class 
loader",
                   value,
@@ -214,6 +265,31 @@ public class ClassLoaderResourceCleanerUtils {
     }
   }
 
+  /**
+   * Whether {@code value}, or what it refers to when it is a {@link 
Reference}, was defined by
+   * {@code classLoader}.
+   *
+   * <p>Looking through a {@link Reference} matters: caches such as Jackson's 
{@code BufferRecycler}
+   * park a {@code SoftReference} in a {@link ThreadLocal}. The reference 
itself is a bootstrap
+   * class, so only its referent identifies the owning catalog. Left in place, 
such an entry keeps
+   * the catalog's ClassLoader alive until heap pressure clears the soft 
reference, which Metaspace
+   * pressure alone never triggers.
+   */
+  @VisibleForTesting
+  static boolean definedBy(@Nullable Object value, ClassLoader classLoader) {
+    if (value == null) {
+      return false;
+    }
+    if (value.getClass().getClassLoader() == classLoader) {
+      return true;
+    }
+    if (value instanceof Reference) {
+      Object referent = ((Reference<?>) value).get();
+      return referent != null && referent.getClass().getClassLoader() == 
classLoader;
+    }
+    return false;
+  }
+
   /**
    * Clear shutdown hooks registered by the target class loader to prevent 
memory leaks.
    *
@@ -236,6 +312,145 @@ public class ClassLoaderResourceCleanerUtils {
             });
   }
 
+  /**
+   * Removes shutdown listeners the class loader registered on the shared 
Log4j {@code
+   * LoggerContext}.
+   *
+   * <p>commons-logging's {@code Log4jApiLogFactory} registers a {@code 
LogAdapter} with the
+   * LoggerContext of the server, which outlives every catalog. {@code 
LogFactory.release} drops the
+   * factory from its own cache but leaves that registration in place, so the 
adapter's class, and
+   * through it the catalog's ClassLoader, stays reachable from a static for 
the life of the
+   * process.
+   */
+  /**
+   * Drops the {@link ResourceBundle} cache entries loaded through this class 
loader.
+   *
+   * <p>{@link ResourceBundle} caches bundles in a JVM-wide static map, behind 
soft references. A
+   * driver that loads message bundles, such as Oracle's {@code 
ErrorMessages}, therefore leaves its
+   * class - and the catalog's ClassLoader - reachable until heap pressure 
clears the soft
+   * reference, which Metaspace pressure alone never causes.
+   */
+  @VisibleForTesting
+  static void clearResourceBundleCache(ClassLoader targetClassLoader) {
+    ResourceBundle.clearCache(targetClassLoader);
+  }
+
+  /**
+   * Removes the JCA security providers the class loader installed.
+   *
+   * <p>{@link Security} keeps installed providers in a JVM-wide static list. 
Hadoop's cloud
+   * connectors install one, such as the shaded {@code OpenSSLProvider} that 
ships in the AWS
+   * bundle, and it is never removed, so the provider's class holds the 
catalog's loader for the
+   * life of the process.
+   */
+  @VisibleForTesting
+  static void removeSecurityProviders(ClassLoader targetClassLoader) {
+    for (Provider provider : Security.getProviders()) {
+      if (provider.getClass().getClassLoader() == targetClassLoader) {
+        Security.removeProvider(provider.getName());
+        LOG.info("Removed security provider {} of a released catalog 
ClassLoader", provider);
+      }
+    }
+  }
+
+  /**
+   * Shuts down MySQL Connector/J's abandoned-connection cleanup thread when 
the driver belongs to
+   * this class loader.
+   *
+   * <p>The driver keeps that thread and its executor in a static field, and 
the executor's thread
+   * factory is a lambda defined by the catalog's loader, so a running cleanup 
thread pins the
+   * loader through its own stack frame. Connector/J exposes {@code 
uncheckedShutdown()} for exactly
+   * this case.
+   */
+  private static void shutdownMysqlConnectionCleanup(ClassLoader 
targetClassLoader)
+      throws Exception {
+    Class<?> cleanupThreadClass =
+        Class.forName(
+            "com.mysql.cj.jdbc.AbandonedConnectionCleanupThread", true, 
targetClassLoader);
+    if (!isOwnedByClassLoader(cleanupThreadClass, targetClassLoader)) {
+      LOG.debug(
+          "MySQL Connector/J is owned by {}, not {}; skipping shared-class 
cleanup",
+          cleanupThreadClass.getClassLoader(),
+          targetClassLoader);
+      return;
+    }
+    // uncheckedShutdown stops the thread even when the driver still believes 
it is in use, which
+    // is what unloading the ClassLoader requires; checkedShutdown returns 
without doing anything.
+    MethodUtils.invokeStaticMethod(cleanupThreadClass, "uncheckedShutdown");
+    LOG.info("Shut down the MySQL abandoned-connection cleanup thread of a 
released ClassLoader");
+  }
+
+  /**
+   * Deregisters the JDBC drivers the class loader registered with {@link 
java.sql.DriverManager}.
+   *
+   * <p>{@code DriverManager} keeps registered drivers in a static list, and a 
driver defined by a
+   * catalog's ClassLoader keeps that loader alive for the life of the 
process. It cannot be removed
+   * from here directly: {@code DriverManager} filters both {@code 
getDrivers()} and {@code
+   * deregisterDriver()} by the class loader of the calling class, so from the 
server's ClassLoader
+   * the catalog's drivers are not even visible. Defining {@link 
JdbcDriverDeregisterer} inside the
+   * target loader and calling it there gives {@code DriverManager} a caller 
that owns them.
+   */
+  @VisibleForTesting
+  static void deregisterJdbcDrivers(ClassLoader targetClassLoader) throws 
Exception {
+    String name = JdbcDriverDeregisterer.class.getName();
+    byte[] bytecode;
+    try (InputStream in =
+        ClassLoaderResourceCleanerUtils.class
+            .getClassLoader()
+            .getResourceAsStream(name.replace('.', '/') + ".class")) {
+      if (in == null) {
+        LOG.debug("Cannot locate the bytecode of {}, skipping JDBC driver 
cleanup", name);
+        return;
+      }
+      ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+      byte[] chunk = new byte[8192];
+      int read;
+      while ((read = in.read(chunk)) != -1) {
+        buffer.write(chunk, 0, read);
+      }
+      bytecode = buffer.toByteArray();
+    }
+
+    Method defineClass =
+        ClassLoader.class.getDeclaredMethod(
+            "defineClass", String.class, byte[].class, int.class, int.class);
+    defineClass.setAccessible(true);
+    Class<?> deregisterer;
+    try {
+      deregisterer =
+          (Class<?>) defineClass.invoke(targetClassLoader, name, bytecode, 0, 
bytecode.length);
+    } catch (InvocationTargetException e) {
+      if (e.getCause() instanceof LinkageError) {
+        // Already defined by an earlier cleanup of the same loader, whose 
drivers are gone.
+        LOG.debug("{} is already defined in {}", name, targetClassLoader);
+        return;
+      }
+      throw e;
+    }
+
+    Object deregistered = MethodUtils.invokeStaticMethod(deregisterer, 
"deregisterAll");
+    if (deregistered instanceof Collection && !((Collection<?>) 
deregistered).isEmpty()) {
+      LOG.info("Deregistered JDBC driver(s) {} of a released catalog 
ClassLoader", deregistered);
+    }
+  }
+
+  @VisibleForTesting
+  static void removeLoggerContextListeners(ClassLoader targetClassLoader) 
throws Exception {
+    Class<?> logManagerClass = 
Class.forName("org.apache.logging.log4j.LogManager");
+    Object contextFactory = MethodUtils.invokeStaticMethod(logManagerClass, 
"getFactory");
+    Object selector = MethodUtils.invokeMethod(contextFactory, "getSelector");
+    Collection<?> contexts =
+        (Collection<?>) MethodUtils.invokeMethod(selector, 
"getLoggerContexts");
+    for (Object context : contexts) {
+      Collection<?> listeners = (Collection<?>) FieldUtils.readField(context, 
"listeners", true);
+      if (listeners != null) {
+        listeners.removeIf(
+            listener ->
+                listener != null && listener.getClass().getClassLoader() == 
targetClassLoader);
+      }
+    }
+  }
+
   /**
    * Release the LogFactory for the target class loader to prevent memory 
leaks.
    *
diff --git 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/utils/JdbcDriverDeregisterer.java
 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/utils/JdbcDriverDeregisterer.java
new file mode 100644
index 0000000000..824281b4da
--- /dev/null
+++ 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/utils/JdbcDriverDeregisterer.java
@@ -0,0 +1,65 @@
+/*
+ * 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.sql.Driver;
+import java.sql.DriverManager;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.List;
+
+/**
+ * Deregisters the JDBC drivers a catalog's ClassLoader registered with {@link 
DriverManager}.
+ *
+ * <p>This class is never called through its own name. {@link DriverManager} 
filters both {@code
+ * getDrivers()} and {@code deregisterDriver()} by the class loader of the 
calling class, so a
+ * driver defined by a catalog's isolated ClassLoader is invisible, and 
undeletable, from the server
+ * ClassLoader. {@link ClassLoaderResourceCleanerUtils} therefore defines a 
copy of this class
+ * inside the catalog's ClassLoader and invokes it reflectively, so that 
{@code DriverManager} sees
+ * a caller that owns the drivers. Keep its dependencies to {@code java.*} 
only: the copy is defined
+ * directly from bytecode and resolves everything through the catalog's 
ClassLoader.
+ */
+public final class JdbcDriverDeregisterer {
+
+  private JdbcDriverDeregisterer() {}
+
+  /**
+   * Deregisters every driver defined by the ClassLoader of this class.
+   *
+   * @return the names of the drivers that were deregistered
+   */
+  public static List<String> deregisterAll() {
+    ClassLoader owner = JdbcDriverDeregisterer.class.getClassLoader();
+    List<String> deregistered = new ArrayList<>();
+    Enumeration<Driver> drivers = DriverManager.getDrivers();
+    while (drivers.hasMoreElements()) {
+      Driver driver = drivers.nextElement();
+      if (driver.getClass().getClassLoader() == owner) {
+        try {
+          DriverManager.deregisterDriver(driver);
+          deregistered.add(driver.getClass().getName());
+        } catch (Exception e) {
+          // Leave the driver registered rather than failing the whole cleanup.
+        }
+      }
+    }
+    return deregistered;
+  }
+}
diff --git 
a/catalogs/catalog-common/src/test/java/org/apache/gravitino/utils/TestClassLoaderResourceCleanerUtils.java
 
b/catalogs/catalog-common/src/test/java/org/apache/gravitino/utils/TestClassLoaderResourceCleanerUtils.java
index c5a241d8c7..52e4d10b6a 100644
--- 
a/catalogs/catalog-common/src/test/java/org/apache/gravitino/utils/TestClassLoaderResourceCleanerUtils.java
+++ 
b/catalogs/catalog-common/src/test/java/org/apache/gravitino/utils/TestClassLoaderResourceCleanerUtils.java
@@ -19,15 +19,169 @@
 
 package org.apache.gravitino.utils;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.lang.ref.SoftReference;
+import java.lang.ref.WeakReference;
 import java.net.URL;
 import java.net.URLClassLoader;
+import java.security.Security;
+import java.util.concurrent.TimeUnit;
 import org.junit.jupiter.api.Test;
 
 class TestClassLoaderResourceCleanerUtils {
 
+  private static final ThreadLocal<Object> SOFT_HOLDER = new ThreadLocal<>();
+  private static final ThreadLocal<Object> UNRELATED_HOLDER = new 
ThreadLocal<>();
+
+  /** A class with no dependencies beyond java.*, so a bare-bones child loader 
can define it. */
+  public static class Leaky {}
+
+  /** A Runnable the child loader can define, standing in for a driver's 
housekeeping task. */
+  public static class LeakyTask implements Runnable {
+    @Override
+    public void run() {
+      try {
+        Thread.sleep(60_000);
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+      }
+    }
+  }
+
+  private static URLClassLoader childLoaderOwning(Class<?> clazz) throws 
Exception {
+    URL location = clazz.getProtectionDomain().getCodeSource().getLocation();
+    // A null parent keeps delegation off the app loader, so the child defines 
the class itself.
+    return new URLClassLoader(new URL[] {location}, null);
+  }
+
+  /** The value's own class identifies the owner in the simple case. */
+  @Test
+  void testDefinedByMatchesTheDeclaringLoader() throws Exception {
+    try (URLClassLoader child = childLoaderOwning(Leaky.class)) {
+      Object leaky = 
child.loadClass(Leaky.class.getName()).getDeclaredConstructor().newInstance();
+      assertTrue(ClassLoaderResourceCleanerUtils.definedBy(leaky, child));
+      assertFalse(ClassLoaderResourceCleanerUtils.definedBy(leaky, 
Leaky.class.getClassLoader()));
+    }
+  }
+
+  /**
+   * Caches such as Jackson's BufferRecycler park a SoftReference in a 
ThreadLocal. The reference is
+   * a bootstrap class, so only its referent identifies the owning catalog.
+   */
+  @Test
+  void testDefinedByLooksThroughAReference() throws Exception {
+    try (URLClassLoader child = childLoaderOwning(Leaky.class)) {
+      Object leaky = 
child.loadClass(Leaky.class.getName()).getDeclaredConstructor().newInstance();
+      assertTrue(ClassLoaderResourceCleanerUtils.definedBy(new 
SoftReference<>(leaky), child));
+      assertTrue(ClassLoaderResourceCleanerUtils.definedBy(new 
WeakReference<>(leaky), child));
+    }
+  }
+
+  /** An empty reference names no owner and must not be mistaken for one. */
+  @Test
+  void testDefinedByIgnoresNullAndClearedReferences() {
+    assertFalse(ClassLoaderResourceCleanerUtils.definedBy(null, 
getClass().getClassLoader()));
+    assertFalse(
+        ClassLoaderResourceCleanerUtils.definedBy(
+            new SoftReference<>(null), getClass().getClassLoader()));
+  }
+
+  /**
+   * A thread local holding the catalog's object behind a SoftReference must 
be cleared. Left in
+   * place it keeps the catalog's ClassLoader alive until heap pressure clears 
the reference, which
+   * Metaspace pressure alone never triggers.
+   */
+  @Test
+  void testClearThreadLocalMapClearsSoftReferencedValues() throws Exception {
+    try (URLClassLoader child = childLoaderOwning(Leaky.class)) {
+      Object leaky = 
child.loadClass(Leaky.class.getName()).getDeclaredConstructor().newInstance();
+      SOFT_HOLDER.set(new SoftReference<>(leaky));
+
+      
ClassLoaderResourceCleanerUtils.clearThreadLocalMap(Thread.currentThread(), 
child);
+
+      assertNull(SOFT_HOLDER.get());
+    }
+  }
+
+  /**
+   * A driver's own housekeeping thread runs code the catalog defined, so the 
thread pins the loader
+   * whatever its context ClassLoader says.
+   */
+  @Test
+  void testRunningWithClassLoaderMatchesTheRunnableOfAThread() throws 
Exception {
+    try (URLClassLoader child = childLoaderOwning(Leaky.class)) {
+      Runnable owned =
+          (Runnable)
+              
child.loadClass(LeakyTask.class.getName()).getDeclaredConstructor().newInstance();
+      Thread thread = new Thread(owned, "leaky-task");
+      thread.setContextClassLoader(null);
+
+      
assertTrue(ClassLoaderResourceCleanerUtils.runningWithClassLoader(thread, 
child));
+      assertFalse(
+          ClassLoaderResourceCleanerUtils.runningWithClassLoader(
+              new Thread(() -> {}, "unrelated"), child));
+    }
+  }
+
+  /**
+   * A thread that merely runs the catalog's code is not the catalog's to 
stop. A request thread
+   * serving an operation on the very catalog being dropped looks exactly like 
this, and
+   * interrupting it fails the request with "Thread was interrupted while 
waiting for lock".
+   */
+  @Test
+  void testRunningWithClassLoaderIgnoresAThreadOnlyExecutingTheLoadersCode() 
throws Exception {
+    try (URLClassLoader child = childLoaderOwning(Leaky.class)) {
+      Runnable owned =
+          (Runnable)
+              
child.loadClass(LeakyTask.class.getName()).getDeclaredConstructor().newInstance();
+      // The worker owns neither side: its class and its runnable are the 
server's, and it just
+      // happens to be executing the catalog's code, which is how a pooled 
request thread looks.
+      Thread worker = new Thread(() -> owned.run(), "pooled-worker");
+      worker.setDaemon(true);
+      worker.start();
+      try {
+        long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+        while (worker.getState() != Thread.State.TIMED_WAITING && 
System.nanoTime() < deadline) {
+          Thread.sleep(10);
+        }
+        assertEquals(Thread.State.TIMED_WAITING, worker.getState());
+
+        
assertFalse(ClassLoaderResourceCleanerUtils.runningWithClassLoader(worker, 
child));
+      } finally {
+        worker.interrupt();
+        worker.join(TimeUnit.SECONDS.toMillis(5));
+      }
+    }
+  }
+
+  /** Providers installed by other loaders, and by the JDK itself, must be 
left alone. */
+  @Test
+  void testRemoveSecurityProvidersLeavesUnrelatedProviders() throws Exception {
+    int before = Security.getProviders().length;
+    try (URLClassLoader child = childLoaderOwning(Leaky.class)) {
+      ClassLoaderResourceCleanerUtils.removeSecurityProviders(child);
+    }
+    assertEquals(before, Security.getProviders().length);
+  }
+
+  /** Entries belonging to another loader must survive the sweep. */
+  @Test
+  void testClearThreadLocalMapLeavesUnrelatedValues() throws Exception {
+    try (URLClassLoader child = childLoaderOwning(Leaky.class)) {
+      Object unrelated = new Object();
+      UNRELATED_HOLDER.set(unrelated);
+
+      
ClassLoaderResourceCleanerUtils.clearThreadLocalMap(Thread.currentThread(), 
child);
+
+      assertSame(unrelated, UNRELATED_HOLDER.get());
+    }
+  }
+
   /**
    * When a class is loaded by exactly the target classloader, 
isOwnedByClassLoader must return true
    * — the guard should allow static-state cleanup to proceed.
diff --git 
a/catalogs/hive-metastore-common/src/main/java/org/apache/gravitino/hive/client/HiveClientFactory.java
 
b/catalogs/hive-metastore-common/src/main/java/org/apache/gravitino/hive/client/HiveClientFactory.java
index 54e8977601..01c2c5b228 100644
--- 
a/catalogs/hive-metastore-common/src/main/java/org/apache/gravitino/hive/client/HiveClientFactory.java
+++ 
b/catalogs/hive-metastore-common/src/main/java/org/apache/gravitino/hive/client/HiveClientFactory.java
@@ -33,6 +33,7 @@ import org.apache.commons.lang3.reflect.MethodUtils;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
 import org.apache.gravitino.hive.kerberos.AuthenticationConfig;
 import org.apache.gravitino.hive.kerberos.HmsKerberosClient;
+import org.apache.gravitino.utils.ClassLoaderResourceCleanerUtils;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.security.UserGroupInformation;
@@ -260,6 +261,12 @@ public final class HiveClientFactory {
 
       synchronized (classLoaderLock) {
         if (backendClassLoader != null) {
+          // The backend ClassLoader is a second, nested isolation layer that 
holds the catalog's
+          // own ClassLoader as its base. Closing it releases its jars but not 
the references other
+          // threads still hold to it: Hadoop's Shell runs sub-processes, and 
the JDK's pooled
+          // "process reaper" threads inherit the spawning thread's context 
ClassLoader, which is a
+          // GC root. Cleaning the nested loader clears those, so both layers 
become collectable.
+          
ClassLoaderResourceCleanerUtils.closeClassLoaderResource(backendClassLoader);
           backendClassLoader.close();
           backendClassLoader = null;
         }
diff --git a/core/src/main/java/org/apache/gravitino/utils/ClassLoaderPool.java 
b/core/src/main/java/org/apache/gravitino/utils/ClassLoaderPool.java
index 7ee2721a14..ccaaa4087c 100644
--- a/core/src/main/java/org/apache/gravitino/utils/ClassLoaderPool.java
+++ b/core/src/main/java/org/apache/gravitino/utils/ClassLoaderPool.java
@@ -20,9 +20,6 @@ package org.apache.gravitino.utils;
 
 import java.io.Closeable;
 import java.net.URLClassLoader;
-import java.sql.Driver;
-import java.sql.DriverManager;
-import java.util.Enumeration;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 import java.util.function.Supplier;
@@ -182,8 +179,7 @@ public class ClassLoaderPool implements Closeable {
    * Performs final cleanup when a ClassLoader's reference count reaches zero. 
This includes:
    *
    * <ol>
-   *   <li>Deregistering all JDBC drivers loaded by the ClassLoader
-   *   <li>Cleaning up ClassLoader resources (ThreadLocals, Hadoop FileSystem, 
etc.)
+   *   <li>Cleaning up ClassLoader resources (JDBC drivers, ThreadLocals, 
Hadoop FileSystem, etc.)
    *   <li>Closing the ClassLoader itself
    * </ol>
    */
@@ -198,9 +194,8 @@ public class ClassLoaderPool implements Closeable {
   }
 
   /**
-   * Performs full resource cleanup for an {@link IsolatedClassLoader}: 
deregisters JDBC drivers,
-   * cleans up ClassLoader-scoped resources (ThreadLocals, Hadoop FileSystem, 
etc.), and closes the
-   * ClassLoader.
+   * Performs full resource cleanup for an {@link IsolatedClassLoader}: cleans 
up ClassLoader-scoped
+   * resources (JDBC drivers, ThreadLocals, Hadoop FileSystem, etc.) and 
closes the ClassLoader.
    *
    * @param classLoader The IsolatedClassLoader to clean up.
    */
@@ -208,7 +203,9 @@ public class ClassLoaderPool implements Closeable {
     try {
       URLClassLoader internalCl = classLoader.getInternalClassLoader();
       if (internalCl != null) {
-        deregisterAllDrivers(internalCl);
+        // closeClassLoaderResource deregisters the loader's JDBC drivers as 
well. It has to: from
+        // here DriverManager filters its drivers by the caller's class 
loader, so the catalog's
+        // drivers are neither visible nor removable outside their own loader.
         ClassLoaderResourceCleanerUtils.closeClassLoaderResource(internalCl);
       }
     } catch (Exception e) {
@@ -216,26 +213,4 @@ public class ClassLoaderPool implements Closeable {
     }
     classLoader.close();
   }
-
-  /**
-   * Deregisters all JDBC drivers that were loaded by the given ClassLoader.
-   *
-   * @param classLoader The ClassLoader whose drivers should be deregistered.
-   */
-  private static void deregisterAllDrivers(ClassLoader classLoader) {
-    // DriverManager.getDrivers() returns a snapshot in JDK 9+, so iterating 
while
-    // calling deregisterDriver() is safe.
-    Enumeration<Driver> drivers = DriverManager.getDrivers();
-    while (drivers.hasMoreElements()) {
-      Driver driver = drivers.nextElement();
-      if (driver.getClass().getClassLoader() == classLoader) {
-        try {
-          DriverManager.deregisterDriver(driver);
-          LOG.info("Deregistered JDBC driver {} for ClassLoader.", driver);
-        } catch (Exception e) {
-          LOG.warn("Failed to deregister JDBC driver {}", driver, e);
-        }
-      }
-    }
-  }
 }

Reply via email to