yuqi1129 commented on code in PR #12124:
URL: https://github.com/apache/gravitino/pull/12124#discussion_r3931970420


##########
catalogs/catalog-common/src/main/java/org/apache/gravitino/utils/ClassLoaderResourceCleanerUtils.java:
##########
@@ -355,6 +378,36 @@ static boolean isOwnedByClassLoader(Class<?> clazz, 
ClassLoader classLoader) {
     return classLoader != null && clazz.getClassLoader() == classLoader;
   }
 
+  /**
+   * Shutdown MySQL's AbandonedConnectionCleanupThread to prevent it from 
holding a reference to the
+   * ClassLoader. Unlike simple thread interruption, {@code 
uncheckedShutdown()} cleans up the
+   * tracked connections map, releasing references to classes loaded by the 
target ClassLoader.
+   *
+   * @param classLoader the classloader where the MySQL driver is loaded
+   */
+  @VisibleForTesting
+  static void shutdownMySQLAbandonedConnectionCleanupThread(ClassLoader 
classLoader)
+      throws Exception {
+    // Load with initialize=false: when this class is owned by the target 
ClassLoader the MySQL
+    // driver has already been used, so the class is initialized and its 
cleanup thread started.
+    Class<?> clazz =
+        Class.forName("com.mysql.cj.jdbc.AbandonedConnectionCleanupThread", 
false, classLoader);
+    // uncheckedShutdown() is a JVM-global static that stops the single 
cleanup thread tied to this
+    // driver class. If the driver was resolved from a parent or shared 
ClassLoader (for example a
+    // MySQL entity store loaded on the app ClassLoader), stopping it here 
would break
+    // abandoned-connection cleanup for the whole JVM. Only act when the 
target ClassLoader owns the
+    // class, the same guard the other cleanup steps in this class use.
+    if (!isOwnedByClassLoader(clazz, classLoader)) {
+      LOG.debug(
+          "AbandonedConnectionCleanupThread is owned by {}, not the target 
classloader {}; skipping shutdown",
+          clazz.getClassLoader(),
+          classLoader);
+      return;
+    }
+    MethodUtils.invokeStaticMethod(clazz, "uncheckedShutdown");

Review Comment:
   I verified the mechanism this is built on, and it holds. Notes for whoever 
reviews next.
   
   `mysql-connector-j` 8.0.33 (the version pinned in `libs.versions.toml`) 
creates the cleanup thread through a factory that explicitly pins the loader:
   
   ```
   16: ldc           class com/mysql/cj/jdbc/AbandonedConnectionCleanupThread
   18: invokevirtual Class.getClassLoader()
   32: invokevirtual Thread.setContextClassLoader(ClassLoader)
   37: putstatic     Field threadRef:Ljava/lang/Thread;
   ```
   
   So a live cleanup thread pins whichever loader defined the driver class, and 
the static `threadRef` keeps the thread reachable. Note it is `implements 
Runnable`, not a `Thread` subclass, so the pin is through the Runnable and the 
TCCL rather than the thread's own class.
   
   I also ran an independent reclaim probe with a control group:
   
   ```
   [CONTROL]  child loader that never touches MySQL   -> collected: true
   [NO FIX]   load the driver class, drop the loader  -> collected: false
   [WITH FIX] same, then uncheckedShutdown()          -> collected: true
   ```
   
   The control matters: without it, `collected: false` could just mean the 
WeakReference test was written badly. With it, the leak and the fix are both 
real.
   
   The `isOwnedByClassLoader` guard here is also correct, and I want to say 
that explicitly so nobody "fixes" it later: two isolated loaders each holding 
their own driver copy have separate classes and separate statics, so there is 
no cross-talk; a shared or parent-loaded driver is skipped; and 
`ClassLoaderPool` only calls this at refcount zero.



##########
catalogs/catalog-common/src/main/java/org/apache/gravitino/utils/ClassLoaderResourceCleanerUtils.java:
##########
@@ -77,6 +79,10 @@ public static void closeClassLoaderResource(ClassLoader 
classLoader) {
     executeAndCatch(ClassLoaderResourceCleanerUtils::closeResourceInAzure, 
classLoader);
 
     executeAndCatch(ClassLoaderResourceCleanerUtils::clearShutdownHooks, 
classLoader);
+
+    executeAndCatch(
+        
ClassLoaderResourceCleanerUtils::shutdownMySQLAbandonedConnectionCleanupThread,

Review Comment:
   **Ordering looks wrong.** `runningWithClassLoader` matches on 
`thread.getContextClassLoader() == targetClassLoader`, and the bytecode above 
shows the MySQL cleanup thread's TCCL is set to exactly the loader that defined 
the driver, i.e. the target loader.
   
   So by the time this new step runs, `stopThreadsAndClearThreadLocalVariables` 
(six steps earlier) has already matched that thread and done 
`setContextClassLoader(null)` + `interrupt()` on it. MySQL's `run()` blocks in 
`referenceQueue.remove()`, so it gets an out-of-band `InterruptedException` 
instead of the driver's own shutdown path, and whether 
`connectionFinalizerPhantomRefs` is left populated at that point is not 
something the driver promises.
   
   The end state is probably still correct, because `uncheckedShutdown()` 
clears the map afterwards, but that is the second step cleaning up after the 
first, which is fragile.
   
   Worth being explicit about the limits of my verification: my probe called 
`shutdownMySQLAbandonedConnectionCleanupThread` **directly**, not through 
`closeClassLoaderResource`, so I confirmed the method works but did **not** 
exercise this ordering.
   
   Minimal fix: move this `executeAndCatch` above the 
`stopThreadsAndClearThreadLocalVariables` call. The driver then exits through 
its own API and the generic sweep finds no thread to interrupt.



##########
catalogs/catalog-common/src/test/java/org/apache/gravitino/utils/TestClassLoaderResourceCleanerUtils.java:
##########
@@ -68,4 +78,288 @@ void 
testIsOwnedByClassLoaderReturnsFalseForBootstrapLoadedClass() {
         ClassLoaderResourceCleanerUtils.isOwnedByClassLoader(
             String.class, ClassLoader.getSystemClassLoader()));
   }
+
+  /**
+   * Loading MySQL's {@code AbandonedConnectionCleanupThread} starts a daemon 
thread {@code
+   * mysql-cj-abandoned-connection-cleanup} whose contextClassLoader is the 
loader that loaded it.
+   * That reference pins the ClassLoader and leaks Metaspace after a catalog 
is dropped. {@code
+   * shutdownMySQLAbandonedConnectionCleanupThread} must terminate that thread 
when the class is
+   * owned by the target loader.
+   */
+  @Test
+  void testShutdownMySQLAbandonedConnectionCleanupThreadStopsThread() throws 
Exception {
+    URL mysqlJar = mysqlConnectorJarUrl();
+    Assumptions.assumeTrue(mysqlJar != null, "mysql-connector jar is not on 
the test classpath");
+
+    // Parent is the platform loader so `child` loads its OWN copy of the 
class (no delegation to
+    // the app loader): the cleanup thread's contextClassLoader is `child` and 
the ownership guard
+    // passes. Load AbandonedConnectionCleanupThread directly rather than the 
Driver so the thread
+    // starts without registering a child-loaded Driver into the JVM-global 
DriverManager. That
+    // registration would pin `child` after this test and leak it, which is 
the leak this utility
+    // exists to prevent.
+    ClassLoader platform = ClassLoader.getSystemClassLoader().getParent();
+    try (URLClassLoader child = new URLClassLoader(new URL[] {mysqlJar}, 
platform)) {
+      Class.forName("com.mysql.cj.jdbc.AbandonedConnectionCleanupThread", 
true, child);
+      assertNotNull(
+          findCleanupThreadBoundTo(child),
+          "loading AbandonedConnectionCleanupThread should start the cleanup 
thread bound to child");
+
+      
ClassLoaderResourceCleanerUtils.shutdownMySQLAbandonedConnectionCleanupThread(child);
+
+      assertTrue(
+          waitForCleanupThreadGone(child),
+          "cleanup thread bound to the child loader should be gone after 
uncheckedShutdown");
+    }
+  }
+
+  /**
+   * When the MySQL class resolves to a parent/shared ClassLoader (the driver 
sits on a shared
+   * classpath while the catalog being closed has its own child loader), {@code
+   * shutdownMySQLAbandonedConnectionCleanupThread} must skip the JVM-global 
{@code
+   * uncheckedShutdown()} so closing one catalog does not stop a cleanup 
thread owned by another
+   * loader.
+   */
+  @Test
+  void 
testShutdownMySQLAbandonedConnectionCleanupThreadSkipsParentOwnedClass() throws 
Exception {
+    URL mysqlJar = mysqlConnectorJarUrl();
+    Assumptions.assumeTrue(mysqlJar != null, "mysql-connector jar is not on 
the test classpath");
+
+    ClassLoader platform = ClassLoader.getSystemClassLoader().getParent();
+    // `owner` holds the class; `child` delegates to it, mirroring a driver on 
a shared parent CL.
+    try (URLClassLoader owner = new URLClassLoader(new URL[] {mysqlJar}, 
platform);
+        URLClassLoader child = new URLClassLoader(new URL[0], owner)) {
+      Class.forName("com.mysql.cj.jdbc.AbandonedConnectionCleanupThread", 
true, owner);
+      assertNotNull(
+          findCleanupThreadBoundTo(owner), "cleanup thread should be bound to 
the owner loader");
+
+      // classLoader=child, but the class resolves to `owner` via delegation, 
so the guard skips.
+      
ClassLoaderResourceCleanerUtils.shutdownMySQLAbandonedConnectionCleanupThread(child);
+      // When uncheckedShutdown() does run it kills the thread within ~100ms 
(see the positive
+      // test's poll). Wait past that window and confirm the owner's thread is 
still alive, which
+      // shows the guard skipped it rather than just not having stopped it 
yet. Without the guard,
+      // 1s is long enough for the shutdown to take effect and this assertion 
fails.
+      assertTrue(
+          cleanupThreadStaysAliveFor(owner, 1000),
+          "cleanup thread owned by the parent loader must survive a 
child-scoped cleanup");
+
+      // An owner-scoped cleanup does run (guard passes), so nothing lingers 
past this test.
+      
ClassLoaderResourceCleanerUtils.shutdownMySQLAbandonedConnectionCleanupThread(owner);
+      assertTrue(
+          waitForCleanupThreadGone(owner), "owner-scoped cleanup should stop 
the thread it owns");
+    }
+  }
+
+  /**
+   * The broadened {@code clearThreadLocalMap} must clear a ThreadLocal whose 
value is owned by the
+   * target ClassLoader even on a non-{@code Gravitino-webserver-*} thread 
(the pre-#10093 code only
+   * touched webserver threads). A ThreadLocal holding a value from a 
different ClassLoader must be
+   * left untouched, which is the {@code isOwnedByClassLoader}-style guard 
inside the sweep.
+   */
+  @Test
+  @SuppressWarnings("ThreadLocalUsage") // local ThreadLocals are required to 
test per-thread sweep
+  void testClearThreadLocalMapClearsOnlyTargetClassLoaderValues() throws 
Exception {
+    URL classesUrl = 
LeakyValue.class.getProtectionDomain().getCodeSource().getLocation();
+    ClassLoader platform = ClassLoader.getSystemClassLoader().getParent();
+    ThreadLocal<Object> targetOwned = new ThreadLocal<>();
+    ThreadLocal<Object> foreign = new ThreadLocal<>();
+    // `child` is the cleanup target; `sibling` is an unrelated loader. Both 
load their own copy of
+    // LeakyValue, so both values have a non-null ClassLoader. This exercises 
the real
+    // discrimination (value CL == target vs a different non-null CL), not 
just the null-CL case.
+    try (URLClassLoader child = new URLClassLoader(new URL[] {classesUrl}, 
platform);
+        URLClassLoader sibling = new URLClassLoader(new URL[] {classesUrl}, 
platform)) {
+      Object childOwnedValue =
+          Class.forName(LeakyValue.class.getName(), true, child)
+              .getDeclaredConstructor()
+              .newInstance();
+      assertSame(child, childOwnedValue.getClass().getClassLoader());
+      Object siblingOwnedValue =
+          Class.forName(LeakyValue.class.getName(), true, sibling)
+              .getDeclaredConstructor()
+              .newInstance();
+      assertSame(sibling, siblingOwnedValue.getClass().getClassLoader());
+
+      // This test runs on the JUnit "main"/worker thread, deliberately NOT a 
webserver thread, so
+      // it also proves the broadened scope.
+      
assertFalse(Thread.currentThread().getName().startsWith("Gravitino-webserver-"));
+
+      targetOwned.set(childOwnedValue);
+      foreign.set(siblingOwnedValue);
+
+      
ClassLoaderResourceCleanerUtils.clearThreadLocalMap(Thread.currentThread(), 
child);
+
+      assertNull(targetOwned.get(), "ThreadLocal owned by the target 
ClassLoader must be cleared");
+      assertSame(
+          siblingOwnedValue,
+          foreign.get(),
+          "ThreadLocal owned by a different non-null ClassLoader must be 
kept");
+    } finally {
+      targetOwned.remove();
+      foreign.remove();
+    }
+  }
+
+  /**
+   * JVM-internal threads live in the {@code system} thread group; the sweep 
must skip them entirely
+   * rather than reflect into their ThreadLocals.
+   */
+  @Test
+  @SuppressWarnings("ThreadLocalUsage") // local ThreadLocal is required to 
test per-thread sweep
+  void testClearThreadLocalMapSkipsSystemThreadGroup() throws Exception {
+    ClassLoader loader = 
ClassLoaderResourceCleanerUtils.class.getClassLoader();
+    ThreadGroup systemGroup = systemThreadGroup();
+    assertNotNull(systemGroup, "expected to locate the system thread group");
+
+    Object[] cleared = new Object[1];
+    Thread systemThread =
+        new Thread(
+            systemGroup,
+            () -> {
+              ThreadLocal<Object> tl = new ThreadLocal<>();
+              tl.set(new LeakyValue());
+              // Sweeping a system-group thread must be a no-op: the value 
stays set.
+              
ClassLoaderResourceCleanerUtils.clearThreadLocalMap(Thread.currentThread(), 
loader);
+              cleared[0] = tl.get();
+            },
+            "clp-system-group-probe");
+    systemThread.start();
+    systemThread.join();
+
+    assertNotNull(cleared[0], "system-group thread ThreadLocals must be left 
untouched");
+  }
+
+  /**
+   * End-to-end proof that broadening the sweep lets a dropped catalog's 
ClassLoader be collected. A
+   * long-lived worker thread that is not a {@code Gravitino-webserver-*} 
thread holds a ThreadLocal
+   * whose value was loaded by the target ClassLoader, which is exactly what 
pins the ClassLoader
+   * after a catalog is dropped. After {@code clearThreadLocalMap} runs and 
the test drops its own
+   * references, a WeakReference to the ClassLoader must clear once GC runs. 
The webserver-only
+   * filter used before would skip this thread and the ClassLoader would stay 
reachable.
+   */
+  @Test
+  @SuppressWarnings("ThreadLocalUsage") // a per-thread ThreadLocal is the 
leak this test reproduces
+  void testClearThreadLocalMapLetsDroppedClassLoaderBeCollected() throws 
Exception {

Review Comment:
   This is a good test and it covers the ThreadLocal half of the issue end to 
end.
   
   The MySQL half has no equivalent: 
`testShutdownMySQLAbandonedConnectionCleanupThreadStopsThread` asserts the 
thread is gone, but not that the ClassLoader then becomes collectable, which is 
the property the issue is actually about. I wrote that test as a throwaway 
while verifying this PR and it passes, so it is worth having here:
   
   ```
   [CONTROL]  child loader that never touches MySQL   -> collected: true
   [NO FIX]   load the driver class, drop the loader  -> collected: false
   [WITH FIX] same, then uncheckedShutdown()          -> collected: true
   ```
   
   The control case is the part worth copying — a bare `WeakReference` + 
`System.gc()` assertion is unreliable enough that without it a green test does 
not mean much.



##########
catalogs/catalog-common/src/main/java/org/apache/gravitino/utils/ClassLoaderResourceCleanerUtils.java:
##########
@@ -178,8 +184,25 @@ private static Thread[] getAllThreads() {
     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;
+    }
+
+    // Sweep every application thread, not only the Gravitino-webserver-* 
ones: a ThreadLocal
+    // pointing at the target ClassLoader can live on any thread, such as a 
Caffeine ForkJoinPool
+    // worker, a catalog-cleaner thread, or a Hadoop daemon. The check below 
clears only entries
+    // whose value was loaded by the target ClassLoader, so ThreadLocals owned 
by other
+    // ClassLoaders are left alone.
+    //
+    // Skip threads whose immediate group is the JVM "system" group (Reference 
Handler, Finalizer,
+    // Signal Dispatcher, and the like): they hold no catalog ClassLoader 
references, and reflecting
+    // into their ThreadLocals is best avoided. This checks the immediate 
group only, so threads in
+    // a sub-group of system (such as InnocuousThreadGroup for common-pool 
workers) are still swept.
+    // That is intended: ForkJoinPool threads can hold catalog ThreadLocals.
+    ThreadGroup group = thread.getThreadGroup();
+    if (group != null && "system".equals(group.getName())) {

Review Comment:
   This widens a genuinely unsafe operation. `clearThreadLocalMap` reflects 
into another thread's `ThreadLocalMap.table` and writes `entry.value = null` 
from a foreign thread with no synchronisation. Until now that was limited to 
`Gravitino-webserver-*`, which are mostly idle between requests. Skipping only 
the immediate `system` group means we now also sweep `InnocuousThreadGroup` 
(ForkJoinPool common-pool workers) and Hadoop daemons, and the comment says 
that is deliberate — those are threads actively running work.
   
   Structural corruption is not the risk (rehash moves `Entry` references, so 
writing `value` still lands on the right object). The risk is nulling a value 
another thread is using right now, producing an NPE in unrelated code at 
catalog-drop time, which is close to impossible to attribute afterwards.
   
   One thing that makes me more comfortable with the change than I expected: I 
checked the blame, and the `Gravitino-webserver-` restriction has been there 
since the class was introduced in #8252, so this is extending an incomplete fix 
rather than reversing a deliberate safety decision. Worth saying so in the PR 
description.
   
   Suggestion: keep the widening but bound it — an allowlist of the thread 
names actually implicated (`Gravitino-webserver-*`, `ForkJoinPool*`, 
`catalog-cleaner*`, Hadoop's statistics cleaner) reads better than "everything 
except the system group", and it documents what we are actually chasing. 
Skipping threads currently in `RUNNABLE` would be another way.



##########
catalogs/catalog-common/src/test/java/org/apache/gravitino/utils/TestClassLoaderResourceCleanerUtils.java:
##########
@@ -20,17 +20,27 @@
 package org.apache.gravitino.utils;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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.WeakReference;
 import java.net.URL;
 import java.net.URLClassLoader;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Assumptions;
 import org.junit.jupiter.api.Test;
 
 class TestClassLoaderResourceCleanerUtils {

Review Comment:
   Test-structure point: every case here calls 
`shutdownMySQLAbandonedConnectionCleanupThread` or `clearThreadLocalMap` 
directly, so nothing exercises `closeClassLoaderResource` itself. That is why 
the step-ordering problem I flagged on the main file is invisible to this suite.
   
   One integration case through `closeClassLoaderResource` would cover it. Note 
it early-returns when the `GRAVITINO_TEST` environment variable is set, so such 
a test has to clear that first.



##########
catalogs/catalog-common/src/main/java/org/apache/gravitino/utils/ClassLoaderResourceCleanerUtils.java:
##########
@@ -355,6 +378,36 @@ static boolean isOwnedByClassLoader(Class<?> clazz, 
ClassLoader classLoader) {
     return classLoader != null && clazz.getClassLoader() == classLoader;
   }
 
+  /**
+   * Shutdown MySQL's AbandonedConnectionCleanupThread to prevent it from 
holding a reference to the
+   * ClassLoader. Unlike simple thread interruption, {@code 
uncheckedShutdown()} cleans up the
+   * tracked connections map, releasing references to classes loaded by the 
target ClassLoader.
+   *
+   * @param classLoader the classloader where the MySQL driver is loaded
+   */
+  @VisibleForTesting
+  static void shutdownMySQLAbandonedConnectionCleanupThread(ClassLoader 
classLoader)

Review Comment:
   Minor, maintainability. This class is accumulating vendor special cases 
(AWS, GCP, Azure, commons-logging, now MySQL), and the same shape exists for 
other JDBC drivers we ship catalogs for.
   
   No need to abstract anything now, but a line in the class javadoc recording 
the pattern would help the next person: guard with `isOwnedByClassLoader`, use 
the driver's own shutdown API rather than an interrupt, and run it **before** 
the generic thread sweep.



-- 
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