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 5f10c225652e CAMEL-25946: camel-mongodb - the change streams consumer
goes on after its collection is dropped or renamed (#28464)
5f10c225652e is described below
commit 5f10c225652e52f15f0d5cec22c9d2a3d6f91a4b
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 26 13:32:08 2026 +0530
CAMEL-25946: camel-mongodb - the change streams consumer goes on after its
collection is dropped or renamed (#28464)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../mongodb/MongoDbChangeStreamsThread.java | 39 ++-
.../MongoDbChangeStreamsInvalidateTest.java | 262 +++++++++++++++++++++
2 files changed, 295 insertions(+), 6 deletions(-)
diff --git
a/components/camel-mongodb/src/main/java/org/apache/camel/component/mongodb/MongoDbChangeStreamsThread.java
b/components/camel-mongodb/src/main/java/org/apache/camel/component/mongodb/MongoDbChangeStreamsThread.java
index 51400d086fe6..0c39e45b2846 100644
---
a/components/camel-mongodb/src/main/java/org/apache/camel/component/mongodb/MongoDbChangeStreamsThread.java
+++
b/components/camel-mongodb/src/main/java/org/apache/camel/component/mongodb/MongoDbChangeStreamsThread.java
@@ -18,6 +18,7 @@ package org.apache.camel.component.mongodb;
import java.util.List;
+import com.mongodb.MongoCommandException;
import com.mongodb.MongoException;
import com.mongodb.client.ChangeStreamIterable;
import com.mongodb.client.MongoCursor;
@@ -36,6 +37,8 @@ import static
org.apache.camel.component.mongodb.MongoDbConstants.MONGO_ID;
class MongoDbChangeStreamsThread extends MongoAbstractConsumerThread {
private static final DocumentCodec DOCUMENT_CODEC = new DocumentCodec();
+ // the server error InvalidResumeToken
+ private static final int INVALID_RESUME_TOKEN = 260;
private List<BsonDocument> bsonFilter;
private BsonDocument resumeToken;
@@ -64,17 +67,41 @@ class MongoDbChangeStreamsThread extends
MongoAbstractConsumerThread {
@Override
protected MongoCursor initializeCursor() {
+ if (resumeToken == null) {
+ return watch().iterator();
+ }
+ try {
+ return watch().resumeAfter(resumeToken).iterator();
+ } catch (MongoCommandException e) {
+ if (e.getErrorCode() != INVALID_RESUME_TOKEN) {
+ throw e;
+ }
+ // this is the answer for the token of an invalidate event, the
last event of a change stream whose
+ // collection was dropped or renamed (or its database dropped):
resumeAfter cannot resume from it, while
+ // startAfter (MongoDB 4.2) opens a new change stream after it,
which sees the changes of a collection
+ // created again under the same name. A token that is invalid for
another reason fails here too, and the
+ // consumer tries again after the cursor regeneration delay, as
before
+ MongoCursor cursor;
+ try {
+ cursor = watch().startAfter(resumeToken).iterator();
+ } catch (RuntimeException startAfterFailure) {
+ startAfterFailure.addSuppressed(e);
+ throw startAfterFailure;
+ }
+ log.info("The change stream of collection {} cannot be resumed
({}). Started a new change stream after the"
+ + " event that ended it.",
+ endpoint.getCollection(), e.getErrorMessage());
+ return cursor;
+ }
+ }
+
+ private ChangeStreamIterable<Document> watch() {
ChangeStreamIterable<Document> iterable = bsonFilter != null
? dbCol.watch(bsonFilter)
: dbCol.watch();
iterable.fullDocument(endpoint.getFullDocument());
-
- if (resumeToken != null) {
- iterable = iterable.resumeAfter(resumeToken);
- }
-
- return iterable.iterator();
+ return iterable;
}
@Override
diff --git
a/components/camel-mongodb/src/test/java/org/apache/camel/component/mongodb/MongoDbChangeStreamsInvalidateTest.java
b/components/camel-mongodb/src/test/java/org/apache/camel/component/mongodb/MongoDbChangeStreamsInvalidateTest.java
new file mode 100644
index 000000000000..8a21b27dbf03
--- /dev/null
+++
b/components/camel-mongodb/src/test/java/org/apache/camel/component/mongodb/MongoDbChangeStreamsInvalidateTest.java
@@ -0,0 +1,262 @@
+/*
+ * 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.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import com.mongodb.MongoCommandException;
+import com.mongodb.ServerAddress;
+import com.mongodb.client.ChangeStreamIterable;
+import com.mongodb.client.ListCollectionNamesIterable;
+import com.mongodb.client.MongoChangeStreamCursor;
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoCollection;
+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.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.processor.state.MemoryStateRepository;
+import org.bson.BsonDocument;
+import org.bson.BsonInt32;
+import org.bson.BsonString;
+import org.bson.Document;
+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.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * A change stream ends with an {@code invalidate} event when its collection
is dropped or renamed, or its database
+ * dropped. The consumer goes on with a new change stream after that event, so
it sees the changes of the collection
+ * created again under the same name.
+ */
+class MongoDbChangeStreamsInvalidateTest {
+
+ private static final ServerAddress SERVER = new ServerAddress("localhost",
27017);
+
+ private static final BsonDocument INSERT_TOKEN = token("insert");
+ private static final BsonDocument DROP_TOKEN = token("drop");
+ private static final BsonDocument INVALIDATE_TOKEN = token("invalidate");
+ private static final BsonDocument RECREATED_INSERT_TOKEN =
token("insert-after-recreate");
+
+ private final MongoClient client = mock(MongoClient.class);
+ private final MongoCollection<Document> collection = mockCollection();
+ // the resume options of every change stream the consumer opened, in order
+ private final List<String> opened = Collections.synchronizedList(new
ArrayList<>());
+ private CamelContext context;
+
+ @SuppressWarnings("unchecked")
+ private static MongoCollection<Document> mockCollection() {
+ return mock(MongoCollection.class);
+ }
+
+ private static BsonDocument token(String name) {
+ return new BsonDocument("_data", new BsonString(name));
+ }
+
+ @BeforeEach
+ 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");
+ when(client.getDatabase("test")).thenReturn(database);
+ when(collection.watch()).thenAnswer(i -> new
FakeChangeStream().iterable);
+
+ context = new DefaultCamelContext();
+ context.getRegistry().bind("myDb", client);
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (context != null) {
+ context.stop();
+ }
+ }
+
+ @Test
+ void consumerGoesOnAfterTheCollectionIsDropped() throws Exception {
+ MockEndpoint mock =
startRoute("mongodb:myDb?database=test&collection=events&consumerType=changeStreams"
+ + "&cursorRegenerationDelay=100");
+
+ mock.expectedMessageCount(4);
+ mock.assertIsSatisfied();
+ assertEquals(List.of("insert", "drop", "invalidate", "insert"),
operationTypes(mock));
+ assertEquals(new Document("_id", 2).append("text", "after the
collection was created again"),
+ mock.getExchanges().get(3).getIn().getBody());
+ // the next change stream resumes after the event of the new
collection as usual
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
opened.contains("resumeAfter " + RECREATED_INSERT_TOKEN));
+ List<String> first;
+ synchronized (opened) {
+ // the consumer thread may still be opening change streams
+ first = List.copyOf(opened.subList(0, 3));
+ }
+ assertEquals(List.of("new", "resumeAfter " + INVALIDATE_TOKEN,
"startAfter " + INVALIDATE_TOKEN), first);
+ }
+
+ @Test
+ void consumerStartsFromAStoredTokenOfAnInvalidateEvent() throws Exception {
+ // the token stored by the previous run of the route is the one of the
invalidate event
+ MemoryStateRepository repository = new MemoryStateRepository();
+ repository.setState("mongo/events", INVALIDATE_TOKEN.toJson());
+ context.getRegistry().bind("tokens", repository);
+
+ MockEndpoint mock =
startRoute("mongodb:myDb?database=test&collection=events&consumerType=changeStreams"
+ +
"&cursorRegenerationDelay=100&changeStreamTokenRepository=#tokens");
+
+ mock.expectedMessageCount(1);
+ mock.assertIsSatisfied();
+ assertEquals(List.of("insert"), operationTypes(mock));
+ // the consumer stores the token after the exchange is done, which can
be after the mock received it
+ await().atMost(10, TimeUnit.SECONDS)
+ .untilAsserted(() ->
assertEquals(RECREATED_INSERT_TOKEN.toJson(),
repository.getState("mongo/events")));
+ }
+
+ private static List<Object> operationTypes(MockEndpoint mock) {
+ return mock.getExchanges().stream()
+ .map(e ->
e.getIn().getHeader(MongoDbConstants.STREAM_OPERATION_TYPE))
+ .toList();
+ }
+
+ 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;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ChangeStreamDocument<Document> event(
+ OperationType type, BsonDocument token, Document fullDocument) {
+ ChangeStreamDocument<Document> event =
mock(ChangeStreamDocument.class);
+ when(event.getOperationType()).thenReturn(type);
+ when(event.getResumeToken()).thenReturn(token);
+ when(event.getFullDocument()).thenReturn(fullDocument);
+ if (fullDocument != null) {
+ when(event.getDocumentKey()).thenReturn(new BsonDocument("_id",
new BsonInt32(fullDocument.getInteger("_id"))));
+ }
+ return event;
+ }
+
+ /**
+ * One change stream opened with {@code collection.watch()}, answering
like a MongoDB server (4.2 or later) on which
+ * the collection was dropped and then created again (change streams
manual, "Invalidate Events" and "Resume a
+ * Change Stream"):
+ * <ul>
+ * <li>without a resume option: the insert, the drop and the invalidate
event; the server then closes the cursor, so
+ * {@code hasNext()} returns false;</li>
+ * <li>{@code resumeAfter} the token of the invalidate event: the
aggregate command fails with InvalidResumeToken
+ * (260), "Attempting to resume a change stream using 'resumeAfter' is not
allowed from an invalidate notification",
+ * which the driver throws from {@code iterator()} as a {@link
MongoCommandException};</li>
+ * <li>{@code startAfter} the token of the invalidate event: a new change
stream, with the insert into the
+ * collection created again;</li>
+ * <li>{@code resumeAfter} any later token: no new event;</li>
+ * <li>both {@code resumeAfter} and {@code startAfter}: the aggregate
command fails with error 50865, "Do not
+ * specify both 'resumeAfter' and 'startAfter' in a $changeStream stage"
(the consumer never sets both).</li>
+ * </ul>
+ */
+ private final class FakeChangeStream {
+
+ private final ChangeStreamIterable<Document> iterable = mockIterable();
+ private BsonDocument resumeAfter;
+ private BsonDocument startAfter;
+
+ @SuppressWarnings("unchecked")
+ private static ChangeStreamIterable<Document> mockIterable() {
+ return mock(ChangeStreamIterable.class);
+ }
+
+ private FakeChangeStream() {
+
when(iterable.fullDocument(any(FullDocument.class))).thenReturn(iterable);
+ when(iterable.resumeAfter(any(BsonDocument.class))).thenAnswer(i
-> {
+ resumeAfter = i.getArgument(0);
+ return iterable;
+ });
+ when(iterable.startAfter(any(BsonDocument.class))).thenAnswer(i ->
{
+ startAfter = i.getArgument(0);
+ return iterable;
+ });
+ when(iterable.iterator()).thenAnswer(i -> open());
+ }
+
+ private MongoChangeStreamCursor<ChangeStreamDocument<Document>> open()
{
+ List<ChangeStreamDocument<Document>> events;
+ if (resumeAfter != null && startAfter != null) {
+ throw commandFailed(50865, "Location50865", "Do not specify
both 'resumeAfter' and 'startAfter' in a"
+ + " $changeStream
stage");
+ } else if (resumeAfter == null && startAfter == null) {
+ opened.add("new");
+ events = List.of(
+ event(OperationType.INSERT, INSERT_TOKEN, new
Document("_id", 1).append("text", "first")),
+ event(OperationType.DROP, DROP_TOKEN, null),
+ event(OperationType.INVALIDATE, INVALIDATE_TOKEN,
null));
+ } else if (INVALIDATE_TOKEN.equals(resumeAfter)) {
+ opened.add("resumeAfter " + resumeAfter);
+ throw commandFailed(260, "InvalidResumeToken", "Attempting to
resume a change stream using"
+ + "
'resumeAfter' is not allowed from an invalidate"
+ + "
notification.");
+ } else if (INVALIDATE_TOKEN.equals(startAfter)) {
+ opened.add("startAfter " + startAfter);
+ events = List.of(event(OperationType.INSERT,
RECREATED_INSERT_TOKEN,
+ new Document("_id", 2).append("text", "after the
collection was created again")));
+ } else {
+ opened.add("resumeAfter " + resumeAfter);
+ events = List.of();
+ }
+ return cursor(events);
+ }
+
+ @SuppressWarnings("unchecked")
+ private MongoChangeStreamCursor<ChangeStreamDocument<Document>> cursor(
+ List<ChangeStreamDocument<Document>> events) {
+ Iterator<ChangeStreamDocument<Document>> it = events.iterator();
+ MongoChangeStreamCursor<ChangeStreamDocument<Document>> cursor =
mock(MongoChangeStreamCursor.class);
+ when(cursor.hasNext()).thenAnswer(i -> it.hasNext());
+ when(cursor.next()).thenAnswer(i -> it.next());
+ return cursor;
+ }
+
+ private static MongoCommandException commandFailed(int code, String
codeName, String message) {
+ return new MongoCommandException(
+ new BsonDocument("ok", new BsonInt32(0)).append("code",
new BsonInt32(code))
+ .append("codeName", new
BsonString(codeName)).append("errmsg", new BsonString(message)),
+ SERVER);
+ }
+ }
+
+}