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

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


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new 5f25befd86 [Cherry-pick to branch-1.3] [#12986] fix(catalog): Release 
the ClassLoader of a dropped catalog (#12987) (#13028)
5f25befd86 is described below

commit 5f25befd86fef1350cb767daa22f1290d6b816e8
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Sep 9 18:58:48 2026 +0800

    [Cherry-pick to branch-1.3] [#12986] fix(catalog): Release the ClassLoader 
of a dropped catalog (#12987) (#13028)
    
    **Cherry-pick Information:**
    - Original commit: ea1d960a0edd086e19363a47596176970ed5c9d7
    - Target branch: `branch-1.3`
    - Status: Conflicts resolved in c419df539f; 296 unit tests passed
    (Docker tests excluded).
    
    Resolution preserves branch-1.3 KerberosClient and removes
    ClassLoaderPool, which does not exist on the target branch. The original
    resource cleanup changes and tests are retained.
    
    ---------
    
    Co-authored-by: Qi Yu <[email protected]>
---
 .../utils/ClassLoaderResourceCleanerUtils.java     | 229 ++++++++++++++++++++-
 .../gravitino/utils/JdbcDriverDeregisterer.java    |  65 ++++++
 .../utils/TestClassLoaderResourceCleanerUtils.java | 154 ++++++++++++++
 .../gravitino/hive/client/HiveClientFactory.java   |   7 +
 4 files changed, 448 insertions(+), 7 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 3a2a80898d..544df5e195 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.KerberosClient;
+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;
         }

Reply via email to