Skip to content

[#12986] fix(catalog): Release the ClassLoader of a dropped catalog - #12987

Merged
jerryshao merged 5 commits into
apache:mainfrom
yuqi1129:fix-catalog-classloader-leak
Sep 9, 2026
Merged

[#12986] fix(catalog): Release the ClassLoader of a dropped catalog#12987
jerryshao merged 5 commits into
apache:mainfrom
yuqi1129:fix-catalog-classloader-leak

Conversation

@yuqi1129

@yuqi1129 yuqi1129 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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.

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

…alog

Dropping or altering a hive, lakehouse-iceberg, lakehouse-paimon or
S3-backed fileset catalog left its isolated ClassLoader reachable, so
its classes stayed in Metaspace for the life of the process. Since an
alter rebuilds the ClassLoader, the loss accumulated: five alters of a
Hive catalog cost about 48 MB that was never returned.

Three retention paths, each traced from a heap dump back to a GC root:

- commons-logging's Log4jApiLogFactory registers a LogAdapter with the
  server's shared Log4j LoggerContext, and LogFactory.release leaves
  that registration in place. Remove listeners belonging to the loader.

- Jackson-style caches park a SoftReference in a ThreadLocal.
  clearThreadLocalMap compared the value's own class, but the value is a
  bootstrap SoftReference and only its referent names the catalog, so
  the entry survived. Soft references are cleared under heap pressure,
  which Metaspace pressure never causes, so these were permanent in
  practice. Look through the reference, check the entry's key as well,
  and stop skipping threads that are not named Gravitino-webserver-*,
  since catalog.close() itself runs on a ForkJoinPool worker.

- HiveClientFactory builds a nested HiveClientClassLoader whose base is
  the catalog's own loader, and only the catalog loader was ever passed
  to the cleaner. Clean the nested loader too, so the context
  ClassLoader that the JDK's pooled process-reaper threads inherit from
  Hadoop's Shell no longer pins either layer.

Claude-Session: https://claude.ai/code/session_013xVSteM2ZUjXRHFbHtayVK
Copilot AI lite review requested due to automatic review settings September 8, 2026 08:53

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@yuqi1129 yuqi1129 self-assigned this Sep 8, 2026
@yuqi1129 yuqi1129 added the branch-1.3 Automatically cherry-pick commit to branch-1.3 label Sep 8, 2026
…es of a dropped catalog

Widening the coverage to every provider that can be exercised locally
turned up four more ways a dropped catalog's ClassLoader stays alive.

- DriverManager keeps registered drivers in a static list, and it
  filters both getDrivers() and deregisterDriver() by the class loader
  of the calling class. ClassLoaderPool called them from the server's
  loader, where the catalog's drivers are neither visible nor
  removable, so every JDBC catalog leaked. Define a small deregisterer
  inside the catalog's loader and call it there, and drop the pool's
  version, which could never have worked.

- PostgreSQL's driver starts a LazyCleaner thread whose class the
  catalog defined. A running thread is a GC root, so it pins the loader
  no matter what its context ClassLoader says; match a thread by the
  class of the thread and of its runnable as well.

- MySQL Connector/J parks an abandoned-connection cleanup executor in a
  static field, and its thread factory is a lambda the catalog defined.
  Shut it down through the driver's own uncheckedShutdown().

- Hadoop's cloud connectors install a JCA security provider, such as
  the shaded OpenSSLProvider in the AWS bundle, into the JVM-wide
  Security list. Remove the providers the loader installed.

Claude-Session: https://claude.ai/code/session_013xVSteM2ZUjXRHFbHtayVK
…pooled catalog tasks

Two more pins found while widening the coverage to Oracle and Glue.

ResourceBundle caches bundles in a JVM-wide static map behind soft
references, so a driver that loads message bundles, such as Oracle's
ErrorMessages, leaves its class reachable until heap pressure clears the
reference. Metaspace pressure never causes that. clearCache(loader) is
the API for it.

A task scheduled on a shared executor carries none of the references a
thread is matched by today: the AWS SDK's idle-connection reaper is a
Runnable on a pool worker, so neither the thread's class, its runnable,
nor its context ClassLoader names the catalog. Match on the classes on
the thread's stack as well, skipping the calling thread so cleanup never
stops itself.

Claude-Session: https://claude.ai/code/session_013xVSteM2ZUjXRHFbHtayVK
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Code Coverage Report

Overall Project 69.64% -0.09% 🟢
Files changed 47.79% 🔴

Module Coverage
aliyun 19.74% 🔴
api 51.57% 🟢
authorization-common 85.96% 🟢
authorization-ranger 4.38% 🔴
aws 53.54% 🟢
azure 32.1% 🔴
catalog-common 27.9% -5.43% 🔴
catalog-fileset 82.17% 🟢
catalog-glue 69.8% 🟢
catalog-hive 82.96% 🟢
catalog-jdbc-common 45.09% 🟢
catalog-jdbc-doris 82.69% 🟢
catalog-jdbc-mysql 79.33% 🟢
catalog-jdbc-postgresql 83.83% 🟢
catalog-jdbc-starrocks 79.16% 🟢
catalog-kafka 76.99% 🟢
catalog-lakehouse-generic 60.88% 🟢
catalog-lakehouse-hudi 79.1% 🟢
catalog-lakehouse-iceberg 85.9% 🟢
catalog-lakehouse-paimon 84.29% 🟢
catalog-model 77.99% 🟢
cli 44.51% 🟢
client-java 77.5% 🟢
common 57.75% 🟢
core 84.3% +0.02% 🟢
filesystem-hadoop3 76.48% 🟢
flink 0.0% 🔴
flink-common 53.22% 🟢
flink-runtime 0.0% 🔴
gcp 32.2% 🔴
hadoop-auth 68.0% 🟢
hadoop-common 17.84% 🔴
hive-metastore-common 53.52% +2.25% 🟢
iceberg-aliyun-bundle 0.0% 🔴
iceberg-common 66.89% 🟢
iceberg-rest-server 76.59% 🟢
idp-basic 86.75% 🟢
integration-test-common 0.0% 🔴
jobs 62.92% 🟢
lance-common 32.52% 🔴
lance-rest-server 68.12% 🟢
lineage 59.39% 🟢
optimizer 83.24% 🟢
optimizer-api 21.95% 🔴
server 89.62% 🟢
server-common 81.35% 🟢
spark 56.27% 🟢
tencent 81.78% 🟢
trino-connector 58.36% 🟢
Files
Module File Coverage
catalog-common ClassLoaderResourceCleanerUtils.java 18.26% 🔴
JdbcDriverDeregisterer.java 0.0% 🔴
core ClassLoaderPool.java 91.94% 🟢
hive-metastore-common HiveClientFactory.java 85.22% 🟢

…g the catalog's code

Matching a thread by the classes on its stack was wrong. A request
thread serving an operation on the very catalog being dropped has the
catalog's frames on its stack, so cleanup interrupted it and the request
failed with "Thread was interrupted while waiting for lock". It broke
CatalogHive{2,3}IT#testAlterCatalogProperties and #testListTables, which
alter a catalog while other operations are in flight.

Ownership has to be read from the thread itself, not from what it
happens to be running: its own class, its runnable, or its context
ClassLoader. Those still cover the case the stack rule was added for on
the providers where it was actually confirmed, PostgreSQL's LazyCleaner
among them; it never did fix the AWS idle-connection reaper it was
written for, which needs the Glue catalog to close its client instead
and is tracked in apache#13016.

Claude-Session: https://claude.ai/code/session_013xVSteM2ZUjXRHFbHtayVK
@yuqi1129

yuqi1129 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

The Backend Integration Test failure was mine, and it had a clear signature: CatalogHive{2,3}IT and CatalogHive3ITWithCatalog/CatalogHiveS3IT all failed on the same two cases, testAlterCatalogProperties and testListTables, with

Failed to operate object [...] operation [DROP] under [...],
reason [Thread was interrupted while waiting for lock]

Cause: the last commit matched a thread against the ClassLoader by the classes on its stack. A request thread serving an operation on the catalog being altered has the catalog's frames on its stack, so cleanup interrupted it mid-request. Altering a catalog releases and re-acquires the pooled ClassLoader, which is why exactly those two tests caught it.

Fixed in 2cc3a6b: ownership is read from the thread itself again, its own class, its runnable or its context ClassLoader, never from what it happens to be executing. I re-ran the leak measurements after the revert and jdbc-postgresql, jdbc-mysql, hive and fileset on s3a all still release their ClassLoader, so the stack rule was contributing nothing that is verified: it was written for the AWS idle-connection reaper and never fixed that either, which is #13016 and needs the Glue catalog to close its client.

Added testRunningWithClassLoaderIgnoresAThreadOnlyExecutingTheLoadersCode so this cannot come back silently.

@jerryshao
jerryshao merged commit ea1d960 into apache:main Sep 9, 2026
38 checks passed
yuqi1129 added a commit that referenced this pull request Sep 9, 2026
…oader of a dropped catalog (#12987) (#13028)

**Cherry-pick Information:**
- Original commit: ea1d960
- Target branch: `branch-1.3`
- Status: Conflicts resolved in c419df5; 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 <yuqi@datastrato.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

branch-1.3 Automatically cherry-pick commit to branch-1.3

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug report] Dropping a Hive, Iceberg, Paimon or cloud-backed fileset catalog never releases its ClassLoader, exhausting Metaspace

4 participants