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 c2962f91b523 CAMEL-25790: camel-google-pubsub - stop pulling once the
consumer stops, also after a restart (#28196)
c2962f91b523 is described below
commit c2962f91b523fe30124fc911e95827d9e2eee882
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Oct 22 00:39:08 2026 +0530
CAMEL-25790: camel-google-pubsub - stop pulling once the consumer stops,
also after a restart (#28196)
* CAMEL-25790: camel-google-pubsub - stop pulling once the consumer stops,
also after a restart
deferShutdown (CAMEL-23291) stops the subscribers and cancels the
synchronous pulls, but the consumer stays started while the shutdown
strategy waits for its pending exchanges. The subscriber loops only
checked isRunAllowed(), so they subscribed or pulled again at once and
kept routing new messages during the graceful shutdown, which then waited
for its full timeout and forced the shutdown.
The same check let a loop of the previous start continue after a route
restart: its subscriber terminated after the new start, the loop saw the
consumer running and subscribed again, next to the loops of the new
start. Every restart added concurrentConsumers subscriber loops, and the
executors of the previous start stayed until doShutdown.
Each loop now belongs to a generation, incremented on start, on
deferShutdown and on stop; a loop of an older generation ends instead of
subscribing or pulling again. A restart also shuts down (not gracefully)
the executors of the previous start, whose loops end on their own.
* CAMEL-25790: camel-google-pubsub - address review: note the aborted
stopRoute case in deferShutdown
When a stopRoute with abortAfterTimeout=true times out, the route is
started again while the consumer is still started, so doStart does not
run and no subscriber loop pulls again until the route is stopped and
started.
* CAMEL-25790: camel-google-pubsub - address review: keep the retry-delay
scheduler across restarts, and check the release of the old executor
From Andrea Cosentino's review of #28196:
- doStart creates the PubSubReconnectTask scheduler only when there is none
and no longer shuts it
down on a restart; doShutdown still does. A loop of the previous start
that waits in the 5 s retry
delay (BackgroundTask.run waits on a latch that the scheduled task
releases) is then still woken
up and ends on the generation check, and a throttled route no longer gets
a new scheduler on
every cycle.
- asyncPullLoopOfPreviousStartEndsWhenConsumerIsRestarted verifies that the
consumer executor of the
first start is shut down through the ExecutorServiceManager and that the
scheduler is created
once. With the previous change the second check fails
(TooManyActualInvocations).
- The test class is package-private.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../google/pubsub/GooglePubsubConsumer.java | 50 ++++-
.../GooglePubsubConsumerStopPullingTest.java | 209 +++++++++++++++++++++
2 files changed, 251 insertions(+), 8 deletions(-)
diff --git
a/components/camel-google/camel-google-pubsub/src/main/java/org/apache/camel/component/google/pubsub/GooglePubsubConsumer.java
b/components/camel-google/camel-google-pubsub/src/main/java/org/apache/camel/component/google/pubsub/GooglePubsubConsumer.java
index c6198be41605..cbb0a5c9597b 100644
---
a/components/camel-google/camel-google-pubsub/src/main/java/org/apache/camel/component/google/pubsub/GooglePubsubConsumer.java
+++
b/components/camel-google/camel-google-pubsub/src/main/java/org/apache/camel/component/google/pubsub/GooglePubsubConsumer.java
@@ -27,6 +27,7 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
import com.google.api.core.AbstractApiService;
import com.google.api.core.ApiFuture;
@@ -67,6 +68,12 @@ public class GooglePubsubConsumer extends DefaultConsumer
implements ShutdownAwa
private final GooglePubsubEndpoint endpoint;
private final Processor processor;
private final AtomicInteger pendingExchanges = new AtomicInteger();
+ /**
+ * The subscriber loops started by the current start of this consumer.
Incremented when the consumer starts, and
+ * when it stops pulling (deferShutdown, doStop): a loop of an earlier
generation ends instead of subscribing or
+ * pulling again, also when the consumer has been started again meanwhile.
+ */
+ private final AtomicLong generation = new AtomicLong();
private ExecutorService executor;
private ScheduledExecutorService taskExecutor;
private final List<Subscriber> subscribers;
@@ -102,11 +109,22 @@ public class GooglePubsubConsumer extends DefaultConsumer
implements ShutdownAwa
resolvedMaxDeliveryAttempts,
endpoint.getDestinationName());
}
+ // started again after a stop: the executor of the previous start is
otherwise only shut down in doShutdown.
+ // Its subscriber loops end on their own (generation), so a regular
shutdown lets them finish and then
+ // releases the threads
+ if (executor != null) {
+
endpoint.getCamelContext().getExecutorServiceManager().shutdown(executor);
+ }
executor = endpoint.createExecutor(this);
- taskExecutor = endpoint.getCamelContext().getExecutorServiceManager()
- .newSingleThreadScheduledExecutor(this, "PubSubReconnectTask");
+ // the scheduler of the retry delay is kept until doShutdown: a loop
of the previous start that waits in that
+ // delay must still be woken up, and then ends on the generation check
+ if (taskExecutor == null) {
+ taskExecutor =
endpoint.getCamelContext().getExecutorServiceManager()
+ .newSingleThreadScheduledExecutor(this,
"PubSubReconnectTask");
+ }
+ long current = generation.incrementAndGet();
for (int i = 0; i < endpoint.getConcurrentConsumers(); i++) {
- executor.submit(new SubscriberWrapper());
+ executor.submit(new SubscriberWrapper(current));
}
}
@@ -114,6 +132,7 @@ public class GooglePubsubConsumer extends DefaultConsumer
implements ShutdownAwa
protected void doStop() throws Exception {
localLog.info("Stopping Google PubSub consumer for {}/{}",
endpoint.getProjectId(), endpoint.getDestinationName());
+ generation.incrementAndGet();
if (!subscribers.isEmpty()) {
localLog.info("Stopping subscribers for {}/{}",
endpoint.getProjectId(), endpoint.getDestinationName());
subscribers.forEach(AbstractApiService::stopAsync);
@@ -144,6 +163,11 @@ public class GooglePubsubConsumer extends DefaultConsumer
implements ShutdownAwa
@Override
public boolean deferShutdown(ShutdownRunningTask shutdownRunningTask) {
+ // stop pulling: the consumer stays started until the pending
exchanges are done, so the subscriber loops must
+ // not subscribe or pull again when their subscriber terminates or
their pull is cancelled.
+ // Note: when a stopRoute with abortAfterTimeout=true times out, the
route is started again while this consumer
+ // is still started, so doStart does not run and no loop pulls again
until the route is stopped and started
+ generation.incrementAndGet();
if (!subscribers.isEmpty()) {
subscribers.forEach(AbstractApiService::stopAsync);
}
@@ -217,11 +241,21 @@ public class GooglePubsubConsumer extends DefaultConsumer
implements ShutdownAwa
private class SubscriberWrapper implements Runnable {
private final String subscriptionName;
+ private final long wrapperGeneration;
- SubscriberWrapper() {
+ SubscriberWrapper(long wrapperGeneration) {
+ this.wrapperGeneration = wrapperGeneration;
subscriptionName =
ProjectSubscriptionName.format(endpoint.getProjectId(),
endpoint.getDestinationName());
}
+ /**
+ * Whether this loop may subscribe or pull (again): the consumer runs
and has not stopped pulling or been
+ * started again since this loop was started.
+ */
+ private boolean isPullAllowed() {
+ return isRunAllowed() && !isSuspendingOrSuspended() &&
wrapperGeneration == generation.get();
+ }
+
@Override
public void run() {
try {
@@ -250,7 +284,7 @@ public class GooglePubsubConsumer extends DefaultConsumer
implements ShutdownAwa
}
private void asynchronousPull(String subscriptionName) throws
IOException {
- while (isRunAllowed() && !isSuspendingOrSuspended()) {
+ while (isPullAllowed()) {
MessageReceiver messageReceiver = new
CamelMessageReceiver(GooglePubsubConsumer.this, endpoint, processor);
Subscriber subscriber =
endpoint.getComponent().getSubscriber(subscriptionName, messageReceiver,
endpoint);
@@ -261,7 +295,7 @@ public class GooglePubsubConsumer extends DefaultConsumer
implements ShutdownAwa
subscribers.add(subscriber);
subscriberAdded = true;
// a stop while the subscriber was starting missed it;
stop it here or awaitTerminated never returns
- if (!isRunAllowed() || isSuspendingOrSuspended()) {
+ if (!isPullAllowed()) {
subscriber.stopAsync();
}
subscriber.awaitTerminated();
@@ -320,7 +354,7 @@ public class GooglePubsubConsumer extends DefaultConsumer
implements ShutdownAwa
}
private void synchronousPull(String subscriptionName) throws
ExecutionException, InterruptedException {
- while (isRunAllowed() && !isSuspendingOrSuspended()) {
+ while (isPullAllowed()) {
ApiFuture<PullResponse> synchronousPullResponseFuture = null;
try (SubscriberStub subscriber =
endpoint.getComponent().getSubscriberStub(endpoint)) {
@@ -333,7 +367,7 @@ public class GooglePubsubConsumer extends DefaultConsumer
implements ShutdownAwa
synchronousPullResponseFuture =
subscriber.pullCallable().futureCall(pullRequest);
pendingSynchronousPullResponses.add(synchronousPullResponseFuture);
// a stop while the pull was being issued missed this
future; cancel it here or get() blocks
- if (!isRunAllowed() || isSuspendingOrSuspended()) {
+ if (!isPullAllowed()) {
synchronousPullResponseFuture.cancel(true);
}
PullResponse pullResponse =
synchronousPullResponseFuture.get();
diff --git
a/components/camel-google/camel-google-pubsub/src/test/java/org/apache/camel/component/google/pubsub/GooglePubsubConsumerStopPullingTest.java
b/components/camel-google/camel-google-pubsub/src/test/java/org/apache/camel/component/google/pubsub/GooglePubsubConsumerStopPullingTest.java
new file mode 100644
index 000000000000..de56be730fd7
--- /dev/null
+++
b/components/camel-google/camel-google-pubsub/src/test/java/org/apache/camel/component/google/pubsub/GooglePubsubConsumerStopPullingTest.java
@@ -0,0 +1,209 @@
+/*
+ * 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.google.pubsub;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.google.api.core.SettableApiFuture;
+import com.google.api.gax.rpc.UnaryCallable;
+import com.google.cloud.pubsub.v1.Subscriber;
+import com.google.cloud.pubsub.v1.stub.SubscriberStub;
+import com.google.pubsub.v1.PullRequest;
+import com.google.pubsub.v1.PullResponse;
+import org.apache.camel.CamelContext;
+import org.apache.camel.ExtendedCamelContext;
+import org.apache.camel.Processor;
+import org.apache.camel.ShutdownRunningTask;
+import org.apache.camel.spi.ExchangeFactory;
+import org.apache.camel.spi.ExecutorServiceManager;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Once the consumer stops pulling (deferShutdown during a graceful shutdown,
or a stop), a subscriber loop must not
+ * subscribe or pull again when its subscriber terminates or its pull is
cancelled: not while the consumer waits for its
+ * pending exchanges, and not after the consumer was started again (route
restart), which starts its own loops.
+ */
+class GooglePubsubConsumerStopPullingTest {
+
+ private final GooglePubsubEndpoint endpoint = mock();
+ private final GooglePubsubComponent component = mock();
+ private final Processor processor = mock();
+ private final CamelContext context = mock();
+ private final ExtendedCamelContext ecc = mock();
+ private final ExchangeFactory ef = mock();
+ private final ExecutorServiceManager executorServiceManager = mock();
+
+ private final List<ExecutorService> executors = new
CopyOnWriteArrayList<>();
+ // subscribers created and subscribers between awaitRunning and the end of
awaitTerminated
+ private final AtomicInteger created = new AtomicInteger();
+ private final AtomicInteger running = new AtomicInteger();
+ // the first subscriber completes its termination (stream close, ack
flush) only when this is released
+ private final CountDownLatch firstTermination = new CountDownLatch(1);
+
+ @BeforeEach
+ void setUp() throws Exception {
+ when(endpoint.getCamelContext()).thenReturn(context);
+ when(context.getCamelContextExtension()).thenReturn(ecc);
+ when(ecc.getExchangeFactory()).thenReturn(ef);
+ when(ef.newExchangeFactory(any())).thenReturn(ef);
+
when(context.getExecutorServiceManager()).thenReturn(executorServiceManager);
+ when(executorServiceManager.newSingleThreadScheduledExecutor(any(),
anyString())).thenAnswer(invocation -> {
+ ScheduledExecutorService taskExecutor =
Executors.newSingleThreadScheduledExecutor();
+ executors.add(taskExecutor);
+ return taskExecutor;
+ });
+
+ when(endpoint.getComponent()).thenReturn(component);
+ when(endpoint.createExecutor(any())).thenAnswer(invocation -> {
+ ExecutorService consumerExecutor =
Executors.newSingleThreadExecutor();
+ executors.add(consumerExecutor);
+ return consumerExecutor;
+ });
+ when(endpoint.getConcurrentConsumers()).thenReturn(1);
+ when(endpoint.getMaxMessagesPerPoll()).thenReturn(1);
+ when(endpoint.getProjectId()).thenReturn("test-project");
+ when(endpoint.getDestinationName()).thenReturn("test-subscription");
+ when(endpoint.isMaxDeliveryAttemptsExplicitlySet()).thenReturn(true);
+ when(endpoint.getMaxDeliveryAttempts()).thenReturn(0);
+
+ when(component.getSubscriber(anyString(), any(), any()))
+ .thenAnswer(invocation ->
newSubscriber(created.incrementAndGet() == 1));
+ }
+
+ @AfterEach
+ void tearDown() {
+ firstTermination.countDown();
+ executors.forEach(ExecutorService::shutdownNow);
+ }
+
+ private Subscriber newSubscriber(boolean first) {
+ Subscriber subscriber = mock();
+ CountDownLatch stopRequested = new CountDownLatch(1);
+ when(subscriber.startAsync()).thenReturn(subscriber);
+ doAnswer(invocation -> {
+ running.incrementAndGet();
+ return null;
+ }).when(subscriber).awaitRunning();
+ doAnswer(invocation -> {
+ stopRequested.countDown();
+ return subscriber;
+ }).when(subscriber).stopAsync();
+ doAnswer(invocation -> {
+ stopRequested.await();
+ if (first) {
+ firstTermination.await();
+ }
+ running.decrementAndGet();
+ return null;
+ }).when(subscriber).awaitTerminated();
+ return subscriber;
+ }
+
+ @Test
+ void asyncPullDoesNotSubscribeAgainAfterDeferShutdown() throws Exception {
+ firstTermination.countDown();
+ GooglePubsubConsumer consumer = new GooglePubsubConsumer(endpoint,
processor);
+ consumer.start();
+ try {
+ await().atMost(5, TimeUnit.SECONDS).until(() -> running.get() ==
1);
+
+ // graceful shutdown: the consumer stays started while the
shutdown strategy waits for its pending exchanges
+
consumer.deferShutdown(ShutdownRunningTask.CompleteCurrentTaskOnly);
+ // the subscriber ends, and the loop does not start another one
while the shutdown waits
+ await().during(1, TimeUnit.SECONDS).atMost(5,
TimeUnit.SECONDS).untilAsserted(() -> {
+ assertEquals(1, created.get(), "subscribers created after the
consumer stopped pulling");
+ assertEquals(0, running.get(), "subscribers running while the
shutdown waits for the pending exchanges");
+ });
+ } finally {
+ consumer.stop();
+ }
+ }
+
+ @Test
+ void asyncPullLoopOfPreviousStartEndsWhenConsumerIsRestarted() throws
Exception {
+ GooglePubsubConsumer consumer = new GooglePubsubConsumer(endpoint,
processor);
+ consumer.start();
+ try {
+ await().atMost(5, TimeUnit.SECONDS).until(() -> running.get() ==
1);
+
+ // route restart: stop, then start again before the first
subscriber has terminated
+ ExecutorService firstConsumerExecutor = executors.get(0);
+ consumer.stop();
+ consumer.start();
+ await().atMost(5, TimeUnit.SECONDS).until(() -> created.get() ==
2);
+ // the executor of the first start is released, and the scheduler
of the retry delay is kept
+ verify(executorServiceManager).shutdown(firstConsumerExecutor);
+ verify(executorServiceManager,
times(1)).newSingleThreadScheduledExecutor(any(), anyString());
+
+ firstTermination.countDown();
+ // the loop of the first start ends once its subscriber
terminated, and does not subscribe again
+ await().during(1, TimeUnit.SECONDS).atMost(5,
TimeUnit.SECONDS).untilAsserted(() -> {
+ assertEquals(2, created.get(), "subscribers created by a start
and a restart with one concurrent consumer");
+ assertEquals(1, running.get(), "subscribers running after the
restart");
+ });
+ } finally {
+ consumer.stop();
+ }
+ }
+
+ @Test
+ void syncPullDoesNotPullAgainAfterDeferShutdown() throws Exception {
+ when(endpoint.isSynchronousPull()).thenReturn(true);
+ AtomicInteger pulls = new AtomicInteger();
+ SubscriberStub subscriberStub = mock();
+ @SuppressWarnings("unchecked")
+ UnaryCallable<PullRequest, PullResponse> pullCallable =
mock(UnaryCallable.class);
+ when(subscriberStub.pullCallable()).thenReturn(pullCallable);
+ doAnswer(invocation -> {
+ pulls.incrementAndGet();
+ // a pull that waits for messages until it is cancelled
+ return SettableApiFuture.<PullResponse> create();
+ }).when(pullCallable).futureCall(any(PullRequest.class));
+ when(component.getSubscriberStub(any())).thenReturn(subscriberStub);
+
+ GooglePubsubConsumer consumer = new GooglePubsubConsumer(endpoint,
processor);
+ consumer.start();
+ try {
+ await().atMost(5, TimeUnit.SECONDS).until(() -> pulls.get() == 1);
+
+
consumer.deferShutdown(ShutdownRunningTask.CompleteCurrentTaskOnly);
+ await().during(1, TimeUnit.SECONDS).atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(() -> assertEquals(1, pulls.get(), "pulls
issued after the consumer stopped pulling"));
+ } finally {
+ consumer.stop();
+ }
+ }
+}