This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 3750d4b83995 CAMEL-26574: camel-netty - Client mode consumer: close
the connection at stop, reconnect without blocking the event loop (#29199)
3750d4b83995 is described below
commit 3750d4b83995790d45dc7fe7d71cf6ec463b7f64
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Nov 5 12:34:29 2026 +0530
CAMEL-26574: camel-netty - Client mode consumer: close the connection at
stop, reconnect without blocking the event loop (#29199)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../ClientModeTCPNettyServerBootstrapFactory.java | 177 +++++++++++--
.../netty/NettyConsumerClientModeStopTest.java | 284 +++++++++++++++++++++
2 files changed, 436 insertions(+), 25 deletions(-)
diff --git
a/components/camel-netty/src/main/java/org/apache/camel/component/netty/ClientModeTCPNettyServerBootstrapFactory.java
b/components/camel-netty/src/main/java/org/apache/camel/component/netty/ClientModeTCPNettyServerBootstrapFactory.java
index eee5bcbec220..ec7be44930b9 100644
---
a/components/camel-netty/src/main/java/org/apache/camel/component/netty/ClientModeTCPNettyServerBootstrapFactory.java
+++
b/components/camel-netty/src/main/java/org/apache/camel/component/netty/ClientModeTCPNettyServerBootstrapFactory.java
@@ -18,8 +18,13 @@ package org.apache.camel.component.netty;
import java.net.ConnectException;
import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
@@ -31,6 +36,7 @@ import io.netty.channel.EventLoop;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.epoll.EpollSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
+import io.netty.util.concurrent.ScheduledFuture;
import org.apache.camel.CamelContext;
import org.apache.camel.support.service.ServiceSupport;
import org.slf4j.Logger;
@@ -48,8 +54,14 @@ public class ClientModeTCPNettyServerBootstrapFactory
extends ServiceSupport imp
private NettyServerBootstrapConfiguration configuration;
private ChannelInitializer<Channel> pipelineFactory;
private Bootstrap clientBootstrap;
- private Channel channel;
private EventLoopGroup workerGroup;
+ // guards the fields below: the lifecycle thread and the event loops
(reconnect) use them
+ private final Lock reconnectLock = new ReentrantLock();
+ private Channel channel;
+ private ChannelFuture pendingConnect;
+ private ScheduledFuture<?> reconnectTask;
+ private boolean stopping;
+ private int generation;
public ClientModeTCPNettyServerBootstrapFactory() {
}
@@ -134,6 +146,14 @@ public class ClientModeTCPNettyServerBootstrapFactory
extends ServiceSupport imp
LOG.debug("Created ClientBootstrap {}", clientBootstrap);
clientBootstrap.handler(pipelineFactory);
+ reconnectLock.lock();
+ try {
+ // a new start: callbacks of an earlier start do nothing
+ stopping = false;
+ generation++;
+ } finally {
+ reconnectLock.unlock();
+ }
ChannelFuture channelFuture
= clientBootstrap.connect(new
InetSocketAddress(configuration.getHost(), configuration.getPort()));
@@ -141,25 +161,109 @@ public class ClientModeTCPNettyServerBootstrapFactory
extends ServiceSupport imp
configuration.getHost(), configuration.getPort(),
clientBootstrap);
LOG.info("ClientModeServerBootstrap binding to {}:{}",
configuration.getHost(), configuration.getPort());
- channel = openChannel(channelFuture);
+ Channel answer = openChannel(channelFuture);
+ reconnectLock.lock();
+ try {
+ if (answer != null && channel == null) {
+ channel = answer;
+ }
+ } finally {
+ reconnectLock.unlock();
+ }
}
protected void stopServerBootstrap() {
// close all channels
LOG.info("ClientModeServerBootstrap unbinding from {}:{}",
configuration.getHost(), configuration.getPort());
+ // the worker group may be shared (workerGroup option), so the
channel, a connect in progress and a
+ // scheduled reconnect must be closed and cancelled here and not left
to the shutdown of the group
+ List<Channel> channels = new ArrayList<>(2);
+ reconnectLock.lock();
+ try {
+ stopping = true;
+ if (reconnectTask != null) {
+ reconnectTask.cancel(false);
+ reconnectTask = null;
+ }
+ if (pendingConnect != null) {
+ channels.add(pendingConnect.channel());
+ pendingConnect = null;
+ }
+ if (channel != null) {
+ channels.add(channel);
+ channel = null;
+ }
+ } finally {
+ reconnectLock.unlock();
+ }
+ for (Channel ch : channels) {
+ try {
+ ChannelFuture closeFuture = ch.close();
+ // cannot wait on the event loop of the channel (a route
stopped from its own processing)
+ if (!ch.eventLoop().inEventLoop()) {
+
closeFuture.awaitUninterruptibly(configuration.getShutdownTimeout());
+ }
+ } catch (RuntimeException e) {
+ // a channel that never got an event loop (its creation or
registration failed) has nothing to close
+ LOG.debug("Cannot close channel {} due to {}. This exception
is ignored.", ch, e.getMessage(), e);
+ }
+ }
+
if (workerGroup != null) {
workerGroup.shutdownGracefully();
workerGroup = null;
}
}
+ /**
+ * Connects again if the channel is not active and the consumer is not
stopping. Does not wait for the connect (it
+ * may run on an event loop): the outcome is handled by a listener.
+ */
protected void doReconnectIfNeeded() throws Exception {
- if (channel == null || !channel.isActive()) {
+ doReconnectIfNeeded(currentGeneration());
+ }
+
+ private void doReconnectIfNeeded(int gen) {
+ // resolve the address before taking the lock (may do a DNS lookup)
+ InetSocketAddress address = new
InetSocketAddress(configuration.getHost(), configuration.getPort());
+ reconnectLock.lock();
+ try {
+ // a stopped or restarted consumer, or a connect already in
progress, does not connect again
+ if (stopping || gen != generation || pendingConnect != null ||
(channel != null && channel.isActive())) {
+ return;
+ }
LOG.debug("ClientModeServerBootstrap re-connect to {}:{}",
configuration.getHost(), configuration.getPort());
- ChannelFuture connectFuture
- = clientBootstrap.connect(new
InetSocketAddress(configuration.getHost(), configuration.getPort()));
- channel = openChannel(connectFuture);
+ final ChannelFuture connectFuture =
clientBootstrap.connect(address);
+ pendingConnect = connectFuture;
+ connectFuture.addListener((ChannelFutureListener) f ->
onReconnectComplete(f, gen));
+ } finally {
+ reconnectLock.unlock();
+ }
+ }
+
+ private void onReconnectComplete(ChannelFuture connectFuture, int gen) {
+ reconnectLock.lock();
+ try {
+ if (pendingConnect == connectFuture) {
+ pendingConnect = null;
+ }
+ if (stopping || gen != generation) {
+ // the consumer was stopped or restarted while connecting
+ connectFuture.channel().close();
+ return;
+ }
+ if (connectFuture.isSuccess()) {
+ channel = connectFuture.channel();
+ LOG.debug("ClientModeServerBootstrap re-connected to {}",
configuration.getAddress());
+ closeFutureReconnect(channel, gen);
+ } else {
+ LOG.debug("Re-connect to {} failed. Will attempt again in {}
millis.", configuration.getAddress(),
+ configuration.getReconnectInterval(),
connectFuture.cause());
+ scheduleReconnect(eventLoopOf(connectFuture.channel()), gen);
+ }
+ } finally {
+ reconnectLock.unlock();
}
}
@@ -172,10 +276,11 @@ public class ClientModeTCPNettyServerBootstrapFactory
extends ServiceSupport imp
// wait for the channel to be open (see io.netty.channel.ChannelFuture
javadoc for example/recommendation)
channelFuture.awaitUninterruptibly();
+ int gen = currentGeneration();
if (!channelFuture.isDone() || !channelFuture.isSuccess()) {
//check if reconnect is enabled and schedule a reconnect, if from
handler then don't schedule a reconnect
if (configuration.isReconnect()) {
- scheduleReconnect(channelFuture);
+ scheduleReconnect(eventLoopOf(channelFuture.channel()), gen);
return null;
} else {
ConnectException cause = new ConnectException("Cannot connect
to " + configuration.getAddress());
@@ -191,31 +296,53 @@ public class ClientModeTCPNettyServerBootstrapFactory
extends ServiceSupport imp
LOG.debug("Creating connector to address: {}",
configuration.getAddress());
}
+ closeFutureReconnect(answer, gen);
+ return answer;
+ }
+
+ private void closeFutureReconnect(Channel answer, int gen) {
// schedule a reconnect to happen when the channel closes
if (configuration.isReconnect()) {
- answer.closeFuture().addListener(new ChannelFutureListener() {
- @Override
- public void operationComplete(ChannelFuture future) throws
Exception {
- scheduleReconnect(channelFuture);
+ answer.closeFuture().addListener((ChannelFutureListener) f ->
scheduleReconnect(answer.eventLoop(), gen));
+ }
+ }
+
+ private void scheduleReconnect(final EventLoop loop, final int gen) {
+ reconnectLock.lock();
+ try {
+ if (stopping || gen != generation) {
+ return;
+ }
+ reconnectTask = loop.schedule(() -> {
+ try {
+ LOG.trace("Re-connecting to {} if needed",
configuration.getAddress());
+ doReconnectIfNeeded(gen);
+ } catch (Exception e) {
+ LOG.warn("Error during re-connect to {}. Will attempt
again in {} millis. This exception is ignored.",
+ configuration.getAddress(),
configuration.getReconnectInterval(),
+ e);
+ scheduleReconnect(loop, gen);
}
- });
+ }, configuration.getReconnectInterval(), TimeUnit.MILLISECONDS);
+ } catch (RejectedExecutionException e) {
+ LOG.debug("Cannot schedule a re-connect to {} as the event loop is
shutting down", configuration.getAddress());
+ } finally {
+ reconnectLock.unlock();
}
+ }
- return answer;
+ private EventLoop eventLoopOf(Channel ch) {
+ // a channel whose registration failed has no event loop
+ return ch.isRegistered() ? ch.eventLoop() :
clientBootstrap.config().group().next();
}
- private void scheduleReconnect(final ChannelFuture channelFuture) {
- final EventLoop loop = channelFuture.channel().eventLoop();
- loop.schedule(() -> {
- try {
- LOG.trace("Re-connecting to {} if needed",
configuration.getAddress());
- doReconnectIfNeeded();
- } catch (Exception e) {
- LOG.warn("Error during re-connect to {}. Will attempt again in
{} millis. This exception is ignored.",
- configuration.getAddress(),
configuration.getReconnectInterval(),
- e);
- }
- }, configuration.getReconnectInterval(), TimeUnit.MILLISECONDS);
+ private int currentGeneration() {
+ reconnectLock.lock();
+ try {
+ return generation;
+ } finally {
+ reconnectLock.unlock();
+ }
}
}
diff --git
a/components/camel-netty/src/test/java/org/apache/camel/component/netty/NettyConsumerClientModeStopTest.java
b/components/camel-netty/src/test/java/org/apache/camel/component/netty/NettyConsumerClientModeStopTest.java
new file mode 100644
index 000000000000..0e00533629f8
--- /dev/null
+++
b/components/camel-netty/src/test/java/org/apache/camel/component/netty/NettyConsumerClientModeStopTest.java
@@ -0,0 +1,284 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.netty;
+
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import io.netty.handler.codec.string.StringEncoder;
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A consumer with clientMode=true closes its connection and stops
reconnecting when it stops, also with a shared worker
+ * group, and reconnects with a single worker thread.
+ */
+class NettyConsumerClientModeStopTest extends BaseNettyTest {
+
+ @BindToRegistry("sharedWorkerGroup")
+ private final EventLoopGroup sharedWorkerGroup
+ = new
NettyWorkerPoolBuilder().withWorkerCount(2).withName("SharedClientWorker").build();
+
+ @BindToRegistry("sharedSingleWorkerGroup")
+ private final EventLoopGroup sharedSingleWorkerGroup
+ = new
NettyWorkerPoolBuilder().withWorkerCount(1).withName("SharedSingleClientWorker").build();
+
+ // holds the event loop of the consumer once its reconnect has started to
connect
+ @BindToRegistry("holdReconnect")
+ private final ServerInitializerFactory holdReconnect = new
HoldReconnectInitializerFactory();
+ private final AtomicInteger initializedChannels = new AtomicInteger();
+ private final CountDownLatch reconnectStarted = new CountDownLatch(1);
+ private final CountDownLatch releaseEventLoop = new CountDownLatch(1);
+
+ // the server side of each connection the consumer opened, in order
+ private final BlockingQueue<Channel> connections = new
LinkedBlockingQueue<>();
+ private EventLoopGroup serverBossGroup;
+ private EventLoopGroup serverWorkerGroup;
+
+ NettyConsumerClientModeStopTest() {
+ testConfiguration().withUseRouteBuilder(false);
+ }
+
+ @AfterEach
+ void shutdownGroups() {
+ releaseEventLoop.countDown();
+ // stop the consumers before the event loops they use
+ context.stop();
+ if (serverBossGroup != null) {
+ serverBossGroup.shutdownGracefully(0, 1,
TimeUnit.SECONDS).awaitUninterruptibly(10, TimeUnit.SECONDS);
+ serverWorkerGroup.shutdownGracefully(0, 1,
TimeUnit.SECONDS).awaitUninterruptibly(10, TimeUnit.SECONDS);
+ }
+ sharedWorkerGroup.shutdownGracefully(0, 1,
TimeUnit.SECONDS).awaitUninterruptibly(10, TimeUnit.SECONDS);
+ sharedSingleWorkerGroup.shutdownGracefully(0, 1,
TimeUnit.SECONDS).awaitUninterruptibly(10, TimeUnit.SECONDS);
+ }
+
+ @Test
+ void stopClosesTheConnectionWithASharedWorkerGroup() throws Exception {
+ startServer();
+ addClientRoute("workerGroup=#sharedWorkerGroup&reconnect=false");
+ Channel first = nextConnection();
+
+ context.getRouteController().stopRoute("client");
+
+ assertTrue(first.closeFuture().await(10, TimeUnit.SECONDS), "the
connection of the stopped consumer is closed");
+ }
+
+ @Test
+ void noReconnectAfterStopWithASharedWorkerGroup() throws Exception {
+ startServer();
+
addClientRoute("workerGroup=#sharedWorkerGroup&reconnect=true&reconnectInterval=100");
+ Channel first = nextConnection();
+
+ context.getRouteController().stopRoute("client");
+ // the server drops the connection (if the stop did not close it)
+ first.close().sync();
+
+ // ten reconnect intervals
+ assertNull(connections.poll(1, TimeUnit.SECONDS), "a connection opened
after the stop");
+ }
+
+ @Test
+ void stopWhileReconnectingWithASharedWorkerGroup() throws Exception {
+ startServer();
+ addClientRoute(
+
"workerGroup=#sharedSingleWorkerGroup&reconnect=true&reconnectInterval=100&serverInitializerFactory=#holdReconnect");
+ nextConnection().close().sync();
+ // the reconnect has started to connect, and its event loop is held
before the connect completes
+ assertTrue(reconnectStarted.await(10, TimeUnit.SECONDS), "the
reconnect started");
+
+ context.getRouteController().stopRoute("client");
+ releaseEventLoop.countDown();
+
+ // a connection the reconnect made anyway is closed, and none follows
+ Channel late = connections.poll(1, TimeUnit.SECONDS);
+ if (late != null) {
+ assertTrue(late.closeFuture().await(10, TimeUnit.SECONDS),
+ "the connection of the reconnect in progress at the stop
is closed");
+ }
+ assertNull(connections.poll(1, TimeUnit.SECONDS), "a connection opened
after the stop");
+ }
+
+ @Test
+ void restartHasOneConnectionWithASharedWorkerGroup() throws Exception {
+ startServer();
+
addClientRoute("workerGroup=#sharedWorkerGroup&reconnect=true&reconnectInterval=100");
+ Channel first = nextConnection();
+
+ context.getRouteController().stopRoute("client");
+ context.getRouteController().startRoute("client");
+ Channel second = nextConnection();
+
+ assertTrue(first.closeFuture().await(10, TimeUnit.SECONDS), "the
connection of the first start is closed");
+ MockEndpoint mock = getMockEndpoint("mock:received");
+ mock.expectedBodiesReceived("Hello");
+ second.writeAndFlush("Hello\n");
+ mock.assertIsSatisfied();
+ assertNull(connections.poll(1, TimeUnit.SECONDS), "a third
connection");
+ }
+
+ @Test
+ void reconnectsAfterTheServerDroppedTheConnectionWithASharedWorkerGroup()
throws Exception {
+ startServer();
+
addClientRoute("workerGroup=#sharedWorkerGroup&reconnect=true&reconnectInterval=100");
+ nextConnection().close().sync();
+
+ Channel second = nextConnection();
+ MockEndpoint mock = getMockEndpoint("mock:received");
+ mock.expectedBodiesReceived("Hello");
+ second.writeAndFlush("Hello\n");
+ mock.assertIsSatisfied();
+ }
+
+ @Test
+ void twoConsumersReconnectWithASharedWorkerGroup() throws Exception {
+ startServer();
+ addClientRoute("client",
"workerGroup=#sharedWorkerGroup&reconnect=true&reconnectInterval=10");
+ addClientRoute("client2",
"workerGroup=#sharedWorkerGroup&reconnect=true&reconnectInterval=11");
+
+ // the server drops both connections at once, a few times: the
reconnects run at the same time on both event
+ // loops of the group, and each connects a channel that may be on the
other loop
+ Channel first = nextConnection();
+ Channel second = nextConnection();
+ for (int i = 0; i < 5; i++) {
+ first.close();
+ second.close().sync();
+ first = nextConnection();
+ second = nextConnection();
+ }
+ MockEndpoint mock = getMockEndpoint("mock:received");
+ mock.expectedBodiesReceivedInAnyOrder("Hello", "World");
+ first.writeAndFlush("Hello\n");
+ second.writeAndFlush("World\n");
+ mock.assertIsSatisfied();
+ // one connection per consumer: no reconnect chain was duplicated
+ assertNull(connections.poll(1, TimeUnit.SECONDS), "a third
connection");
+ }
+
+ @Test
+ void reconnectsWithOneWorkerThread() throws Exception {
+ // the server is not up yet: the consumer starts and schedules a
reconnect
+ addClientRoute("workerCount=1&reconnect=true&reconnectInterval=100");
+ startServer();
+
+ Channel first = nextConnection();
+ MockEndpoint mock = getMockEndpoint("mock:received");
+ mock.expectedBodiesReceived("Hello", "World");
+ first.writeAndFlush("Hello\n");
+ // and again after the server dropped the connection
+ first.close().sync();
+ nextConnection().writeAndFlush("World\n");
+ mock.assertIsSatisfied();
+ }
+
+ @Test
+ void stopClosesTheConnectionWithItsOwnWorkerGroup() throws Exception {
+ startServer();
+ addClientRoute("reconnect=true&reconnectInterval=100");
+ Channel first = nextConnection();
+
+ context.getRouteController().stopRoute("client");
+
+ assertTrue(first.closeFuture().await(10, TimeUnit.SECONDS), "the
connection of the stopped consumer is closed");
+ assertNull(connections.poll(1, TimeUnit.SECONDS), "a connection opened
after the stop");
+ }
+
+ private final class HoldReconnectInitializerFactory extends
ServerInitializerFactory {
+
+ @Override
+ public ServerInitializerFactory createPipelineFactory(NettyConsumer
consumer) {
+ return new DefaultServerInitializerFactory(consumer) {
+ @Override
+ protected void initChannel(Channel ch) throws Exception {
+ super.initChannel(ch);
+ if (initializedChannels.incrementAndGet() == 2) {
+ // the channel of the first reconnect: hold its event
loop until the test releases it
+ ch.eventLoop().execute(() -> {
+ try {
+ releaseEventLoop.await(20, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ });
+ reconnectStarted.countDown();
+ }
+ }
+ };
+ }
+
+ @Override
+ protected void initChannel(Channel ch) {
+ throw new UnsupportedOperationException("only used to create the
pipeline factory of the consumer");
+ }
+ }
+
+ private Channel nextConnection() throws InterruptedException {
+ Channel channel = connections.poll(10, TimeUnit.SECONDS);
+ assertNotNull(channel, "a connection from the consumer");
+ return channel;
+ }
+
+ private void addClientRoute(String options) throws Exception {
+ addClientRoute("client", options);
+ }
+
+ private void addClientRoute(String routeId, String options) throws
Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("netty:tcp://localhost:{{port}}?textline=true&clientMode=true&" +
options).routeId(routeId)
+ .to("mock:received");
+ }
+ });
+ context.start();
+ }
+
+ private void startServer() throws InterruptedException {
+ serverBossGroup = new
NettyWorkerPoolBuilder().withWorkerCount(1).withName("TestServerBoss").build();
+ serverWorkerGroup = new
NettyWorkerPoolBuilder().withWorkerCount(1).withName("TestServerWorker").build();
+ new ServerBootstrap().group(serverBossGroup,
serverWorkerGroup).channel(NioServerSocketChannel.class)
+ .childHandler(new ChannelInitializer<SocketChannel>() {
+ @Override
+ protected void initChannel(SocketChannel ch) {
+ ch.pipeline().addLast(new StringEncoder());
+ ch.pipeline().addLast(new
ChannelInboundHandlerAdapter() {
+ @Override
+ public void channelActive(ChannelHandlerContext
ctx) {
+ connections.add(ctx.channel());
+ }
+ });
+ }
+ }).bind("localhost", getPort()).sync();
+ }
+}