Skip to content

fix(metrics): handle stale DistributedRegistry across SparkContext restarts. - #19790

Open
prashantwason wants to merge 1 commit into
apache:masterfrom
prashantwason:pwason/fix-stale-distributed-registry
Open

fix(metrics): handle stale DistributedRegistry across SparkContext restarts.#19790
prashantwason wants to merge 1 commit into
apache:masterfrom
prashantwason:pwason/fix-stale-distributed-registry

Conversation

@prashantwason

Copy link
Copy Markdown
Member

Describe the issue this Pull Request addresses

DistributedRegistry is a JVM-wide singleton (cached in Registry.REGISTRY_MAP) and a Spark AccumulatorV2. When executor metrics are enabled (hoodie.metrics.executor.enable=true) and multiple Hudi write clients are created within the same JVM (e.g. batch frameworks processing multiple tables sequentially), the cached DistributedRegistry instance retains metadata set from a prior SparkContext registration.

On a subsequent initRegistry() call:

  • AccumulatorV2.isRegistered() returns false — the id is no longer in the current AccumulatorContext
  • The guard in DistributedRegistry.register() lets the call through
  • AccumulatorV2.register() throws IllegalStateException("Cannot register an Accumulator twice") because metadata is non-null

This crashes the write with HoodieException: Failed to instantiate Metadata table.

Summary and Changelog

Fix DistributedRegistry.register() to handle the stale-singleton case where the accumulator was registered to a previous SparkContext that no longer exists.

  • DistributedRegistry.register(): on IllegalStateException, create a fresh AccumulatorV2, copy counters from the stale instance, register the fresh instance, and swap it into Registry.REGISTRY_MAP. Changed return type from void to DistributedRegistry so callers receive the potentially-replaced instance.
  • SparkHoodieBackedTableMetadataWriter.initRegistry(): use the returned registry from register().
  • SparkHoodieBackedTableMetadataWriterTableVersionSix.initRegistry(): same.
  • HoodieSparkEngineContext.getMetricRegistry(): same.
  • TestDistributedRegistry: added testRegisterIdempotent and testRegisterHandlesStaleAccumulator.

Impact

No public API or user-facing feature change. No performance impact — the fix only alters behavior in the error path (stale accumulator from a dead SparkContext). The normal registration path is unchanged.

Risk Level

Low. The catch block only fires when AccumulatorV2.register() throws IllegalStateException, which is the exact crash this fixes. The fresh instance is functionally identical to the original, with counters preserved.

Documentation Update

None. No new configs or user-facing changes.

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable

…starts

Summary:
Intent:
- Fix IllegalStateException("Cannot register an Accumulator twice") when executor metrics are enabled and multiple Hudi write clients are created within the same JVM (e.g. Marmaray processing multiple tables sequentially)

Changes:
- DistributedRegistry.register() now handles the stale-singleton case: when a cached DistributedRegistry was registered to a previous SparkContext, it creates a fresh AccumulatorV2, copies counters, registers it, and swaps it into Registry.REGISTRY_MAP
- Changed register() return type from void to DistributedRegistry so callers use the potentially-replaced instance
- Updated callers in SparkHoodieBackedTableMetadataWriter, SparkHoodieBackedTableMetadataWriterTableVersionSix, and HoodieSparkEngineContext

Test Plan:
Added TestDistributedRegistry.testRegisterIdempotent and testRegisterHandlesStaleAccumulator

Jira Issues: T3-HUDI-9618

---

<sub>Generated by the 🪄 [pr-create](https://sg.uberinternal.com/code.uber.internal/uber-code/devexp-agent-marketplace/-/blob/claude-code/plugins/dev/uber-dev/skills/pr-create/SKILL.md) skill in devexp-agent-marketplace</sub>
@github-actions github-actions Bot added the size:M PR with lines of changes in (100, 300] label Aug 28, 2026
@hudi-bot

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

@voonhous

Copy link
Copy Markdown
Member

Could the description reference #19063? This is gap 2 there.

Worth a line saying it is the same stale singleton seen from the other side: gap 2's text describes register() silently no-opping when the id is still in AccumulatorContext, this PR fixes the throw when it is not. Without that, the two read as different bugs and gap 2 never gets ticked.

Part of #19063 rather than a closing keyword, since the umbrella's other gaps are still open.

@bhasudha

Copy link
Copy Markdown
Contributor

@danny0405 @rahil-c can you help review this PR ?

@danny0405 danny0405 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.

Two inline findings below. I validated the affected PR classes against Spark 3.5.5: direct stale-accumulator recovery preserves counters and accepts executor updates, but the engine cache can bypass recovery. Both added tests also pass with the recovery block removed. These were focused checks, not a full repository test run.

Comment on lines 284 to +286
return DISTRIBUTED_REGISTRY_MAP.computeIfAbsent(prefixedName, key -> {
Registry registry = Registry.getRegistryOfClass(tableName, registryName, DistributedRegistry.class.getName());
((DistributedRegistry) registry).register(javaSparkContext);
return registry;
return ((DistributedRegistry) registry).register(javaSparkContext);

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.

[P1] Recover existing entries in the engine registry cache

DISTRIBUTED_REGISTRY_MAP is static and survives SparkContext restarts, so putting the recovery call inside computeIfAbsent skips it for registries already cached by the previous context. Under the stale-accumulator condition this PR handles, a subsequent lookup therefore still returns the unregistered instance. I reproduced this with the PR classes on Spark 3.5.5: obtain a registry through the first engine context, restart Spark, remove the old accumulator ID from AccumulatorContext to induce the stale state, and look up the same registry through the new engine context. The lookup returns the old instance with isRegistered() == false, and engineContext.map(...) fails with Task not serializable, caused by Accumulator must be registered before send to executor. Please scope/reset this cache with the SparkContext or recover its stale entries before reuse; replacing only Registry.REGISTRY_MAP does not update the map captured by engine operations.

Comment on lines +277 to +281
// When: register() is called with the new SparkContext
// In local mode, isRegistered() may still return true since AccumulatorContext
// persists across stop/start. Force the stale path by calling register on jsc2.
// The key invariant: it must not throw IllegalStateException.
DistributedRegistry result = staleRegistry.register(jsc2);

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.

[P2] Force the stale state so this test exercises recovery

Stopping and recreating the SparkContext does not remove this strongly referenced accumulator from the JVM-wide AccumulatorContext on Spark 3.5.5. Consequently, this call returns from the isRegistered() guard and never exercises the catch block or cache replacement. I ran both added tests successfully, then removed the entire recovery block while retaining the new return type, and both tests still passed. Please explicitly remove staleRegistry.id() from AccumulatorContext and assert !staleRegistry.isRegistered() before calling register(). Then assert that the result is a different instance and that Registry.REGISTRY_MAP.get(cacheKey) is that result, alongside the counter checks, so removing or breaking the fix actually fails the regression test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M PR with lines of changes in (100, 300]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants