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 f31c2a0b11 [#11301] fix(catalog): guard
ClassLoaderResourceCleanerUtils against shared-class static-field mutation
(#11431)
f31c2a0b11 is described below
commit f31c2a0b11e5743dce185405011e432e787989ae
Author: Qi Yu <[email protected]>
AuthorDate: Fri Jun 5 14:11:51 2026 +0800
[#11301] fix(catalog): guard ClassLoaderResourceCleanerUtils against
shared-class static-field mutation (#11431)
### What changes were proposed in this pull request?
When `IsolatedClassLoader` resolves Hadoop/AWS/Azure classes via parent
delegation (e.g. `hadoop-common.jar` or `gravitino-aws-bundle.jar`
appears
on the AppClassLoader's classpath), `Class.forName(...,
targetClassLoader)`
returns the parent-loaded class. The static fields on that class
(`MutableQuantiles.scheduler`, `FileSystem.CACHE`, `AwsSdkMetrics`
MBean,
`AbfsClientThrottlingIntercept` timers) are JVM-global and shared across
every catalog. Calling `scheduler.shutdownNow()` on the global instance
permanently terminates Hadoop metrics scheduling, causing
`RejectedExecutionException` on any subsequent catalog that creates a
`MutableQuantiles` instance (e.g. S3A FileSystem instrumentation).
This PR adds `isOwnedByClassLoader(clazz, targetClassLoader)` guards
before
any static-state mutation in `ClassLoaderResourceCleanerUtils`. If the
class
was delegated to a parent ClassLoader, cleanup is skipped; if it belongs
to the
catalog's own ClassLoader (the normal isolated case), cleanup proceeds
as before.
The same guard is applied to:
- `closeStatsDataClearerInFileSystem`: `FileSystem.closeAll()`,
scheduler shutdown, `STATS_DATA_CLEANER` thread interrupt.
- `closeResourceInAWS`: `AwsSdkMetrics` MBean unregistration.
- `closeResourceInGCP`: shaded `LogFactory` release.
- `closeResourceInAzure`: `AbfsClientThrottlingIntercept` timer
cancellation.
Additionally, `closeClassLoaderResource` calls are added to
`HiveCatalogOperations` and `FilesetCatalogOperations`, which use Hadoop
FileSystem extensively but had no classloader cleanup on close, risking
thread
and memory leaks.
### Why are the changes needed?
Fix: #11301
After an Iceberg or Paimon catalog is evicted from the cache and
re-created,
any subsequent S3A FileSystem initialization fails with:
```
RejectedExecutionException: Task ... rejected from
ScheduledThreadPoolExecutor@...[Terminated, pool size = 0, active threads =
0,
queued tasks = 0, completed tasks = 23664]
```
because `closeStatsDataClearerInFileSystem` shut down the JVM-global
`MutableQuantiles.scheduler` when Hadoop happened to be on the
AppClassLoader's
classpath.
### Does this PR introduce _any_ user-facing change?
No functional behavior change for correctly isolated deployments. For
deployments
where Hadoop is on the server's main classpath, the broken
scheduler-shutdown is
now skipped, restoring correct behavior after catalog re-creation.
### How was this patch tested?
- `./gradlew :catalogs:catalog-common:spotlessApply
:catalogs:catalog-hive:spotlessApply
:catalogs:catalog-fileset:spotlessApply`
- `./gradlew :catalogs:catalog-hive:test :catalogs:catalog-fileset:test
-PskipDockerTests=true`
---------
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
.../utils/ClassLoaderResourceCleanerUtils.java | 84 ++++++++++++++++++----
.../utils/TestClassLoaderResourceCleanerUtils.java | 71 ++++++++++++++++++
.../catalog/fileset/FilesetCatalogOperations.java | 3 +
.../catalog/hive/HiveCatalogOperations.java | 2 +
4 files changed, 145 insertions(+), 15 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 9ecece6eec..6a91f4ac48 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
@@ -19,6 +19,7 @@
package org.apache.gravitino.utils;
+import com.google.common.annotations.VisibleForTesting;
import java.lang.reflect.Field;
import java.util.IdentityHashMap;
import java.util.Timer;
@@ -87,6 +88,21 @@ public class ClassLoaderResourceCleanerUtils {
throws Exception {
Class<?> fileSystemClass =
Class.forName("org.apache.hadoop.fs.FileSystem", true,
targetClassLoader);
+
+ // If FileSystem was resolved from a parent/AppClassLoader rather than the
catalog's own
+ // classloader, its CACHE, Statistics cleaner, and MutableQuantiles
scheduler are shared
+ // across all catalogs in the JVM. Operating on shared static state here
would close every
+ // catalog's FileSystems and permanently terminate the global scheduler,
breaking any
+ // subsequent catalog that uses Hadoop metrics. Skip cleanup for shared
classes and let
+ // the JVM manage them.
+ if (!isOwnedByClassLoader(fileSystemClass, targetClassLoader)) {
+ LOG.debug(
+ "Hadoop FileSystem is owned by {}, not the target classloader {};
skipping shared-class cleanup",
+ fileSystemClass.getClassLoader(),
+ targetClassLoader);
+ return;
+ }
+
MethodUtils.invokeStaticMethod(fileSystemClass, "closeAll");
Class<?> mutableQuantilesClass =
@@ -94,16 +110,22 @@ public class ClassLoaderResourceCleanerUtils {
Class<?> statisticsClass =
Class.forName("org.apache.hadoop.fs.FileSystem$Statistics", true,
targetClassLoader);
- ScheduledExecutorService scheduler =
- (ScheduledExecutorService)
- FieldUtils.readStaticField(mutableQuantilesClass, "scheduler",
true);
- scheduler.shutdownNow();
- Field statisticsCleanerField = FieldUtils.getField(statisticsClass,
"STATS_DATA_CLEANER", true);
- Object statisticsCleaner = statisticsCleanerField.get(null);
- if (statisticsCleaner != null) {
- ((Thread) statisticsCleaner).interrupt();
- ((Thread) statisticsCleaner).setContextClassLoader(null);
- ((Thread) statisticsCleaner).join();
+ if (isOwnedByClassLoader(mutableQuantilesClass, targetClassLoader)) {
+ ScheduledExecutorService scheduler =
+ (ScheduledExecutorService)
+ FieldUtils.readStaticField(mutableQuantilesClass, "scheduler",
true);
+ scheduler.shutdownNow();
+ }
+
+ if (isOwnedByClassLoader(statisticsClass, targetClassLoader)) {
+ Field statisticsCleanerField =
+ FieldUtils.getField(statisticsClass, "STATS_DATA_CLEANER", true);
+ Object statisticsCleaner = statisticsCleanerField.get(null);
+ if (statisticsCleaner != null) {
+ ((Thread) statisticsCleaner).interrupt();
+ ((Thread) statisticsCleaner).setContextClassLoader(null);
+ ((Thread) statisticsCleaner).join();
+ }
}
}
@@ -246,19 +268,35 @@ public class ClassLoaderResourceCleanerUtils {
* @param classLoader the classloader where AWS SDK is loaded
*/
private static void closeResourceInAWS(ClassLoader classLoader) throws
Exception {
- // For Aws SDK metrics, unregister the metric admin MBean
Class<?> awsSdkMetricsClass =
Class.forName("com.amazonaws.metrics.AwsSdkMetrics", true,
classLoader);
+ // AwsSdkMetrics holds a static MBeanServer registration. If the class was
delegated to a
+ // parent/AppClassLoader, unregistering here would remove the MBean for
the entire JVM.
+ if (!isOwnedByClassLoader(awsSdkMetricsClass, classLoader)) {
+ LOG.debug(
+ "AwsSdkMetrics is owned by {}, not {}; skipping MBean unregister",
+ awsSdkMetricsClass.getClassLoader(),
+ classLoader);
+ return;
+ }
MethodUtils.invokeStaticMethod(awsSdkMetricsClass,
"unregisterMetricAdminMBean");
}
private static void closeResourceInGCP(ClassLoader classLoader) throws
Exception {
- // For GCS
Class<?> relocatedLogFactory =
Class.forName(
"org.apache.gravitino.gcp.shaded.org.apache.commons.logging.LogFactory",
true,
classLoader);
+ // The GCP shaded LogFactory is always bundled inside the GCP plugin; if
it resolves to a
+ // different classloader, skip to avoid releasing a shared factory.
+ if (!isOwnedByClassLoader(relocatedLogFactory, classLoader)) {
+ LOG.debug(
+ "GCP shaded LogFactory is owned by {}, not {}; skipping release",
+ relocatedLogFactory.getClassLoader(),
+ classLoader);
+ return;
+ }
MethodUtils.invokeStaticMethod(relocatedLogFactory, "release",
classLoader);
}
@@ -272,12 +310,20 @@ public class ClassLoaderResourceCleanerUtils {
* @param classLoader the classloader where Azure Blob File System is loaded
*/
private static void closeResourceInAzure(ClassLoader classLoader) throws
Exception {
- // Clear timer in AbfsClientThrottlingAnalyzer
Class<?> abfsClientThrottlingInterceptClass =
Class.forName(
"org.apache.hadoop.fs.azurebfs.services.AbfsClientThrottlingIntercept",
true,
classLoader);
+ // AbfsClientThrottlingIntercept holds a static singleton with Timers. If
the ABFS class was
+ // delegated to a parent/AppClassLoader, cancelling its timers would break
ABFS for the JVM.
+ if (!isOwnedByClassLoader(abfsClientThrottlingInterceptClass,
classLoader)) {
+ LOG.debug(
+ "AbfsClientThrottlingIntercept is owned by {}, not {}; skipping
Azure cleanup",
+ abfsClientThrottlingInterceptClass.getClassLoader(),
+ classLoader);
+ return;
+ }
Object abfsClientThrottlingIntercept =
FieldUtils.readStaticField(abfsClientThrottlingInterceptClass,
"singleton", true);
@@ -291,8 +337,6 @@ public class ClassLoaderResourceCleanerUtils {
Timer writeTimer = (Timer) FieldUtils.readField(writeThrottler, "timer",
true);
writeTimer.cancel();
- // Release the LogFactory for the Azure shaded commons logging which has
been relocated
- // by the Azure SDK
Class<?> relocatedLogFactory =
Class.forName(
"org.apache.gravitino.azure.shaded.org.apache.commons.logging.LogFactory",
@@ -301,6 +345,16 @@ public class ClassLoaderResourceCleanerUtils {
MethodUtils.invokeStaticMethod(relocatedLogFactory, "release",
classLoader);
}
+ /**
+ * Returns true if {@code clazz} was loaded directly by {@code classLoader}
(not delegated to a
+ * parent). Use this before touching static fields that must belong to the
catalog's own
+ * classloader to avoid accidentally mutating JVM-global shared state.
+ */
+ @VisibleForTesting
+ static boolean isOwnedByClassLoader(Class<?> clazz, ClassLoader classLoader)
{
+ return classLoader != null && clazz.getClassLoader() == classLoader;
+ }
+
@FunctionalInterface
private interface ThrowableConsumer<T> {
void accept(T t) throws Exception;
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
new file mode 100644
index 0000000000..c5a241d8c7
--- /dev/null
+++
b/catalogs/catalog-common/src/test/java/org/apache/gravitino/utils/TestClassLoaderResourceCleanerUtils.java
@@ -0,0 +1,71 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URL;
+import java.net.URLClassLoader;
+import org.junit.jupiter.api.Test;
+
+class TestClassLoaderResourceCleanerUtils {
+
+ /**
+ * When a class is loaded by exactly the target classloader,
isOwnedByClassLoader must return true
+ * — the guard should allow static-state cleanup to proceed.
+ */
+ @Test
+ void testIsOwnedByClassLoaderReturnsTrueForOwningLoader() {
+ ClassLoader loader =
ClassLoaderResourceCleanerUtils.class.getClassLoader();
+ assertTrue(
+ ClassLoaderResourceCleanerUtils.isOwnedByClassLoader(
+ ClassLoaderResourceCleanerUtils.class, loader));
+ }
+
+ /**
+ * When a class was resolved via parent delegation (i.e. the actual loader
is the parent, not the
+ * child), isOwnedByClassLoader must return false — the guard should skip
cleanup to avoid
+ * mutating shared JVM-global static state.
+ */
+ @Test
+ void testIsOwnedByClassLoaderReturnsFalseForParentDelegatedClass() throws
Exception {
+ ClassLoader parent =
ClassLoaderResourceCleanerUtils.class.getClassLoader();
+ // Child delegates everything to the parent;
ClassLoaderResourceCleanerUtils is therefore
+ // parent-loaded, not child-loaded.
+ try (URLClassLoader child = new URLClassLoader(new URL[0], parent)) {
+ assertFalse(
+ ClassLoaderResourceCleanerUtils.isOwnedByClassLoader(
+ ClassLoaderResourceCleanerUtils.class, child));
+ }
+ }
+
+ /**
+ * Bootstrap-loaded classes (whose getClassLoader() returns null) are never
"owned" by a named
+ * classloader — the guard must return false for them too.
+ */
+ @Test
+ void testIsOwnedByClassLoaderReturnsFalseForBootstrapLoadedClass() {
+ // String is loaded by the bootstrap classloader; getClassLoader() returns
null.
+ assertFalse(
+ ClassLoaderResourceCleanerUtils.isOwnedByClassLoader(
+ String.class, ClassLoader.getSystemClassLoader()));
+ }
+}
diff --git
a/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
b/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
index ed660b850f..4b86c37fd6 100644
---
a/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
+++
b/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
@@ -104,6 +104,7 @@ import org.apache.gravitino.meta.FilesetEntity;
import org.apache.gravitino.meta.SchemaEntity;
import org.apache.gravitino.metrics.MetricsSystem;
import org.apache.gravitino.metrics.source.FilesetCatalogMetricsSource;
+import org.apache.gravitino.utils.ClassLoaderResourceCleanerUtils;
import org.apache.gravitino.utils.FilesetUtil;
import org.apache.gravitino.utils.NameIdentifierUtil;
import org.apache.gravitino.utils.NamespaceUtil;
@@ -985,6 +986,8 @@ public class FilesetCatalogOperations extends
ManagedSchemaOperations
if (metricsSystem != null) {
metricsSystem.unregister(catalogMetricsSource);
}
+
+
ClassLoaderResourceCleanerUtils.closeClassLoaderResource(this.getClass().getClassLoader());
}
private void validateLocationHierarchy(
diff --git
a/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveCatalogOperations.java
b/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveCatalogOperations.java
index 87825ca913..1a474d4f53 100644
---
a/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveCatalogOperations.java
+++
b/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveCatalogOperations.java
@@ -89,6 +89,7 @@ import
org.apache.gravitino.rel.expressions.transforms.Transform;
import org.apache.gravitino.rel.expressions.transforms.Transforms;
import org.apache.gravitino.rel.indexes.Index;
import org.apache.gravitino.rel.types.Type;
+import org.apache.gravitino.utils.ClassLoaderResourceCleanerUtils;
import org.apache.gravitino.utils.PrincipalUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -196,6 +197,7 @@ public class HiveCatalogOperations
clientPool.close();
clientPool = null;
}
+
ClassLoaderResourceCleanerUtils.closeClassLoaderResource(this.getClass().getClassLoader());
}
/**