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 232c79615dd2 CAMEL-25856: camel-file - keep the lock of a file name
while it is in use (#28379)
232c79615dd2 is described below
commit 232c79615dd27c3b796cb28f361be63895b1440b
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Oct 25 13:00:14 2026 +0530
CAMEL-25856: camel-file - keep the lock of a file name while it is in use
(#28379)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
components/camel-file/pom.xml | 6 +
.../camel/component/file/GenericFileProducer.java | 47 +++---
.../file/FileProducerSameFileLockTest.java | 178 +++++++++++++++++++++
3 files changed, 212 insertions(+), 19 deletions(-)
diff --git a/components/camel-file/pom.xml b/components/camel-file/pom.xml
index 409f05cfaca0..31f573f0338c 100644
--- a/components/camel-file/pom.xml
+++ b/components/camel-file/pom.xml
@@ -54,6 +54,12 @@
<artifactId>camel-core-engine</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.awaitility</groupId>
+ <artifactId>awaitility</artifactId>
+ <version>${awaitility-version}</version>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
diff --git
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileProducer.java
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileProducer.java
index 30bac75a664f..2502dc7dbe6e 100644
---
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileProducer.java
+++
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileProducer.java
@@ -18,6 +18,7 @@ package org.apache.camel.component.file;
import java.io.File;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@@ -26,8 +27,6 @@ import org.apache.camel.Exchange;
import org.apache.camel.Expression;
import org.apache.camel.support.DefaultAsyncProducer;
import org.apache.camel.support.DefaultExchange;
-import org.apache.camel.support.LRUCacheFactory;
-import org.apache.camel.support.service.ServiceHelper;
import org.apache.camel.util.FileUtil;
import org.apache.camel.util.ObjectHelper;
import org.apache.camel.util.StringHelper;
@@ -42,9 +41,10 @@ public class GenericFileProducer<T> extends
DefaultAsyncProducer {
protected final GenericFileEndpoint<T> endpoint;
protected GenericFileOperations<T> operations;
- // assume writing to 100 different files concurrently at most for the same
- // file producer
- private final Map<String, Lock> locks = LRUCacheFactory.newLRUCache(100);
+ // one lock per file name that is being written, so two exchanges do not
write the same file at the same time; a
+ // lock is kept while an exchange holds or waits for it, and removed by
the last one, so it is never replaced by a
+ // new lock while it is in use
+ private final Map<String, FileLock> locks = new ConcurrentHashMap<>();
protected GenericFileProducer(GenericFileEndpoint<T> endpoint,
GenericFileOperations<T> operations) {
super(endpoint);
@@ -82,17 +82,13 @@ public class GenericFileProducer<T> extends
DefaultAsyncProducer {
// use lock for same file name to avoid concurrent writes to the same
// file
// for example when you concurrently append to the same file
- Lock lock = locks.computeIfAbsent(target, f -> new ReentrantLock());
-
- lock.lock();
+ FileLock lock = acquireLock(target);
try {
processExchange(exchange, target);
} catch (Exception e) {
exchange.setException(e);
} finally {
- // do not remove as the locks cache has an upper bound
- // this ensure the locks is appropriate reused
- lock.unlock();
+ releaseLock(target, lock);
// and remove the write file name header as we only want to use it
// once (by design)
exchange.getIn().removeHeader(Exchange.OVERRULE_FILE_NAME);
@@ -513,16 +509,29 @@ public class GenericFileProducer<T> extends
DefaultAsyncProducer {
return answer;
}
- @Override
- protected void doStart() throws Exception {
- ServiceHelper.startService(locks);
- super.doStart();
+ private FileLock acquireLock(String target) {
+ // count the user before taking the lock, so the lock stays in the map
while this exchange waits for it
+ FileLock lock = locks.compute(target, (k, v) -> {
+ FileLock answer = v != null ? v : new FileLock();
+ answer.users++;
+ return answer;
+ });
+ lock.lock.lock();
+ return lock;
}
- @Override
- protected void doStop() throws Exception {
- super.doStop();
- ServiceHelper.stopService(locks);
+ private void releaseLock(String target, FileLock lock) {
+ lock.lock.unlock();
+ locks.computeIfPresent(target, (k, v) -> {
+ v.users--;
+ return v.users == 0 ? null : v;
+ });
+ }
+
+ private static final class FileLock {
+ private final Lock lock = new ReentrantLock();
+ // guarded by the map entry (compute / computeIfPresent)
+ private int users;
}
}
diff --git
a/components/camel-file/src/test/java/org/apache/camel/component/file/FileProducerSameFileLockTest.java
b/components/camel-file/src/test/java/org/apache/camel/component/file/FileProducerSameFileLockTest.java
new file mode 100644
index 000000000000..c3c79ff4ef3d
--- /dev/null
+++
b/components/camel-file/src/test/java/org/apache/camel/component/file/FileProducerSameFileLockTest.java
@@ -0,0 +1,178 @@
+/*
+ * 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.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.LockSupport;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Endpoint;
+import org.apache.camel.Exchange;
+import org.apache.camel.Producer;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The file producer writes one file name at a time, so that two exchanges
that append to the same file do not mix their
+ * content. That must still hold when the producer has written many other
files while one write was going on.
+ */
+class FileProducerSameFileLockTest {
+
+ @TempDir
+ Path dir;
+
+ private CamelContext context;
+ private Producer producer;
+ private ExecutorService executor;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ // the context is not started: camel-file is tested without the simple
language here
+ context = new DefaultCamelContext();
+ Endpoint endpoint = context.getEndpoint("file:" + dir.toAbsolutePath()
+ "?fileExist=Append&bufferSize=4");
+ producer = endpoint.createProducer();
+ producer.start();
+ executor = Executors.newFixedThreadPool(2);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (executor != null) {
+ executor.shutdownNow();
+ }
+ if (producer != null) {
+ producer.stop();
+ }
+ context.stop();
+ }
+
+ @Test
+ void testSameFileIsWrittenOneAtATime() throws Exception {
+ assertSecondWriterWaits(10);
+ }
+
+ @Test
+ void testSameFileIsWrittenOneAtATimeAfterManyOtherFiles() throws Exception
{
+ // more file names than the 100 the producer used to keep a lock for
+ assertSecondWriterWaits(150);
+ }
+
+ private void assertSecondWriterWaits(int otherFiles) throws Exception {
+ CountDownLatch firstPartWritten = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+
+ // a slow writer: it appends AAAA, waits, then appends AAAA again
+ Future<Exchange> slow = executor.submit(() -> send("same.txt", new
SlowStream(firstPartWritten, release)));
+ assertTrue(firstPartWritten.await(10, TimeUnit.SECONDS));
+
+ // meanwhile the producer writes other files
+ for (int i = 0; i < otherFiles; i++) {
+ Exchange other = send("other-" + i + ".txt", "x");
+ assertNull(other.getException());
+ }
+
+ // a second writer of the same file must wait until the slow writer is
done: it either parks on a lock (the
+ // lock of the file name) or, without the lock, completes its write
+ FutureTask<Exchange> second = new FutureTask<>(() -> send("same.txt",
"BBBB"));
+ Thread secondWriter = new Thread(second, "second-writer");
+ secondWriter.start();
+ await().atMost(10, TimeUnit.SECONDS)
+ .until(() -> second.isDone() ||
LockSupport.getBlocker(secondWriter) != null);
+ boolean secondDoneWhileFirstWriting = second.isDone();
+ assertTrue(secondDoneWhileFirstWriting ||
LockSupport.getBlocker(secondWriter) != null,
+ "the second write of same.txt neither waits nor completes");
+
+ release.countDown();
+ assertNull(slow.get(10, TimeUnit.SECONDS).getException());
+ assertNull(second.get(10, TimeUnit.SECONDS).getException());
+ assertEquals("AAAAAAAABBBB", Files.readString(dir.resolve("same.txt")),
+ "the two appends to same.txt are mixed");
+ assertFalse(secondDoneWhileFirstWriting, "the second write of same.txt
ran while the first one was writing");
+ }
+
+ private Exchange send(String fileName, Object body) throws Exception {
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getIn().setHeader(Exchange.FILE_NAME, fileName);
+ exchange.getIn().setBody(body);
+ producer.process(exchange);
+ return exchange;
+ }
+
+ private static final class SlowStream extends InputStream {
+ private final CountDownLatch firstPartWritten;
+ private final CountDownLatch release;
+ private int reads;
+
+ SlowStream(CountDownLatch firstPartWritten, CountDownLatch release) {
+ this.firstPartWritten = firstPartWritten;
+ this.release = release;
+ }
+
+ @Override
+ public int read() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int read(byte[] b) {
+ return read(b, 0, b.length);
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) {
+ reads++;
+ if (reads == 2) {
+ // the first AAAA has been handed to the file; hold the write
here
+ firstPartWritten.countDown();
+ try {
+ if (!release.await(20, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("not released");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ }
+ if (reads > 2) {
+ return -1;
+ }
+ byte[] data = "AAAA".getBytes(StandardCharsets.US_ASCII);
+ int n = Math.min(len, data.length);
+ System.arraycopy(data, 0, b, off, n);
+ return n;
+ }
+ }
+}