[SPARK-58292][CORE] Recreate the netty worker EventLoopGroup when a worker event loop thread dies#57462
Conversation
…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
|
Hi @cloud-fan @Ngone51 could you help me review this PR ? |
cloud-fan
left a comment
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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())) { |
There was a problem hiding this comment.
Shall we always print a log for this error even if recreation is disabled? I think it'd be good for visibility.
There was a problem hiding this comment.
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
What changes were proposed in this pull request?
A netty worker event-loop thread that dies (a
Throwableescapingrun()at therunIo()/select level; per-task exceptions are swallowed bysafeExecute) is driven toST_TERMINATEDbySingleThreadEventExecutor.doStartThread()'s finally block. Once that happens the thread is:MultithreadEventExecutorGroup(childrenis final, there is no repopulation),EventExecutorChooser, which has no liveness check, andstartThread()only starts fromST_NOT_STARTED/ST_SUSPENDED, never fromST_TERMINATED).So the dead loop permanently poisons any channel pinned to it, which surfaces in
TransportClientFactoryas two failure modes:RejectedExecutionException("event executor terminated")(caught byAbstractChannel.AbstractUnsafe.register), socreateClientthrowsIOException. Each connect round-robins across the N worker threads, so roughly 1 in N attempts binds to the dead loop and fails.TransportClientpinned to the dead loop still has an open socket, soisActive()was true andcreateClientkept returning it.writeAndFlush().addListener()then submits to the dead loop; netty'ssafeExecuteswallows theRejectedExecutionException(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()returnsfalsewhenchannel.eventLoop().isShuttingDown()is true, so a poisoned pooled client is no longer treated as active and is not reused —createClientcreates a new one instead.TransportClientFactory.createClient, when a connect fails and the cause chain contains aRejectedExecutionExceptionwhose message is exactly"event executor terminated"(the terminated-loop rejection only — the queue-full default handler throws with no message), replacesworkerGroupwith a fresh group and rethrows, so the existingIOExceptionretry path (e.g.RetryingBlockTransferor) reconnects onto a fresh, all-live group.recreateWorkerGroupissynchronizedand identity-guarded (workerGroup != connectGroup→ no-op), so N concurrent callers that all hit the same dead group swap it exactly once.workerGroupisvolatile.WeakReferenceand shut down best-effort inclose(); its threads are daemon, so a not-yet-collected group cannot block JVM shutdown.spark.network.recreateWorkerGroupOnDeadEventLoop, defaulttrue.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(defaulttrue); 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 reportsisShuttingDown()is not active even though the channel still reports open/active.TransportClientFactorySuite.recreatesWorkerGroupWhenEventLoopIsDead— shutting down the factory's worker group makes the nextcreateClientfail 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 TransportClientFactorySuitepasses (12 tests).corecompiles;network-commoncheckstyle (main + test) andcorescalastyle report no issues.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Anthropic)