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 3af2c90d63ad CAMEL-25679: camel-mongodb - keep the consumer thread
when a new cursor cannot be opened (#27918)
3af2c90d63ad is described below
commit 3af2c90d63ad4e3a8f80e00beaafd700a6272fc3
Author: allthingssecurity <[email protected]>
AuthorDate: Sat Oct 17 16:06:09 2026 +0530
CAMEL-25679: camel-mongodb - keep the consumer thread when a new cursor
cannot be opened (#27918)
The tailable and change streams consumers read from a cursor on one thread.
When reading fails (for example the
connection to the server is lost) the thread closes the cursor, waits
cursorRegenerationDelay and opens a new one.
Opening it was done outside the try of the loop: while the server is still
unreachable the driver gives up after its
server selection timeout (30 seconds by default) with a
MongoTimeoutException, which ended the thread. The route stayed
started and consumed nothing until it was restarted; the only trace was the
uncaught exception on stderr.
The new cursor is now opened at the start of the next iteration, inside the
try: a failure is logged like a failed
read ("Will try again on next poll") and tried again after the delay. A
cursor opened while the consumer is being
stopped is closed instead of read, as stop() could not close it. The cursor
field is volatile, as stop() reads it from
another thread.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../mongodb/MongoAbstractConsumerThread.java | 23 +-
.../MongoDbConsumerCursorRegenerationTest.java | 279 +++++++++++++++++++++
2 files changed, 297 insertions(+), 5 deletions(-)
diff --git
a/components/camel-mongodb/src/main/java/org/apache/camel/component/mongodb/MongoAbstractConsumerThread.java
b/components/camel-mongodb/src/main/java/org/apache/camel/component/mongodb/MongoAbstractConsumerThread.java
index b1f6cf415a9c..a31ef2cc2f7f 100644
---
a/components/camel-mongodb/src/main/java/org/apache/camel/component/mongodb/MongoAbstractConsumerThread.java
+++
b/components/camel-mongodb/src/main/java/org/apache/camel/component/mongodb/MongoAbstractConsumerThread.java
@@ -39,7 +39,7 @@ abstract class MongoAbstractConsumerThread implements
Runnable {
protected final MongoCollection<Document> dbCol;
protected final Consumer consumer;
protected final MongoDbEndpoint endpoint;
- protected MongoCursor cursor;
+ protected volatile MongoCursor cursor;
volatile boolean keepRunning = true;
private volatile boolean stopped;
@@ -99,8 +99,20 @@ abstract class MongoAbstractConsumerThread implements
Runnable {
public void run() {
stoppedLatch = new CountDownLatch(1);
try {
- while (keepRunning) {
+ // an interrupt ends the thread, as it did before the cursor was
reopened inside the try
+ while (keepRunning && !Thread.currentThread().isInterrupted()) {
try {
+ if (cursor == null) {
+ // a new cursor after the previous one ended. It is
opened here, inside the try, so that a
+ // failure to open it (the server cannot be reached
yet) is reported and tried again after
+ // the delay, instead of ending this thread while the
consumer stays started
+ cursor = initializeCursor();
+ if (!keepRunning) {
+ // stopped while the cursor was being opened, so
stop() could not close it
+ cursor.close();
+ break;
+ }
+ }
doRun();
} catch (Exception e) {
if (keepRunning) {
@@ -115,7 +127,10 @@ abstract class MongoAbstractConsumerThread implements
Runnable {
}
// regenerate the cursor, if reading failed for some reason
if (keepRunning) {
- cursor.close();
+ if (cursor != null) {
+ cursor.close();
+ cursor = null;
+ }
regeneratingCursor();
if (cursorRegenerationDelayEnabled) {
@@ -126,8 +141,6 @@ abstract class MongoAbstractConsumerThread implements
Runnable {
Thread.currentThread().interrupt();
}
}
-
- cursor = initializeCursor();
}
}
} finally {
diff --git
a/components/camel-mongodb/src/test/java/org/apache/camel/component/mongodb/MongoDbConsumerCursorRegenerationTest.java
b/components/camel-mongodb/src/test/java/org/apache/camel/component/mongodb/MongoDbConsumerCursorRegenerationTest.java
new file mode 100644
index 000000000000..2909cb1b117c
--- /dev/null
+++
b/components/camel-mongodb/src/test/java/org/apache/camel/component/mongodb/MongoDbConsumerCursorRegenerationTest.java
@@ -0,0 +1,279 @@
+/*
+ * 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.mongodb;
+
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import com.mongodb.CursorType;
+import com.mongodb.MongoSocketReadException;
+import com.mongodb.MongoTimeoutException;
+import com.mongodb.ServerAddress;
+import com.mongodb.client.ChangeStreamIterable;
+import com.mongodb.client.FindIterable;
+import com.mongodb.client.ListCollectionNamesIterable;
+import com.mongodb.client.MongoChangeStreamCursor;
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoCursor;
+import com.mongodb.client.MongoDatabase;
+import com.mongodb.client.model.changestream.ChangeStreamDocument;
+import com.mongodb.client.model.changestream.FullDocument;
+import com.mongodb.client.model.changestream.OperationType;
+import org.apache.camel.CamelContext;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.bson.BsonDocument;
+import org.bson.BsonInt32;
+import org.bson.Document;
+import org.bson.conversions.Bson;
+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.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * When the MongoDB server cannot be reached, reading from the cursor fails
and the consumer thread opens a new cursor
+ * after {@code cursorRegenerationDelay}. Opening it fails too while the
server is still unreachable (the driver gives
+ * up after its server selection timeout, 30 seconds by default). The consumer
thread must then try again, and not end
+ * while the route stays started.
+ */
+class MongoDbConsumerCursorRegenerationTest {
+
+ private static final ServerAddress SERVER = new ServerAddress("localhost",
27017);
+
+ private final MongoClient client = mock(MongoClient.class);
+ private final MongoCollection<Document> collection = mockCollection();
+ private CamelContext context;
+
+ @SuppressWarnings("unchecked")
+ private static MongoCollection<Document> mockCollection() {
+ return mock(MongoCollection.class);
+ }
+
+ @BeforeEach
+ @SuppressWarnings("unchecked")
+ void setUp() {
+ MongoDatabase database = mock(MongoDatabase.class);
+ ListCollectionNamesIterable names =
mock(ListCollectionNamesIterable.class);
+ when(names.spliterator()).thenAnswer(i ->
List.of("events").spliterator());
+ when(database.listCollectionNames()).thenReturn(names);
+ when(database.getCollection("events",
Document.class)).thenReturn(collection);
+ when(database.getName()).thenReturn("test");
+ // the tailable consumer only accepts a capped collection
+ when(database.runCommand(any(Bson.class))).thenReturn(new
Document("capped", true));
+ when(client.getDatabase("test")).thenReturn(database);
+
+ context = new DefaultCamelContext();
+ context.getRegistry().bind("myDb", client);
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (context != null) {
+ context.stop();
+ }
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void tailableConsumerOpensANewCursorAfterAFailedRegeneration() throws
Exception {
+ MongoCursor<Document> broken = mock(MongoCursor.class);
+ when(broken.hasNext()).thenThrow(new
MongoSocketReadException("Prematurely reached end of stream", SERVER));
+
+ MongoCursor<Document> recovered = mock(MongoCursor.class);
+ when(recovered.hasNext()).thenReturn(true, false);
+ when(recovered.next()).thenReturn(new Document("increasing",
1).append("text", "after the outage"));
+
+ MongoCursor<Document> idle = mock(MongoCursor.class);
+ when(idle.hasNext()).thenReturn(false);
+
+ FindIterable<Document> find = mock(FindIterable.class);
+ when(collection.find()).thenReturn(find);
+ when(collection.find(any(Bson.class))).thenReturn(find);
+ when(find.cursorType(CursorType.TailableAwait)).thenReturn(find);
+ when(find.iterator())
+ // the cursor of the start, whose read fails when the server
goes away
+ .thenReturn(broken)
+ // the first regeneration, while the server is still
unreachable
+ .thenThrow(new MongoTimeoutException("Timed out while waiting
for a server"))
+ // the server is back
+ .thenReturn(recovered)
+ .thenReturn(idle);
+
+ MockEndpoint mock =
startRoute("mongodb:myDb?database=test&collection=events&tailTrackIncreasingField=increasing"
+ + "&cursorRegenerationDelay=100");
+
+ mock.expectedBodiesReceived(new Document("increasing",
1).append("text", "after the outage"));
+ mock.assertIsSatisfied();
+ verify(find, atLeast(3)).iterator();
+ assertEquals(ServiceStatus.Started,
context.getRouteController().getRouteStatus("mongo"));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void cursorOpenedWhileTheConsumerStopsIsClosedAndNotRead() throws
Exception {
+ MongoCursor<Document> broken = mock(MongoCursor.class);
+ when(broken.hasNext()).thenThrow(new
MongoSocketReadException("Prematurely reached end of stream", SERVER));
+ MongoCursor<Document> opened = mock(MongoCursor.class);
+
+ // the thread of the tailable consumer, run here on a thread of the
test so that the test can stop it while
+ // it opens a new cursor
+ context.start();
+ MongoDbEndpoint endpoint = context.getEndpoint(
+
"mongodb:myDb?database=test&collection=events&tailTrackIncreasingField=increasing"
+ +
"&cursorRegenerationDelay=100",
+ MongoDbEndpoint.class);
+ MongoDbTailableCursorConsumer consumer
+ = (MongoDbTailableCursorConsumer)
endpoint.createConsumer(exchange -> {
+ });
+ MongoDbTailingThread tailingThread = new
MongoDbTailingThread(endpoint, consumer, consumer.initTailTracking());
+
+ CountDownLatch opening = new CountDownLatch(1);
+ FindIterable<Document> find = mock(FindIterable.class);
+ when(collection.find()).thenReturn(find);
+ when(find.cursorType(CursorType.TailableAwait)).thenReturn(find);
+ when(find.iterator())
+ .thenReturn(broken)
+ .thenAnswer(i -> {
+ // the regeneration is still opening the cursor when the
consumer is stopped
+ opening.countDown();
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
!tailingThread.keepRunning);
+ return opened;
+ });
+
+ tailingThread.init();
+ Thread thread = new Thread(tailingThread, "tailing");
+ thread.start();
+ assertTrue(opening.await(10, TimeUnit.SECONDS), "the consumer did not
open a new cursor");
+ // returns when the thread has ended
+ tailingThread.stop();
+ thread.join(TimeUnit.SECONDS.toMillis(10));
+ assertFalse(thread.isAlive(), "the thread of the consumer has ended");
+
+ // stop() found no cursor to close; the one opened afterwards is
closed by the thread
+ verify(opened).close();
+ verify(opened, never()).hasNext();
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void interruptEndsTheThreadWhileTheCursorCannotBeOpened() throws Exception
{
+ context.start();
+ MongoDbEndpoint endpoint = context.getEndpoint(
+
"mongodb:myDb?database=test&collection=events&tailTrackIncreasingField=increasing"
+ +
"&cursorRegenerationDelay=100",
+ MongoDbEndpoint.class);
+ MongoDbTailableCursorConsumer consumer
+ = (MongoDbTailableCursorConsumer)
endpoint.createConsumer(exchange -> {
+ });
+ MongoDbTailingThread tailingThread = new
MongoDbTailingThread(endpoint, consumer, consumer.initTailTracking());
+
+ // the first cursor breaks, then the server cannot be reached: every
attempt to open a new cursor fails
+ MongoCursor<Document> broken = mock(MongoCursor.class);
+ when(broken.hasNext()).thenThrow(new
MongoSocketReadException("Prematurely reached end of stream", SERVER));
+ CountDownLatch attempts = new CountDownLatch(2);
+ FindIterable<Document> find = mock(FindIterable.class);
+ when(collection.find()).thenReturn(find);
+ when(find.cursorType(CursorType.TailableAwait)).thenReturn(find);
+ when(find.iterator())
+ .thenReturn(broken)
+ .thenAnswer(i -> {
+ attempts.countDown();
+ throw new MongoTimeoutException("Timed out while waiting
for a server");
+ });
+
+ tailingThread.init();
+ Thread thread = new Thread(tailingThread, "tailing");
+ thread.start();
+ assertTrue(attempts.await(10, TimeUnit.SECONDS), "the consumer did not
try to open a cursor again");
+ try {
+ thread.interrupt();
+ thread.join(TimeUnit.SECONDS.toMillis(5));
+ assertFalse(thread.isAlive(), "an interrupt ends the thread of the
consumer");
+ assertTrue(tailingThread.keepRunning,
+ "the thread ended because of the interrupt, not because it
was stopped");
+ } finally {
+ // without the guard the thread keeps looping: end it so it does
not outlive the test
+ tailingThread.keepRunning = false;
+ thread.join(TimeUnit.SECONDS.toMillis(5));
+ }
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void changeStreamsConsumerOpensANewCursorAfterAFailedRegeneration() throws
Exception {
+ MongoChangeStreamCursor<ChangeStreamDocument<Document>> broken =
mock(MongoChangeStreamCursor.class);
+ when(broken.hasNext()).thenThrow(new
MongoSocketReadException("Prematurely reached end of stream", SERVER));
+
+ ChangeStreamDocument<Document> event =
mock(ChangeStreamDocument.class);
+ when(event.getFullDocument()).thenReturn(new Document("_id",
1).append("text", "after the outage"));
+ when(event.getDocumentKey()).thenReturn(new BsonDocument("_id", new
BsonInt32(1)));
+ when(event.getOperationType()).thenReturn(OperationType.INSERT);
+ when(event.getResumeToken()).thenReturn(new BsonDocument("_data", new
BsonInt32(1)));
+ MongoChangeStreamCursor<ChangeStreamDocument<Document>> recovered =
mock(MongoChangeStreamCursor.class);
+ when(recovered.hasNext()).thenReturn(true, false);
+ when(recovered.next()).thenReturn(event);
+
+ MongoChangeStreamCursor<ChangeStreamDocument<Document>> idle =
mock(MongoChangeStreamCursor.class);
+ when(idle.hasNext()).thenReturn(false);
+
+ ChangeStreamIterable<Document> watch =
mock(ChangeStreamIterable.class);
+ when(collection.watch()).thenReturn(watch);
+ when(watch.fullDocument(any(FullDocument.class))).thenReturn(watch);
+ when(watch.resumeAfter(any(BsonDocument.class))).thenReturn(watch);
+ when(watch.iterator())
+ .thenReturn(broken)
+ .thenThrow(new MongoTimeoutException("Timed out while waiting
for a server"))
+ .thenReturn(recovered)
+ .thenReturn(idle);
+
+ MockEndpoint mock =
startRoute("mongodb:myDb?database=test&collection=events&consumerType=changeStreams"
+ + "&cursorRegenerationDelay=100");
+
+ mock.expectedBodiesReceived(new Document("_id", 1).append("text",
"after the outage"));
+ mock.assertIsSatisfied();
+ verify(watch, atLeast(3)).iterator();
+ assertEquals(ServiceStatus.Started,
context.getRouteController().getRouteStatus("mongo"));
+ }
+
+ private MockEndpoint startRoute(String uri) throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from(uri).routeId("mongo").to("mock:result");
+ }
+ });
+ context.start();
+ MockEndpoint mock = context.getEndpoint("mock:result",
MockEndpoint.class);
+ mock.setResultWaitTime(10000);
+ return mock;
+ }
+}