This is an automated email from the ASF dual-hosted git repository.

danny0405 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new b6fe7c3fd8b8 fix(metrics): handle stale DistributedRegistry across 
SparkContext restarts (#19790)
b6fe7c3fd8b8 is described below

commit b6fe7c3fd8b86d3d62b8bd74fdfcc1dfa183f316
Author: Prashant Wason <[email protected]>
AuthorDate: Tue Oct 6 19:15:30 2026 -0700

    fix(metrics): handle stale DistributedRegistry across SparkContext restarts 
(#19790)
    
    DistributedRegistry is a JVM-wide singleton cached in Registry.REGISTRY_MAP 
and a
    Spark AccumulatorV2. After a SparkContext restart in the same JVM, 
register()
    threw "Cannot register an Accumulator twice". Detect the stale state 
explicitly,
    register a fresh accumulator that shares the stale instance's counters, and 
swap
    it into REGISTRY_MAP and HoodieSparkEngineContext's 
DISTRIBUTED_REGISTRY_MAP.
---
 .../client/common/HoodieSparkEngineContext.java    |  30 ++++--
 .../SparkHoodieBackedTableMetadataWriter.java      |   4 +-
 ...ieBackedTableMetadataWriterTableVersionSix.java |   4 +-
 .../apache/hudi/metrics/DistributedRegistry.java   |  36 +++++++-
 .../hudi/metrics/TestDistributedRegistry.java      | 102 +++++++++++++++++++++
 5 files changed, 161 insertions(+), 15 deletions(-)

diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java
index 97317b61e490..77eff393353c 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java
@@ -137,7 +137,7 @@ public class HoodieSparkEngineContext extends 
HoodieEngineContext {
 
   @Override
   public <I, O> List<O> map(List<I> data, SerializableFunction<I, O> func, int 
parallelism) {
-    final Map<String, Registry> registries = DISTRIBUTED_REGISTRY_MAP;
+    final Map<String, Registry> registries = getDistributedRegistries();
     return javaSparkContext.parallelize(data, parallelism).map(i -> {
       setRegistries(registries);
       return func.apply(i);
@@ -146,7 +146,7 @@ public class HoodieSparkEngineContext extends 
HoodieEngineContext {
 
   @Override
   public <I, K, V> List<V> mapToPairAndReduceByKey(List<I> data, 
SerializablePairFunction<I, K, V> mapToPairFunc, SerializableBiFunction<V, V, 
V> reduceFunc, int parallelism) {
-    final Map<String, Registry> registries = DISTRIBUTED_REGISTRY_MAP;
+    final Map<String, Registry> registries = getDistributedRegistries();
     return javaSparkContext.parallelize(data, parallelism).mapToPair(input -> {
       setRegistries(registries);
       Pair<K, V> pair = mapToPairFunc.call(input);
@@ -158,7 +158,7 @@ public class HoodieSparkEngineContext extends 
HoodieEngineContext {
   public <I, K, V> Stream<ImmutablePair<K, V>> 
mapPartitionsToPairAndReduceByKey(
       Stream<I> data, SerializablePairFlatMapFunction<Iterator<I>, K, V> 
flatMapToPairFunc,
       SerializableBiFunction<V, V, V> reduceFunc, int parallelism) {
-    final Map<String, Registry> registries = DISTRIBUTED_REGISTRY_MAP;
+    final Map<String, Registry> registries = getDistributedRegistries();
     return javaSparkContext.parallelize(data.collect(Collectors.toList()), 
parallelism)
         .mapPartitionsToPair((PairFlatMapFunction<Iterator<I>, K, V>) iterator 
-> {
           setRegistries(registries);
@@ -179,7 +179,7 @@ public class HoodieSparkEngineContext extends 
HoodieEngineContext {
 
   @Override
   public <I, O> List<O> flatMap(List<I> data, SerializableFunction<I, 
Stream<O>> func, int parallelism) {
-    final Map<String, Registry> registries = DISTRIBUTED_REGISTRY_MAP;
+    final Map<String, Registry> registries = getDistributedRegistries();
     return javaSparkContext.parallelize(data, parallelism).flatMap(x -> {
       setRegistries(registries);
       return func.apply(x).iterator();
@@ -193,7 +193,7 @@ public class HoodieSparkEngineContext extends 
HoodieEngineContext {
 
   @Override
   public <I, K, V> Map<K, V> mapToPair(List<I> data, 
SerializablePairFunction<I, K, V> func, Integer parallelism) {
-    final Map<String, Registry> registries = DISTRIBUTED_REGISTRY_MAP;
+    final Map<String, Registry> registries = getDistributedRegistries();
     if (Objects.nonNull(parallelism)) {
       return javaSparkContext.parallelize(data, parallelism).mapToPair(input 
-> {
         setRegistries(registries);
@@ -281,13 +281,25 @@ public class HoodieSparkEngineContext extends 
HoodieEngineContext {
   @Override
   public Registry getMetricRegistry(String tableName, String registryName) {
     final String prefixedName = tableName.isEmpty() ? registryName : tableName 
+ "." + registryName;
-    return DISTRIBUTED_REGISTRY_MAP.computeIfAbsent(prefixedName, key -> {
-      Registry registry = Registry.getRegistryOfClass(tableName, registryName, 
DistributedRegistry.class.getName());
-      ((DistributedRegistry) registry).register(javaSparkContext);
-      return registry;
+    // Registers on every lookup, not only on first insert, so that an entry 
cached under a previous
+    // SparkContext is recovered too.
+    return DISTRIBUTED_REGISTRY_MAP.compute(prefixedName, (key, cached) -> {
+      Registry registry = cached != null
+          ? cached : Registry.getRegistryOfClass(tableName, registryName, 
DistributedRegistry.class.getName());
+      return ((DistributedRegistry) registry).register(javaSparkContext);
     });
   }
 
+  /**
+   * Returns the distributed registries to ship to executors, first recovering 
any entry registered to a
+   * previous SparkContext: the map is static and outlives SparkContext 
restarts, and an unregistered
+   * accumulator fails task serialization.
+   */
+  private Map<String, Registry> getDistributedRegistries() {
+    DISTRIBUTED_REGISTRY_MAP.replaceAll((key, registry) -> 
((DistributedRegistry) registry).register(javaSparkContext));
+    return DISTRIBUTED_REGISTRY_MAP;
+  }
+
   /**
    * Accumulator-backed registries owned by this context rather than by the 
process, keyed by table base
    * path. Unlike {@link #getMetricRegistry}, these are never published into 
{@code Registry.REGISTRY_MAP}:
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriter.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriter.java
index ec21dac28ea7..27a442f50fcb 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriter.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriter.java
@@ -119,9 +119,9 @@ public class SparkHoodieBackedTableMetadataWriter extends 
HoodieBackedTableMetad
     if (metadataWriteConfig.isMetricsOn()) {
       Registry registry;
       if (metadataWriteConfig.isExecutorMetricsEnabled() && 
metadataWriteConfig.getMetricsReporterType() != MetricsReporterType.INMEMORY) {
-        registry = Registry.getRegistry("HoodieMetadata", 
DistributedRegistry.class.getName());
+        DistributedRegistry distRegistry = (DistributedRegistry) 
Registry.getRegistry("HoodieMetadata", DistributedRegistry.class.getName());
         HoodieSparkEngineContext sparkEngineContext = 
(HoodieSparkEngineContext) engineContext;
-        ((DistributedRegistry) 
registry).register(sparkEngineContext.getJavaSparkContext());
+        registry = 
distRegistry.register(sparkEngineContext.getJavaSparkContext());
       } else {
         registry = Registry.getRegistry("HoodieMetadata");
       }
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriterTableVersionSix.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriterTableVersionSix.java
index 8190a5112d59..337115c4b897 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriterTableVersionSix.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieBackedTableMetadataWriterTableVersionSix.java
@@ -97,9 +97,9 @@ public class 
SparkHoodieBackedTableMetadataWriterTableVersionSix extends HoodieB
     if (metadataWriteConfig.isMetricsOn()) {
       Registry registry;
       if (metadataWriteConfig.isExecutorMetricsEnabled() && 
metadataWriteConfig.getMetricsReporterType() != MetricsReporterType.INMEMORY) {
-        registry = Registry.getRegistry("HoodieMetadata", 
DistributedRegistry.class.getName());
+        DistributedRegistry distRegistry = (DistributedRegistry) 
Registry.getRegistry("HoodieMetadata", DistributedRegistry.class.getName());
         HoodieSparkEngineContext sparkEngineContext = 
(HoodieSparkEngineContext) engineContext;
-        ((DistributedRegistry) 
registry).register(sparkEngineContext.getJavaSparkContext());
+        registry = 
distRegistry.register(sparkEngineContext.getJavaSparkContext());
       } else {
         registry = Registry.getRegistry("HoodieMetadata");
       }
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/DistributedRegistry.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/DistributedRegistry.java
index c8b9fdde6458..da7d42ea6aee 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/DistributedRegistry.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/DistributedRegistry.java
@@ -41,6 +41,8 @@ public class DistributedRegistry extends 
AccumulatorV2<Map<String, Long>, Map<St
 
   private final String name;
   ConcurrentHashMap<String, Long> counters = new ConcurrentHashMap<>();
+  // The registered accumulator that replaced this one if it went stale, see 
register().
+  private transient volatile DistributedRegistry replacement;
   /** Driver-only, to detect a SparkContext restart in the same JVM (shells, 
notebooks, Spark Connect). */
   private transient String registeredAppId;
 
@@ -53,13 +55,43 @@ public class DistributedRegistry extends 
AccumulatorV2<Map<String, Long>, Map<St
     return name;
   }
 
-  public void register(JavaSparkContext jsc) {
-    if (!isRegistered()) {
+  /**
+   * Registers this accumulator with the given SparkContext if it is not 
registered yet.
+   *
+   * <p>A cached instance that was registered to a previous SparkContext can 
no longer be registered
+   * (AccumulatorV2 rejects a second registration). In that case a fresh 
accumulator sharing the same
+   * counters is registered instead and swapped into {@link 
Registry#REGISTRY_MAP}. Callers should use the
+   * returned instance; updates made through the stale instance are still 
reported via the shared counters.
+   *
+   * @return this instance, or its registered replacement.
+   */
+  public synchronized DistributedRegistry register(JavaSparkContext jsc) {
+    if (isRegistered()) {
+      return this;
+    }
+    if (registeredAppId == null) {
       jsc.sc().register(this);
       // Only when this call actually registers: stamping unconditionally 
would re-brand an accumulator
       // bound to a dead context and mask the staleness this field exists to 
detect.
       this.registeredAppId = jsc.sc().applicationId();
+      return this;
+    }
+    // Stale: registered to a previous SparkContext that no longer exists, so 
the id is gone from the
+    // current AccumulatorContext but AccumulatorV2 refuses to register this 
instance again.
+    if (replacement != null) {
+      // Another cache still holds this stale instance after it was already 
replaced. Delegate, so a
+      // replacement that went stale in turn (another SparkContext restart) is 
replaced the same way.
+      replacement = replacement.register(jsc);
+      return replacement;
     }
+    DistributedRegistry fresh = new DistributedRegistry(this.name);
+    // Share the counters rather than copying them, so updates made through a 
caller that still holds
+    // this stale instance are reported by the replacement too.
+    fresh.counters = this.counters;
+    fresh.register(jsc);
+    Registry.REGISTRY_MAP.replaceAll((key, registry) -> registry == this ? 
fresh : registry);
+    replacement = fresh;
+    return fresh;
   }
 
   /** False when bound to a different (typically stopped) context, meaning it 
must be recreated. */
diff --git 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistry.java
 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistry.java
index 6058eeef3522..928ca6de052b 100644
--- 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistry.java
+++ 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistry.java
@@ -23,12 +23,14 @@ import org.apache.hudi.common.metrics.Registry;
 import org.apache.hudi.testutils.HoodieClientTestUtils;
 
 import org.apache.spark.api.java.JavaSparkContext;
+import org.apache.spark.util.AccumulatorContext;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -330,4 +332,104 @@ public class TestDistributedRegistry {
     Assertions.assertTrue(countsWithoutPrefix.containsKey(METRIC_1));
     Assertions.assertTrue(countsWithoutPrefix.containsKey(METRIC_2));
   }
+
+  @Test
+  public void testRegisterIdempotent() {
+    // Given: a DistributedRegistry registered to the SparkContext
+    String registryName = REGISTRY_NAME + "_testIdempotent";
+    DistributedRegistry registry = new DistributedRegistry(registryName);
+    registry.add(METRIC_1, 42);
+    DistributedRegistry result = registry.register(jsc);
+
+    // Then: first registration returns the same instance
+    Assertions.assertSame(registry, result);
+    Assertions.assertTrue(registry.isRegistered());
+
+    // When: register() is called again on the same SparkContext
+    DistributedRegistry result2 = registry.register(jsc);
+
+    // Then: it is a no-op, returns same instance
+    Assertions.assertSame(registry, result2);
+  }
+
+  @Test
+  public void testRegisterHandlesStaleAccumulator() {
+    // Given: a registered registry cached in the global REGISTRY_MAP
+    String registryName = REGISTRY_NAME + "_testStale";
+    DistributedRegistry staleRegistry = new DistributedRegistry(registryName);
+    staleRegistry.add(METRIC_1, 100);
+    staleRegistry.add(METRIC_2, 200);
+    String cacheKey = Registry.makeKey("", registryName);
+    Registry.REGISTRY_MAP.put(cacheKey, staleRegistry);
+    try {
+      Assertions.assertSame(staleRegistry, staleRegistry.register(jsc));
+
+      // Given: the accumulator went stale, as after a SparkContext restart 
once the previous context
+      // dropped it: it is no longer in the AccumulatorContext but can't be 
registered again
+      makeStale(staleRegistry);
+
+      // When: register() is called
+      DistributedRegistry result = staleRegistry.register(jsc);
+
+      // Then: a registered replacement carrying the counters is returned and 
cached
+      Assertions.assertNotSame(staleRegistry, result);
+      Assertions.assertTrue(result.isRegistered());
+      Assertions.assertEquals(registryName, result.getName());
+      Map<String, Long> counts = result.getAllCounts();
+      Assertions.assertEquals(100, counts.get(METRIC_1));
+      Assertions.assertEquals(200, counts.get(METRIC_2));
+      Assertions.assertSame(result, Registry.REGISTRY_MAP.get(cacheKey));
+
+      // Then: another holder of the stale instance gets the same replacement
+      Assertions.assertSame(result, staleRegistry.register(jsc));
+
+      // Then: updates through the stale instance are reported by the 
replacement
+      staleRegistry.add(METRIC_1, 5);
+      Assertions.assertEquals(105, result.getAllCounts().get(METRIC_1));
+
+      // Given: the replacement went stale too, as after another SparkContext 
restart
+      makeStale(result);
+
+      // When: a holder of the original stale instance calls register()
+      DistributedRegistry result2 = staleRegistry.register(jsc);
+
+      // Then: a new registered replacement sharing the counters is returned 
and cached
+      Assertions.assertNotSame(result, result2);
+      Assertions.assertTrue(result2.isRegistered());
+      Assertions.assertEquals(105, result2.getAllCounts().get(METRIC_1));
+      Assertions.assertSame(result2, Registry.REGISTRY_MAP.get(cacheKey));
+      Assertions.assertSame(result2, result.register(jsc));
+    } finally {
+      Registry.REGISTRY_MAP.remove(cacheKey);
+    }
+  }
+
+  @Test
+  public void testEngineContextRecoversStaleRegistry() {
+    // Given: a registry obtained through the engine context, which caches it 
in a static map
+    String registryName = REGISTRY_NAME + "_testEngineStale";
+    DistributedRegistry staleRegistry = (DistributedRegistry) 
engineContext.getMetricRegistry("", registryName);
+    staleRegistry.add(METRIC_1, 7);
+    try {
+      // Given: the cached accumulator went stale
+      makeStale(staleRegistry);
+
+      // Then: engine operations ship only registered accumulators to executors
+      Assertions.assertEquals(Arrays.asList(2, 4), 
engineContext.map(Arrays.asList(1, 2), i -> i * 2, 2));
+
+      // Then: lookups return the registered replacement with the counters
+      DistributedRegistry result = (DistributedRegistry) 
engineContext.getMetricRegistry("", registryName);
+      Assertions.assertNotSame(staleRegistry, result);
+      Assertions.assertTrue(result.isRegistered());
+      Assertions.assertEquals(7, result.getAllCounts().get(METRIC_1));
+      Assertions.assertSame(result, 
Registry.REGISTRY_MAP.get(Registry.makeKey("", registryName)));
+    } finally {
+      Registry.REGISTRY_MAP.remove(Registry.makeKey("", registryName));
+    }
+  }
+
+  private static void makeStale(DistributedRegistry registry) {
+    AccumulatorContext.remove(registry.id());
+    Assertions.assertFalse(registry.isRegistered());
+  }
 }

Reply via email to