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 9a833d26edba CAMEL-24321: Allow stream caching spool directory to be
resolved per-Exchange
9a833d26edba is described below
commit 9a833d26edba330e4fe727e3ebf75e6ec20d2c5f
Author: Guillaume Nodet <[email protected]>
AuthorDate: Thu Aug 6 21:34:12 2026 +0200
CAMEL-24321: Allow stream caching spool directory to be resolved
per-Exchange
Add resolveSpoolDirectory(Exchange) default method to StreamCachingStrategy,
allowing custom implementations to direct each route's spooled bytes to a
separate directory. The default delegates to getSpoolDirectory() for full
backward compatibility.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
.../apache/camel/spi/StreamCachingStrategy.java | 16 ++
.../CachedOutputStreamPerExchangeSpoolDirTest.java | 274 +++++++++++++++++++++
.../camel/converter/stream/CachedOutputStream.java | 4 +-
.../converter/stream/FileInputStreamCache.java | 5 +-
.../modules/ROOT/pages/stream-caching.adoc | 23 ++
5 files changed, 319 insertions(+), 3 deletions(-)
diff --git
a/core/camel-api/src/main/java/org/apache/camel/spi/StreamCachingStrategy.java
b/core/camel-api/src/main/java/org/apache/camel/spi/StreamCachingStrategy.java
index 631d844425c4..90f1ae7ed179 100644
---
a/core/camel-api/src/main/java/org/apache/camel/spi/StreamCachingStrategy.java
+++
b/core/camel-api/src/main/java/org/apache/camel/spi/StreamCachingStrategy.java
@@ -196,6 +196,22 @@ public interface StreamCachingStrategy extends
StaticService {
@Nullable
File getSpoolDirectory();
+ /**
+ * Resolves the spool directory to use for the given {@link Exchange}.
This allows custom implementations to direct
+ * each route's spooled bytes to a separate directory, for example to
isolate spool data per route or to monitor
+ * spool usage at the route level.
+ * <p/>
+ * The default implementation delegates to {@link #getSpoolDirectory()},
which returns the single context-wide spool
+ * directory. Custom implementations may override this to return a
per-route or per-exchange directory.
+ *
+ * @param exchange the exchange being processed
+ * @return the spool directory to use for this exchange, or
{@code null} if spooling is not configured
+ * @since 4.22
+ */
+ default @Nullable File resolveSpoolDirectory(Exchange exchange) {
+ return getSpoolDirectory();
+ }
+
void setSpoolDirectory(String path);
/**
diff --git
a/core/camel-core/src/test/java/org/apache/camel/converter/stream/CachedOutputStreamPerExchangeSpoolDirTest.java
b/core/camel-core/src/test/java/org/apache/camel/converter/stream/CachedOutputStreamPerExchangeSpoolDirTest.java
new file mode 100644
index 000000000000..23ca409c40c5
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/converter/stream/CachedOutputStreamPerExchangeSpoolDirTest.java
@@ -0,0 +1,274 @@
+/*
+ * 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.converter.stream;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.StringJoiner;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.StreamCache;
+import org.apache.camel.impl.engine.DefaultStreamCachingStrategy;
+import org.apache.camel.impl.engine.DefaultUnitOfWork;
+import org.apache.camel.spi.UnitOfWork;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.util.IOHelper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for per-Exchange spool directory resolution via
+ * {@link
org.apache.camel.spi.StreamCachingStrategy#resolveSpoolDirectory(Exchange)}.
+ */
+class CachedOutputStreamPerExchangeSpoolDirTest extends ContextTestSupport {
+
+ private static final String TEST_STRING = "This is a test string that is
long enough to exceed the spool threshold"
+ + "
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ";
+
+ private Exchange exchange;
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ context.setStreamCaching(true);
+
context.getStreamCachingStrategy().setSpoolDirectory(testDirectory().toFile());
+ context.getStreamCachingStrategy().setSpoolEnabled(true);
+ context.getStreamCachingStrategy().setSpoolThreshold(16);
+ return context;
+ }
+
+ @Override
+ @BeforeEach
+ public void setUp() throws Exception {
+ super.setUp();
+
+ exchange = new DefaultExchange(context);
+ UnitOfWork uow = new DefaultUnitOfWork(exchange);
+ exchange.getExchangeExtension().setUnitOfWork(uow);
+ }
+
+ @Override
+ public boolean isUseRouteBuilder() {
+ return false;
+ }
+
+ private static String toString(InputStream input) throws IOException {
+ BufferedReader reader = IOHelper.buffered(new
InputStreamReader(input));
+ StringJoiner builder = new StringJoiner(", ");
+ while (true) {
+ String line = reader.readLine();
+ if (line == null) {
+ return builder.toString();
+ }
+ builder.add(line);
+ }
+ }
+
+ @Test
+ void testDefaultResolveSpoolDirectoryReturnsSameDirectory() throws
Exception {
+ context.start();
+
+ // The default resolveSpoolDirectory should return the same directory
as getSpoolDirectory
+ File spoolDir = context.getStreamCachingStrategy().getSpoolDirectory();
+ File resolvedDir =
context.getStreamCachingStrategy().resolveSpoolDirectory(exchange);
+ assertThat(resolvedDir).isEqualTo(spoolDir);
+
+ // Verify spooling still works with default behavior
+ CachedOutputStream cos = new CachedOutputStream(exchange);
+ cos.write(TEST_STRING.getBytes(StandardCharsets.UTF_8));
+
+ StreamCache cache = cos.newStreamCache();
+ assertThat(cache).isInstanceOf(FileInputStreamCache.class);
+ String content = toString((InputStream) cache);
+ assertThat(content).isEqualTo(TEST_STRING);
+
+ ((InputStream) cache).close();
+ exchange.getUnitOfWork().done(exchange);
+ IOHelper.close(cos);
+ }
+
+ @Test
+ void testPerExchangeSpoolDirectoryResolution() throws Exception {
+ // Use a custom strategy that returns a per-route subdirectory
+ File baseDir = testDirectory().toFile();
+ DefaultStreamCachingStrategy customStrategy = new
DefaultStreamCachingStrategy() {
+ @Override
+ public File resolveSpoolDirectory(Exchange exchange) {
+ String routeId = exchange.getFromRouteId();
+ if (routeId != null) {
+ return new File(getSpoolDirectory(), routeId);
+ }
+ return getSpoolDirectory();
+ }
+ };
+ customStrategy.setCamelContext(context);
+ customStrategy.setEnabled(true);
+ customStrategy.setSpoolEnabled(true);
+ customStrategy.setSpoolDirectory(baseDir);
+ customStrategy.setSpoolThreshold(16);
+ context.setStreamCachingStrategy(customStrategy);
+
+ context.start();
+
+ // Create an exchange that simulates coming from a route
+ Exchange exchangeWithRoute = new DefaultExchange(context);
+ exchangeWithRoute.getExchangeExtension().setFromRouteId("routeA");
+ UnitOfWork uow = new DefaultUnitOfWork(exchangeWithRoute);
+ exchangeWithRoute.getExchangeExtension().setUnitOfWork(uow);
+
+ CachedOutputStream cos = new CachedOutputStream(exchangeWithRoute);
+ cos.write(TEST_STRING.getBytes(StandardCharsets.UTF_8));
+
+ // The temp file should be in the per-route subdirectory
+ StreamCache cache = cos.newStreamCache();
+ assertThat(cache).isInstanceOf(FileInputStreamCache.class);
+
+ // Verify the subdirectory was created
+ File routeDir = new File(baseDir, "routeA");
+ assertThat(routeDir).exists().isDirectory();
+
+ // Verify files are in the per-route subdirectory
+ String[] files = routeDir.list();
+ assertThat(files).isNotNull().hasSize(1);
+ assertThat(files[0]).startsWith("cos");
+
+ // Verify content is correct
+ String content = toString((InputStream) cache);
+ assertThat(content).isEqualTo(TEST_STRING);
+
+ ((InputStream) cache).close();
+ exchangeWithRoute.getUnitOfWork().done(exchangeWithRoute);
+ IOHelper.close(cos);
+ }
+
+ @Test
+ void testPerExchangeSpoolDirWithDifferentRoutes() throws Exception {
+ // Use a custom strategy that returns a per-route subdirectory
+ File baseDir = testDirectory().toFile();
+ DefaultStreamCachingStrategy customStrategy = new
DefaultStreamCachingStrategy() {
+ @Override
+ public File resolveSpoolDirectory(Exchange exchange) {
+ String routeId = exchange.getFromRouteId();
+ if (routeId != null) {
+ return new File(getSpoolDirectory(), routeId);
+ }
+ return getSpoolDirectory();
+ }
+ };
+ customStrategy.setCamelContext(context);
+ customStrategy.setEnabled(true);
+ customStrategy.setSpoolEnabled(true);
+ customStrategy.setSpoolDirectory(baseDir);
+ customStrategy.setSpoolThreshold(16);
+ context.setStreamCachingStrategy(customStrategy);
+
+ context.start();
+
+ // Create exchange for route A
+ Exchange exchangeA = new DefaultExchange(context);
+ exchangeA.getExchangeExtension().setFromRouteId("routeA");
+ UnitOfWork uowA = new DefaultUnitOfWork(exchangeA);
+ exchangeA.getExchangeExtension().setUnitOfWork(uowA);
+
+ CachedOutputStream cosA = new CachedOutputStream(exchangeA);
+ cosA.write(TEST_STRING.getBytes(StandardCharsets.UTF_8));
+
+ // Create exchange for route B
+ Exchange exchangeB = new DefaultExchange(context);
+ exchangeB.getExchangeExtension().setFromRouteId("routeB");
+ UnitOfWork uowB = new DefaultUnitOfWork(exchangeB);
+ exchangeB.getExchangeExtension().setUnitOfWork(uowB);
+
+ CachedOutputStream cosB = new CachedOutputStream(exchangeB);
+ cosB.write(TEST_STRING.getBytes(StandardCharsets.UTF_8));
+
+ // Verify both subdirectories were created and contain spool files
+ File routeADir = new File(baseDir, "routeA");
+ File routeBDir = new File(baseDir, "routeB");
+
+ assertThat(routeADir).exists().isDirectory();
+ assertThat(routeBDir).exists().isDirectory();
+
+ String[] filesA = routeADir.list();
+ String[] filesB = routeBDir.list();
+ assertThat(filesA).isNotNull().hasSize(1);
+ assertThat(filesB).isNotNull().hasSize(1);
+
+ // Verify content of both streams
+ StreamCache cacheA = cosA.newStreamCache();
+ StreamCache cacheB = cosB.newStreamCache();
+ assertThat(toString((InputStream) cacheA)).isEqualTo(TEST_STRING);
+ assertThat(toString((InputStream) cacheB)).isEqualTo(TEST_STRING);
+
+ ((InputStream) cacheA).close();
+ ((InputStream) cacheB).close();
+ exchangeA.getUnitOfWork().done(exchangeA);
+ exchangeB.getUnitOfWork().done(exchangeB);
+ IOHelper.close(cosA);
+ IOHelper.close(cosB);
+ }
+
+ @Test
+ void testPerExchangeSpoolDirFallsBackWhenNoRouteId() throws Exception {
+ // Use a custom strategy that returns a per-route subdirectory
+ File baseDir = testDirectory().toFile();
+ DefaultStreamCachingStrategy customStrategy = new
DefaultStreamCachingStrategy() {
+ @Override
+ public File resolveSpoolDirectory(Exchange exchange) {
+ String routeId = exchange.getFromRouteId();
+ if (routeId != null) {
+ return new File(getSpoolDirectory(), routeId);
+ }
+ return getSpoolDirectory();
+ }
+ };
+ customStrategy.setCamelContext(context);
+ customStrategy.setEnabled(true);
+ customStrategy.setSpoolEnabled(true);
+ customStrategy.setSpoolDirectory(baseDir);
+ customStrategy.setSpoolThreshold(16);
+ context.setStreamCachingStrategy(customStrategy);
+
+ context.start();
+
+ // Exchange without fromRouteId should fall back to base directory
+ CachedOutputStream cos = new CachedOutputStream(exchange);
+ cos.write(TEST_STRING.getBytes(StandardCharsets.UTF_8));
+
+ StreamCache cache = cos.newStreamCache();
+ assertThat(cache).isInstanceOf(FileInputStreamCache.class);
+
+ // Files should be in the base directory (no subdirectory)
+ String[] files = baseDir.list();
+ assertThat(files).isNotNull().hasSizeGreaterThanOrEqualTo(1);
+
+ String content = toString((InputStream) cache);
+ assertThat(content).isEqualTo(TEST_STRING);
+
+ ((InputStream) cache).close();
+ exchange.getUnitOfWork().done(exchange);
+ IOHelper.close(cos);
+ }
+}
diff --git
a/core/camel-support/src/main/java/org/apache/camel/converter/stream/CachedOutputStream.java
b/core/camel-support/src/main/java/org/apache/camel/converter/stream/CachedOutputStream.java
index 3a160d49c60a..4863a2fd102f 100644
---
a/core/camel-support/src/main/java/org/apache/camel/converter/stream/CachedOutputStream.java
+++
b/core/camel-support/src/main/java/org/apache/camel/converter/stream/CachedOutputStream.java
@@ -43,6 +43,7 @@ import org.apache.camel.util.IOHelper;
public class CachedOutputStream extends OutputStream {
private final StreamCachingStrategy strategy;
+ private final Exchange exchange;
private OutputStream currentStream;
private boolean inMemory = true;
private int totalLength;
@@ -55,6 +56,7 @@ public class CachedOutputStream extends OutputStream {
public CachedOutputStream(Exchange exchange, final boolean
closedOnCompletion) {
this.closedOnCompletion = closedOnCompletion;
+ this.exchange = exchange;
this.tempFileManager = new TempFileManager(closedOnCompletion);
this.tempFileManager.addExchange(exchange);
this.strategy = exchange.getContext().getStreamCachingStrategy();
@@ -159,7 +161,7 @@ public class CachedOutputStream extends OutputStream {
CachedByteArrayOutputStream bout = (CachedByteArrayOutputStream)
currentStream;
try {
// creates a tmp file and a file output stream
- currentStream = tempFileManager.createOutputStream(strategy);
+ currentStream = tempFileManager.createOutputStream(strategy,
exchange);
IOHelper.copy(bout.newInputStreamCache(), currentStream,
strategy.getBufferSize());
} finally {
// ensure flag is flipped to file based
diff --git
a/core/camel-support/src/main/java/org/apache/camel/converter/stream/FileInputStreamCache.java
b/core/camel-support/src/main/java/org/apache/camel/converter/stream/FileInputStreamCache.java
index 3992b02c5224..d1d16a403aa7 100644
---
a/core/camel-support/src/main/java/org/apache/camel/converter/stream/FileInputStreamCache.java
+++
b/core/camel-support/src/main/java/org/apache/camel/converter/stream/FileInputStreamCache.java
@@ -303,7 +303,7 @@ public final class FileInputStreamCache extends InputStream
implements StreamCac
}
}
- OutputStream createOutputStream(StreamCachingStrategy strategy) throws
IOException {
+ OutputStream createOutputStream(StreamCachingStrategy strategy,
Exchange exchange) throws IOException {
// should only be called once
if (tempFile != null) {
throw new IllegalStateException("The method
'createOutputStream' can only be called once!");
@@ -320,7 +320,8 @@ public final class FileInputStreamCache extends InputStream
implements StreamCac
LOG.error(error);
throw new IOException(error);
}
- tempFile = FileUtil.createTempFile("cos", ".tmp",
strategy.getSpoolDirectory());
+ File spoolDir = strategy.resolveSpoolDirectory(exchange);
+ tempFile = FileUtil.createTempFile("cos", ".tmp", spoolDir);
LOG.trace("Creating temporary stream cache file: {}", tempFile);
OutputStream out = new BufferedOutputStream(
diff --git a/docs/user-manual/modules/ROOT/pages/stream-caching.adoc
b/docs/user-manual/modules/ROOT/pages/stream-caching.adoc
index 00d7fc9f9be8..55f08813aa76 100644
--- a/docs/user-manual/modules/ROOT/pages/stream-caching.adoc
+++ b/docs/user-manual/modules/ROOT/pages/stream-caching.adoc
@@ -344,6 +344,29 @@
camel.main.streamCachingSpoolRules=#class:com.foo.MySpoolRule
====
+== Using custom spool directory per Exchange (advanced)
+
+By default, all spooled streams are written to a single spool directory. If
you want to control where each exchange's spool files are written (for example,
to isolate spool data per route), you can implement a custom
`StreamCachingStrategy` that overrides the `resolveSpoolDirectory` method.
+
+The `resolveSpoolDirectory` method is called for each exchange when a stream
is about to be spooled to disk. The default implementation returns the
configured `spoolDirectory`. Custom implementations can return a different
directory based on the exchange, for example using the route id as a
subdirectory:
+
+[source,java]
+----
+DefaultStreamCachingStrategy strategy = new DefaultStreamCachingStrategy() {
+ @Override
+ public File resolveSpoolDirectory(Exchange exchange) {
+ String routeId = exchange.getFromRouteId();
+ if (routeId != null) {
+ return new File(getSpoolDirectory(), routeId);
+ }
+ return getSpoolDirectory();
+ }
+};
+context.setStreamCachingStrategy(strategy);
+----
+
+The resolved directory is created automatically if it does not exist.
+
== Using StreamCachingProcessor
Since *Camel 4.11* this processor can be used to convert the current message
body to a `StreamCache`. This allows the body to be re-read multiple times and
can be placed at any point in a Camel route.