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 b0d94141a1ae CAMEL-26229: camel-mongodb-gridfs - the consumer offers
the file of a failed exchange again, closes the download stream and sets
CamelFileName, an unknown producer operation fails, and listAll gives the hex
id (#29071)
b0d94141a1ae is described below
commit b0d94141a1aefc86394294cc37c1c1d8a1e10168
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Nov 3 18:04:56 2026 +0100
CAMEL-26229: camel-mongodb-gridfs - the consumer offers the file of a
failed exchange again, closes the download stream and sets CamelFileName, an
unknown producer operation fails, and listAll gives the hex id (#29071)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
---
.../camel/catalog/components/mongodb-gridfs.json | 8 +-
.../catalog/docs/mongodb-gridfs-component.adoc | 34 ++-
.../component/mongodb/gridfs/mongodb-gridfs.json | 8 +-
.../src/main/docs/mongodb-gridfs-component.adoc | 34 ++-
.../component/mongodb/gridfs/GridFsConstants.java | 6 +-
.../component/mongodb/gridfs/GridFsConsumer.java | 137 ++++++++--
.../component/mongodb/gridfs/GridFsEndpoint.java | 15 +-
.../component/mongodb/gridfs/GridFsProducer.java | 22 +-
.../gridfs/GridFsConsumerTimestampTest.java | 291 ++++++++++++++++++++-
.../mongodb/gridfs/GridFsProducerTest.java | 129 +++++++++
.../gridfs/integration/GridFsConsumerIT.java | 1 +
.../integration/GridFsProducerOperationsIT.java | 19 ++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 27 +-
.../endpoint/dsl/GridFsEndpointBuilderFactory.java | 8 +-
14 files changed, 679 insertions(+), 60 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/mongodb-gridfs.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/mongodb-gridfs.json
index 7a2d571f88ea..6ea9b6144d29 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/mongodb-gridfs.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/mongodb-gridfs.json
@@ -31,8 +31,8 @@
"headers": {
"CamelFileContentType": { "index": 0, "kind": "header", "displayName": "",
"group": "consumer", "label": "consumer", "required": false, "javaType":
"String", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The content type of the file.",
"constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#FILE_CONTENT_TYPE" },
"CamelFileLength": { "index": 1, "kind": "header", "displayName": "",
"group": "consumer", "label": "consumer", "required": false, "javaType":
"long", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The size of the file.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#FILE_LENGTH" },
- "CamelFileLastModified": { "index": 2, "kind": "header", "displayName":
"", "group": "consumer", "label": "consumer", "required": false, "javaType":
"Date", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The size of the file.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#FILE_LAST_MODIFIED"
},
- "CamelFileName": { "index": 3, "kind": "header", "displayName": "",
"group": "producer", "label": "producer", "required": false, "javaType":
"String", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The name of the file.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#FILE_NAME" },
+ "CamelFileLastModified": { "index": 2, "kind": "header", "displayName":
"", "group": "consumer", "label": "consumer", "required": false, "javaType":
"Date", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The upload date of the file.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#FILE_LAST_MODIFIED"
},
+ "CamelFileName": { "index": 3, "kind": "header", "displayName": "",
"group": "common", "label": "", "required": false, "javaType": "String",
"deprecated": false, "deprecationNote": "", "autowired": false, "secret":
false, "description": "(producer) The name of the file: the name that create
gives the file, and the file that findOne, remove, listAll and count look for.
(consumer) The name of the file that was found.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsCo [...]
"Content-Type": { "index": 4, "kind": "header", "displayName": "",
"group": "producer", "label": "producer", "required": false, "javaType":
"String", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The content type of the file.",
"constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#CONTENT_TYPE" },
"CamelFileNameProduced": { "index": 5, "kind": "header", "displayName":
"", "group": "producer", "label": "producer", "required": false, "javaType":
"String", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The file name produced.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#FILE_NAME_PRODUCED"
},
"CamelGridFsMetadata": { "index": 6, "kind": "header", "displayName": "",
"group": "common", "label": "", "required": false, "javaType": "String",
"deprecated": false, "deprecationNote": "", "autowired": false, "secret":
false, "description": "Any additional metadata stored along with the file in
JSON format.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#GRIDFS_METADATA" },
@@ -42,9 +42,9 @@
"CamelGridFsObjectId": { "index": 10, "kind": "header", "displayName": "",
"group": "producer", "label": "producer", "required": false, "javaType":
"org.bson.types.ObjectId", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "description": "The ObjectId of the
file.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#GRIDFS_OBJECT_ID" }
},
"bodyTypes": {
- "consumerOut": { "carriers": [ "stream" ], "description": "One message per
new file: a stream of the content of the file (the stream cache when stream
caching is on), with its length, upload date, content type and metadata in
headers" },
+ "consumerOut": { "carriers": [ "stream" ], "description": "One message per
new file: a stream of the content of the file (the stream cache when stream
caching is on), closed when the exchange is done, with its name, length, upload
date, content type and metadata in headers" },
"producerIn": { "carriers": [ "text", "bytes", "stream", "file" ],
"byOption": [ { "option": "operation", "value": "findOne", "carriers": [ "any"
] }, { "option": "operation", "value": "listAll", "carriers": [ "any" ] }, {
"option": "operation", "value": "count", "carriers": [ "any" ] }, { "option":
"operation", "value": "remove", "carriers": [ "any" ] } ], "description": "The
content of the file that create, the default operation, uploads under the name
in the CamelFileName header: [...]
- "producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option":
"operation", "value": "findOne", "carriers": [ "stream" ] }, { "option":
"operation", "value": "listAll", "carriers": [ "stream" ] }, { "option":
"operation", "value": "count", "carriers": [ "value" ], "javaType":
"java.lang.Long" }, { "option": "operation", "value": "remove", "carriers": [
"unchanged" ] } ], "description": "create, the default, leaves the body
unchanged, with the id of the file in the CamelGridF [...]
+ "producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option":
"operation", "value": "findOne", "carriers": [ "stream" ] }, { "option":
"operation", "value": "listAll", "carriers": [ "stream" ] }, { "option":
"operation", "value": "count", "carriers": [ "value" ], "javaType":
"java.lang.Long" }, { "option": "operation", "value": "remove", "carriers": [
"unchanged" ] } ], "description": "create, the default, leaves the body
unchanged, with the id of the file in the CamelGridF [...]
},
"properties": {
"connectionBean": { "index": 0, "kind": "path", "displayName": "Connection
Bean", "group": "common", "label": "", "required": true, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "description": "Name of
com.mongodb.client.MongoClient to use." },
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-gridfs-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-gridfs-component.adoc
index 0b7208e9a083..c4569730e750 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-gridfs-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mongodb-gridfs-component.adoc
@@ -42,6 +42,10 @@ include::partial$component-endpoint-headers.adoc[]
=== GridFS operations - producer endpoint
+The `operation` option, or the `CamelGridFsOperation` header when the option
is not set, chooses the operation:
+`create` (the default), `remove`, `findOne`, `listAll` or `count`. Another
operation fails the exchange with an
+`IllegalArgumentException` that lists the supported operations.
+
==== count
Returns the total number of files in the collection, returning a Long
@@ -68,14 +72,14 @@ Long count = template.requestBodyAndHeaders("direct:count",
"irrelevantBody", he
==== listAll
Returns a Reader that lists all the filenames and their IDs in a tab
-separated stream.
+separated stream. The ID of a file is the hex string of its ObjectId.
----
//
from("direct:listAll").to("mongodb-gridfs?database=tickets&operation=listAll");
Reader result = template.requestBodyAndHeader("direct:listAll",
"irrelevantBody");
-filename1.txt 1252314321
-filename2.txt 2897651254
+filename1.txt 665f1c2e8b3e4a5d6c7b8a90
+filename2.txt 665f1c2e8b3e4a5d6c7b8a91
----
==== findOne
@@ -122,6 +126,30 @@ headers.put(Exchange.FILE_NAME, "filename.txt");
template.requestBodyAndHeaders("direct:remove", "", headers);
----
+=== GridFS consumer
+
+The consumer polls the bucket for new files, as the `queryStrategy` option
chooses, and routes one message per file.
+The body is a stream of the content of the file, which is closed when the
exchange is done. The `CamelFileName`,
+`CamelFileLength`, `CamelFileLastModified` (the upload date),
`CamelFileContentType` and `CamelGridFsMetadata` headers
+describe the file.
+
+When the exchange of a file fails, the consumer reports the failure to its
exception handler (which logs it, unless
+the `exceptionHandler` or `bridgeErrorHandler` option is set) and leaves the
file as it was, so that the next poll
+offers it again:
+
+* With a file attribute (`FileAttribute`, `TimeStampAndFileAttribute` and
`PersistentTimestampAndFileAttribute`), the
+ file is not marked as processed, and the poll goes on with the other files.
+* With `TimeStamp` and `PersistentTimestamp`, the files are routed in the
order of their upload date, and the poll stops
+ at the file that failed: the timestamp stays before that file, so the next
poll offers it again, and then the files
+ uploaded after it. A file that always fails holds back the files after it.
+
+Only the first failure of a file is reported to the exception handler: when
the file fails again at the next poll, the
+consumer logs the failure at `DEBUG`. A file is reported again when it fails
after a poll that routed it or did not
+offer it.
+
+A failure that the route handles, such as with a dead letter channel or
`onException` with `handled(true)`, does not
+fail the exchange: the file is then done.
+
== Examples
=== Example route
diff --git
a/components/camel-mongodb-gridfs/src/generated/resources/META-INF/org/apache/camel/component/mongodb/gridfs/mongodb-gridfs.json
b/components/camel-mongodb-gridfs/src/generated/resources/META-INF/org/apache/camel/component/mongodb/gridfs/mongodb-gridfs.json
index 7a2d571f88ea..6ea9b6144d29 100644
---
a/components/camel-mongodb-gridfs/src/generated/resources/META-INF/org/apache/camel/component/mongodb/gridfs/mongodb-gridfs.json
+++
b/components/camel-mongodb-gridfs/src/generated/resources/META-INF/org/apache/camel/component/mongodb/gridfs/mongodb-gridfs.json
@@ -31,8 +31,8 @@
"headers": {
"CamelFileContentType": { "index": 0, "kind": "header", "displayName": "",
"group": "consumer", "label": "consumer", "required": false, "javaType":
"String", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The content type of the file.",
"constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#FILE_CONTENT_TYPE" },
"CamelFileLength": { "index": 1, "kind": "header", "displayName": "",
"group": "consumer", "label": "consumer", "required": false, "javaType":
"long", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The size of the file.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#FILE_LENGTH" },
- "CamelFileLastModified": { "index": 2, "kind": "header", "displayName":
"", "group": "consumer", "label": "consumer", "required": false, "javaType":
"Date", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The size of the file.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#FILE_LAST_MODIFIED"
},
- "CamelFileName": { "index": 3, "kind": "header", "displayName": "",
"group": "producer", "label": "producer", "required": false, "javaType":
"String", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The name of the file.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#FILE_NAME" },
+ "CamelFileLastModified": { "index": 2, "kind": "header", "displayName":
"", "group": "consumer", "label": "consumer", "required": false, "javaType":
"Date", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The upload date of the file.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#FILE_LAST_MODIFIED"
},
+ "CamelFileName": { "index": 3, "kind": "header", "displayName": "",
"group": "common", "label": "", "required": false, "javaType": "String",
"deprecated": false, "deprecationNote": "", "autowired": false, "secret":
false, "description": "(producer) The name of the file: the name that create
gives the file, and the file that findOne, remove, listAll and count look for.
(consumer) The name of the file that was found.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsCo [...]
"Content-Type": { "index": 4, "kind": "header", "displayName": "",
"group": "producer", "label": "producer", "required": false, "javaType":
"String", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The content type of the file.",
"constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#CONTENT_TYPE" },
"CamelFileNameProduced": { "index": 5, "kind": "header", "displayName":
"", "group": "producer", "label": "producer", "required": false, "javaType":
"String", "deprecated": false, "deprecationNote": "", "autowired": false,
"secret": false, "description": "The file name produced.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#FILE_NAME_PRODUCED"
},
"CamelGridFsMetadata": { "index": 6, "kind": "header", "displayName": "",
"group": "common", "label": "", "required": false, "javaType": "String",
"deprecated": false, "deprecationNote": "", "autowired": false, "secret":
false, "description": "Any additional metadata stored along with the file in
JSON format.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#GRIDFS_METADATA" },
@@ -42,9 +42,9 @@
"CamelGridFsObjectId": { "index": 10, "kind": "header", "displayName": "",
"group": "producer", "label": "producer", "required": false, "javaType":
"org.bson.types.ObjectId", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "description": "The ObjectId of the
file.", "constantName":
"org.apache.camel.component.mongodb.gridfs.GridFsConstants#GRIDFS_OBJECT_ID" }
},
"bodyTypes": {
- "consumerOut": { "carriers": [ "stream" ], "description": "One message per
new file: a stream of the content of the file (the stream cache when stream
caching is on), with its length, upload date, content type and metadata in
headers" },
+ "consumerOut": { "carriers": [ "stream" ], "description": "One message per
new file: a stream of the content of the file (the stream cache when stream
caching is on), closed when the exchange is done, with its name, length, upload
date, content type and metadata in headers" },
"producerIn": { "carriers": [ "text", "bytes", "stream", "file" ],
"byOption": [ { "option": "operation", "value": "findOne", "carriers": [ "any"
] }, { "option": "operation", "value": "listAll", "carriers": [ "any" ] }, {
"option": "operation", "value": "count", "carriers": [ "any" ] }, { "option":
"operation", "value": "remove", "carriers": [ "any" ] } ], "description": "The
content of the file that create, the default operation, uploads under the name
in the CamelFileName header: [...]
- "producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option":
"operation", "value": "findOne", "carriers": [ "stream" ] }, { "option":
"operation", "value": "listAll", "carriers": [ "stream" ] }, { "option":
"operation", "value": "count", "carriers": [ "value" ], "javaType":
"java.lang.Long" }, { "option": "operation", "value": "remove", "carriers": [
"unchanged" ] } ], "description": "create, the default, leaves the body
unchanged, with the id of the file in the CamelGridF [...]
+ "producerOut": { "carriers": [ "unchanged" ], "byOption": [ { "option":
"operation", "value": "findOne", "carriers": [ "stream" ] }, { "option":
"operation", "value": "listAll", "carriers": [ "stream" ] }, { "option":
"operation", "value": "count", "carriers": [ "value" ], "javaType":
"java.lang.Long" }, { "option": "operation", "value": "remove", "carriers": [
"unchanged" ] } ], "description": "create, the default, leaves the body
unchanged, with the id of the file in the CamelGridF [...]
},
"properties": {
"connectionBean": { "index": 0, "kind": "path", "displayName": "Connection
Bean", "group": "common", "label": "", "required": true, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "description": "Name of
com.mongodb.client.MongoClient to use." },
diff --git
a/components/camel-mongodb-gridfs/src/main/docs/mongodb-gridfs-component.adoc
b/components/camel-mongodb-gridfs/src/main/docs/mongodb-gridfs-component.adoc
index 0b7208e9a083..c4569730e750 100644
---
a/components/camel-mongodb-gridfs/src/main/docs/mongodb-gridfs-component.adoc
+++
b/components/camel-mongodb-gridfs/src/main/docs/mongodb-gridfs-component.adoc
@@ -42,6 +42,10 @@ include::partial$component-endpoint-headers.adoc[]
=== GridFS operations - producer endpoint
+The `operation` option, or the `CamelGridFsOperation` header when the option
is not set, chooses the operation:
+`create` (the default), `remove`, `findOne`, `listAll` or `count`. Another
operation fails the exchange with an
+`IllegalArgumentException` that lists the supported operations.
+
==== count
Returns the total number of files in the collection, returning a Long
@@ -68,14 +72,14 @@ Long count = template.requestBodyAndHeaders("direct:count",
"irrelevantBody", he
==== listAll
Returns a Reader that lists all the filenames and their IDs in a tab
-separated stream.
+separated stream. The ID of a file is the hex string of its ObjectId.
----
//
from("direct:listAll").to("mongodb-gridfs?database=tickets&operation=listAll");
Reader result = template.requestBodyAndHeader("direct:listAll",
"irrelevantBody");
-filename1.txt 1252314321
-filename2.txt 2897651254
+filename1.txt 665f1c2e8b3e4a5d6c7b8a90
+filename2.txt 665f1c2e8b3e4a5d6c7b8a91
----
==== findOne
@@ -122,6 +126,30 @@ headers.put(Exchange.FILE_NAME, "filename.txt");
template.requestBodyAndHeaders("direct:remove", "", headers);
----
+=== GridFS consumer
+
+The consumer polls the bucket for new files, as the `queryStrategy` option
chooses, and routes one message per file.
+The body is a stream of the content of the file, which is closed when the
exchange is done. The `CamelFileName`,
+`CamelFileLength`, `CamelFileLastModified` (the upload date),
`CamelFileContentType` and `CamelGridFsMetadata` headers
+describe the file.
+
+When the exchange of a file fails, the consumer reports the failure to its
exception handler (which logs it, unless
+the `exceptionHandler` or `bridgeErrorHandler` option is set) and leaves the
file as it was, so that the next poll
+offers it again:
+
+* With a file attribute (`FileAttribute`, `TimeStampAndFileAttribute` and
`PersistentTimestampAndFileAttribute`), the
+ file is not marked as processed, and the poll goes on with the other files.
+* With `TimeStamp` and `PersistentTimestamp`, the files are routed in the
order of their upload date, and the poll stops
+ at the file that failed: the timestamp stays before that file, so the next
poll offers it again, and then the files
+ uploaded after it. A file that always fails holds back the files after it.
+
+Only the first failure of a file is reported to the exception handler: when
the file fails again at the next poll, the
+consumer logs the failure at `DEBUG`. A file is reported again when it fails
after a poll that routed it or did not
+offer it.
+
+A failure that the route handles, such as with a dead letter channel or
`onException` with `handled(true)`, does not
+fail the exchange: the file is then done.
+
== Examples
=== Example route
diff --git
a/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsConstants.java
b/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsConstants.java
index 674fa58a6207..da1d7121f1f2 100644
---
a/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsConstants.java
+++
b/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsConstants.java
@@ -25,9 +25,11 @@ public final class GridFsConstants {
public static final String FILE_CONTENT_TYPE = Exchange.FILE_CONTENT_TYPE;
@Metadata(label = "consumer", description = "The size of the file.",
javaType = "long")
public static final String FILE_LENGTH = Exchange.FILE_LENGTH;
- @Metadata(label = "consumer", description = "The size of the file.",
javaType = "Date")
+ @Metadata(label = "consumer", description = "The upload date of the
file.", javaType = "Date")
public static final String FILE_LAST_MODIFIED =
Exchange.FILE_LAST_MODIFIED;
- @Metadata(label = "producer", description = "The name of the file.",
javaType = "String")
+ @Metadata(description = "(producer) The name of the file: the name that
create gives the file, and the file that"
+ + " findOne, remove, listAll and count look for.
(consumer) The name of the file that was found.",
+ javaType = "String")
public static final String FILE_NAME = Exchange.FILE_NAME;
@Metadata(label = "producer", description = "The content type of the
file.", javaType = "String")
public static final String CONTENT_TYPE = Exchange.CONTENT_TYPE;
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 439fc15b4664..896c6366324b 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
@@ -19,6 +19,8 @@ package org.apache.camel.component.mongodb.gridfs;
import java.io.InputStream;
import java.time.Duration;
import java.util.Date;
+import java.util.HashSet;
+import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
@@ -26,17 +28,23 @@ import com.mongodb.BasicDBObject;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoCursor;
import com.mongodb.client.gridfs.GridFSDownloadStream;
+import com.mongodb.client.gridfs.GridFSFindIterable;
import com.mongodb.client.gridfs.model.GridFSFile;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.FindOneAndUpdateOptions;
import com.mongodb.client.model.ReturnDocument;
+import com.mongodb.client.model.Sorts;
import com.mongodb.client.model.Updates;
import org.apache.camel.Exchange;
+import org.apache.camel.Message;
import org.apache.camel.Processor;
import org.apache.camel.support.DefaultConsumer;
+import org.apache.camel.support.SynchronizationAdapter;
import org.apache.camel.support.task.BlockingTask;
import org.apache.camel.support.task.Tasks;
import org.apache.camel.support.task.budget.Budgets;
+import org.apache.camel.util.IOHelper;
+import org.bson.BsonValue;
import org.bson.Document;
import org.bson.conversions.Bson;
import org.slf4j.Logger;
@@ -68,6 +76,10 @@ public class GridFsConsumer extends DefaultConsumer
implements Runnable {
private volatile Document persistentTimestamp;
// the number of polls in a row that failed, so that only the first
failure of an outage is reported
private volatile int consecutiveFailures;
+ // the ids of the files whose exchange failed in the last poll: when such
a file fails again in the next poll, the
+ // failure is logged at DEBUG instead of being reported again, so a file
that fails at every poll is reported once,
+ // as for the failures of the polls. It holds the files of one poll only,
so it does not grow with the bucket
+ private volatile Set<BsonValue> lastFailedFiles = Set.of();
public GridFsConsumer(GridFsEndpoint endpoint, Processor processor) {
super(endpoint, processor);
@@ -115,6 +127,7 @@ public class GridFsConsumer extends DefaultConsumer
implements Runnable {
ptsCollection = null;
persistentTimestamp = null;
consecutiveFailures = 0;
+ lastFailedFiles = Set.of();
if (usesTimestamp && !persistsTimestamp) {
fromDate = startDate;
}
@@ -193,9 +206,18 @@ public class GridFsConsumer extends DefaultConsumer
implements Runnable {
return false;
}
- Date pollFromDate = fromDate;
- try (MongoCursor<GridFSFile> cursor =
getGridFSFileMongoCursor(pollFromDate, usesTimestamp, usesAttribute)) {
- boolean dateModified = false;
+ // without a file attribute, the timestamp is the only record of the
files that were routed: the files are
+ // routed in the order of their upload date, and the poll stops at a
file whose exchange failed, so that the
+ // timestamp stays before that file and the next poll offers it again
(and the files after it)
+ boolean stopsAtFailure = usesTimestamp && !usesAttribute;
+ Date pollStartDate = fromDate;
+ Date pollFromDate = pollStartDate;
+ // the upload date of the oldest file of this poll whose exchange
failed
+ Date failedDate = null;
+ // the ids of the files of this poll whose exchange failed
+ Set<BsonValue> failedFiles = new HashSet<>();
+ try (MongoCursor<GridFSFile> cursor
+ = getGridFSFileMongoCursor(pollStartDate, usesTimestamp,
usesAttribute, stopsAtFailure)) {
while (cursor.hasNext() && isStarted()) {
GridFSFile file = cursor.next();
@@ -208,55 +230,122 @@ public class GridFsConsumer extends DefaultConsumer
implements Runnable {
fOrig =
endpoint.getFilesCollection().findOneAndUpdate(filter, update, options);
}
if (fOrig != null) {
- Exchange exchange = createExchange(true);
// by id: several files can have the same name
(revisions), and by name GridFS opens the newest
GridFSDownloadStream downloadStream =
endpoint.getGridFsBucket().openDownloadStream(file.getId());
file = downloadStream.getGridFSFile();
- Document metadata = file.getMetadata();
- if (metadata != null) {
- String contentType =
metadata.get(GRIDFS_FILE_KEY_CONTENT_TYPE, String.class);
- if (contentType != null) {
-
exchange.getIn().setHeader(GridFsConstants.FILE_CONTENT_TYPE, contentType);
- }
-
exchange.getIn().setHeader(GridFsConstants.GRIDFS_METADATA, metadata.toJson());
- }
-
- exchange.getIn().setHeader(GridFsConstants.FILE_LENGTH,
file.getLength());
-
exchange.getIn().setHeader(GridFsConstants.FILE_LAST_MODIFIED,
file.getUploadDate());
- exchange.getIn().setBody(downloadStream,
InputStream.class);
- try {
- getProcessor().process(exchange);
+ if (processFile(file, downloadStream,
lastFailedFiles.contains(file.getId()))) {
if (usesAttribute) {
Bson update =
Updates.set(endpoint.getFileAttributeName(), GRIDFS_FILE_ATTRIBUTE_DONE);
endpoint.getFilesCollection().findOneAndUpdate(eq("_id", fOrig.getId()),
update);
}
if (usesTimestamp &&
file.getUploadDate().compareTo(pollFromDate) > 0) {
pollFromDate = file.getUploadDate();
- dateModified = true;
if (!usesAttribute) {
fromDate = pollFromDate;
}
}
- } catch (Exception e) {
- // ignore
+ } else {
+ failedFiles.add(file.getId());
+ // leave the file as it was, so that the next poll
offers it again
+ if (usesAttribute) {
+ Bson update =
Updates.unset(endpoint.getFileAttributeName());
+
endpoint.getFilesCollection().findOneAndUpdate(eq("_id", fOrig.getId()),
update);
+ }
+ if (usesTimestamp && (failedDate == null ||
file.getUploadDate().before(failedDate))) {
+ failedDate = file.getUploadDate();
+ }
+ if (stopsAtFailure) {
+ break;
+ }
}
}
}
- if (persistsTimestamp && dateModified) {
+ if (failedDate != null && !pollFromDate.before(failedDate)) {
+ // a file routed in this poll is not older than a file that
failed (it has the same upload date or, with
+ // a file attribute, it is newer): keep the timestamp before
the failed file. With a file attribute the
+ // routed files are not routed again; without it, the next
poll also offers again the routed files with
+ // the same upload date as the failed file
+ pollFromDate = new Date(failedDate.getTime() - 1);
+ if (!usesAttribute) {
+ fromDate = pollFromDate;
+ }
+ }
+ if (persistsTimestamp && !pollFromDate.equals(pollStartDate)) {
Bson update = Updates.set(PERSISTENT_TIMESTAMP_KEY,
pollFromDate);
ptsCollection.findOneAndUpdate(eq("_id",
persistentTimestamp.getObjectId("_id")), update);
}
+ } finally {
+ lastFailedFiles = failedFiles;
}
return false;
}
- private MongoCursor<GridFSFile> getGridFSFileMongoCursor(Date fromDate,
boolean usesTimestamp, boolean usesAttribute) {
+ /**
+ * Routes the exchange of one file. The download stream is closed when the
exchange is done. Returns false when the
+ * exchange failed: the failure is reported to the exception handler of
the consumer, or logged at DEBUG when the
+ * file failed in the last poll too, as that failure was reported already.
+ */
+ private boolean processFile(GridFSFile file, GridFSDownloadStream
downloadStream, boolean failedInLastPoll) {
+ Exchange exchange = createExchange(false);
+ try {
+ exchange.getExchangeExtension().addOnCompletion(new
SynchronizationAdapter() {
+ @Override
+ public void onDone(Exchange exchange) {
+ IOHelper.close(downloadStream);
+ }
+ });
+
+ Message in = exchange.getIn();
+ in.setHeader(GridFsConstants.FILE_NAME, file.getFilename());
+ Document metadata = file.getMetadata();
+ if (metadata != null) {
+ String contentType =
metadata.get(GRIDFS_FILE_KEY_CONTENT_TYPE, String.class);
+ if (contentType != null) {
+ in.setHeader(GridFsConstants.FILE_CONTENT_TYPE,
contentType);
+ }
+ in.setHeader(GridFsConstants.GRIDFS_METADATA,
metadata.toJson());
+ }
+ in.setHeader(GridFsConstants.FILE_LENGTH, file.getLength());
+ in.setHeader(GridFsConstants.FILE_LAST_MODIFIED,
file.getUploadDate());
+ in.setBody(downloadStream, InputStream.class);
+
+ try {
+ getProcessor().process(exchange);
+ } catch (Exception e) {
+ exchange.setException(e);
+ // the exchange may not have reached its unit of work, which
runs the on completion
+ IOHelper.close(downloadStream);
+ }
+ Exception cause = exchange.getException();
+ if (cause != null) {
+ if (failedInLastPoll) {
+ LOG.debug("Error processing the GridFS file {} again,
which the next poll offers again: {}",
+ file.getFilename(), cause.getMessage());
+ } else {
+ getExceptionHandler().handleException(
+ "Error processing the GridFS file " +
file.getFilename() + ", which the next poll offers again",
+ exchange, cause);
+ }
+ return false;
+ }
+ return true;
+ } finally {
+ releaseExchange(exchange, false);
+ }
+ }
+
+ private MongoCursor<GridFSFile> getGridFSFileMongoCursor(
+ Date fromDate, boolean usesTimestamp, boolean usesAttribute,
boolean byUploadDate) {
String queryString = endpoint.getQuery();
Bson query = getBsonDocument(fromDate, usesTimestamp, usesAttribute,
queryString);
- return endpoint.getGridFsBucket().find(query).cursor();
+ GridFSFindIterable files = endpoint.getGridFsBucket().find(query);
+ if (byUploadDate) {
+ files = files.sort(Sorts.ascending(GRIDFS_FILE_KEY_UPLOAD_DATE));
+ }
+ return files.cursor();
}
private Bson getBsonDocument(Date fromDate, boolean usesTimestamp, boolean
usesAttribute, String queryString) {
diff --git
a/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsEndpoint.java
b/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsEndpoint.java
index 35c33bc3d8e6..ef07bcf9eb2c 100644
---
a/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsEndpoint.java
+++
b/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsEndpoint.java
@@ -48,8 +48,9 @@ import org.slf4j.LoggerFactory;
headersClass = GridFsConstants.class)
@BodyTypes(consumerOut = @BodyType(value = Carrier.STREAM,
description = "One message per new file: a
stream of the content of the file (the"
- + " stream cache when stream
caching is on), with its length, upload date,"
- + " content type and metadata
in headers"),
+ + " stream cache when stream
caching is on), closed when the exchange is"
+ + " done, with its name,
length, upload date, content type and metadata in"
+ + " headers"),
producerIn = @BodyType(value = { Carrier.TEXT, Carrier.BYTES,
Carrier.STREAM, Carrier.FILE },
byOption = {
@BodyType.When(option = "operation",
value = "findOne", carriers = Carrier.ANY),
@@ -74,8 +75,9 @@ import org.slf4j.LoggerFactory;
description = "create, the default, leaves
the body unchanged, with the id of the file in"
+ " the CamelGridFsFileId
header. findOne gives a stream of the content of"
+ " the file named by
CamelFileName; listAll a Reader of text, one line per"
- + " file with its name and id
separated by a tab; count the number of files"
- + " as a Long; remove leaves
the body unchanged"))
+ + " file with its name and id
(the hex string of an ObjectId) separated by a"
+ + " tab; count the number of
files as a Long; remove leaves the body"
+ + " unchanged"))
public class GridFsEndpoint extends DefaultEndpoint {
private static final Logger LOG =
LoggerFactory.getLogger(GridFsEndpoint.class);
@@ -129,7 +131,10 @@ public class GridFsEndpoint extends DefaultEndpoint {
@Override
public Consumer createConsumer(Processor processor) throws Exception {
initializeConnection();
- return new GridFsConsumer(this, processor);
+ GridFsConsumer consumer = new GridFsConsumer(this, processor);
+ // the exceptionHandler and bridgeErrorHandler options
+ configureConsumer(consumer);
+ return consumer;
}
public void initializeConnection() throws Exception {
diff --git
a/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsProducer.java
b/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsProducer.java
index 1d68bcf68ee0..4958047c6435 100644
---
a/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsProducer.java
+++
b/components/camel-mongodb-gridfs/src/main/java/org/apache/camel/component/mongodb/gridfs/GridFsProducer.java
@@ -26,6 +26,7 @@ import com.mongodb.client.gridfs.model.GridFSFile;
import com.mongodb.client.gridfs.model.GridFSUploadOptions;
import org.apache.camel.Exchange;
import org.apache.camel.support.DefaultProducer;
+import org.bson.BsonValue;
import org.bson.Document;
import org.bson.types.ObjectId;
@@ -126,6 +127,10 @@ public class GridFsProducer extends DefaultProducer {
count =
endpoint.getFilesCollection().countDocuments(eq(GRIDFS_FILE_KEY_FILENAME,
filename));
}
exchange.getIn().setBody(count, Long.class);
+ } else {
+ throw new IllegalArgumentException(
+ "Unsupported operation: " + operation + ". The supported
operations are: create, remove, findOne,"
+ + " listAll and count");
}
}
@@ -148,10 +153,25 @@ public class GridFsProducer extends DefaultProducer {
}
while (cursor.hasNext() && current.length() < 4000) {
GridFSFile file = cursor.next();
-
current.append(file.getFilename()).append("\t").append(file.getId()).append("\n");
+
current.append(file.getFilename()).append("\t").append(idOf(file)).append("\n");
}
}
+ /**
+ * The id of the file as text: the hex string of an ObjectId (the id
GridFS gives a file), the text of a String
+ * id, and the BSON value of another custom id.
+ */
+ private static String idOf(GridFSFile file) {
+ BsonValue id = file.getId();
+ if (id.isObjectId()) {
+ return id.asObjectId().getValue().toHexString();
+ }
+ if (id.isString()) {
+ return id.asString().getValue();
+ }
+ return String.valueOf(id);
+ }
+
@Override
public int read(char[] cbuf, int off, int len) throws IOException {
if (pos == current.length()) {
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
index 8f6cd8fd17cf..58f4300dc6a3 100644
---
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
@@ -21,6 +21,7 @@ import java.util.Comparator;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
@@ -32,6 +33,7 @@ import java.util.concurrent.atomic.AtomicReference;
import com.mongodb.MongoClientSettings;
import com.mongodb.MongoTimeoutException;
import com.mongodb.client.FindIterable;
+import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoCursor;
import com.mongodb.client.MongoDatabase;
@@ -43,6 +45,7 @@ import com.mongodb.client.model.FindOneAndUpdateOptions;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.Processor;
+import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.impl.DefaultCamelContext;
import org.apache.camel.spi.ExceptionHandler;
import org.bson.BsonArray;
@@ -62,13 +65,16 @@ 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.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
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.
+ * the fake files collection marks a file as the consumer asks, and the bucket
no longer returns a marked file. The
+ * tests that start the consumer in a route also cover a file whose exchange
fails, the headers and the download stream.
*/
class GridFsConsumerTimestampTest {
@@ -81,6 +87,14 @@ class GridFsConsumerTimestampTest {
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();
+ // the ids of the files that the consumer has marked as done
+ private final Set<BsonValue> done = ConcurrentHashMap.newKeySet();
+ // the CamelFileName header of each exchange of the route
+ private final List<String> fileNames = new CopyOnWriteArrayList<>();
+ // the download streams that the consumer opened
+ private final List<GridFSDownloadStream> streams = new
CopyOnWriteArrayList<>();
+ // the number of times the route fails the exchange of a file, by file name
+ private final Map<String, AtomicInteger> failures = new
ConcurrentHashMap<>();
// the number of polls (queries of the bucket) that fail, as when MongoDB
cannot be reached
private final AtomicInteger failingPolls = new AtomicInteger();
// the number of reads of the persistent timestamp that fail
@@ -92,6 +106,22 @@ class GridFsConsumerTimestampTest {
private final List<Date> created = new CopyOnWriteArrayList<>();
// the failures that the consumer reported through its ExceptionHandler
private final List<Throwable> reported = new CopyOnWriteArrayList<>();
+ private final ExceptionHandler exceptionHandler = new ExceptionHandler() {
+ @Override
+ public void handleException(Throwable exception) {
+ reported.add(exception);
+ }
+
+ @Override
+ public void handleException(String message, Throwable exception) {
+ reported.add(exception);
+ }
+
+ @Override
+ public void handleException(String message, Exchange exchange,
Throwable exception) {
+ reported.add(exception);
+ }
+ };
// called by a query of the bucket, before the files are returned (or the
failure is thrown)
private volatile Runnable onPoll = () -> {
};
@@ -136,6 +166,16 @@ class GridFsConsumerTimestampTest {
return marked.add(id)
? stored.stream().filter(f ->
f.getId().equals(id)).findFirst().orElseThrow() : null;
});
+ // the consumer marks a routed file as done, and removes the mark of a
file whose exchange failed
+ when(files.findOneAndUpdate(any(Bson.class),
any(Bson.class))).thenAnswer(invocation -> {
+ BsonValue id = find(toDocument(invocation.getArgument(0)), "_id");
+ if (toDocument(invocation.getArgument(1)).containsKey("$unset")) {
+ marked.remove(id);
+ } else {
+ done.add(id);
+ }
+ return null;
+ });
MongoCollection<Document> timestamps = timestampCollection();
when(database.getCollection("camel-timestamps")).thenReturn(timestamps);
}
@@ -299,6 +339,188 @@ class GridFsConsumerTimestampTest {
assertEquals(List.of(), reported, "no poll follows, the failure must
not be reported as one that is retried");
}
+ @ParameterizedTest
+ @EnumSource(value = QueryStrategy.class,
+ names = { "FileAttribute", "TimeStampAndFileAttribute",
"PersistentTimestampAndFileAttribute" })
+ void
strategyWithFileAttributeOffersTheFileOfAFailedExchangeAgain(QueryStrategy
strategy) throws Exception {
+ failures.put("bad", new AtomicInteger(1));
+ startRoute(strategy);
+
+ GridFSFile bad = file("bad", 60);
+ GridFSFile good = file("good", 120);
+ stored.addAll(List.of(bad, good));
+ awaitFullPolls(2);
+
+ assertEquals(List.of("bad", "good", "bad"), routed,
+ "the file whose exchange failed must not be marked as done:
the poll goes on with the other files, and the"
+ + " next poll
offers it again");
+ assertEquals(Set.of(bad.getId(), good.getId()), done);
+ assertEquals(1, reported.size(), "the failure must be reported to the
exception handler");
+ assertEquals("Forced failure of bad (test)",
reported.get(0).getMessage());
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = QueryStrategy.class, names = { "TimeStamp",
"PersistentTimestamp" })
+ void
timestampStrategyStopsAtTheFileOfAFailedExchangeAndOffersItAgain(QueryStrategy
strategy) throws Exception {
+ failures.put("first", new AtomicInteger(1));
+ startRoute(strategy);
+
+ GridFSFile first = file("first", 60);
+ GridFSFile second = file("second", 120);
+ stored.addAll(List.of(first, second));
+ awaitFullPolls(2);
+
+ assertEquals(List.of("first", "first", "second"), routed,
+ "the poll must stop at the file whose exchange failed, so that
the timestamp stays before it and the next"
+ + " poll
offers it again");
+ assertEquals(1, reported.size(), "the failure must be reported to the
exception handler");
+ }
+
+ @Test
+ void timestampStrategyRoutesTheFilesInTheOrderOfTheirUploadDate() throws
Exception {
+ failures.put("older", new AtomicInteger(1));
+ startRoute(QueryStrategy.TimeStamp);
+
+ // the newer file comes first in the bucket (another uploader, or a
client with another clock)
+ stored.addAll(List.of(file("newer", 120), file("older", 60)));
+ awaitFullPolls(2);
+
+ assertEquals(List.of("older", "older", "newer"), routed,
+ "a newer file routed first would move the timestamp past the
older file whose exchange failed");
+ }
+
+ @Test
+ void
timestampStrategyOffersAgainTheFilesWithTheUploadDateOfTheFileOfAFailedExchange()
throws Exception {
+ failures.put("b", new AtomicInteger(1));
+ startRoute(QueryStrategy.TimeStamp);
+
+ // two files with the same upload date: the timestamp must stay before
both, so a is routed again
+ Date uploadDate = new Date(System.currentTimeMillis() +
TimeUnit.SECONDS.toMillis(60));
+ stored.addAll(List.of(new GridFSFile(new BsonObjectId(), "a", 1, 1,
uploadDate, null),
+ new GridFSFile(new BsonObjectId(), "b", 1, 1, uploadDate,
null)));
+ awaitFullPolls(2);
+
+ assertEquals(List.of("a", "b", "a", "b"), routed);
+ }
+
+ @Test
+ void
persistentTimestampAndFileAttributeStrategyStoresATimestampBeforeTheFileOfAFailedExchange()
throws Exception {
+ failures.put("bad", new AtomicInteger(1));
+ startRoute(QueryStrategy.PersistentTimestampAndFileAttribute);
+
+ GridFSFile bad = file("bad", 60);
+ stored.addAll(List.of(bad, file("good", 120)));
+ awaitFullPolls(2);
+
+ assertEquals(List.of("bad", "good", "bad"), routed);
+ assertTrue(persisted.get(0).before(bad.getUploadDate()),
+ "a consumer started again with the stored timestamp must find
the file whose exchange failed");
+ }
+
+ @ParameterizedTest
+ @EnumSource(QueryStrategy.class)
+ void consumerSetsTheFileNameHeader(QueryStrategy strategy) throws
Exception {
+ startRoute(strategy);
+
+ stored.add(file("report.csv", 60));
+ awaitFullPolls(2);
+
+ assertEquals(List.of("report.csv"), fileNames);
+ }
+
+ @Test
+ void downloadStreamIsClosedWhenTheExchangeIsDone() throws Exception {
+ startRoute(QueryStrategy.TimeStamp);
+
+ stored.addAll(List.of(file("first", 60), file("second", 120)));
+ awaitFullPolls(2);
+
+ assertEquals(2, streams.size());
+ for (GridFSDownloadStream stream : streams) {
+ verify(stream, atLeastOnce()).close();
+ }
+ }
+
+ @Test
+ void downloadStreamOfAFailedExchangeIsClosed() throws Exception {
+ failures.put("bad", new AtomicInteger(1));
+ startRoute(QueryStrategy.FileAttribute);
+
+ stored.add(file("bad", 60));
+ awaitFullPolls(2);
+
+ assertFalse(streams.isEmpty());
+ for (GridFSDownloadStream stream : streams) {
+ verify(stream, atLeastOnce()).close();
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(QueryStrategy.class)
+ void fileThatFailsAtEveryPollIsReportedOnce(QueryStrategy strategy) throws
Exception {
+ failures.put("bad", new AtomicInteger(Integer.MAX_VALUE));
+ startRoute(strategy);
+
+ stored.add(file("bad", 60));
+ awaitFullPolls(3);
+
+ assertTrue(routed.size() >= 3, "each poll must offer the file again: "
+ routed);
+ assertEquals(List.of("Forced failure of bad (test)"),
messagesOf(reported),
+ "the failures of the file at the next polls must not be
reported again");
+ }
+
+ @Test
+ void fileThatFailsAgainAfterItWasRoutedIsReportedAgain() throws Exception {
+ failures.put("bad", new AtomicInteger(2));
+ startRoute(QueryStrategy.FileAttribute);
+
+ GridFSFile bad = file("bad", 60);
+ stored.add(bad);
+ // two polls fail the file, the third one routes it
+ awaitFullPolls(3);
+ assertEquals(List.of("bad", "bad", "bad"), routed);
+ assertEquals(1, reported.size(), "the second failure must not be
reported again");
+
+ // the file is offered again (its mark is removed), and fails once more
+ failures.get("bad").set(1);
+ marked.remove(bad.getId());
+ awaitFullPolls(3);
+
+ assertEquals(List.of("bad", "bad", "bad", "bad", "bad"), routed);
+ assertEquals(2, reported.size(), "a file that fails again after it was
routed must be reported again");
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = QueryStrategy.class,
+ names = { "FileAttribute", "TimeStampAndFileAttribute",
"PersistentTimestampAndFileAttribute" })
+ void filesThatFailAtEveryPollAreEachReportedOnce(QueryStrategy strategy)
throws Exception {
+ failures.put("first", new AtomicInteger(Integer.MAX_VALUE));
+ failures.put("second", new AtomicInteger(Integer.MAX_VALUE));
+ startRoute(strategy);
+
+ stored.addAll(List.of(file("first", 60), file("second", 120)));
+ awaitFullPolls(3);
+
+ assertEquals(List.of("Forced failure of first (test)", "Forced failure
of second (test)"), messagesOf(reported));
+ }
+
+ @Test
+ void timestampStrategyReportsEachFileThatFailsOnce() throws Exception {
+ // the poll stops at the first file while it fails, then the second
file fails at every poll
+ failures.put("first", new AtomicInteger(3));
+ failures.put("second", new AtomicInteger(Integer.MAX_VALUE));
+ startRoute(QueryStrategy.TimeStamp);
+
+ stored.addAll(List.of(file("first", 60), file("second", 120)));
+ awaitFullPolls(6);
+
+ assertEquals(List.of("Forced failure of first (test)", "Forced failure
of second (test)"), messagesOf(reported));
+ }
+
+ private static List<String> messagesOf(List<Throwable> exceptions) {
+ return exceptions.stream().map(Throwable::getMessage).toList();
+ }
+
private void startConsumer(QueryStrategy strategy) {
startConsumer(strategy, 1);
}
@@ -333,23 +555,62 @@ class GridFsConsumerTimestampTest {
routedIds.add(file.getId());
};
consumer = new GridFsConsumer(endpoint, processor);
- consumer.setExceptionHandler(new ExceptionHandler() {
+ consumer.setExceptionHandler(exceptionHandler);
+ consumer.start();
+ }
+
+ /**
+ * Starts the consumer in a route, which runs the exchanges in a unit of
work, as in an application. The route fails
+ * the exchange of a file as many times as the failures map says, and the
exceptionHandler option of the endpoint
+ * gets the failures that the consumer reports.
+ */
+ private void startRoute(QueryStrategy strategy) throws Exception {
+ context.getRegistry().bind("fakeMongo", mock(MongoClient.class));
+ GridFsComponent component = new GridFsComponent();
+ component.setCamelContext(context);
+ GridFsEndpoint endpoint = new
GridFsEndpoint("mongodb-gridfs:fakeMongo", component) {
+ @Override
+ public void initializeConnection() {
+ // the fake bucket and collections
+ }
+
+ @Override
+ public GridFSBucket getGridFsBucket() {
+ return bucket;
+ }
+
@Override
- public void handleException(Throwable exception) {
- reported.add(exception);
+ public MongoDatabase getDB() {
+ return database;
}
@Override
- public void handleException(String message, Throwable exception) {
- reported.add(exception);
+ public MongoCollection<GridFSFile> getFilesCollection() {
+ return files;
}
+ };
+ endpoint.setCamelContext(context);
+ endpoint.setConnectionBean("fakeMongo");
+ endpoint.setQueryStrategy(strategy);
+ endpoint.setInitialDelay(1);
+ endpoint.setDelay(1);
+ endpoint.setExceptionHandler(exceptionHandler);
+ context.addRoutes(new RouteBuilder() {
@Override
- public void handleException(String message, Exchange exchange,
Throwable exception) {
- reported.add(exception);
+ public void configure() {
+ // the fake download streams have no content to cache
+ from(endpoint).noStreamCaching().process(exchange -> {
+ GridFSFile file =
exchange.getMessage().getBody(GridFSDownloadStream.class).getGridFSFile();
+ routed.add(file.getFilename());
+
fileNames.add(exchange.getMessage().getHeader(Exchange.FILE_NAME,
String.class));
+ AtomicInteger left = failures.get(file.getFilename());
+ if (left != null && left.getAndDecrement() > 0) {
+ throw new IllegalStateException("Forced failure of " +
file.getFilename() + " (test)");
+ }
+ });
}
});
- consumer.start();
}
/**
@@ -362,9 +623,10 @@ class GridFsConsumerTimestampTest {
assertTrue(latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), "the
consumer did not poll " + (count + 1) + " times");
}
- private static GridFSDownloadStream downloadStreamOf(GridFSFile file) {
+ private GridFSDownloadStream downloadStreamOf(GridFSFile file) {
GridFSDownloadStream stream = mock(GridFSDownloadStream.class);
when(stream.getGridFSFile()).thenReturn(file);
+ streams.add(stream);
return stream;
}
@@ -424,6 +686,15 @@ class GridFsConsumerTimestampTest {
when(cursor.next()).thenAnswer(invocation -> iterator.next());
GridFSFindIterable iterable = mock(GridFSFindIterable.class);
when(iterable.cursor()).thenReturn(cursor);
+ // as MongoDB: the files sorted by their upload date, in the direction
that the sort document gives
+ when(iterable.sort(any(Bson.class))).thenAnswer(invocation -> {
+ int direction =
toDocument(invocation.getArgument(0)).getInt32(GridFsConstants.GRIDFS_FILE_KEY_UPLOAD_DATE)
+ .getValue();
+ Comparator<GridFSFile> byUploadDate =
Comparator.comparing(GridFSFile::getUploadDate);
+ List<GridFSFile> sorted = new ArrayList<>(files);
+ sorted.sort(direction < 0 ? byUploadDate.reversed() :
byUploadDate);
+ return iterableOf(sorted);
+ });
return iterable;
}
diff --git
a/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/GridFsProducerTest.java
b/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/GridFsProducerTest.java
new file mode 100644
index 000000000000..4d84743034b8
--- /dev/null
+++
b/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/GridFsProducerTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.Date;
+import java.util.Iterator;
+import java.util.List;
+
+import com.mongodb.client.MongoCursor;
+import com.mongodb.client.gridfs.GridFSBucket;
+import com.mongodb.client.gridfs.GridFSFindIterable;
+import com.mongodb.client.gridfs.model.GridFSFile;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.bson.BsonObjectId;
+import org.bson.BsonString;
+import org.bson.types.ObjectId;
+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.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * The producer against a mocked bucket: an operation it does not know fails,
and listAll lists the ids as text.
+ */
+class GridFsProducerTest {
+
+ private final GridFSBucket bucket = mock(GridFSBucket.class);
+ private CamelContext context;
+
+ @BeforeEach
+ void setUp() {
+ context = new DefaultCamelContext();
+ context.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ context.stop();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "findone", "delete", "list" })
+ void operationOfTheHeaderThatIsNotSupportedFails(String operation) {
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getMessage().setHeader(GridFsConstants.GRIDFS_OPERATION,
operation);
+ exchange.getMessage().setHeader(GridFsConstants.FILE_NAME,
"report.csv");
+
+ IllegalArgumentException e
+ = assertThrows(IllegalArgumentException.class, () ->
producer(null).process(exchange));
+ assertEquals("Unsupported operation: " + operation
+ + ". The supported operations are: create, remove,
findOne, listAll and count",
+ e.getMessage());
+ verifyNoInteractions(bucket);
+ }
+
+ @Test
+ void operationOfTheOptionThatIsNotSupportedFails() {
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getMessage().setHeader(GridFsConstants.FILE_NAME,
"report.csv");
+
+ assertThrows(IllegalArgumentException.class, () ->
producer("findone").process(exchange));
+ verifyNoInteractions(bucket);
+ }
+
+ @Test
+ void listAllGivesTheNameAndTheIdOfEachFile() throws Exception {
+ ObjectId id = new ObjectId();
+ GridFSFindIterable found = iterableOf(List.of(
+ new GridFSFile(new BsonObjectId(id), "report.csv", 1, 1, new
Date(), null),
+ // a file that another client uploaded with an id of its own
+ new GridFSFile(new BsonString("custom-id"), "custom.csv", 1,
1, new Date(), null)));
+ when(bucket.find()).thenReturn(found);
+
+ Exchange exchange = new DefaultExchange(context);
+ producer("listAll").process(exchange);
+
+ assertEquals("report.csv\t" + id.toHexString() +
"\ncustom.csv\tcustom-id\n",
+ exchange.getMessage().getBody(String.class));
+ }
+
+ private GridFsProducer producer(String operation) {
+ GridFsComponent component = new GridFsComponent();
+ component.setCamelContext(context);
+ GridFsEndpoint endpoint = new GridFsEndpoint("mongodb-gridfs:fake",
component) {
+ @Override
+ public GridFSBucket getGridFsBucket() {
+ return bucket;
+ }
+ };
+ endpoint.setCamelContext(context);
+ endpoint.setOperation(operation);
+ return new GridFsProducer(endpoint);
+ }
+
+ @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;
+ }
+}
diff --git
a/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/integration/GridFsConsumerIT.java
b/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/integration/GridFsConsumerIT.java
index dc9d3bb9f4b9..ee6851c57d0c 100644
---
a/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/integration/GridFsConsumerIT.java
+++
b/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/integration/GridFsConsumerIT.java
@@ -113,6 +113,7 @@ class GridFsConsumerIT extends AbstractMongoDbITSupport {
MockEndpoint mock = getMockEndpoint(mockUri);
mock.expectedBodiesReceived(FILE_DATA);
mock.expectedHeaderReceived(GridFsConstants.GRIDFS_METADATA,
"{\"contentType\": \"text/plain\"}");
+ mock.expectedHeaderReceived(Exchange.FILE_NAME, FILE_NAME);
Map<String, Object> headers = new HashMap<>();
assertFalse(gridfs.find(eq(GRIDFS_FILE_KEY_FILENAME,
FILE_NAME)).cursor().hasNext());
diff --git
a/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/integration/GridFsProducerOperationsIT.java
b/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/integration/GridFsProducerOperationsIT.java
index 87303e28c07c..0b2159230076 100644
---
a/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/integration/GridFsProducerOperationsIT.java
+++
b/components/camel-mongodb-gridfs/src/test/java/org/apache/camel/component/mongodb/gridfs/integration/GridFsProducerOperationsIT.java
@@ -31,6 +31,7 @@ import org.junit.jupiter.api.Test;
import static com.mongodb.client.model.Filters.eq;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -91,6 +92,24 @@ public class GridFsProducerOperationsIT extends
AbstractMongoDbITSupport {
assertFalse(s.contains("2-" + FILE_NAME));
}
+ @Test
+ void testListAllGivesTheIdAndAnUnknownOperationFails() {
+ Exchange created = template.request("direct:create", exchange -> {
+ exchange.getMessage().setBody(FILE_DATA);
+ exchange.getMessage().setHeader(Exchange.FILE_NAME, FILE_NAME);
+ });
+ ObjectId objectId =
created.getMessage().getHeader(GridFsConstants.GRIDFS_OBJECT_ID,
ObjectId.class);
+ assertNotNull(objectId);
+
+ String listing = template.requestBody("direct:listAll", null,
String.class);
+ assertTrue(listing.contains(FILE_NAME + "\t" + objectId.toHexString()
+ "\n"), listing);
+
+ Exchange unknown = template.request(
+
"mongodb-gridfs:myDb?database={{mongodb.testDb}}&operation=findone&bucket=" +
getBucket(),
+ exchange ->
exchange.getMessage().setHeader(Exchange.FILE_NAME, FILE_NAME));
+ assertInstanceOf(IllegalArgumentException.class,
unknown.getException());
+ }
+
@Test
public void testRemoveByObjectId() {
Map<String, Object> headers = new HashMap<>();
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 89212f6aeb35..8061e87cc40e 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
@@ -4325,7 +4325,7 @@ poll. The failure is given to the exception handler of
the consumer, with the ob
whole bucket, one such object stopped the consumer from consuming any object.
The poll still fails when none of the
listed objects could be got.
-=== camel-mongodb-gridfs - the consumer routes a file once
+=== camel-mongodb-gridfs - the consumer routes a file once, offers the file of
a failed exchange again and sets CamelFileName, and an unknown operation fails
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
@@ -4337,6 +4337,31 @@ visible later: GridFS sets the upload date on the
client. Use `TimeStampAndFileA
`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.
+When the exchange of a file fails, the consumer no longer marks the file as
processed or moves its timestamp past the
+file: it reports the failure to its exception handler (which logs it by
default), and the next poll offers the file
+again. Before, the failure was ignored and the file was never offered again. A
failure that the route handles, such as
+with a dead letter channel, still completes the file. With the `TimeStamp` and
`PersistentTimestamp` query strategies,
+the files of a poll are now routed in the order of their upload date, and the
poll stops at a file whose exchange
+fails, so a file that always fails holds back the files uploaded after it:
handle such failures in the route, or use a
+strategy with a file attribute, which goes on with the other files. Only the
first failure of a file is reported to
+the exception handler: when the file fails again at the next poll, the
consumer logs the failure at `DEBUG`, so a file
+that always fails is reported once (it is reported again when it fails after a
poll that routed it or did not offer
+it). The `exceptionHandler` and `bridgeErrorHandler` options now apply to the
consumer; before, they had no effect.
+
+The consumer now sets the `CamelFileName` header to the name of the file. A
route that sends the message on to an
+endpoint that uses this header, such as a `file` endpoint, now writes the file
under that name instead of a generated
+one. Remove the header before that endpoint to keep the generated name.
+
+The consumer now closes the stream of the content of the file when the
exchange is done. With stream caching turned off
+(it is on by default), read the stream before the exchange is done.
+
+The producer now fails with an `IllegalArgumentException` that lists the
supported operations when the `operation`
+option or the `CamelGridFsOperation` header is not `create`, `remove`,
`findOne`, `listAll` or `count`. Before, a
+misspelled operation, such as `findone`, did nothing and the exchange
succeeded.
+
+`listAll` now gives the id of each file as the hex string of its ObjectId,
such as `665f1c2e8b3e4a5d6c7b8a90`. Before,
+each line held `BsonObjectId{value=665f1c2e8b3e4a5d6c7b8a90}`.
+
=== camel-paho-mqtt5, camel-paho - consumers that share a client get the
messages of their own topic
A client has a single callback, and each consumer set it on its client. The
consumers of several endpoints that use
diff --git
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/GridFsEndpointBuilderFactory.java
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/GridFsEndpointBuilderFactory.java
index e9085cb32562..6a162c465846 100644
---
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/GridFsEndpointBuilderFactory.java
+++
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/GridFsEndpointBuilderFactory.java
@@ -842,7 +842,7 @@ public interface GridFsEndpointBuilderFactory {
return "CamelFileLength";
}
/**
- * The size of the file.
+ * The upload date of the file.
*
* The option is a: {@code Date} type.
*
@@ -854,11 +854,13 @@ public interface GridFsEndpointBuilderFactory {
return "CamelFileLastModified";
}
/**
- * The name of the file.
+ * (producer) The name of the file: the name that create gives the
file,
+ * and the file that findOne, remove, listAll and count look for.
+ * (consumer) The name of the file that was found.
*
* The option is a: {@code String} type.
*
- * Group: producer
+ * Group: common
*
* @return the name of the header {@code FileName}.
*/