sunchao commented on code in PR #3778:
URL: https://github.com/apache/celeborn/pull/3778#discussion_r3759741619
##########
common/src/main/java/org/apache/celeborn/common/network/client/TransportResponseHandler.java:
##########
@@ -263,6 +263,32 @@ private void failOutstandingRequests(Throwable cause) {
}
}
+ /**
+ * Fail all outstanding requests because the netty event loop this channel
is pinned to has died
+ * (see {@link TransportClient#isEventLoopDead()}). Unlike {@link
#channelInactive()} this runs on
+ * an arbitrary caller thread, because the dead loop will never deliver
channelInactive() itself.
+ *
+ * <p>Idempotent: the outstanding maps are drained by {@code remove}, so a
second call is a no-op.
+ *
+ * @param cause the failure handed to every outstanding callback.
+ */
+ void failOutstandingRequestsOnDeadEventLoop(Throwable cause) {
+ if (hasOutstandingRequests()) {
+ logger.error(
+ "Failing {} outstanding requests to {}: the netty event loop this
channel is pinned to "
+ + "is no longer usable, so they can never complete",
+ numOutstandingRequests(),
+ NettyUtils.getRemoteAddress(channel));
+ failOutstandingRequests(cause);
+ }
+ if (pushCheckerScheduleFuture != null) {
Review Comment:
[P1] Keep timeout checkers alive until new push/fetch requests are rejected.
Dead-loop invalidation cancels the per-client push/fetch timeout checkers,
but `TransportClient.fetchChunk()`, `pushData()`, and `pushMergedData()` still
register and write new requests without a dead-loop guard. For example,
`WorkerPartitionReader` can observe its client active, another thread can then
invalidate the now-dead client and cancel this checker, and the reader can
resume into `fetchChunk()`. The write listener never runs and the timeout
backstop has been removed, so `next()` polls forever. I reproduced an expired
fetch remaining outstanding after cancellation while an otherwise identical
uncanceled checker fails the request normally. Please keep the checkers running
until actual channel closure, or atomically mark the handler terminal and
immediately fail every later push/fetch registration.
##########
common/src/main/java/org/apache/celeborn/common/network/client/TransportClientFactory.java:
##########
@@ -239,23 +273,31 @@ public TransportClient createClient(
"DNS resolution {} for {} took {} ms", resolveMsg, resolvedAddress,
hostResolveTimeMs);
}
- synchronized (clientPool.locks[clientIndex]) {
- cachedClient = clientPool.clients[clientIndex];
-
- if (cachedClient != null) {
- if (cachedClient.isActive()) {
- logger.debug(
- "Returning cached connection from {} to {}: {}",
- cachedClient.getChannel().localAddress(),
- resolvedAddress,
- cachedClient);
- return cachedClient;
- } else {
- logger.info("Found inactive connection to {}, creating a new one.",
resolvedAddress);
+ final int recreationCountBefore = workerGroupRecreationCount;
+ try {
+ synchronized (clientPool.locks[clientIndex]) {
+ cachedClient = clientPool.clients[clientIndex];
+
+ if (cachedClient != null) {
+ if (cachedClient.isActive()) {
+ logger.debug(
+ "Returning cached connection from {} to {}: {}",
+ cachedClient.getChannel().localAddress(),
+ resolvedAddress,
+ cachedClient);
+ return cachedClient;
+ } else {
+ logger.info("Found inactive connection to {}, creating a new
one.", resolvedAddress);
+ }
}
+ clientPool.clients[clientIndex] =
internalCreateClient(resolvedAddress, decoder);
Review Comment:
[P1] Preserve the displaced client until its outstanding work is failed.
With the default one connection per peer, reconnecting to the same peer
replaces the dead `cachedClient` here before the `finally` block runs
`failClientsOnDeadEventLoopsIfRecreated()`. That sweep walks only the current
pool slots, so the displaced client is already unreachable: its outstanding RPC
never completes, and its dead channel remains tracked. I reproduced the actual
recovery path on this head: the replacement was active while the old callback
was never failed and its RPC remained outstanding. The new regression test
calls `failClientsOnDeadEventLoops()` directly while the old client is still
pooled, so it misses this case. Please retain the evicted client and
invalidate/untrack it after releasing the pool lock, including when
reconnection succeeds on another live loop without recreating the group.
##########
common/src/main/java/org/apache/celeborn/common/network/client/TransportClient.java:
##########
@@ -92,7 +93,53 @@ public Channel getChannel() {
}
public boolean isActive() {
- return !timedOut && (channel.isOpen() || channel.isActive());
+ // A client on a dead event loop can neither send nor complete anything,
so it must not be
+ // reused. See isEventLoopDead().
+ return !timedOut && !isEventLoopDead() && (channel.isOpen() ||
channel.isActive());
+ }
+
+ /**
+ * Whether this channel's netty event loop can no longer be relied on, i.e.
it has terminated or
+ * is shutting down. The motivating case is SPARK-58292: a channel is pinned
to one loop for its
+ * lifetime, and netty neither replaces a loop whose thread has died within
a fixed-size group nor
+ * stops handing it out.
+ *
+ * <p>Everything submitted to a terminated loop is silently dropped - the
write never happens, and
+ * the listener that would have failed the callback never runs either, since
netty's {@code
+ * safeExecute} only logs the rejection. So a request issued here orphans
rather than failing,
+ * {@code channelInactive()} is never delivered, and even {@code close()}
cannot take effect
+ * because it is itself submitted to the dead loop. Nothing but an explicit
sweep recovers such a
+ * client.
+ *
+ * <p>Deliberately keyed on {@code isShuttingDown()} rather than the exact
{@code isShutdown()}
+ * that netty's "event executor terminated" rejection uses: a loop in {@code
shutdownGracefully}'s
+ * quiet period would still drain its queue, so this is conservative. The
one visible consequence
+ * is that a best-effort message guarded by {@link #isActive()} - e.g. the
BUFFER_STREAM_END a
+ * reader sends on close - is skipped while the owning factory is closing.
The server reclaims
+ * those streams when the connection drops, and refusing new work on a group
that is going away is
+ * what we want anyway.
+ */
+ public boolean isEventLoopDead() {
+ return channel.eventLoop().isShuttingDown();
+ }
+
+ /**
+ * Invalidate this client if its event loop has died, by failing every
outstanding request so that
+ * owners holding the client directly - rather than reacquiring it from
{@link
+ * TransportClientFactory} - are notified instead of waiting for a
completion that can never come.
+ * No-op for a healthy client, and idempotent.
+ */
+ public void invalidateIfEventLoopDead() {
Review Comment:
[P1] Notify established inbound streams when invalidating a dead client.
An established Flink credit stream normally has no outstanding RPC:
`OPEN_STREAM` and subsequent credit/segment updates have already been
acknowledged. Its ownership instead lives in `ReadClientHandler.streamClients`,
and `ReadClientHandler.channelInactive()` is what sends `TransportableError` to
the corresponding stream readers. This method only drains the response handler
and never invokes the inbound request handler, while a genuinely dead loop
cannot deliver `channelInactive()` itself. Consequently, the recovery sweep
notifies nobody and an already-open Flink reader remains stalled indefinitely.
I reproduced that dead-loop invalidation leaves an installed inbound handler
unnotified, whereas the normal `TransportChannelHandler.channelInactive()` path
notifies it. Please propagate dead-client invalidation to the request/inbound
stream handler exactly once.
##########
common/src/main/java/org/apache/celeborn/common/network/client/TransportClient.java:
##########
@@ -179,6 +226,12 @@ public long sendRpc(ByteBuffer message,
RpcResponseCallback callback) {
}
long requestId = requestId();
+ if (isEventLoopDead()) {
Review Comment:
[P1] Close the race between the dead-loop check and RPC registration.
`sendRpc()` checks the loop before adding its callback to `outstandingRpcs`.
A sender can observe a live loop, pause, and let another thread observe the
loop die, recreate the group, and finish the entire invalidation sweep while
this callback is not yet in the map. The sender then registers its callback and
writes to the dead loop; neither the write listener nor `channelInactive()`
runs, RPCs have no timeout checker, and no further sweep occurs unless another
recreation happens. A controlled interleaving on this head leaves the RPC
outstanding after the sweep with its callback never invoked. Please register
before rechecking/invalidation, or synchronize registration with terminal
invalidation so neither operation can miss the other.
##########
common/src/main/java/org/apache/celeborn/common/network/client/TransportClientFactory.java:
##########
@@ -317,29 +379,45 @@ public void initChannel(SocketChannel ch) {
// Connect to the remote server
long preConnect = System.nanoTime();
ChannelFuture cf = bootstrap.connect(address);
- if (connectTimeoutMs <= 0) {
- awaitWithChannelCleanup(
- () -> {
- cf.await();
- return true;
- },
- cf);
- assert cf.isDone();
- if (cf.isCancelled()) {
+ try {
+ if (connectTimeoutMs <= 0) {
+ awaitWithChannelCleanup(
+ () -> {
+ cf.await();
+ return true;
+ },
+ cf);
+ assert cf.isDone();
+ if (cf.isCancelled()) {
+ closeChannel(cf);
+ throw new IOException(String.format("Connecting to %s cancelled",
address));
+ } else if (!cf.isSuccess()) {
+ closeChannel(cf);
+ throw new IOException(String.format("Failed to connect to %s",
address), cf.cause());
+ }
+ } else if (!awaitWithChannelCleanup(() -> cf.await(connectTimeoutMs),
cf)) {
closeChannel(cf);
- throw new IOException(String.format("Connecting to %s cancelled",
address));
- } else if (!cf.isSuccess()) {
+ throw new CelebornIOException(
+ String.format("Connecting to %s timed out (%s ms)", address,
connectTimeoutMs));
+ } else if (cf.cause() != null) {
closeChannel(cf);
- throw new IOException(String.format("Failed to connect to %s",
address), cf.cause());
+ throw new CelebornIOException(
+ String.format("Failed to connect to %s", address), cf.cause());
+ }
+ } catch (IOException e) {
+ // Registration may have been rejected because the loop it landed on is
dead, which degrades
+ // the whole group permanently. Replace it, then reconnect straight away
so the request that
+ // triggered the recovery is the first to benefit from it rather than
the one that pays.
+ if (recreateWorkerGroupIfEventLoopDead(connectGroup, cf.cause())
+ && retryOnRecreatedWorkerGroup) {
+ logger.warn("Retrying the connection to {} on a fresh worker group",
address, e);
+ // Reusing `decoder` is safe here: the dead loop rejected the channel
registration, so the
+ // ChannelInitializer above never ran and the decoder was never added
to a pipeline.
+ return internalCreateClient(address, decoder, false);
Review Comment:
[P2] Create a fresh decoder for the replacement connection.
The comment assumes `"event executor terminated"` means the original
`ChannelInitializer` never ran, but Netty 4.2.10 can initialize/register the
channel and install this decoder before `Bootstrap.doConnect()` submits the
actual connect task to the event loop. If the loop dies between those steps,
the same terminated-executor rejection triggers this retry with a decoder that
is already present in the old pipeline. Both `TransportFrameDecoder` and the
Flink decoder are non-`@Sharable`; I reproduced Netty rejecting the second
installation with `ChannelPipelineException`. Recovery therefore fails despite
creating a healthy replacement, including for unmanaged clients and
`maxRetries=1`. Please pass a decoder supplier through the retry and allocate a
fresh handler for the second channel.
##########
common/src/main/java/org/apache/celeborn/common/network/client/TransportClientFactory.java:
##########
@@ -432,6 +510,181 @@ static boolean awaitWithChannelCleanup(
}
}
+ /**
+ * If the given connection-failure cause was a rejection by a dead netty
event loop (its worker
+ * thread terminated and netty rejects new registrations with a {@link
+ * RejectedExecutionException}), replace the worker group so subsequent
connections bind to fresh,
+ * live threads. Without this the degradation is permanent - see {@link
+ * TransportClient#isEventLoopDead()}.
+ *
+ * @return whether the caller may now retry: either this call replaced the
group, or a concurrent
+ * caller already did and the current group is therefore a fresh one.
+ */
+ private boolean recreateWorkerGroupIfEventLoopDead(EventLoopGroup
connectGroup, Throwable cause) {
+ if (!recreateWorkerGroupOnDeadEventLoop) {
+ return false;
+ }
+ boolean eventLoopDead = false;
+ for (Throwable t = cause; t != null; t = t.getCause()) {
+ // Match ONLY the terminated-loop rejection, not a transient
task-queue-full rejection.
+ // 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())) {
+ eventLoopDead = true;
+ break;
+ }
+ }
+ return eventLoopDead && recreateWorkerGroup(connectGroup);
+ }
+
+ /**
+ * Replace the worker group with a fresh one, if it is still the group the
failed connection used
+ * ({@code connectGroup}). The superseded group is not shut down here: its
still-live threads may
+ * be serving already-open channels, so it is retired by {@link
#retireWorkerGroupIfDrained}
+ * instead. Synchronized and identity-guarded so concurrent callers that all
hit the same dead
+ * group replace it exactly once rather than spawning many groups.
+ *
+ * @return whether a fresh group is now installed and the caller may retry
on it.
+ */
+ private synchronized boolean recreateWorkerGroup(EventLoopGroup
connectGroup) {
+ // The factory is closed (or closing): its worker group was shut down by
close(), so a
+ // createClient() racing or following close() must not recreate a fresh
group and resurrect a
+ // closed factory (which would leak threads that close() will never reap
again).
+ if (closed) {
+ return false;
+ }
+ // A concurrent caller that hit the same dead group already swapped it
out. Nothing to do, but
+ // the current group is a fresh one, so the caller can still retry on it.
+ if (workerGroup != connectGroup) {
+ return true;
+ }
+ // Tag the thread names so a dead-event-loop recovery is obvious in a
thread dump, and so
+ // successive recreations stay distinguishable if a loop dies more than
once.
+ workerGroupRecreationCount++;
+ TransportConf conf = context.getConf();
+ String threadPrefix = conf.getModuleName() + "-client-recreated-" +
workerGroupRecreationCount;
+ workerGroup =
+ NettyUtils.createEventLoop(
+ ioMode, conf.clientThreads(), conf.conflictAvoidChooserEnable(),
threadPrefix);
+ supersededWorkerGroups.add(connectGroup);
+ logger.warn(
+ "Detected a dead netty event loop in the {} client worker group;
replaced it with {}. "
+ + "The superseded group keeps serving its {} already-open channels
until they drain "
+ + "(SPARK-58292).",
+ conf.getModuleName(),
+ threadPrefix,
+ channelCount(connectGroup));
+ // If it has no channels left, nothing will ever untrack one on its
behalf. Check once, here.
+ retireWorkerGroupIfDrained(connectGroup);
Review Comment:
[P2] Include in-flight registrations when deciding whether the old group
drained.
`trackChannel()` is called only after a connect completes, but this
immediate retirement treats an empty tracked-channel set as proof that the old
group has no users. With multiple loops, a concurrent connection can already be
registered on a still-healthy loop in that group while its connect is pending
and its channel has not yet reached `trackChannel()`. Recovery on another dead
loop then shuts down the entire old group and closes the otherwise valid
in-flight connection. I reproduced this with a real registered
`NioSocketChannel`: recreation observed no tracked channels, shut down the old
group, and closed the registered channel. Please track pending registrations
under the same retirement coordination, or register channels before testing
whether the group has drained.
##########
common/src/main/java/org/apache/celeborn/common/network/client/TransportClientFactory.java:
##########
@@ -432,6 +510,181 @@ static boolean awaitWithChannelCleanup(
}
}
+ /**
+ * If the given connection-failure cause was a rejection by a dead netty
event loop (its worker
+ * thread terminated and netty rejects new registrations with a {@link
+ * RejectedExecutionException}), replace the worker group so subsequent
connections bind to fresh,
+ * live threads. Without this the degradation is permanent - see {@link
+ * TransportClient#isEventLoopDead()}.
+ *
+ * @return whether the caller may now retry: either this call replaced the
group, or a concurrent
+ * caller already did and the current group is therefore a fresh one.
+ */
+ private boolean recreateWorkerGroupIfEventLoopDead(EventLoopGroup
connectGroup, Throwable cause) {
+ if (!recreateWorkerGroupOnDeadEventLoop) {
+ return false;
+ }
+ boolean eventLoopDead = false;
+ for (Throwable t = cause; t != null; t = t.getCause()) {
+ // Match ONLY the terminated-loop rejection, not a transient
task-queue-full rejection.
+ // 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())) {
+ eventLoopDead = true;
+ break;
+ }
+ }
+ return eventLoopDead && recreateWorkerGroup(connectGroup);
+ }
+
+ /**
+ * Replace the worker group with a fresh one, if it is still the group the
failed connection used
+ * ({@code connectGroup}). The superseded group is not shut down here: its
still-live threads may
+ * be serving already-open channels, so it is retired by {@link
#retireWorkerGroupIfDrained}
+ * instead. Synchronized and identity-guarded so concurrent callers that all
hit the same dead
+ * group replace it exactly once rather than spawning many groups.
+ *
+ * @return whether a fresh group is now installed and the caller may retry
on it.
+ */
+ private synchronized boolean recreateWorkerGroup(EventLoopGroup
connectGroup) {
+ // The factory is closed (or closing): its worker group was shut down by
close(), so a
+ // createClient() racing or following close() must not recreate a fresh
group and resurrect a
+ // closed factory (which would leak threads that close() will never reap
again).
+ if (closed) {
+ return false;
+ }
+ // A concurrent caller that hit the same dead group already swapped it
out. Nothing to do, but
+ // the current group is a fresh one, so the caller can still retry on it.
+ if (workerGroup != connectGroup) {
+ return true;
+ }
+ // Tag the thread names so a dead-event-loop recovery is obvious in a
thread dump, and so
+ // successive recreations stay distinguishable if a loop dies more than
once.
+ workerGroupRecreationCount++;
+ TransportConf conf = context.getConf();
+ String threadPrefix = conf.getModuleName() + "-client-recreated-" +
workerGroupRecreationCount;
+ workerGroup =
+ NettyUtils.createEventLoop(
+ ioMode, conf.clientThreads(), conf.conflictAvoidChooserEnable(),
threadPrefix);
+ supersededWorkerGroups.add(connectGroup);
+ logger.warn(
+ "Detected a dead netty event loop in the {} client worker group;
replaced it with {}. "
+ + "The superseded group keeps serving its {} already-open channels
until they drain "
+ + "(SPARK-58292).",
+ conf.getModuleName(),
+ threadPrefix,
+ channelCount(connectGroup));
+ // If it has no channels left, nothing will ever untrack one on its
behalf. Check once, here.
+ retireWorkerGroupIfDrained(connectGroup);
+ return true;
+ }
+
+ /**
+ * Register a newly connected channel against the worker group it is pinned
to. Tracking exists
+ * solely so a superseded group can be retired, which cannot happen unless
recreation is enabled,
+ * so skip the bookkeeping entirely when it is off.
+ */
+ private void trackChannel(EventLoopGroup group, Channel channel) {
+ if (!recreateWorkerGroupOnDeadEventLoop) {
+ return;
+ }
+ workerGroupChannels
+ .computeIfAbsent(group, unused -> ConcurrentHashMap.newKeySet())
+ .add(channel);
+ channel.closeFuture().addListener(future -> untrackChannel(group,
channel));
+ }
+
+ /** Called from the channel's close future, and from {@link
#failClientsOnDeadEventLoops()}. */
+ private void untrackChannel(EventLoopGroup group, Channel channel) {
+ Set<Channel> channels = workerGroupChannels.get(group);
+ if (channels != null) {
+ channels.remove(channel);
+ }
+ retireWorkerGroupIfDrained(group);
+ }
+
+ /**
+ * Shut down a superseded worker group once it has no channels left to
serve. It cannot simply be
+ * dropped and left to the GC: a netty thread keeps its executor, and the
executor its parent
+ * group, strongly reachable. So without this, one dead event loop would
cost the process the
+ * group's other clientThreads() - 1 selector threads for the lifetime of
the factory, and
+ * repeated recoveries would accumulate them.
+ *
+ * <p>Best-effort: a connection that captured this group before it was
superseded may still
+ * register a channel afterwards, but such a connection is failing anyway,
and {@link #close()}
+ * remains the backstop for any group that never drains.
+ */
+ private synchronized void retireWorkerGroupIfDrained(EventLoopGroup group) {
+ if (closed || group == workerGroup) {
+ return;
+ }
+ Set<Channel> channels = workerGroupChannels.get(group);
+ if (channels != null && !channels.isEmpty()) {
+ return;
+ }
+ workerGroupChannels.remove(group);
+ if (supersededWorkerGroups.remove(group) && !group.isShuttingDown()) {
+ logger.info(
+ "A superseded {} client worker group has drained; shutting it down.
{} superseded "
+ + "group(s) still retained.",
+ context.getConf().getModuleName(),
+ supersededWorkerGroups.size());
+ group.shutdownGracefully();
+ }
+ }
+
+ /** Number of channels currently tracked as open on the given worker group.
*/
+ private int channelCount(EventLoopGroup group) {
+ Set<Channel> channels = workerGroupChannels.get(group);
+ return channels == null ? 0 : channels.size();
+ }
+
+ /** Sweep only if the worker group has been recreated since {@code
recreationCountBefore}. */
+ private void failClientsOnDeadEventLoopsIfRecreated(int
recreationCountBefore) {
+ if (workerGroupRecreationCount == recreationCountBefore) {
+ return;
+ }
+ try {
+ failClientsOnDeadEventLoops();
+ } catch (Throwable t) {
+ // Never let this mask the outcome of the createClient call it is
attached to.
+ logger.warn("Error while invalidating clients pinned to a dead netty
event loop", t);
+ }
+ }
+
+ /**
+ * Synchronously fail the outstanding requests of every pooled client pinned
to a dead event loop.
+ * Marking such a client inactive stops the factory handing it out again,
but does nothing for
+ * whoever already holds it: an owner that keeps a client for the lifetime
of a stream - e.g.
+ * Flink's {@code CelebornBufferStream}, which sends credits on the client
it captured rather than
+ * reacquiring one - would otherwise wait forever. Failing its callbacks is
the only way it can
+ * notice; the client cannot be force-closed. See {@link
TransportClient#isEventLoopDead()}.
+ *
+ * <p>MUST be called with no pool lock held: failing a request invokes its
callback on this
+ * thread, and callbacks re-enter the factory.
+ *
+ * <p>Only pooled clients are reachable from here, so a client handed out by
{@link
+ * #createUnmanagedClient} is left to its owner to invalidate via {@link
+ * TransportClient#invalidateIfEventLoopDead()}.
+ */
+ @VisibleForTesting
+ public void failClientsOnDeadEventLoops() {
+ for (ClientPool clientPool : connectionPool.values()) {
Review Comment:
[P2] Untrack dead unmanaged channels before retiring superseded groups.
`createUnmanagedClient()` still registers its channel in
`workerGroupChannels`, but this sweep visits only `connectionPool`. If an
unmanaged channel is pinned to the dead loop, its `closeFuture()` never
completes, so it is never untracked; even calling
`TransportClient.invalidateIfEventLoopDead()` only fails response callbacks and
does not inform the factory. The superseded group therefore appears permanently
nonempty, keeping its remaining selector threads alive until the whole factory
closes. I reproduced a tracked dead unmanaged channel surviving both the pool
sweep and owner-side invalidation while `supersededWorkerGroupCount()` remains
one. Please sweep the tracked channels themselves and explicitly remove
dead-loop channels regardless of whether their clients were pooled.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]