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 0d9b605765ff CAMEL-25728: camel-file - keep a done file with a fixed
name for the files a poll left (#28035)
0d9b605765ff is described below
commit 0d9b605765ff9f302648c8a07e0cd62afe38e92f
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 19 00:35:35 2026 +0530
CAMEL-25728: camel-file - keep a done file with a fixed name for the files
a poll left (#28035)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/catalog/docs/file-component.adoc | 5 +
.../camel-file/src/main/docs/file-component.adoc | 5 +
.../camel/component/file/GenericFileConsumer.java | 94 +++-
.../component/file/GenericFileKeptDoneFiles.java | 85 ++++
.../component/file/GenericFileOnCompletion.java | 15 +
.../FileConsumerDoneFileNameFilesLeftTest.java | 472 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 9 +
7 files changed, 683 insertions(+), 2 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/file-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/file-component.adoc
index 8e7e55ca5ec5..f695acd43a25 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/file-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/file-component.adoc
@@ -765,6 +765,11 @@ YAML::
It will only consume files from the _bar_ folder if a _done file_ exists in
the same directory as the target files. Camel will automatically delete
the _done file_ when it's done consuming the files.
+When a poll leaves files of the directory for the next poll (beyond
+`maxMessagesPerPoll`, or not started, for example when the read lock is not
+acquired), the _done file_ is kept until they are consumed; meanwhile the
+polls consume every file of the directory, as they do whenever the _done file_
+is there.
[NOTE]
====
diff --git a/components/camel-file/src/main/docs/file-component.adoc
b/components/camel-file/src/main/docs/file-component.adoc
index 8e7e55ca5ec5..f695acd43a25 100644
--- a/components/camel-file/src/main/docs/file-component.adoc
+++ b/components/camel-file/src/main/docs/file-component.adoc
@@ -765,6 +765,11 @@ YAML::
It will only consume files from the _bar_ folder if a _done file_ exists in
the same directory as the target files. Camel will automatically delete
the _done file_ when it's done consuming the files.
+When a poll leaves files of the directory for the next poll (beyond
+`maxMessagesPerPoll`, or not started, for example when the read lock is not
+acquired), the _done file_ is kept until they are consumed; meanwhile the
+polls consume every file of the directory, as they do whenever the _done file_
+is there.
[NOTE]
====
diff --git
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileConsumer.java
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileConsumer.java
index 493ad6067234..f26f005fcffa 100644
---
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileConsumer.java
+++
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileConsumer.java
@@ -20,10 +20,12 @@ import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
+import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
+import java.util.Set;
import java.util.function.Supplier;
import java.util.regex.Pattern;
@@ -67,6 +69,8 @@ public abstract class GenericFileConsumer<T> extends
ScheduledBatchPollingConsum
private boolean retrieveFile = true;
// files already reported as resolving outside the starting directory
(bounded)
private final Map<String, Boolean> skippedOutsideStartingDirectory =
LRUCacheFactory.newLRUCache(1000);
+ // the done files with a fixed name kept for the files a poll left (null
if the endpoint has no such done file)
+ private final GenericFileKeptDoneFiles keptDoneFiles;
protected GenericFileConsumer(GenericFileEndpoint<T> endpoint, Processor
processor, GenericFileOperations<T> operations,
GenericFileProcessStrategy<T>
processStrategy) {
@@ -79,6 +83,10 @@ public abstract class GenericFileConsumer<T> extends
ScheduledBatchPollingConsum
this.excludePattern = endpoint.getExcludePattern();
this.includeExt = endpoint.getIncludeExt() != null ?
endpoint.getIncludeExt().toLowerCase().split(",") : null;
this.excludeExt = endpoint.getExcludeExt() != null ?
endpoint.getExcludeExt().toLowerCase().split(",") : null;
+ // a done file with a fixed name stands for all the files of its
directory, and is deleted (unless noop) after
+ // the last file of a poll, so it must be kept when the poll left some
of them for the next poll
+ this.keptDoneFiles = endpoint.getDoneFileName() != null &&
!endpoint.isNoop()
+ && !endpoint.getDoneFileName().contains("{file:name") ? new
GenericFileKeptDoneFiles() : null;
}
public Processor getCustomProcessor() {
@@ -165,6 +173,22 @@ public abstract class GenericFileConsumer<T> extends
ScheduledBatchPollingConsum
LOG.debug("Took {} to poll: {}", TimeUtils.printDuration(delta,
true), name);
}
+ GenericFileKeptDoneFiles kept = getKeptDoneFiles();
+ if (kept != null) {
+ // one file more than the limit is listed when a done file with a
fixed name is used (see
+ // canPollMoreFiles): then the poll leaves files for the next one,
so cut the list to the files the eager
+ // limit takes and keep the done files of the files listed
+ boolean listingCut = eagerLimitMaxMessagesPerPoll &&
maxMessagesPerPoll > 0 && files.size() > maxMessagesPerPoll;
+ Set<String> listedDoneFiles = startDoneFileBatch(kept, files,
!listingCut);
+ if (listingCut) {
+ List<GenericFile<T>> excessive =
files.subList(maxMessagesPerPoll, files.size());
+ removeExcessiveInProgressFiles(excessive);
+ excessive.clear();
+ limitHit = true;
+ listedDoneFiles.forEach(kept::keep);
+ }
+ }
+
// log if we hit the limit
if (limitHit) {
LOG.debug("Limiting maximum messages to poll at {} files as there
were more messages in this poll.",
@@ -201,6 +225,10 @@ public abstract class GenericFileConsumer<T> extends
ScheduledBatchPollingConsum
if (files.size() > maxMessagesPerPoll) {
LOG.debug("Limiting maximum messages to poll at {} files as
there were more messages in this poll.",
maxMessagesPerPoll);
+ // the done files of the files cut are kept for the next poll
+ for (Exchange exchange : exchanges.subList(maxMessagesPerPoll,
exchanges.size())) {
+ keepDoneFile(getExchangeFileProperty(exchange));
+ }
// must first remove excessive files from the in progress
// repository
removeExcessiveInProgressFiles(exchanges, maxMessagesPerPoll);
@@ -247,6 +275,9 @@ public abstract class GenericFileConsumer<T> extends
ScheduledBatchPollingConsum
// update pending number of exchanges
pendingExchanges = total - index - 1;
+ // the file as polled: the begin strategy may pre move it, and
then binds the pre moved file to the exchange
+ GenericFile<?> polledFile = getExchangeFileProperty(exchange);
+
// process the current exchange
boolean started;
if (customProcessor != null) {
@@ -259,12 +290,19 @@ public abstract class GenericFileConsumer<T> extends
ScheduledBatchPollingConsum
// if we did not start process the file then decrement the counter
if (!started) {
+ // the done file must stay for this file, also if it is not
started as the last file of the batch
+ keepDoneFile(polledFile);
answer--;
// this exchange was not started processing so remember to
release it afterward
notStarted.add(exchange);
}
}
+ // the files not processed (the batch was stopped) are left for the
next poll too
+ for (Object exchange : exchanges) {
+ keepDoneFile(getExchangeFileProperty((Exchange) exchange));
+ }
+
// drain any in progress files as we are done with this batch
removeExcessiveInProgressFiles(CastUtils.cast((Deque<?>) notStarted,
Exchange.class), 0);
removeExcessiveInProgressFiles(CastUtils.cast((Deque<?>) exchanges,
Exchange.class), 0);
@@ -323,6 +361,47 @@ public abstract class GenericFileConsumer<T> extends
ScheduledBatchPollingConsum
}
}
+ /**
+ * The done files with a fixed name kept for the files left by the last
poll, or <tt>null</tt> if not used (no such
+ * done file, or a custom processor that does not complete the files).
+ */
+ GenericFileKeptDoneFiles getKeptDoneFiles() {
+ return customProcessor == null ? keptDoneFiles : null;
+ }
+
+ /**
+ * Starts the batch of the kept done files with the files listed, and
deletes the done files kept for files that are
+ * gone.
+ *
+ * @return the done files of the files listed
+ */
+ private Set<String> startDoneFileBatch(GenericFileKeptDoneFiles kept,
List<GenericFile<T>> files, boolean complete) {
+ Set<String> doneFiles = new HashSet<>();
+ for (GenericFile<T> file : files) {
+
doneFiles.add(endpoint.createDoneFileName(file.getAbsoluteFilePath()));
+ }
+ List<String> gone = kept.startBatch(doneFiles, complete);
+ if (gone != null) {
+ // the done file was kept for files that are no longer there, so
it is no longer needed
+ for (String doneFileName : gone) {
+ try {
+ boolean deleted = operations.deleteFile(doneFileName);
+ LOG.trace("Done file: {} kept for files that are gone was
deleted: {}", doneFileName, deleted);
+ } catch (Exception e) {
+ LOG.debug("Cannot delete done file: {} due to: {}",
doneFileName, e.getMessage(), e);
+ }
+ }
+ }
+ return doneFiles;
+ }
+
+ private void keepDoneFile(GenericFile<?> file) {
+ GenericFileKeptDoneFiles kept = getKeptDoneFiles();
+ if (kept != null) {
+ kept.keep(endpoint.createDoneFileName(file.getAbsoluteFilePath()));
+ }
+ }
+
/**
* Whether or not we can continue polling for more files
*
@@ -340,6 +419,12 @@ public abstract class GenericFileConsumer<T> extends
ScheduledBatchPollingConsum
return true;
}
+ if (getKeptDoneFiles() != null) {
+ // with a done file with a fixed name, one more file is listed to
know whether the poll leaves files for the
+ // next one, and then the done file is kept (the list is cut to
the limit after polling)
+ return fileList.size() <= maxMessagesPerPoll;
+ }
+
// then only poll if we haven't reached the max limit
return fileList.size() < maxMessagesPerPoll;
}
@@ -483,8 +568,10 @@ public abstract class GenericFileConsumer<T> extends
ScheduledBatchPollingConsum
// register on completion callback that does the completion
// strategies
// (for instance to move the file after we have processed it)
- exchange.getExchangeExtension().addOnCompletion(
- new GenericFileOnCompletion<>(endpoint, operations,
processStrategy, target, absoluteFileName));
+ GenericFileOnCompletion<T> onCompletion
+ = new GenericFileOnCompletion<>(endpoint, operations,
processStrategy, target, absoluteFileName);
+ onCompletion.setKeptDoneFiles(getKeptDoneFiles());
+ exchange.getExchangeExtension().addOnCompletion(onCompletion);
LOG.debug("About to process file: {} using exchange: {}", target,
exchange);
@@ -1011,6 +1098,9 @@ public abstract class GenericFileConsumer<T> extends
ScheduledBatchPollingConsum
protected void doStop() throws Exception {
prepareOnStartup = false;
skippedOutsideStartingDirectory.clear();
+ if (keptDoneFiles != null) {
+ keptDoneFiles.clear();
+ }
super.doStop();
ServiceHelper.stopService(processStrategy);
}
diff --git
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileKeptDoneFiles.java
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileKeptDoneFiles.java
new file mode 100644
index 000000000000..f7a9eaa8cf0b
--- /dev/null
+++
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileKeptDoneFiles.java
@@ -0,0 +1,85 @@
+/*
+ * 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.file;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * The done files with a fixed name (such as {@code doneFileName=ready}) that
a consumer keeps because a poll left files
+ * of their directory for the next poll.
+ * <p/>
+ * A file is left when the poll did not take it: beyond {@code
maxMessagesPerPoll}, or its processing could not start
+ * (for example the read lock was not acquired). The last file of the poll
then does not delete the done file, so the
+ * next poll consumes the files left, and any other file of the directory, as
it does whenever the done file is there. A
+ * file that was taken and failed is not left: as before, the done file is
deleted after the last file of the poll
+ * unless that file failed.
+ * <p/>
+ * The state is in memory and cleared when the consumer stops.
+ */
+final class GenericFileKeptDoneFiles {
+
+ private final Set<String> kept = new HashSet<>();
+
+ /**
+ * Called once the directory is listed, before the files are consumed:
forgets the done files kept by the last poll,
+ * as the files of this poll decide again whether they are kept.
+ *
+ * @param listedDoneFiles the done files of the files this poll found
+ * @param listingComplete whether every file was listed; if not (the
listing stopped at the limit), a done file not
+ * listed is kept, as its files may be among those
not listed
+ * @return the done files kept for files that are gone
(the listing was complete and found none of
+ * their files), to delete
+ */
+ synchronized List<String> startBatch(Set<String> listedDoneFiles, boolean
listingComplete) {
+ List<String> answer = null;
+ if (listingComplete) {
+ for (String doneFileName : kept) {
+ if (!listedDoneFiles.contains(doneFileName)) {
+ if (answer == null) {
+ answer = new ArrayList<>();
+ }
+ answer.add(doneFileName);
+ }
+ }
+ kept.clear();
+ } else {
+ kept.removeAll(listedDoneFiles);
+ }
+ return answer;
+ }
+
+ /**
+ * Keeps the done file, as this poll leaves files of its directory for the
next poll.
+ */
+ synchronized void keep(String doneFileName) {
+ kept.add(doneFileName);
+ }
+
+ /**
+ * Whether the done file must be kept after the last file of the poll.
+ */
+ synchronized boolean isKept(String doneFileName) {
+ return kept.contains(doneFileName);
+ }
+
+ synchronized void clear() {
+ kept.clear();
+ }
+}
diff --git
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileOnCompletion.java
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileOnCompletion.java
index 9f6cfbad39e2..bd5529765ac2 100644
---
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileOnCompletion.java
+++
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileOnCompletion.java
@@ -40,6 +40,7 @@ public class GenericFileOnCompletion<T> implements
Synchronization {
private ExceptionHandler exceptionHandler;
private final GenericFile<T> file;
private final String absoluteFileName;
+ private GenericFileKeptDoneFiles keptDoneFiles;
public GenericFileOnCompletion(GenericFileEndpoint<T> endpoint,
GenericFileOperations<T> operations,
GenericFileProcessStrategy processStrategy,
GenericFile<T> file,
@@ -65,6 +66,14 @@ public class GenericFileOnCompletion<T> implements
Synchronization {
onCompletion(exchange);
}
+ /**
+ * The done files kept for the files the poll left, when the endpoint uses
a done file with a fixed name;
+ * <tt>null</tt> otherwise.
+ */
+ void setKeptDoneFiles(GenericFileKeptDoneFiles keptDoneFiles) {
+ this.keptDoneFiles = keptDoneFiles;
+ }
+
public ExceptionHandler getExceptionHandler() {
return exceptionHandler;
}
@@ -185,6 +194,12 @@ public class GenericFileOnCompletion<T> implements
Synchronization {
// done file must be in same path as the original input file
String doneFileName =
endpoint.createDoneFileName(absoluteFileName);
StringHelper.notEmpty(doneFileName, "doneFileName", endpoint);
+ // a done file with a fixed name is kept while the poll left files
for the next one (beyond
+ // maxMessagesPerPoll, or not started)
+ if (complete && keptDoneFiles != null &&
keptDoneFiles.isKept(doneFileName)) {
+ LOG.trace("Done file: {} is kept for the files left by this
poll", doneFileName);
+ complete = false;
+ }
// we should delete the dynamic done file
if (endpoint.getDoneFileName().contains("{file:name") || complete)
{
try {
diff --git
a/core/camel-core/src/test/java/org/apache/camel/component/file/FileConsumerDoneFileNameFilesLeftTest.java
b/core/camel-core/src/test/java/org/apache/camel/component/file/FileConsumerDoneFileNameFilesLeftTest.java
new file mode 100644
index 000000000000..34fd26d0b357
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/component/file/FileConsumerDoneFileNameFilesLeftTest.java
@@ -0,0 +1,472 @@
+/*
+ * 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.file;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.builder.RouteBuilder;
+import
org.apache.camel.component.file.strategy.MarkerFileExclusiveReadLockStrategy;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.Registry;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * A done file with a fixed name must not be deleted while the poll left files
of its directory for the next one (beyond
+ * maxMessagesPerPoll, or not started), or they wait for a new done file.
While it is kept, the next polls consume the
+ * files of the directory as they do whenever the done file is there,
including a file written meanwhile. A file that
+ * fails is handled as before: the done file is deleted after the last file of
the poll, unless that file failed.
+ * <p/>
+ * A test that expects a file not to be consumed checks it once the done file
is gone: from then on the file cannot be
+ * consumed without a new done file, and the routes are synchronous, so any
file consumed before is already received.
+ */
+public class FileConsumerDoneFileNameFilesLeftTest extends ContextTestSupport {
+
+ private final AtomicBoolean failB = new AtomicBoolean(true);
+ private final AtomicBoolean failLastB = new AtomicBoolean(true);
+ private final AtomicBoolean writeLate = new AtomicBoolean(true);
+ private final AtomicBoolean lockB = new AtomicBoolean(true);
+ private final AtomicBoolean produceLate = new AtomicBoolean(true);
+ private final AtomicInteger badAttempts = new AtomicInteger();
+
+ @Override
+ protected Registry createCamelRegistry() throws Exception {
+ Registry registry = super.createCamelRegistry();
+ // the read lock of b.txt is not acquired the first time, so its
processing is not started
+ registry.bind("lockBOnce", new MarkerFileExclusiveReadLockStrategy() {
+ @Override
+ public boolean acquireExclusiveReadLock(
+ GenericFileOperations<File> operations, GenericFile<File>
file, Exchange exchange)
+ throws Exception {
+ if ("b.txt".equals(file.getFileNameOnly()) &&
lockB.getAndSet(false)) {
+ return false;
+ }
+ return super.acquireExclusiveReadLock(operations, file,
exchange);
+ }
+ });
+ return registry;
+ }
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ // a file component whose download of a.txt fails, after the preMove
of the begin strategy
+ context.addComponent("failingfile", new FileComponent() {
+ @Override
+ protected GenericFileEndpoint<File> buildFileEndpoint(String uri,
String remaining, Map<String, Object> parameters)
+ throws Exception {
+ FileEndpoint endpoint = new FileEndpoint(uri, this) {
+ @Override
+ protected FileConsumer newFileConsumer(Processor
processor, GenericFileOperations<File> operations) {
+ return super.newFileConsumer(processor, new
FileOperations(this) {
+ @Override
+ public boolean retrieveFile(String name, Exchange
exchange, long size) {
+ if (name.endsWith("a.txt")) {
+ throw new
GenericFileOperationFailedException("Cannot download " + name);
+ }
+ return super.retrieveFile(name, exchange,
size);
+ }
+ });
+ }
+ };
+ File file = new File(remaining);
+ endpoint.setFile(file);
+ GenericFileConfiguration config = new
GenericFileConfiguration();
+ config.setDirectory(file.getPath());
+ endpoint.setConfiguration(config);
+ return endpoint;
+ }
+ });
+ return context;
+ }
+
+ @Test
+ public void testFilesLeftByMaxMessagesPerPoll() throws Exception {
+ writeFilesAndDoneFile("limit");
+ getMockEndpoint("mock:limit").expectedBodiesReceivedInAnyOrder("A",
"B", "C");
+
+ context.getRouteController().startRoute("limit");
+
+ assertMockEndpointsSatisfied();
+ awaitDoneFileDeleted("limit/ready");
+ }
+
+ @Test
+ public void testFilesLeftByMaxMessagesPerPollNotEager() throws Exception {
+ writeFilesAndDoneFile("noteager");
+ getMockEndpoint("mock:noteager").expectedBodiesReceivedInAnyOrder("A",
"B", "C");
+
+ context.getRouteController().startRoute("noteager");
+
+ assertMockEndpointsSatisfied();
+ awaitDoneFileDeleted("noteager/ready");
+ }
+
+ @Test
+ public void testFileNotStartedIsConsumedByNextPoll() throws Exception {
+ writeFilesAndDoneFile("notstarted");
+
getMockEndpoint("mock:notstarted").expectedBodiesReceivedInAnyOrder("A", "B",
"C");
+
+ context.getRouteController().startRoute("notstarted");
+
+ assertMockEndpointsSatisfied();
+ awaitDoneFileDeleted("notstarted/ready");
+ }
+
+ @Test
+ public void testDoneFileDeletedWhenExactlyMaxMessagesPerPollFiles() throws
Exception {
+ template.sendBodyAndHeader(fileUri("exact"), "A", Exchange.FILE_NAME,
"a.txt");
+ template.sendBodyAndHeader(fileUri("exact"), "B", Exchange.FILE_NAME,
"b.txt");
+ template.sendBodyAndHeader(fileUri("exact"), "", Exchange.FILE_NAME,
"ready");
+ getMockEndpoint("mock:exact").expectedBodiesReceivedInAnyOrder("A",
"B");
+
+ context.getRouteController().startRoute("exact");
+
+ assertMockEndpointsSatisfied();
+ awaitDoneFileDeleted("exact/ready");
+ }
+
+ @Test
+ public void testFileWrittenWhileFilesAreLeftIsConsumed() throws Exception {
+ // x.txt is written while a.txt is routed: b.txt and c.txt are left
for later polls, and the done file kept for
+ // them is there, so x.txt is consumed as well (as when a poll finds a
file written after the done file)
+ writeFilesAndDoneFile("gate");
+ MockEndpoint mock = getMockEndpoint("mock:gate");
+ mock.expectedBodiesReceivedInAnyOrder("A", "B", "C", "X");
+
+ context.getRouteController().startRoute("gate");
+
+ mock.assertIsSatisfied();
+ awaitDoneFileDeleted("gate/ready");
+ assertFileNotExists(testFile("gate/x.txt"));
+ }
+
+ @Test
+ public void testFileWrittenByCamelProducerWhileFilesAreLeftIsConsumed()
throws Exception {
+ // a Camel producer with doneFileName writes x.txt and rewrites the
done file while a.txt is routed: x.txt must
+ // be consumed, not stranded once the done file is deleted after the
files left
+ writeFilesAndDoneFile("producer");
+ MockEndpoint mock = getMockEndpoint("mock:producer");
+ mock.expectedBodiesReceivedInAnyOrder("A", "B", "C", "X");
+
+ context.getRouteController().startRoute("producer");
+
+ mock.assertIsSatisfied();
+ awaitDoneFileDeleted("producer/ready");
+ assertFileNotExists(testFile("producer/x.txt"));
+ }
+
+ @Test
+ public void testConsumerTemplateReceivesEveryFileOfTheDoneFile() throws
Exception {
+ // the polling consumer takes one file per receive
(maxMessagesPerPoll=1): the done file is kept until the last
+ // file is received (before, the first receive deleted it and the
other files waited for a new done file)
+ writeFilesAndDoneFile("receive");
+ String uri = fileUri("receive?doneFileName=ready");
+
+ Set<String> bodies = new HashSet<>();
+ for (int i = 0; i < 3; i++) {
+ bodies.add(consumer.receiveBody(uri, 5000, String.class));
+ }
+
+ assertEquals(Set.of("A", "B", "C"), bodies);
+ awaitDoneFileDeleted("receive/ready");
+ assertNull(consumer.receiveBodyNoWait(uri, String.class));
+ }
+
+ @Test
+ public void
testDownloadFailingAfterPreMoveKeepsTheDoneFileOfTheDirectory() throws
Exception {
+ // the download of a.txt fails after it was pre moved to work/a.txt;
the done file kept for it must be the one of
+ // the directory polled, not work/ready, which belongs to whatever
writes to the work directory
+ writeFilesAndDoneFile("premove");
+ template.sendBodyAndHeader(fileUri("premove/work"), "",
Exchange.FILE_NAME, "ready");
+ MockEndpoint mock = getMockEndpoint("mock:premove");
+ mock.expectedBodiesReceivedInAnyOrder("B", "C");
+
+ context.getRouteController().startRoute("premove");
+
+ mock.assertIsSatisfied();
+ // a.txt is no longer in the directory, so the next poll deletes the
done file kept for it
+ awaitDoneFileDeleted("premove/ready");
+
+ // a later batch is consumed, so the polls after the failed download
have run
+ mock.reset();
+ mock.expectedBodiesReceived("D");
+ template.sendBodyAndHeader(fileUri("premove"), "D",
Exchange.FILE_NAME, "d.txt");
+ template.sendBodyAndHeader(fileUri("premove"), "", Exchange.FILE_NAME,
"ready");
+ mock.assertIsSatisfied();
+ awaitDoneFileDeleted("premove/ready");
+
+ assertFileExists(testFile("premove/work/ready"));
+ assertFileExists(testFile("premove/work/a.txt"));
+ }
+
+ @Test
+ public void
testFileWrittenDuringExactlyMaxMessagesPerPollFilesIsNotConsumed() throws
Exception {
+ // y.txt is written while b.txt, the last file, is routed, without a
done file
+ template.sendBodyAndHeader(fileUri("exactlate"), "A",
Exchange.FILE_NAME, "a.txt");
+ template.sendBodyAndHeader(fileUri("exactlate"), "B",
Exchange.FILE_NAME, "b.txt");
+ template.sendBodyAndHeader(fileUri("exactlate"), "",
Exchange.FILE_NAME, "ready");
+ MockEndpoint mock = getMockEndpoint("mock:exactlate");
+ mock.expectedBodiesReceived("A", "B");
+
+ context.getRouteController().startRoute("exactlate");
+
+ awaitDoneFileDeleted("exactlate/ready");
+ mock.assertIsSatisfied();
+ assertFileExists(testFile("exactlate/y.txt"));
+ }
+
+ @Test
+ public void testFileRolledBackWaitsForNextDoneFile() throws Exception {
+ // as before: b.txt fails once and waits for the next done file
+ writeFilesAndDoneFile("retry");
+ MockEndpoint mock = getMockEndpoint("mock:retry");
+ mock.expectedBodiesReceivedInAnyOrder("A", "C");
+
+ context.getRouteController().startRoute("retry");
+
+ awaitDoneFileDeleted("retry/ready");
+ mock.assertIsSatisfied();
+ assertFileExists(testFile("retry/b.txt"));
+
+ mock.reset();
+ mock.expectedBodiesReceived("B");
+ template.sendBodyAndHeader(fileUri("retry"), "", Exchange.FILE_NAME,
"ready");
+
+ mock.assertIsSatisfied();
+ awaitDoneFileDeleted("retry/ready");
+ }
+
+ @Test
+ public void testLastFileRolledBackKeepsDoneFileAsBefore() throws Exception
{
+ // as before: b.txt, the last file of the batch, fails once and writes
y.txt; the done file stays, so the next
+ // poll consumes both
+ template.sendBodyAndHeader(fileUri("rollback"), "A",
Exchange.FILE_NAME, "a.txt");
+ template.sendBodyAndHeader(fileUri("rollback"), "B",
Exchange.FILE_NAME, "b.txt");
+ template.sendBodyAndHeader(fileUri("rollback"), "",
Exchange.FILE_NAME, "ready");
+ getMockEndpoint("mock:rollback").expectedBodiesReceived("A", "B", "Y");
+
+ context.getRouteController().startRoute("rollback");
+
+ assertMockEndpointsSatisfied();
+ awaitDoneFileDeleted("rollback/ready");
+ }
+
+ @Test
+ public void testFileThatAlwaysFailsDoesNotHoldDoneFile() throws Exception {
+ writeFilesAndDoneFile("poison", "BAD");
+ MockEndpoint mock = getMockEndpoint("mock:poison");
+ mock.expectedBodiesReceivedInAnyOrder("A", "C");
+
+ context.getRouteController().startRoute("poison");
+
+ awaitDoneFileDeleted("poison/ready");
+ mock.assertIsSatisfied();
+
+ // the next batch is consumed with its done file (b.txt is tried again
and fails again)
+ mock.reset();
+ mock.expectedBodiesReceived("D");
+ template.sendBodyAndHeader(fileUri("poison"), "D", Exchange.FILE_NAME,
"d.txt");
+ template.sendBodyAndHeader(fileUri("poison"), "", Exchange.FILE_NAME,
"ready");
+
+ mock.assertIsSatisfied();
+ awaitDoneFileDeleted("poison/ready");
+ assertFileExists(testFile("poison/b.txt"));
+ }
+
+ @Test
+ public void testFileThatAlwaysFailsWithFilesLeftDoesNotHoldDoneFile()
throws Exception {
+ // b.txt fails as the last file of the first poll, c.txt is left for
the next one, which tries b.txt again (as
+ // the next poll does on main when the last file failed) and deletes
the done file after c.txt
+ writeFilesAndDoneFile("poisonlimit", "BAD");
+ MockEndpoint mock = getMockEndpoint("mock:poisonlimit");
+ mock.expectedBodiesReceivedInAnyOrder("A", "C");
+
+ context.getRouteController().startRoute("poisonlimit");
+
+ awaitDoneFileDeleted("poisonlimit/ready");
+ mock.assertIsSatisfied();
+ assertEquals(2, badAttempts.get(), "b.txt is tried again with the file
left");
+
+ mock.reset();
+ mock.expectedBodiesReceived("D");
+ template.sendBodyAndHeader(fileUri("poisonlimit"), "D",
Exchange.FILE_NAME, "d.txt");
+ template.sendBodyAndHeader(fileUri("poisonlimit"), "",
Exchange.FILE_NAME, "ready");
+
+ mock.assertIsSatisfied();
+ assertFileExists(testFile("poisonlimit/b.txt"));
+ }
+
+ @Test
+ public void testDynamicDoneFileNameUnchanged() throws Exception {
+ template.sendBodyAndHeader(fileUri("dynamic"), "A",
Exchange.FILE_NAME, "a.txt");
+ template.sendBodyAndHeader(fileUri("dynamic"), "", Exchange.FILE_NAME,
"a.txt.done");
+ template.sendBodyAndHeader(fileUri("dynamic"), "B",
Exchange.FILE_NAME, "b.txt");
+ template.sendBodyAndHeader(fileUri("dynamic"), "", Exchange.FILE_NAME,
"b.txt.done");
+ template.sendBodyAndHeader(fileUri("dynamic"), "C",
Exchange.FILE_NAME, "c.txt");
+ MockEndpoint mock = getMockEndpoint("mock:dynamic");
+ // the eager limit takes the first listed file, so the order is not
known
+ mock.expectedBodiesReceivedInAnyOrder("A", "B");
+
+ context.getRouteController().startRoute("dynamic");
+
+ awaitDoneFileDeleted("dynamic/a.txt.done");
+ awaitDoneFileDeleted("dynamic/b.txt.done");
+ mock.assertIsSatisfied();
+ assertFileExists(testFile("dynamic/c.txt"));
+ }
+
+ private void awaitDoneFileDeleted(String doneFile) {
+ Awaitility.await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
assertFileNotExists(testFile(doneFile)));
+ }
+
+ private void writeFilesAndDoneFile(String dir) {
+ writeFilesAndDoneFile(dir, "B");
+ }
+
+ private void writeFilesAndDoneFile(String dir, String bodyB) {
+ template.sendBodyAndHeader(fileUri(dir), "A", Exchange.FILE_NAME,
"a.txt");
+ template.sendBodyAndHeader(fileUri(dir), bodyB, Exchange.FILE_NAME,
"b.txt");
+ template.sendBodyAndHeader(fileUri(dir), "C", Exchange.FILE_NAME,
"c.txt");
+ template.sendBodyAndHeader(fileUri(dir), "", Exchange.FILE_NAME,
"ready");
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from(fileUri("limit?doneFileName=ready&maxMessagesPerPoll=2&sortBy=file:name&initialDelay=0&delay=10"))
+ .routeId("limit").autoStartup(false)
+ .convertBodyTo(String.class).to("mock:limit");
+
+
from(fileUri("noteager?doneFileName=ready&maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false"
+ + "&sortBy=file:name&initialDelay=0&delay=10"))
+ .routeId("noteager").autoStartup(false)
+ .convertBodyTo(String.class).to("mock:noteager");
+
+
from(fileUri("notstarted?doneFileName=ready&exclusiveReadLockStrategy=#lockBOnce&sortBy=file:name"
+ + "&initialDelay=0&delay=10"))
+ .routeId("notstarted").autoStartup(false)
+ .convertBodyTo(String.class).to("mock:notstarted");
+
+
from(fileUri("retry?doneFileName=ready&sortBy=file:name&initialDelay=0&delay=10"))
+ .routeId("retry").autoStartup(false)
+ .convertBodyTo(String.class)
+ .process(e -> {
+ if ("B".equals(e.getMessage().getBody()) &&
failB.getAndSet(false)) {
+ throw new IllegalStateException("B fails
once");
+ }
+ })
+ .to("mock:retry");
+
+
from(fileUri("exact?doneFileName=ready&maxMessagesPerPoll=2&initialDelay=0&delay=10"))
+ .routeId("exact").autoStartup(false)
+ .convertBodyTo(String.class).to("mock:exact");
+
+
from(fileUri("gate?doneFileName=ready&maxMessagesPerPoll=1&sortBy=file:name&initialDelay=0&delay=10"))
+ .routeId("gate").autoStartup(false)
+ .convertBodyTo(String.class)
+ .process(e -> {
+ if (writeLate.compareAndSet(true, false)) {
+ Files.writeString(testFile("gate/x.txt"), "X");
+ }
+ })
+ .to("mock:gate");
+
+
from(fileUri("producer?doneFileName=ready&maxMessagesPerPoll=1&sortBy=file:name&initialDelay=0&delay=10"))
+ .routeId("producer").autoStartup(false)
+ .convertBodyTo(String.class)
+ .process(e -> {
+ if (produceLate.compareAndSet(true, false)) {
+ // deletes and rewrites the done file after
writing x.txt
+
template.sendBodyAndHeader(fileUri("producer?doneFileName=ready"), "X",
Exchange.FILE_NAME,
+ "x.txt");
+ }
+ })
+ .to("mock:producer");
+
+ from("failingfile:" + testDirectory("premove")
+ +
"?doneFileName=ready&preMove=work&sortBy=file:name&initialDelay=0&delay=10")
+ .routeId("premove").autoStartup(false)
+ .convertBodyTo(String.class).to("mock:premove");
+
+
from(fileUri("exactlate?doneFileName=ready&maxMessagesPerPoll=2&sortBy=file:name&initialDelay=0&delay=10"))
+ .routeId("exactlate").autoStartup(false)
+ .convertBodyTo(String.class)
+ .process(e -> {
+ if ("B".equals(e.getMessage().getBody())) {
+ Files.writeString(testFile("exactlate/y.txt"),
"Y");
+ }
+ })
+ .to("mock:exactlate");
+
+
from(fileUri("rollback?doneFileName=ready&sortBy=file:name&initialDelay=0&delay=10"))
+ .routeId("rollback").autoStartup(false)
+ .convertBodyTo(String.class)
+ .process(e -> {
+ if ("B".equals(e.getMessage().getBody()) &&
failLastB.getAndSet(false)) {
+ Files.writeString(testFile("rollback/y.txt"),
"Y");
+ throw new IllegalStateException("B fails
once");
+ }
+ })
+ .to("mock:rollback");
+
+
from(fileUri("poison?doneFileName=ready&sortBy=file:name&initialDelay=0&delay=10"))
+ .routeId("poison").autoStartup(false)
+ .convertBodyTo(String.class)
+ .process(e -> {
+ if ("BAD".equals(e.getMessage().getBody())) {
+ throw new IllegalStateException("B always
fails");
+ }
+ })
+ .to("mock:poison");
+
+
from(fileUri("poisonlimit?doneFileName=ready&maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false"
+ + "&sortBy=file:name&initialDelay=0&delay=10"))
+ .routeId("poisonlimit").autoStartup(false)
+ .convertBodyTo(String.class)
+ .process(e -> {
+ if ("BAD".equals(e.getMessage().getBody())) {
+ badAttempts.incrementAndGet();
+ throw new IllegalStateException("B always
fails");
+ }
+ })
+ .to("mock:poisonlimit");
+
+
from(fileUri("dynamic?doneFileName=${file:name}.done&maxMessagesPerPoll=1&sortBy=file:name"
+ + "&initialDelay=0&delay=10"))
+ .routeId("dynamic").autoStartup(false)
+ .convertBodyTo(String.class).to("mock:dynamic");
+ }
+ };
+ }
+}
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 2c4a315cdf28..68a45bb42317 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
@@ -203,6 +203,15 @@ that a route sends to and no route consumes, so they
report the same endpoints o
So `camel validate` can report an endpoint it did not report before.
+=== camel-file - a done file with a fixed name is kept for the files a poll
left
+
+A done file with a fixed name (such as `doneFileName=ready`) is no longer
deleted while a poll left files of its
+directory for the next poll (beyond `maxMessagesPerPoll`, or not started);
before, they were left until a new done file
+was written. Meanwhile the polls consume every file of the directory, as they
do whenever the done file is there. This
+also applies to `pollEnrich` and `ConsumerTemplate.receive`, which take one
file per call: the done file is now kept
+until the last file of the directory is received, where before the first
received file deleted it. This applies to
+camel-ftp, camel-sftp, camel-smb and camel-azure-files as well.
+
=== camel-jbang - camel validate normalize writes one file per input file to
an output directory
`camel validate normalize <files> --output=<dir>` now writes one normalized
file per input file into the