Skip to content

[SPARK-58292][CORE] Recreate the netty worker EventLoopGroup when a worker event loop thread dies#57462

Open
ChuckLin2025 wants to merge 3 commits into
apache:masterfrom
ChuckLin2025:SPARK-58292-recreate-eventloop
Open

[SPARK-58292][CORE] Recreate the netty worker EventLoopGroup when a worker event loop thread dies#57462
ChuckLin2025 wants to merge 3 commits into
apache:masterfrom
ChuckLin2025:SPARK-58292-recreate-eventloop

Conversation

@ChuckLin2025

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

A netty worker event-loop thread that dies (a Throwable escaping run() at the runIo()/select level; per-task exceptions are swallowed by safeExecute) is driven to ST_TERMINATED by SingleThreadEventExecutor.doStartThread()'s finally block. Once that happens the thread is:

  • never replaced in the fixed-size MultithreadEventExecutorGroup (children is final, there is no repopulation),
  • still handed out by the round-robin EventExecutorChooser, which has no liveness check, and
  • not restartable (startThread() only starts from ST_NOT_STARTED/ST_SUSPENDED, never from ST_TERMINATED).

So the dead loop permanently poisons any channel pinned to it, which surfaces in TransportClientFactory as two failure modes:

  1. New connections (~1/N fail): a fresh channel bound to the dead loop fails registration with RejectedExecutionException("event executor terminated") (caught by AbstractChannel.AbstractUnsafe.register), so createClient throws IOException. Each connect round-robins across the N worker threads, so roughly 1 in N attempts binds to the dead loop and fails.
  2. Reused cached client (worse — silent hang): a pooled TransportClient pinned to the dead loop still has an open socket, so isActive() was true and createClient kept returning it. writeAndFlush().addListener() then submits to the dead loop; netty's safeExecute swallows the RejectedExecutionException (only logs "Failed to submit a listener notification task. Event loop shut down?"), the callback/listener is orphaned, and the fetch (broadcast/RDD/RPC) hangs forever.

This PR makes the client network stack self-heal in-process, all within common/network-common:

  • TransportClient.isActive() returns false when channel.eventLoop().isShuttingDown() is true, so a poisoned pooled client is no longer treated as active and is not reused — createClient creates a new one instead.
  • TransportClientFactory.createClient, when a connect fails and the cause chain contains a RejectedExecutionException whose message is exactly "event executor terminated" (the terminated-loop rejection only — the queue-full default handler throws with no message), replaces workerGroup with a fresh group and rethrows, so the existing IOException retry path (e.g. RetryingBlockTransferor) reconnects onto a fresh, all-live group.
    • recreateWorkerGroup is synchronized and identity-guarded (workerGroup != connectGroup → no-op), so N concurrent callers that all hit the same dead group swap it exactly once. workerGroup is volatile.
    • The superseded group is not shut down eagerly — its still-live threads may be serving already-open channels. It is retained via a WeakReference and shut down best-effort in close(); its threads are daemon, so a not-yet-collected group cannot block JVM shutdown.
  • Gated by a new config spark.network.recreateWorkerGroupOnDeadEventLoop, default true.

Why are the changes needed?

Without this, a single dead netty worker thread degrades the client network stack for the lifetime of the JVM: new connections fail ~1/N of the time, and — worse — a reused pooled client submits to the dead loop where the rejection is swallowed, orphaning the callback so the fetch hangs forever. Only a fresh JVM fully clears the poison. Recreating the worker group on the terminated-loop rejection lets the existing retry path recover in-process instead.

Does this PR introduce any user-facing change?

No. This is an internal reliability fix. It adds an internal-style network config spark.network.recreateWorkerGroupOnDeadEventLoop (default true); when disabled, the previous behavior is preserved. When no event loop dies, behavior is unchanged.

How was this patch tested?

New unit tests in common/network-common:

  • TransportClientSuite.isActiveFalseWhenEventLoopIsShuttingDown — a client whose event loop reports isShuttingDown() is not active even though the channel still reports open/active.
  • TransportClientFactorySuite.recreatesWorkerGroupWhenEventLoopIsDead — shutting down the factory's worker group makes the next createClient fail with the terminated-loop rejection; the factory swaps in a fresh live group and a subsequent connection succeeds.
  • TransportClientFactorySuite.doesNotRecreateWorkerGroupWhenDisabled — negative control with the config off: the connect still fails and the worker group is left unchanged.

network-common/testOnly TransportClientSuite TransportClientFactorySuite passes (12 tests). core compiles; network-common checkstyle (main + test) and core scalastyle report no issues.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Anthropic)

…orker event loop thread dies

When a netty worker event-loop thread dies, it is never replaced in the
fixed-size MultithreadEventExecutorGroup, cannot be restarted, and keeps
being handed out by the round-robin chooser. This poisons the
TransportClientFactory worker group so a broadcast/RDD/RPC fetch can hang
forever.

- TransportClient.isActive() returns false when the event loop is shutting
  down, so a poisoned pooled client is no longer reused.
- TransportClientFactory.createClient recreates the worker EventLoopGroup on
  a "event executor terminated" rejection (identity-guarded, done once) and
  rethrows so the existing IOException retry path binds onto a fresh group.
- Gated by spark.network.recreateWorkerGroupOnDeadEventLoop (default true).

Co-authored-by: Isaac
@ChuckLin2025

Copy link
Copy Markdown
Contributor Author

Hi @cloud-fan @Ngone51 could you help me review this PR ?

@cloud-fan cloud-fan 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.

0 blocking, 0 non-blocking, 0 nits.
The recovery path, lifecycle handling, configuration contract, concurrency guard, and focused tests are coherent; I found no actionable issues.

Verification

Reviewed all six changed files and traced pooled-client reuse, failed channel registration, cause-chain matching, synchronized group replacement, superseded-group shutdown, and both configuration branches. Mechanical link, text, and contract scanners completed their full manifests. No tests were run as part of this review.

return;
}
workerGroup = NettyUtils.createEventLoop(
ioMode, conf.clientThreads(), conf.getModuleName() + "-client");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a unique name here ?

…nct thread-name prefix

Address review feedback: tag the recreated worker EventLoopGroup with a
"-client-recreated-<n>" thread-name prefix so the dead-event-loop recovery is
self-identifying in thread dumps and logs. Netty's DefaultThreadFactory
already appends an incrementing pool id so names would not collide, but the
explicit suffix makes the recreation obvious, and the <n> counter
distinguishes successive recreations if a loop dies more than once.

Co-authored-by: Isaac
// netty's SingleThreadEventExecutor.reject() throws exactly this message when isShutdown();
// the queue-full handler path throws a RejectedExecutionException with no message.
if (t instanceof RejectedExecutionException
&& "event executor terminated".equals(t.getMessage())) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we always print a log for this error even if recreation is disabled? I think it'd be good for visibility.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My feeling is that for other case, we should rely on outer exception handler. It's a common pattern that only log the error on the outermost exception handler. But the outermost exception have the more stace trace information. The infrastruction layer logs the exception and then re-throw which is not common and usually redudant.

Also we don't need to care about the !recreateWorkerGroupOnDeadEventLoop case, because it's default on.

… new network config

SparkConfigBindingPolicySuite enforces that every ConfigEntry declares a
bindingPolicy. spark.network.recreateWorkerGroupOnDeadEventLoop is a
transport-layer config read once at TransportClientFactory construction, not
a session/SQL binding, so NOT_APPLICABLE is the correct policy.

Co-authored-by: Isaac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants