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 dbfb4ee519 [Cherry-pick to branch-1.3] [#11301] fix(catalog): guard
ClassLoaderResourceCleanerUtils against shared-class static-field mutation
(#11431) (#11446)
dbfb4ee519 is described below
commit dbfb4ee519ee959ae18466bbd61ec28c690bc9be
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Jun 5 16:27:51 2026 +0800
[Cherry-pick to branch-1.3] [#11301] fix(catalog): guard
ClassLoaderResourceCleanerUtils against shared-class static-field mutation
(#11431) (#11446)
**Cherry-pick Information:**
- Original commit: f31c2a0b11e5743dce185405011e432e787989ae
- Target branch: `branch-1.3`
- Status: ✅ Clean cherry-pick (no conflicts)
Co-authored-by: Qi Yu <[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());
}
/**