This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch camel-4.18.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.18.x by this push:
new 0a72bcca941f [backport camel-4.18.x] CAMEL-26640: Fix
StreamCachingAdvice order to prevent 'Cannot reset stream from file' (#29482)
0a72bcca941f is described below
commit 0a72bcca941f416a1494cdc71e6165c4ee2ffb55
Author: Guillaume Nodet <[email protected]>
AuthorDate: Mon Nov 9 17:10:55 2026 +0100
[backport camel-4.18.x] CAMEL-26640: Fix StreamCachingAdvice order to
prevent 'Cannot reset stream from file' (#29482)
---
.../camel/impl/engine/CamelInternalProcessor.java | 24 ++-
.../RouteStreamCachingSpoolAdviceOrderTest.java | 181 +++++++++++++++++++++
2 files changed, 202 insertions(+), 3 deletions(-)
diff --git
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/CamelInternalProcessor.java
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/CamelInternalProcessor.java
index 6e65fc2303d7..45f35969b904 100644
---
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/CamelInternalProcessor.java
+++
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/CamelInternalProcessor.java
@@ -1114,7 +1114,7 @@ public class CamelInternalProcessor extends
DelegateAsyncProcessor implements In
* Advice to inject new {@link UnitOfWork} to the {@link Exchange} if
needed, and as well to ensure the
* {@link UnitOfWork} is done and stopped.
*/
- public static class UnitOfWorkProcessorAdvice implements
CamelInternalProcessorAdvice<UnitOfWork> {
+ public static class UnitOfWorkProcessorAdvice implements
CamelInternalProcessorAdvice<UnitOfWork>, Ordered {
private final Route route;
private String routeId;
@@ -1194,6 +1194,21 @@ public class CamelInternalProcessor extends
DelegateAsyncProcessor implements In
}
}
+ @Override
+ public int getOrder() {
+ // UnitOfWorkProcessorAdvice must run first in before() (to set up
the UoW) and
+ // last in after() (to tear it down). Using HIGHEST
(Integer.MIN_VALUE) places it
+ // at position 0 in the sorted advice list; before() runs in
ascending order (UoW
+ // first), after() runs in reverse (UoW last).
+ //
+ // StreamCachingAdvice uses HIGHEST + 1 so that:
+ // before(): UoW → StreamCaching → tracers/debuggers (stream is
cached before being read)
+ // after(): tracers → StreamCaching → UoW (stream is reset
before TempFileManager
+ // onCompletion deletes the spool file)
+ // This fixes CAMEL-26640 without regressing CAMEL-10096 or
CAMEL-24392.
+ return Ordered.HIGHEST;
+ }
+
}
/**
@@ -1313,8 +1328,11 @@ public class CamelInternalProcessor extends
DelegateAsyncProcessor implements In
@Override
public int getOrder() {
- // we want stream caching first
- return Ordered.HIGHEST;
+ // HIGHEST + 1: immediately after UnitOfWorkProcessorAdvice
(HIGHEST), so the stream
+ // is cached before tracers read it (before()) and reset before
TempFileManager deletes
+ // the spool file (after()). See
UnitOfWorkProcessorAdvice.getOrder() for the full
+ // ordering rationale.
+ return Ordered.HIGHEST + 1;
}
}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/RouteStreamCachingSpoolAdviceOrderTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/RouteStreamCachingSpoolAdviceOrderTest.java
new file mode 100644
index 000000000000..d0d4bc012cf4
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/RouteStreamCachingSpoolAdviceOrderTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.processor;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.converter.stream.FileInputStreamCache;
+import org.apache.camel.support.AsyncProcessorConverterHelper;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Regression test for CAMEL-26640: route-level StreamCachingAdvice fails the
exchange with "Cannot reset stream from
+ * file" when the spooled stream cache is used.
+ *
+ * <p>
+ * Root cause: StreamCachingAdvice.getOrder() was Ordered.HIGHEST
(Integer.MIN_VALUE), which made it sort first in the
+ * advice list. Since after() runs in reverse order,
UnitOfWorkProcessorAdvice.after() (which triggers TempFileManager's
+ * onCompletion that deletes the spool file) ran BEFORE
StreamCachingAdvice.after() (which tried to reset the
+ * FileInputStreamCache from the now-deleted file).
+ *
+ * <p>
+ * Fix: UnitOfWorkProcessorAdvice now implements {@link
org.apache.camel.Ordered} and returns {@code Ordered.HIGHEST}
+ * (Integer.MIN_VALUE). StreamCachingAdvice returns {@code Ordered.HIGHEST +
1}. This gives:
+ * <ul>
+ * <li>before(): UoW → StreamCaching → tracers/debuggers (stream is cached
before being read by tracers)</li>
+ * <li>after(): tracers → StreamCaching → UoW (stream is reset before
TempFileManager deletes the spool file)</li>
+ * </ul>
+ *
+ * <p>
+ * The body is intentionally NOT converted with {@code .convertBodyTo()} so it
stays as a {@link FileInputStreamCache}
+ * through the after() phase. Converting to byte[] would replace the
StreamCache body before after() runs, making
+ * {@code MessageHelper.resetStreamCache()} a no-op and masking the bug.
+ *
+ * <p>
+ * Note: {@code template.sendBody()} runs the exchange inside ProducerCache's
own unit of work, so the route's
+ * UnitOfWorkProcessorAdvice never actually finishes the exchange and
TempFileManager does not delete the spool file
+ * during the route. The critical test ({@link
#spoolWhenRouteOwnsTheUnitOfWork()}) bypasses the template and drives the
+ * route processor directly, so the route owns the unit of work and the bug is
reproducible.
+ */
+class RouteStreamCachingSpoolAdviceOrderTest extends ContextTestSupport {
+
+ // 32 KB — well above the 1 KB spool threshold set in createRouteBuilder
+ private static final byte[] DATA = createData(32 * 1024);
+
+ /**
+ * The critical regression test: drives the route processor directly
(without template.sendBody) so that the route
+ * owns the unit of work. Without the fix, TempFileManager.onCompletion()
deletes the spool file during
+ * UnitOfWorkProcessorAdvice.after(), and the subsequent
StreamCachingAdvice.after() throws "Cannot reset stream
+ * from file".
+ */
+ @Test
+ void spoolWhenRouteOwnsTheUnitOfWork() throws Exception {
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getIn().setBody(stream());
+
+
AsyncProcessorConverterHelper.convert(context.getRoute("spool").getProcessor()).process(exchange);
+
+ assertNull(exchange.getException());
+ try (Stream<Path> files = Files.list(testDirectory())) {
+ assertThat(files.filter(p ->
p.getFileName().toString().startsWith("cos"))).isEmpty();
+ }
+ }
+
+ @Test
+ void testRouteLevelSpooledStreamCacheDoesNotFailExchange() throws
Exception {
+ MockEndpoint result = getMockEndpoint("mock:result");
+ result.expectedMessageCount(1);
+ // Body must arrive at mock:result as a FileInputStreamCache (not
converted), confirming it
+ // was really spooled to disk and that StreamCachingAdvice.after()
will call reset() on it.
+ result.message(0).body().isInstanceOf(FileInputStreamCache.class);
+
+ // Send a stream that will be spooled to disk (exceeds the spool
threshold)
+ template.sendBody("direct:test", stream());
+
+ assertMockEndpointsSatisfied();
+ Exchange exchange = result.getReceivedExchanges().get(0);
+ assertNull(exchange.getException());
+
+ // Spool file must have been cleaned up by TempFileManager after the
exchange completes.
+ // The spool directory itself is kept
(RemoveSpoolDirectoryWhenStopping=false) but it
+ // must contain no cos*.tmp files — TempFileManager deletes each file
in onCompletion.
+ Path spoolDir = testDirectory();
+ try (Stream<Path> files = Files.list(spoolDir)) {
+ long spoolFiles = files
+ .filter(p -> p.getFileName().toString().startsWith("cos")
&& p.getFileName().toString().endsWith(".tmp"))
+ .count();
+ assertThat(spoolFiles).as("spool files left after exchange
completion").isZero();
+ }
+ }
+
+ @Test
+ void testBodyCanBeReadDuringRouteProcessing() throws Exception {
+ MockEndpoint result = getMockEndpoint("mock:result");
+ result.expectedMessageCount(1);
+
+ template.sendBody("direct:test-read", stream());
+
+ assertMockEndpointsSatisfied();
+ // The processor read the body bytes while still in-route and stored
them in a property
+ byte[] received =
result.getReceivedExchanges().get(0).getProperty("bodyBytes", byte[].class);
+ assertThat(received).isEqualTo(DATA);
+ assertNull(result.getReceivedExchanges().get(0).getException());
+ }
+
+ private static InputStream stream() {
+ // BufferedInputStream wrapping ByteArrayInputStream is treated as a
re-readable stream
+ // by the type converter; it will be spooled to disk when it exceeds
spoolThreshold
+ return new BufferedInputStream(new ByteArrayInputStream(DATA));
+ }
+
+ private static byte[] createData(int size) {
+ byte[] data = new byte[size];
+ for (int i = 0; i < size; i++) {
+ data[i] = (byte) ('a' + i % 26);
+ }
+ return data;
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // Route-level stream caching with spooling enabled and a low
threshold so the
+ // 32 KB body is always spooled to disk rather than held in
memory.
+
context.getStreamCachingStrategy().setSpoolDirectory(testDirectory().toFile());
+ context.getStreamCachingStrategy().setSpoolEnabled(true);
+ context.getStreamCachingStrategy().setSpoolThreshold(1024);
+
context.getStreamCachingStrategy().setRemoveSpoolDirectoryWhenStopping(false);
+
+ // Route 1: body stays as FileInputStreamCache —
StreamCachingAdvice.after() will
+ // call reset() on it. Without the fix that call throws
"Cannot reset stream from
+ // file" because UnitOfWorkProcessorAdvice.after() already
deleted the spool file.
+ from("direct:test").routeId("spool")
+ .streamCache(true)
+ .to("mock:result");
+
+ // Route 2: a processor reads the body bytes while still
in-route (spool file alive),
+ // stores them in an exchange property, and keeps the body as
StreamCache so that
+ // after() still exercises the reset path.
+ from("direct:test-read")
+ .streamCache(true)
+ .process(exchange -> {
+ byte[] bytes =
exchange.getMessage().getBody(byte[].class);
+ exchange.setProperty("bodyBytes", bytes);
+ // Confirm the body is still a StreamCache after
the read
+ assertInstanceOf(FileInputStreamCache.class,
exchange.getMessage().getBody());
+ })
+ .to("mock:result");
+ }
+ };
+ }
+}