This is an automated email from the ASF dual-hosted git repository.
oscerd 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 f571019e880f CAMEL-24932: camel-pulsar - do not kill the polling loop
on a receive error (#26778)
f571019e880f is described below
commit f571019e880f9a05651c3b5213f67b02ad815e7a
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Sep 25 10:56:18 2026 +0200
CAMEL-24932: camel-pulsar - do not kill the polling loop on a receive error
(#26778)
* CAMEL-24932: camel-pulsar - do not kill the polling loop on a receive
error
With messageListener=false the polling loop reported receive errors through
endpoint.getExceptionHandler(). That is DefaultEndpoint's @UriParam field,
which stays null unless the
route sets ?exceptionHandler=#bean; bridgeErrorHandler does not help
either, since configureConsumer
installs both handlers on the consumer, never on the endpoint. So any
PulsarClientException that is not
an interrupt threw a NullPointerException out of run(), into the Future
returned by submit() that
nobody reads, and that consumer stopped receiving in silence.
Use the consumer's exception handler, which DefaultConsumer always
initialises, exactly as
PulsarMessageListener already does.
Co-Authored-By: Claude Opus 5 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24932: camel-pulsar - address review feedback
Keeping the loop alive traded a dead thread for a hot one: a failure that
never clears - a closed
consumer, a consumer in Failed state, an unreachable broker - made
receive() throw straight away on
every iteration, on each consumer thread, reporting the same error to the
exception handler as fast as
the CPU allows, and creating a bridged exchange every time under
bridgeErrorHandler.
Treat AlreadyClosedException as terminal, like the interrupt, since there
is nothing left to poll. For
anything else wait a second before polling again, interruptibly, so a
stopping consumer still leaves
promptly. Reword both handler messages to "Error consuming from pulsar",
since the catches also cover
listener.received(...), not only the receive.
The test now makes the second receive report a closed consumer and asserts,
with Mockito after(), that
no third call follows - it proves the loop exits rather than spins, which
the previous version did not:
it only left the loop when shutdown interrupted it after the graceful
timeout.
Co-Authored-By: Claude Opus 5 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24932: camel-pulsar - use {@code} rather than the deprecated <tt>
in javadoc
Co-Authored-By: Claude Opus 5 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
---------
Signed-off-by: Andrea Cosentino <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
---
.../camel/component/pulsar/PulsarConsumer.java | 33 ++++-
.../pulsar/PulsarConsumerReceiveErrorTest.java | 142 +++++++++++++++++++++
2 files changed, 173 insertions(+), 2 deletions(-)
diff --git
a/components/camel-pulsar/src/main/java/org/apache/camel/component/pulsar/PulsarConsumer.java
b/components/camel-pulsar/src/main/java/org/apache/camel/component/pulsar/PulsarConsumer.java
index 62542de2f674..b4890387c9b8 100644
---
a/components/camel-pulsar/src/main/java/org/apache/camel/component/pulsar/PulsarConsumer.java
+++
b/components/camel-pulsar/src/main/java/org/apache/camel/component/pulsar/PulsarConsumer.java
@@ -41,6 +41,11 @@ import static
org.apache.camel.component.pulsar.utils.PulsarUtils.stopExecutors;
public class PulsarConsumer extends DefaultConsumer implements Suspendable {
private static final Logger LOGGER =
LoggerFactory.getLogger(PulsarConsumer.class);
+ /**
+ * How long a consumer thread waits after a failure it cannot classify,
before polling again.
+ */
+ private static final long ERROR_RETRY_DELAY_MILLIS = 1000;
+
private final PulsarEndpoint pulsarEndpoint;
private final ConsumerCreationStrategyFactory
consumerCreationStrategyFactory;
@@ -128,6 +133,10 @@ public class PulsarConsumer extends DefaultConsumer
implements Suspendable {
try {
Message<byte[]> msg = consumer.receive();
listener.received(consumer, msg);
+ } catch (PulsarClientException.AlreadyClosedException e) {
+ // the consumer is gone, so there is nothing left for this
loop to poll
+ LOGGER.info("Pulsar consumer is closed, exiting");
+ running = false;
} catch (PulsarClientException e) {
if (e.getCause() instanceof InterruptedException) {
// this means that our executor is shutting down
@@ -136,12 +145,32 @@ public class PulsarConsumer extends DefaultConsumer
implements Suspendable {
// by exiting the loop. We make it explicit instead of
breaking the loop.
running = false;
} else {
- endpoint.getExceptionHandler().handleException(e);
+ getExceptionHandler().handleException("Error consuming
from pulsar", e);
+ running = waitBeforeRetry();
}
} catch (Exception e) {
- endpoint.getExceptionHandler().handleException(e);
+ getExceptionHandler().handleException("Error consuming
from pulsar", e);
+ running = waitBeforeRetry();
}
}
}
+
+ /**
+ * Waits before polling again, so that a failure which does not clear
- an unreachable broker, a consumer in
+ * Failed state - does not turn this into a hot loop on every consumer
thread, reporting the same error to the
+ * exception handler as fast as the CPU allows.
+ *
+ * @return {@code false} when the wait was interrupted, which is how a
stopping consumer leaves the loop
+ */
+ private boolean waitBeforeRetry() {
+ try {
+ Thread.sleep(ERROR_RETRY_DELAY_MILLIS);
+ return true;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ LOGGER.info("Received shutdown signal, exiting");
+ return false;
+ }
+ }
}
}
diff --git
a/components/camel-pulsar/src/test/java/org/apache/camel/component/pulsar/PulsarConsumerReceiveErrorTest.java
b/components/camel-pulsar/src/test/java/org/apache/camel/component/pulsar/PulsarConsumerReceiveErrorTest.java
new file mode 100644
index 000000000000..db2bfceef9b6
--- /dev/null
+++
b/components/camel-pulsar/src/test/java/org/apache/camel/component/pulsar/PulsarConsumerReceiveErrorTest.java
@@ -0,0 +1,142 @@
+/*
+ * 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.pulsar;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.ExceptionHandler;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.pulsar.client.api.Consumer;
+import org.apache.pulsar.client.api.ConsumerBuilder;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.PulsarClientException;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Answers.RETURNS_SELF;
+import static org.mockito.Mockito.after;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * A receive error used to be reported through the endpoint exception handler,
which is null unless the route configures
+ * one, so the polling loop died on a NullPointerException and that consumer
stopped consuming in silence.
+ */
+public class PulsarConsumerReceiveErrorTest extends CamelTestSupport {
+
+ private static final String ROUTE_ID = "pulsar-receive-error";
+
+ private Consumer<byte[]> pulsarConsumer;
+
+ private final AtomicInteger receiveCalls = new AtomicInteger();
+
+ @Test
+ public void testReceiveErrorIsHandledAndTheLoopSurvives() throws Exception
{
+ final PulsarConsumer consumer = (PulsarConsumer)
context.getRoute(ROUTE_ID).getConsumer();
+
+ final CapturingExceptionHandler exceptionHandler = new
CapturingExceptionHandler();
+ consumer.setExceptionHandler(exceptionHandler);
+
+ context.getRouteController().startRoute(ROUTE_ID);
+
+ assertTrue(exceptionHandler.latch.await(10, TimeUnit.SECONDS),
+ "the receive error should be handed to the consumer exception
handler");
+ assertNotNull(exceptionHandler.captured.get(), "the failure cause
should be reported");
+ assertEquals("simulated receive failure",
exceptionHandler.captured.get().getMessage());
+
+ // the loop must still be polling: the first call failed, so a second
one proves it did not die
+ verify(pulsarConsumer, timeout(10000).times(2)).receive();
+
+ // the second call reported a closed consumer, which is terminal: the
loop must leave rather than
+ // spin on an error that will never clear
+ verify(pulsarConsumer, after(1500).times(2)).receive();
+ }
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ final CamelContext context = super.createCamelContext();
+
+ pulsarConsumer = mock(Consumer.class);
+ when(pulsarConsumer.receive()).thenAnswer(invocation -> {
+ if (receiveCalls.incrementAndGet() == 1) {
+ throw new PulsarClientException("simulated receive failure");
+ }
+ // a failure that never clears, which the loop must treat as
terminal
+ throw new PulsarClientException.AlreadyClosedException("consumer
is closed");
+ });
+
+ final ConsumerBuilder<byte[]> builder = mock(ConsumerBuilder.class,
RETURNS_SELF);
+ when(builder.subscribe()).thenReturn(pulsarConsumer);
+
+ final PulsarClient pulsarClient = mock(PulsarClient.class);
+ when(pulsarClient.newConsumer()).thenReturn(builder);
+
+ final PulsarComponent component = new PulsarComponent(context);
+ component.setPulsarClient(pulsarClient);
+ context.addComponent("pulsar", component);
+
+ return context;
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // started by the test, so that the exception handler is in
place before the loop runs;
+ // no exceptionHandler option on the endpoint, which is
exactly the case that used to NPE
+ from("pulsar:persistent://public/default/camel-receive-error"
+ +
"?messageListener=false&subscriptionName=camel-subscription")
+ .routeId(ROUTE_ID).autoStartup(false)
+ .to("mock:result");
+ }
+ };
+ }
+
+ private static final class CapturingExceptionHandler implements
ExceptionHandler {
+
+ private final CountDownLatch latch = new CountDownLatch(1);
+ private final AtomicReference<Throwable> captured = new
AtomicReference<>();
+
+ @Override
+ public void handleException(Throwable exception) {
+ handleException(null, null, exception);
+ }
+
+ @Override
+ public void handleException(String message, Throwable exception) {
+ handleException(message, null, exception);
+ }
+
+ @Override
+ public void handleException(String message, Exchange exchange,
Throwable exception) {
+ captured.compareAndSet(null, exception);
+ latch.countDown();
+ }
+ }
+}