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 28e150782cdc CAMEL-25559: camel-core - PooledExchangeFactory puts an
exchange that was given back without being routed into the pool twice (#27698)
28e150782cdc is described below
commit 28e150782cdc96cdaf0e635639c08d8aefc50306
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 12 23:59:29 2026 +0530
CAMEL-25559: camel-core - PooledExchangeFactory puts an exchange that was
given back without being routed into the pool twice (#27698)
With the pooled exchange factory (camel.main.exchangeFactory=pooled), a
consumer that gave back an exchange it did not route put it into the pool
twice: release() called done(), whose on-done task called release() again,
and
both calls offered the exchange. The next two messages of the consumer then
shared one exchange object. This hit GenericFileConsumer (files whose read
lock
was not acquired, files beyond maxMessagesPerPoll), the mail, cloud storage
and
other consumers that give back unrouted exchanges.
PooledExchangeFactory.release now offers an auto-release exchange to the
pool
exactly once: the on-done task offers it directly on the routed path, a
release
of an already done exchange does nothing, and a release of an exchange not
yet
routed suspends the task for its own done() and offers the exchange itself,
so
it returns false when the pool is full. Every caller is covered without
changing any consumer.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../camel/impl/engine/PooledExchangeFactory.java | 55 +++++-
...tEagerMaxMessagesPerPollPooledExchangeTest.java | 69 ++++++++
...nsumeReadLockNotAcquiredPooledExchangeTest.java | 146 ++++++++++++++++
.../PooledExchangeFactoryReleaseNotRoutedTest.java | 188 +++++++++++++++++++++
4 files changed, 451 insertions(+), 7 deletions(-)
diff --git
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/PooledExchangeFactory.java
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/PooledExchangeFactory.java
index 7cb87af1592e..a264fbece4cd 100644
---
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/PooledExchangeFactory.java
+++
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/PooledExchangeFactory.java
@@ -96,13 +96,49 @@ public final class PooledExchangeFactory extends
PrototypeExchangeFactory {
return exchange;
}
+ /**
+ * Releases the exchange back into the pool.
+ * <p/>
+ * An exchange created with auto release is put into the pool when it is
done. If it is not done yet (it was given
+ * back without being routed) this method does that and returns whether
the pool took it, so false when the pool was
+ * full and the exchange was discarded. If it is already done, its unit of
work has handed it to the pool before,
+ * and this method does nothing and returns true; whether the pool took it
at that time is not known here.
+ */
@Override
public boolean release(Exchange exchange) {
try {
// done exchange before returning to pool
PooledExchange ee = (PooledExchange) exchange;
- ee.done();
+ if (ee.isAutoRelease()) {
+ // an auto-release exchange is offered to the pool once: by
its on-done task when the unit of work is
+ // done, or here when the exchange was given back without
being routed. Offering it in both places
+ // would put it in the pool twice.
+ if (ee.getClock().getCreated() <= 0) {
+ // already done (the clock is unset by done), so the
on-done task has offered it
+ return true;
+ }
+ // done() would run the on-done task and offer the exchange,
but the result of that offer cannot be
+ // read from here. The caller owns the exchange until it is
offered, so take the task off for this
+ // call, offer the exchange here, and put the task back before
the offer makes the exchange visible
+ // to other threads.
+ ee.onDone(null);
+ try {
+ ee.done();
+ } finally {
+ ee.onDone(onDone);
+ }
+ } else {
+ ee.done();
+ }
+ } catch (Exception e) {
+ return discard(exchange);
+ }
+ return doRelease(exchange);
+ }
+
+ private boolean doRelease(Exchange exchange) {
+ try {
// only release back in pool if reset was success
boolean inserted = pool.offer(exchange);
@@ -115,12 +151,16 @@ public final class PooledExchangeFactory extends
PrototypeExchangeFactory {
}
return inserted;
} catch (Exception e) {
- if (statisticsEnabled) {
- statistics.discarded.increment();
- }
- LOG.debug("Error resetting exchange: {}. This exchange is
discarded.", exchange);
- return false;
+ return discard(exchange);
+ }
+ }
+
+ private boolean discard(Exchange exchange) {
+ if (statisticsEnabled) {
+ statistics.discarded.increment();
}
+ LOG.debug("Error resetting exchange: {}. This exchange is discarded.",
exchange);
+ return false;
}
private PooledExchange createPooledExchange(Endpoint fromEndpoint, boolean
autoRelease) {
@@ -160,7 +200,8 @@ public final class PooledExchangeFactory extends
PrototypeExchangeFactory {
@Override
public void onDone(Exchange exchange) {
- release(exchange);
+ // the exchange is done (reset) when this is called
+ doRelease(exchange);
}
}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/component/file/FileConsumeNotEagerMaxMessagesPerPollPooledExchangeTest.java
b/core/camel-core/src/test/java/org/apache/camel/component/file/FileConsumeNotEagerMaxMessagesPerPollPooledExchangeTest.java
new file mode 100644
index 000000000000..ae3a4abc31a4
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/component/file/FileConsumeNotEagerMaxMessagesPerPollPooledExchangeTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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 org.apache.camel.CamelContext;
+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.impl.engine.PooledExchangeFactory;
+import org.junit.jupiter.api.Test;
+
+/**
+ * With eagerMaxMessagesPerPoll=false the consumer creates an exchange for
every file of the poll and gives back the
+ * ones beyond maxMessagesPerPoll without routing them. With pooled exchanges,
the files of the next poll must each get
+ * their own exchange.
+ */
+class FileConsumeNotEagerMaxMessagesPerPollPooledExchangeTest extends
ContextTestSupport {
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext camelContext = super.createCamelContext();
+ camelContext.getCamelContextExtension().setExchangeFactory(new
PooledExchangeFactory());
+ return camelContext;
+ }
+
+ @Test
+ void filesBeyondMaxMessagesPerPollAreConsumedByTheNextPoll() throws
Exception {
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("AAA", "BBB", "CCC", "DDD");
+ mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME_ONLY,
"a.txt", "b.txt", "c.txt", "d.txt");
+
+ template.sendBodyAndHeader(fileUri(), "AAA", Exchange.FILE_NAME,
"a.txt");
+ template.sendBodyAndHeader(fileUri(), "BBB", Exchange.FILE_NAME,
"b.txt");
+ template.sendBodyAndHeader(fileUri(), "CCC", Exchange.FILE_NAME,
"c.txt");
+ template.sendBodyAndHeader(fileUri(), "DDD", Exchange.FILE_NAME,
"d.txt");
+
+ context.getRouteController().startRoute("foo");
+
+ assertMockEndpointsSatisfied();
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from(fileUri("?initialDelay=0&delay=10&maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false&sortBy=file:name"))
+ .routeId("foo").autoStartup(false)
+ .convertBodyTo(String.class)
+ .to("mock:result");
+ }
+ };
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/component/file/FileConsumeReadLockNotAcquiredPooledExchangeTest.java
b/core/camel-core/src/test/java/org/apache/camel/component/file/FileConsumeReadLockNotAcquiredPooledExchangeTest.java
new file mode 100644
index 000000000000..e42619361377
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/component/file/FileConsumeReadLockNotAcquiredPooledExchangeTest.java
@@ -0,0 +1,146 @@
+/*
+ * 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.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.LoggingLevel;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.impl.engine.PooledExchangeFactory;
+import org.apache.camel.spi.Registry;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The consumer gives back the exchange of every file whose read lock it could
not get, without routing it. With pooled
+ * exchanges, the files of the next poll must each get their own exchange.
+ */
+class FileConsumeReadLockNotAcquiredPooledExchangeTest extends
ContextTestSupport {
+
+ private final RefuseTwiceReadLockStrategy readLockStrategy = new
RefuseTwiceReadLockStrategy();
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext camelContext = super.createCamelContext();
+ camelContext.getCamelContextExtension().setExchangeFactory(new
PooledExchangeFactory());
+ return camelContext;
+ }
+
+ @Override
+ protected Registry createCamelRegistry() throws Exception {
+ Registry registry = super.createCamelRegistry();
+ registry.bind("refuseTwice", readLockStrategy);
+ return registry;
+ }
+
+ @Test
+ void filesWithoutReadLockAreConsumedByTheNextPoll() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("AAA", "BBB");
+ mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME_ONLY,
"a.txt", "b.txt");
+
+ template.sendBodyAndHeader(fileUri(), "AAA", Exchange.FILE_NAME,
"a.txt");
+ template.sendBodyAndHeader(fileUri(), "BBB", Exchange.FILE_NAME,
"b.txt");
+
+ context.getRouteController().startRoute("foo");
+
+ assertMockEndpointsSatisfied();
+ // both files were refused in the first poll and locked in the second
one
+ assertEquals(4, readLockStrategy.attempts.get());
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from(fileUri("?initialDelay=0&delay=10&sortBy=file:name&exclusiveReadLockStrategy=#refuseTwice"))
+ .routeId("foo").autoStartup(false)
+ .convertBodyTo(String.class)
+ .to("mock:result");
+ }
+ };
+ }
+
+ /**
+ * Does not give the lock for the first two files it is asked for (the two
files of the first poll).
+ */
+ private static final class RefuseTwiceReadLockStrategy implements
GenericFileExclusiveReadLockStrategy<File> {
+
+ private final AtomicInteger attempts = new AtomicInteger();
+
+ @Override
+ public void prepareOnStartup(GenericFileOperations<File> operations,
GenericFileEndpoint<File> endpoint) {
+ // noop
+ }
+
+ @Override
+ public boolean acquireExclusiveReadLock(
+ GenericFileOperations<File> operations, GenericFile<File>
file, Exchange exchange) {
+ return attempts.incrementAndGet() > 2;
+ }
+
+ @Override
+ public void releaseExclusiveReadLockOnAbort(
+ GenericFileOperations<File> operations, GenericFile<File>
file, Exchange exchange) {
+ // noop
+ }
+
+ @Override
+ public void releaseExclusiveReadLockOnRollback(
+ GenericFileOperations<File> operations, GenericFile<File>
file, Exchange exchange) {
+ // noop
+ }
+
+ @Override
+ public void releaseExclusiveReadLockOnCommit(
+ GenericFileOperations<File> operations, GenericFile<File>
file, Exchange exchange) {
+ // noop
+ }
+
+ @Override
+ public void setTimeout(long timeout) {
+ // noop
+ }
+
+ @Override
+ public void setCheckInterval(long checkInterval) {
+ // noop
+ }
+
+ @Override
+ public void setReadLockLoggingLevel(LoggingLevel readLockLoggingLevel)
{
+ // noop
+ }
+
+ @Override
+ public void setMarkerFiler(boolean markerFile) {
+ // noop
+ }
+
+ @Override
+ public void setDeleteOrphanLockFiles(boolean deleteOrphanLockFiles) {
+ // noop
+ }
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/impl/engine/PooledExchangeFactoryReleaseNotRoutedTest.java
b/core/camel-core/src/test/java/org/apache/camel/impl/engine/PooledExchangeFactoryReleaseNotRoutedTest.java
new file mode 100644
index 000000000000..5cb0d8ea6b51
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/impl/engine/PooledExchangeFactoryReleaseNotRoutedTest.java
@@ -0,0 +1,188 @@
+/*
+ * 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.impl.engine;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.ExtendedCamelContext;
+import org.apache.camel.PooledExchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.ExchangeFactoryManager;
+import org.apache.camel.spi.PooledObjectFactory;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A consumer that gives back an exchange it never routed (a batch cut down to
maxMessagesPerPoll, a consumer that is
+ * stopping, a failure while the message is prepared) must put it into the
pool once, so that the next two exchanges are
+ * two different objects.
+ */
+class PooledExchangeFactoryReleaseNotRoutedTest extends ContextTestSupport {
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext camelContext = super.createCamelContext();
+ ExtendedCamelContext ecc = camelContext.getCamelContextExtension();
+ ecc.setExchangeFactory(new PooledExchangeFactory());
+ ecc.getExchangeFactory().setStatisticsEnabled(true);
+ return camelContext;
+ }
+
+ @Test
+ void autoReleaseExchangeThatWasNotRoutedIsPooledOnce() {
+ Consumer consumer = context.getRoute("foo").getConsumer();
+
+ Exchange exchange = consumer.createExchange(true);
+ exchange.getMessage().setBody("not routed");
+ consumer.releaseExchange(exchange, true);
+
+ assertPooledOnce(consumer, exchange);
+ }
+
+ @Test
+ void
autoReleaseExchangeThatWasNotRoutedIsPooledOnceWhenReleasedAsNotAutoRelease() {
+ Consumer consumer = context.getRoute("foo").getConsumer();
+
+ // several consumers create their exchanges with auto release and give
back the ones they do not route with false
+ Exchange exchange = consumer.createExchange(true);
+ exchange.getMessage().setBody("not routed");
+ consumer.releaseExchange(exchange, false);
+
+ assertPooledOnce(consumer, exchange);
+ }
+
+ /**
+ * Releasing an exchange a second time is still a bug of the consumer. It
does no harm here only because nobody took
+ * the exchange from the pool in between; otherwise the second release
would reset the exchange of another message.
+ */
+ @Test
+ void autoReleaseExchangeThatWasRoutedIsPooledOnceWhenReleasedAgain()
throws Exception {
+ Consumer consumer = context.getRoute("foo").getConsumer();
+
+ Exchange exchange = consumer.createExchange(true);
+ exchange.getMessage().setBody("routed");
+ // the unit of work of the route is done here and gives the exchange
back to the pool
+ consumer.getProcessor().process(exchange);
+ assertEquals(1, statistics().getReleasedCounter());
+
+ consumer.releaseExchange(exchange, true);
+
+ assertPooledOnce(consumer, exchange);
+ }
+
+ @Test
+ void exchangeWithoutAutoReleaseIsPooledOnce() {
+ Consumer consumer = context.getRoute("foo").getConsumer();
+
+ Exchange exchange = consumer.createExchange(false);
+ exchange.getMessage().setBody("not routed");
+ consumer.releaseExchange(exchange, false);
+
+ assertPooledOnce(consumer, exchange);
+ }
+
+ @Test
+ void
releaseReturnsFalseWhenThePoolIsFullAndTheNotRoutedExchangeIsDiscarded() {
+ PooledExchangeFactory factory = newFactoryWithCapacityOne();
+
+ Exchange first = factory.create(true);
+ Exchange second = factory.create(true);
+
+ assertTrue(factory.release(first), "the pool has room for the first
exchange");
+ assertFalse(factory.release(second), "the pool is full, so the second
exchange is discarded");
+
+ assertEquals(1, factory.getSize(), "exchanges in the pool");
+ assertEquals(1, factory.getStatistics().getReleasedCounter());
+ assertEquals(1, factory.getStatistics().getDiscardedCounter());
+
+ // the discarded exchange was reset
+ assertEquals(0, second.getClock().getCreated());
+ assertSame(first, factory.create(true));
+ assertNotSame(first, factory.create(true));
+ }
+
+ @Test
+ void releaseReturnsTrueWhenTheExchangeWasAlreadyGivenBackByItsUnitOfWork()
{
+ PooledExchangeFactory factory = newFactoryWithCapacityOne();
+
+ Exchange routed = factory.create(true);
+ // what the unit of work does when the route is done
+ ((PooledExchange) routed).done();
+ assertEquals(1, factory.getSize(), "exchanges in the pool");
+
+ assertTrue(factory.release(routed), "nothing to do: the exchange is in
the pool already");
+
+ assertEquals(1, factory.getSize(), "exchanges in the pool");
+ assertEquals(1, factory.getStatistics().getReleasedCounter());
+ assertEquals(0, factory.getStatistics().getDiscardedCounter());
+ }
+
+ private PooledExchangeFactory newFactoryWithCapacityOne() {
+ PooledExchangeFactory factory = new PooledExchangeFactory();
+ factory.setCamelContext(context);
+ factory.setCapacity(1);
+ factory.setStatisticsEnabled(true);
+ // build creates the pool; the factory is not started, as it has no
consumer to be registered with
+ factory.build();
+ return factory;
+ }
+
+ private void assertPooledOnce(Consumer consumer, Exchange released) {
+ // the exchange was reset
+ assertNull(released.getMessage().getBody());
+
+ assertEquals(1, manager().getPooledCounter(), "exchanges in the pool");
+ assertEquals(1, statistics().getCreatedCounter());
+ assertEquals(1, statistics().getReleasedCounter());
+ assertEquals(0, statistics().getDiscardedCounter());
+
+ Exchange first = consumer.createExchange(true);
+ Exchange second = consumer.createExchange(true);
+
+ assertSame(released, first, "the pooled exchange is used again");
+ assertNotSame(first, second, "two messages must not share one
exchange");
+ assertEquals(0, manager().getPooledCounter(), "exchanges in the pool");
+ assertEquals(2, statistics().getCreatedCounter());
+ assertEquals(1, statistics().getAcquiredCounter());
+ }
+
+ private ExchangeFactoryManager manager() {
+ return context.getCamelContextExtension().getExchangeFactoryManager();
+ }
+
+ private PooledObjectFactory.Statistics statistics() {
+ return manager().getStatistics();
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:start").routeId("foo").to("mock:result");
+ }
+ };
+ }
+}