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 5bef69484652 CAMEL-25607: camel-mongodb-gridfs - keep the timestamp of
the consumer between the polls (#27794)
5bef69484652 is described below
commit 5bef6948465255c2205a1058df8e8c9701363d53
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Oct 15 11:19:01 2026 +0530
CAMEL-25607: camel-mongodb-gridfs - keep the timestamp of the consumer
between the polls (#27794)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
components/camel-mongodb-gridfs/pom.xml | 6 +
.../component/mongodb/gridfs/GridFsConsumer.java | 26 +-
.../gridfs/GridFsConsumerTimestampTest.java | 297 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 12 +
4 files changed, 333 insertions(+), 8 deletions(-)
diff --git a/components/camel-mongodb-gridfs/pom.xml
b/components/camel-mongodb-gridfs/pom.xml
index 63fb0dda8c80..14b7707909f6 100644
--- a/components/camel-mongodb-gridfs/pom.xml
+++ b/components/camel-mongodb-gridfs/pom.xml
@@ -79,5 +79,11 @@
<artifactId>camel-test-junit6</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-junit-jupiter</artifactId>
+ <version>${mockito-version}</version>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
diff --git
a/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsConsumer.java
b/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsConsumer.java
index 06223f1c63ff..40afc3d4f142 100644
---
a/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsConsumer.java
+++
b/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsConsumer.java
@@ -51,6 +51,13 @@ public class GridFsConsumer extends DefaultConsumer
implements Runnable {
private final GridFsEndpoint endpoint;
private volatile ScheduledExecutorService taskExecutor;
private volatile ExecutorService executor;
+ // the polls ask for the files uploaded after this date. With the
TimeStamp and PersistentTimestamp strategies it
+ // is the upload date of the newest file that was routed, kept between the
polls. The strategies with a file
+ // attribute keep the start date, because the attribute already makes sure
that a file is routed once, and a file
+ // whose upload date is older than one that was routed already (another
uploader, clock skew) is still found.
+ // Two threads use it: run() sets the start value on the thread of the
executor, before it schedules the polls;
+ // the polls then read and move it on the thread of the taskExecutor.
+ private volatile Date fromDate;
public GridFsConsumer(GridFsEndpoint endpoint, Processor processor) {
super(endpoint, processor);
@@ -82,7 +89,7 @@ public class GridFsConsumer extends DefaultConsumer
implements Runnable {
@Override
public void run() {
- Date fromDate = null;
+ fromDate = null;
QueryStrategy queryStrategy = endpoint.getQueryStrategy();
boolean usesTimestamp = queryStrategy != QueryStrategy.FileAttribute;
@@ -126,22 +133,22 @@ public class GridFsConsumer extends DefaultConsumer
implements Runnable {
.build();
MongoCollection<Document> finalPtsCollection = ptsCollection;
- Date finalFromDate = fromDate;
Document finalPersistentTimestamp = persistentTimestamp;
task.run(endpoint.getCamelContext(),
- () -> processCollection(finalFromDate, usesTimestamp,
persistsTimestamp, usesAttribute, finalPtsCollection,
+ () -> processCollection(usesTimestamp, persistsTimestamp,
usesAttribute, finalPtsCollection,
finalPersistentTimestamp));
}
private boolean processCollection(
- Date fromDate, boolean usesTimestamp, boolean persistsTimestamp,
boolean usesAttribute,
+ boolean usesTimestamp, boolean persistsTimestamp, boolean
usesAttribute,
final MongoCollection<Document> ptsCollection, final Document
persistentTimestamp) {
if (!isStarted()) {
return false;
}
- try (MongoCursor<GridFSFile> cursor =
getGridFSFileMongoCursor(fromDate, usesTimestamp, usesAttribute)) {
+ Date pollFromDate = fromDate;
+ try (MongoCursor<GridFSFile> cursor =
getGridFSFileMongoCursor(pollFromDate, usesTimestamp, usesAttribute)) {
boolean dateModified = false;
while (cursor.hasNext() && isStarted()) {
@@ -177,9 +184,12 @@ public class GridFsConsumer extends DefaultConsumer
implements Runnable {
Bson update =
Updates.set(endpoint.getFileAttributeName(), GRIDFS_FILE_ATTRIBUTE_DONE);
endpoint.getFilesCollection().findOneAndUpdate(eq("_id", fOrig.getId()),
update);
}
- if (usesTimestamp &&
file.getUploadDate().compareTo(fromDate) > 0) {
- fromDate = file.getUploadDate();
+ if (usesTimestamp &&
file.getUploadDate().compareTo(pollFromDate) > 0) {
+ pollFromDate = file.getUploadDate();
dateModified = true;
+ if (!usesAttribute) {
+ fromDate = pollFromDate;
+ }
}
} catch (Exception e) {
// ignore
@@ -188,7 +198,7 @@ public class GridFsConsumer extends DefaultConsumer
implements Runnable {
}
if (persistsTimestamp && dateModified) {
- Bson update = Updates.set(PERSISTENT_TIMESTAMP_KEY, fromDate);
+ Bson update = Updates.set(PERSISTENT_TIMESTAMP_KEY,
pollFromDate);
ptsCollection.findOneAndUpdate(eq("_id",
persistentTimestamp.getObjectId("_id")), update);
}
}
diff --git
a/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/GridFsConsumerTimestampTest.java
b/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/GridFsConsumerTimestampTest.java
new file mode 100644
index 000000000000..47a214d76dfc
--- /dev/null
+++
b/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/GridFsConsumerTimestampTest.java
@@ -0,0 +1,297 @@
+/*
+ * 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.gridfs;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import com.mongodb.MongoClientSettings;
+import com.mongodb.client.FindIterable;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoCursor;
+import com.mongodb.client.MongoDatabase;
+import com.mongodb.client.gridfs.GridFSBucket;
+import com.mongodb.client.gridfs.GridFSDownloadStream;
+import com.mongodb.client.gridfs.GridFSFindIterable;
+import com.mongodb.client.gridfs.model.GridFSFile;
+import com.mongodb.client.model.FindOneAndUpdateOptions;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Processor;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.bson.BsonArray;
+import org.bson.BsonDocument;
+import org.bson.BsonObjectId;
+import org.bson.BsonValue;
+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 org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * The consumer with a timestamp query strategy against a fake bucket that
answers the upload date query of each poll
+ * the way MongoDB does: a file that was routed must not be routed again by
the following polls. With a file attribute,
+ * the fake files collection marks a file as the consumer asks, and the bucket
no longer returns a marked file.
+ */
+class GridFsConsumerTimestampTest {
+
+ private static final long TIMEOUT_SECONDS = 20;
+
+ private final List<GridFSFile> stored = new CopyOnWriteArrayList<>();
+ private final List<String> routed = new CopyOnWriteArrayList<>();
+ private final List<Date> persisted = new CopyOnWriteArrayList<>();
+ // the ids of the files that the consumer has marked with its file
attribute
+ private final Set<BsonValue> marked = ConcurrentHashMap.newKeySet();
+ private final GridFSBucket bucket = mock(GridFSBucket.class);
+ private final MongoDatabase database = mock(MongoDatabase.class);
+ @SuppressWarnings("unchecked")
+ private final MongoCollection<GridFSFile> files =
mock(MongoCollection.class);
+
+ private volatile CountDownLatch polls = new CountDownLatch(0);
+ private CamelContext context;
+ private GridFsConsumer consumer;
+
+ @BeforeEach
+ void setUp() {
+ context = new DefaultCamelContext();
+ context.start();
+
+ when(bucket.find(any(Bson.class))).thenAnswer(invocation -> {
+ List<GridFSFile> found =
filesUploadedAfter(invocation.getArgument(0));
+ polls.countDown();
+ return iterableOf(found);
+ });
+ when(bucket.openDownloadStream(anyString())).thenAnswer(invocation -> {
+ String name = invocation.getArgument(0);
+ GridFSDownloadStream stream = mock(GridFSDownloadStream.class);
+ when(stream.getGridFSFile())
+ .thenReturn(stored.stream().filter(f ->
f.getFilename().equals(name)).findFirst().orElseThrow());
+ return stream;
+ });
+ // the consumer marks a file before it routes it: only a file that is
not marked yet is returned
+ when(files.findOneAndUpdate(any(Bson.class), any(Bson.class),
any(FindOneAndUpdateOptions.class)))
+ .thenAnswer(invocation -> {
+ BsonValue id = find(toDocument(invocation.getArgument(0)),
"_id");
+ return marked.add(id)
+ ? stored.stream().filter(f ->
f.getId().equals(id)).findFirst().orElseThrow() : null;
+ });
+ MongoCollection<Document> timestamps = timestampCollection();
+
when(database.getCollection("camel-timestamps")).thenReturn(timestamps);
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (consumer != null) {
+ consumer.stop();
+ }
+ context.stop();
+ }
+
+ @Test
+ void timestampStrategyRoutesAFileOnce() throws Exception {
+ startConsumer(QueryStrategy.TimeStamp);
+
+ stored.add(file("first", 60));
+ awaitFullPolls(3);
+
+ assertEquals(List.of("first"), routed, "three polls saw the file, it
must be routed by the first of them only");
+ }
+
+ @Test
+ void timestampStrategyRoutesALaterFileOnce() throws Exception {
+ startConsumer(QueryStrategy.TimeStamp);
+
+ stored.add(file("first", 60));
+ awaitFullPolls(2);
+ stored.add(file("second", 120));
+ awaitFullPolls(3);
+
+ assertEquals(List.of("first", "second"), routed);
+ }
+
+ @Test
+ void persistentTimestampStrategyRoutesAFileOnce() throws Exception {
+ startConsumer(QueryStrategy.PersistentTimestamp);
+
+ GridFSFile first = file("first", 60);
+ stored.add(first);
+ awaitFullPolls(3);
+
+ assertEquals(List.of("first"), routed, "three polls saw the file, it
must be routed by the first of them only");
+ assertEquals(List.of(first.getUploadDate()), persisted, "the timestamp
is stored when it moves, not at every poll");
+ }
+
+ @Test
+ void timestampStrategySkipsAFileWithAnOlderUploadDateThanARoutedFile()
throws Exception {
+ startConsumer(QueryStrategy.TimeStamp);
+
+ stored.add(file("newer", 120));
+ awaitFullPolls(2);
+ // visible only now, but uploaded before the routed file (another
uploader, or a client with another clock)
+ stored.add(file("older", 60));
+ awaitFullPolls(2);
+
+ assertEquals(List.of("newer"), routed, "the strategy only asks for
files uploaded after the newest routed file");
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = QueryStrategy.class, names = {
"TimeStampAndFileAttribute", "PersistentTimestampAndFileAttribute" })
+ void
strategyWithFileAttributeRoutesAFileWithAnOlderUploadDateThanARoutedFile(QueryStrategy
strategy)
+ throws Exception {
+ startConsumer(strategy);
+
+ stored.add(file("newer", 120));
+ awaitFullPolls(2);
+ // visible only now, but uploaded before the routed file (another
uploader, or a client with another clock)
+ stored.add(file("older", 60));
+ awaitFullPolls(2);
+
+ assertEquals(List.of("newer", "older"), routed,
+ "the file attribute makes sure a file is routed once, the
query keeps the start date");
+ }
+
+ private void startConsumer(QueryStrategy strategy) {
+ GridFsComponent component = new GridFsComponent();
+ component.setCamelContext(context);
+ GridFsEndpoint endpoint = new GridFsEndpoint("mongodb-gridfs:fake",
component) {
+ @Override
+ public GridFSBucket getGridFsBucket() {
+ return bucket;
+ }
+
+ @Override
+ public MongoDatabase getDB() {
+ return database;
+ }
+
+ @Override
+ public MongoCollection<GridFSFile> getFilesCollection() {
+ return files;
+ }
+ };
+ endpoint.setCamelContext(context);
+ endpoint.setQueryStrategy(strategy);
+ endpoint.setInitialDelay(1);
+ endpoint.setDelay(1);
+
+ Processor processor = exchange ->
routed.add(exchange.getMessage().getBody(GridFSDownloadStream.class)
+ .getGridFSFile().getFilename());
+ consumer = new GridFsConsumer(endpoint, processor);
+ consumer.start();
+ }
+
+ /**
+ * Waits until the consumer has started that many polls and one more, so
that many polls have run to their end: the
+ * consumer polls on one thread.
+ */
+ private void awaitFullPolls(int count) throws InterruptedException {
+ CountDownLatch latch = new CountDownLatch(count + 1);
+ polls = latch;
+ assertTrue(latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), "the
consumer did not poll " + (count + 1) + " times");
+ }
+
+ /**
+ * A file uploaded the given number of seconds after the test (and so the
consumer) started.
+ */
+ private static GridFSFile file(String name, int secondsFromNow) {
+ Date uploadDate = new Date(System.currentTimeMillis() +
TimeUnit.SECONDS.toMillis(secondsFromNow));
+ return new GridFSFile(new BsonObjectId(), name, 1, 1, uploadDate,
null);
+ }
+
+ /**
+ * Evaluates the query of the consumer on the stored files:
<tt>{uploadDate: {$gt: date}}</tt>, and with a file
+ * attribute also <tt>{camel-processed: null}</tt>.
+ */
+ private List<GridFSFile> filesUploadedAfter(Bson query) {
+ BsonDocument document = toDocument(query);
+ long after = find(document,
GridFsConstants.GRIDFS_FILE_KEY_UPLOAD_DATE).asDocument().getDateTime("$gt").getValue();
+ boolean notMarkedOnly = find(document, "camel-processed") != null;
+ List<GridFSFile> found = new ArrayList<>();
+ for (GridFSFile file : stored) {
+ if (file.getUploadDate().getTime() > after && !(notMarkedOnly &&
marked.contains(file.getId()))) {
+ found.add(file);
+ }
+ }
+ return found;
+ }
+
+ private static BsonDocument toDocument(Bson bson) {
+ return bson.toBsonDocument(BsonDocument.class,
MongoClientSettings.getDefaultCodecRegistry());
+ }
+
+ /**
+ * The value of the given key in a filter, or in one of the filters of an
<tt>$and</tt>.
+ */
+ private static BsonValue find(BsonDocument filter, String key) {
+ if (filter.containsKey(key)) {
+ return filter.get(key);
+ }
+ BsonArray and = filter.getArray("$and", new BsonArray());
+ for (BsonValue part : and) {
+ BsonValue value = find(part.asDocument(), key);
+ if (value != null) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static GridFSFindIterable iterableOf(List<GridFSFile> files) {
+ Iterator<GridFSFile> iterator = files.iterator();
+ MongoCursor<GridFSFile> cursor = mock(MongoCursor.class);
+ when(cursor.hasNext()).thenAnswer(invocation -> iterator.hasNext());
+ when(cursor.next()).thenAnswer(invocation -> iterator.next());
+ GridFSFindIterable iterable = mock(GridFSFindIterable.class);
+ when(iterable.cursor()).thenReturn(cursor);
+ return iterable;
+ }
+
+ /**
+ * The collection of the persistent timestamp: empty at first, records the
dates the consumer stores in it.
+ */
+ @SuppressWarnings("unchecked")
+ private MongoCollection<Document> timestampCollection() {
+ MongoCollection<Document> timestamps = mock(MongoCollection.class);
+ FindIterable<Document> none = mock(FindIterable.class);
+ when(timestamps.find(any(Bson.class))).thenReturn(none);
+ when(timestamps.findOneAndUpdate(any(Bson.class),
any(Bson.class))).thenAnswer(invocation -> {
+ Bson update = invocation.getArgument(1);
+ BsonDocument document = toDocument(update);
+ persisted.add(new Date(
+
document.getDocument("$set").getDateTime(GridFsConstants.PERSISTENT_TIMESTAMP_KEY).getValue()));
+ return null;
+ });
+ return timestamps;
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index fdb8d76a2559..68e31b6ceb25 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -711,3 +711,15 @@ project in the same way.
A name in camel case that starts with a lower-case letter, such as the
datasource `myOrders` or `my_DataSource`, still
looks like an option name and is written in dash case as a whole, underscores
included (`my-orders`,
`my-data-source`). Quote it to keep it:
`quarkus.datasource."myOrders".db-kind`.
+
+=== camel-mongodb-gridfs - the consumer routes a file once
+
+With the `TimeStamp` (default) and `PersistentTimestamp` query strategies, the
`mongodb-gridfs` consumer routed
+every file uploaded since the consumer started again at each poll (every
`delay`, 500 ms by default). The upload
+date of the newest routed file is now kept between the polls, so a file is
routed once. A route that relied on
+receiving the same file repeatedly now receives it once.
+These two strategies now skip a file whose upload date is older than that of a
file already routed (as they did
+before Camel 3.13), for example a file from a second uploader, or from a
client with another clock, that becomes
+visible later: GridFS sets the upload date on the client. Use
`TimeStampAndFileAttribute` or
+`PersistentTimestampAndFileAttribute` if such files must be routed: these
strategies keep the start date in their
+query, as before, and mark each file so that it is routed once.