Skip to content

[#12123] fix(catalog): shut down MySQL AbandonedConnectionCleanupThread and broaden ThreadLocal cleanup to prevent Metaspace leak#12124

Open
LuciferYang wants to merge 3 commits into
apache:mainfrom
LuciferYang:fix-classloader-mysql-cleanup-leak
Open

[#12123] fix(catalog): shut down MySQL AbandonedConnectionCleanupThread and broaden ThreadLocal cleanup to prevent Metaspace leak#12124
LuciferYang wants to merge 3 commits into
apache:mainfrom
LuciferYang:fix-classloader-mysql-cleanup-leak

Conversation

@LuciferYang

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Two additions to ClassLoaderResourceCleanerUtils.closeClassLoaderResource:

  • Shut down MySQL's AbandonedConnectionCleanupThread via uncheckedShutdown(), which also clears the tracked-connection map so the driver's ClassLoader can be collected. The call is guarded by isOwnedByClassLoader, matching the other cleanup steps in this class, so it never stops a cleanup thread owned by a parent or shared ClassLoader (for example a MySQL entity store loaded on the app ClassLoader, whose driver class delegates up to the app loader).
  • Broaden the ThreadLocal sweep from Gravitino-webserver-* threads to all application threads, skipping only the JVM system thread group. Only entries whose value was loaded by the target ClassLoader are cleared, so ThreadLocals owned by other ClassLoaders are left alone.

Why are the changes needed?

After a MySQL-backed catalog is dropped, its isolated ClassLoader stays reachable through the mysql-cj-abandoned-connection-cleanup thread's context ClassLoader and through ThreadLocals on non-webserver threads (Caffeine ForkJoinPool, catalog-cleaner, Hadoop daemons). closeClassLoaderResource releases neither today, so the ClassLoader leaks on every reload and Metaspace grows until OutOfMemoryError: Metaspace.

Fix: #12123

Does this PR introduce any user-facing change?

No.

How was this patch tested?

Added TestClassLoaderResourceCleanerUtils cases (mysql-connector-j added as a test-only dependency):

  • testShutdownMySQLAbandonedConnectionCleanupThreadStopsThread: loading AbandonedConnectionCleanupThread in a child URLClassLoader starts the cleanup thread bound to that loader; after shutdownMySQLAbandonedConnectionCleanupThread the thread is gone. It loads the cleanup-thread class directly rather than the driver, so the test does not register a driver into the JVM-global DriverManager.
  • testShutdownMySQLAbandonedConnectionCleanupThreadSkipsParentOwnedClass: when the class resolves to a parent loader, a child-scoped cleanup leaves the parent's thread alive; an owner-scoped cleanup stops it.
  • testClearThreadLocalMapClearsOnlyTargetClassLoaderValues: on a non-webserver thread, a ThreadLocal whose value is loaded by the target ClassLoader is cleared, while one loaded by a different (non-null) ClassLoader is kept.
  • testClearThreadLocalMapSkipsSystemThreadGroup: a ThreadLocal on a system-group thread is left untouched.

Ran ./gradlew :catalogs:catalog-common:test --tests "org.apache.gravitino.utils.TestClassLoaderResourceCleanerUtils": 7/7 green.

… and broaden ThreadLocal cleanup to prevent Metaspace leak on catalog reload

closeClassLoaderResource now shuts down MySQL's AbandonedConnectionCleanupThread
(via uncheckedShutdown, which also clears its tracked-connection map) and sweeps
ThreadLocals on all application threads instead of only Gravitino-webserver-*.
Both release references that otherwise pin a dropped catalog's isolated
ClassLoader and leak Metaspace on reload.

The MySQL shutdown is guarded by isOwnedByClassLoader, matching the other
destructive steps in this class, so it never stops a cleanup thread owned by a
parent/shared ClassLoader (e.g. a MySQL entity store on the app ClassLoader).

Add TestClassLoaderResourceCleanerUtils cases covering the owned-loader
shutdown, the parent-owned skip, the broadened ThreadLocal sweep (target-CL
value cleared, other non-null-CL value kept), and the system-group skip;
mysql-connector-j added as a test-only dependency.
// 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())) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you tested and verified that it works or not? I'm not sure whether removing logic Gravitino-webserver and replacing it with the thread group can function well when the catalog is closed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, I verified it with a test rather than reasoning about it.

Added testClearThreadLocalMapLetsDroppedClassLoaderBeCollected: a long-lived, non-Gravitino-webserver-* worker thread holds a ThreadLocal whose value is loaded by the target ClassLoader, which is what pins a dropped catalog's ClassLoader in production. After clearThreadLocalMap runs and the test drops its own references, a WeakReference to the ClassLoader clears once GC runs. Under the old Gravitino-webserver- name filter this exact test fails (the thread is skipped, the ClassLoader stays reachable), so it both proves the fix and guards against a regression.

Two things worth clarifying about the scope of the change:

  • It only changes which threads' ThreadLocals are swept, not which threads are stopped. The interrupt path in stopThreadsAndClearThreadLocalVariables is still guarded by runningWithClassLoader (contextClassLoader == target), unchanged. And an entry is only cleared when value.getClass().getClassLoader() == targetClassLoader, so ThreadLocals owned by other ClassLoaders are never touched. The worst case of the wider scope is visiting a thread and skipping it.
  • The reflective ThreadLocalMap access needs --add-opens java.base/java.lang=ALL-UNNAMED, which bin/gravitino.sh.template already sets, so it works at runtime.

./gradlew :catalogs:catalog-common:test --tests "*TestClassLoaderResourceCleanerUtils": 8/8 green.

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

Code Coverage Report

Overall Project 67.81% -0.08% 🟢
Files changed 22.22% 🔴

Module Coverage
aliyun 1.72% 🔴
api 47.82% 🟢
authorization-common 85.96% 🟢
aws 42.04% 🟢
azure 2.47% 🔴
catalog-common 15.86% +2.67% 🔴
catalog-fileset 79.74% 🟢
catalog-glue 66.91% 🟢
catalog-hive 79.4% 🟢
catalog-jdbc-common 45.7% 🟢
catalog-jdbc-doris 81.72% 🟢
catalog-jdbc-mysql 79.33% 🟢
catalog-jdbc-postgresql 83.39% 🟢
catalog-jdbc-starrocks 79.16% 🟢
catalog-kafka 77.01% 🟢
catalog-lakehouse-generic 59.18% 🟢
catalog-lakehouse-hudi 79.1% 🟢
catalog-lakehouse-iceberg 85.93% 🟢
catalog-lakehouse-paimon 84.23% 🟢
catalog-model 77.72% 🟢
cli 44.5% 🟢
client-java 78.27% 🟢
common 51.95% 🟢
core 82.54% 🟢
filesystem-hadoop3 77.28% 🟢
flink 0.0% 🔴
flink-common 47.09% 🟢
flink-runtime 0.0% 🔴
gcp 14.12% 🔴
hadoop-auth 68.0% 🟢
hadoop-common 12.7% 🔴
hive-metastore-common 53.4% 🟢
iceberg-common 64.65% 🟢
iceberg-rest-server 74.91% 🟢
idp-basic 86.02% 🟢
integration-test-common 0.0% 🔴
jobs 66.17% 🟢
lance-common 20.67% 🔴
lance-rest-server 64.84% 🟢
lineage 53.02% 🟢
optimizer 83.24% 🟢
optimizer-api 21.95% 🔴
server 86.05% 🟢
server-common 76.12% 🟢
spark 28.57% 🔴
spark-common 46.01% 🟢
tencent 69.84% 🟢
trino-connector 40.29% 🟢
Files
Module File Coverage
catalog-common ClassLoaderResourceCleanerUtils.java 22.22% 🔴

…able after the broadened ThreadLocal sweep

Add a WeakReference-based test that reproduces the leak on a long-lived
non-webserver thread holding a ThreadLocal whose value is loaded by the target
ClassLoader, then asserts the ClassLoader is collected once clearThreadLocalMap
runs. The test fails under the previous webserver-only filter.
- assert the ThreadLocal value is loaded by the target ClassLoader, so a
  no-op sweep can no longer pass the test
- bound valueSet.await with a timeout so a stuck worker fails instead of hanging
- move the reclaim assertion inside try and release/join in finally, keeping
  assert-before-release so the worker cannot exit and clear its own threadLocals
  before the assertion
- drop the no-op Runtime.runFinalization() and a tautological assertion
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug report] Dropping a MySQL-backed catalog leaks its ClassLoader via AbandonedConnectionCleanupThread and non-webserver ThreadLocals

2 participants