From cbb5186d4f3b2fcd00af5c8167f31edc4b83ba74 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 7 Jan 2026 09:29:49 +0100 Subject: [PATCH 1/8] Add ContextValue abstraction for ScopedValue support on JDK 25+ This commit introduces a new ContextValue abstraction that provides a unified API for thread-scoped data sharing, with implementations using either ThreadLocal (JDK 17+) or ScopedValue (JDK 25+ with virtual threads). Key changes: - Add ContextValue interface in camel-util with factory methods for creating context values and executing operations within scoped contexts - Add ContextValueFactory with ThreadLocal implementation for base JDK - Add Java 25 multi-release JAR variant using ScopedValue when available - Deprecate NamedThreadLocal in favor of ContextValue.newThreadLocal() - Add new scoped API methods to ExtendedCamelContext: - setupRoutes(Runnable) and setupRoutes(Callable) - createRoute(String, Runnable) and createRoute(String, Callable) - createProcessor(String, Runnable) and createProcessor(String, Callable) - Deprecate the old boolean/void signaling methods (setupRoutes(boolean), createRoute(String), createProcessor(String)) - Update DefaultCamelContextExtension to use ContextValue.where() for scoped execution, enabling proper ScopedValue support on virtual threads - Update DefaultReactiveExecutor to use ContextValue instead of NamedThreadLocal - Simplify Worker class by removing cached stats field The ContextValue abstraction allows Camel to leverage ScopedValue on JDK 25+ when virtual threads are enabled, providing better performance characteristics for virtual thread workloads while maintaining backward compatibility with ThreadLocal on older JDK versions. Documentation added to ContextValue explaining that ThreadLocal variants should hold lightweight objects to avoid memory leaks with pooled threads. --- .../component/kamelet/KameletReifier.java | 10 +- .../apache/camel/ExtendedCamelContext.java | 89 ++++++- .../impl/engine/AbstractCamelContext.java | 11 +- .../engine/DefaultCamelContextExtension.java | 74 +++++- .../impl/engine/DefaultReactiveExecutor.java | 27 ++- .../camel/impl/DefaultCamelContext.java | 26 ++- .../camel/reifier/ProcessorReifier.java | 18 +- .../xml/AbstractCamelContextFactoryBean.java | 14 +- core/camel-util/pom.xml | 29 +++ .../camel/util/concurrent/ContextValue.java | 188 +++++++++++++++ .../util/concurrent/ContextValueFactory.java | 140 +++++++++++ .../util/concurrent/NamedThreadLocal.java | 5 + .../util/concurrent/ContextValueFactory.java | 220 ++++++++++++++++++ .../util/concurrent/ContextValueTest.java | 135 +++++++++++ 14 files changed, 922 insertions(+), 64 deletions(-) create mode 100644 core/camel-util/src/main/java/org/apache/camel/util/concurrent/ContextValue.java create mode 100644 core/camel-util/src/main/java/org/apache/camel/util/concurrent/ContextValueFactory.java create mode 100644 core/camel-util/src/main/java25/org/apache/camel/util/concurrent/ContextValueFactory.java create mode 100644 core/camel-util/src/test/java/org/apache/camel/util/concurrent/ContextValueTest.java diff --git a/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletReifier.java b/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletReifier.java index fd378851f2309..dd544eacc95c5 100644 --- a/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletReifier.java +++ b/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletReifier.java @@ -39,17 +39,15 @@ public Processor createProcessor() throws Exception { } // wrap in uow String outputId = definition.idOrCreate(camelContext.getCamelContextExtension().getContextPlugin(NodeIdFactory.class)); - camelContext.getCamelContextExtension().createProcessor(outputId); - try { - Processor answer = new KameletProcessor(camelContext, parseString(definition.getName()), processor); + final Processor childProcessor = processor; + return camelContext.getCamelContextExtension().createProcessor(outputId, () -> { + Processor answer = new KameletProcessor(camelContext, parseString(definition.getName()), childProcessor); if (answer instanceof DisabledAware da) { da.setDisabled(isDisabled(camelContext, definition)); } answer = PluginHelper.getInternalProcessorFactory(camelContext) .addUnitOfWorkProcessorAdvice(camelContext, answer, null); return answer; - } finally { - camelContext.getCamelContextExtension().createProcessor(null); - } + }); } } diff --git a/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java b/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java index 855f963a2ffd7..a05f4a5f3486b 100644 --- a/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java +++ b/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java @@ -19,6 +19,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.Callable; import java.util.function.Supplier; import org.apache.camel.catalog.RuntimeCamelCatalog; @@ -108,9 +109,12 @@ default Registry getRegistry() { /** * Method to signal to {@link CamelContext} that the process to initialize setup routes is in progress. * - * @param done false to start the process, call again with true to signal its done. - * @see #isSetupRoutes() + * @param done false to start the process, call again with true to signal its done. + * @see #isSetupRoutes() + * @deprecated use {@link #setupRoutes(Runnable)} or {@link #setupRoutes(Callable)} for ScopedValue + * compatibility */ + @Deprecated(since = "4.17.0") void setupRoutes(boolean done); /** @@ -127,12 +131,36 @@ default Registry getRegistry() { */ boolean isSetupRoutes(); + /** + * Executes the given operation within a "setup routes" context. + *

+ * This is the preferred method for ScopedValue compatibility on virtual threads. + * + * @param operation the operation to execute + */ + void setupRoutes(Runnable operation); + + /** + * Executes the given callable within a "setup routes" context and returns its result. + *

+ * This is the preferred method for ScopedValue compatibility on virtual threads. + * + * @param the return type + * @param callable the callable to execute + * @return the result of the callable + * @throws Exception if the callable throws + */ + T setupRoutes(Callable callable) throws Exception; + /** * Method to signal to {@link CamelContext} that the process to create routes is in progress. * - * @param routeId the current id of the route being created - * @see #getCreateRoute() + * @param routeId the current id of the route being created + * @see #getCreateRoute() + * @deprecated use {@link #createRoute(String, Runnable)} or {@link #createRoute(String, Callable)} for + * ScopedValue compatibility */ + @Deprecated(since = "4.17.0") void createRoute(String routeId); /** @@ -145,12 +173,38 @@ default Registry getRegistry() { */ String getCreateRoute(); + /** + * Executes the given operation within a "create route" context. + *

+ * This is the preferred method for ScopedValue compatibility on virtual threads. + * + * @param routeId the id of the route being created + * @param operation the operation to execute + */ + void createRoute(String routeId, Runnable operation); + + /** + * Executes the given callable within a "create route" context and returns its result. + *

+ * This is the preferred method for ScopedValue compatibility on virtual threads. + * + * @param the return type + * @param routeId the id of the route being created + * @param callable the callable to execute + * @return the result of the callable + * @throws Exception if the callable throws + */ + T createRoute(String routeId, Callable callable) throws Exception; + /** * Method to signal to {@link CamelContext} that creation of a given processor is in progress. * - * @param processorId the current id of the processor being created - * @see #getCreateProcessor() + * @param processorId the current id of the processor being created + * @see #getCreateProcessor() + * @deprecated use {@link #createProcessor(String, Runnable)} or + * {@link #createProcessor(String, Callable)} for ScopedValue compatibility */ + @Deprecated(since = "4.17.0") void createProcessor(String processorId); /** @@ -163,6 +217,29 @@ default Registry getRegistry() { */ String getCreateProcessor(); + /** + * Executes the given operation within a "create processor" context. + *

+ * This is the preferred method for ScopedValue compatibility on virtual threads. + * + * @param processorId the id of the processor being created + * @param operation the operation to execute + */ + void createProcessor(String processorId, Runnable operation); + + /** + * Executes the given callable within a "create processor" context and returns its result. + *

+ * This is the preferred method for ScopedValue compatibility on virtual threads. + * + * @param the return type + * @param processorId the id of the processor being created + * @param callable the callable to execute + * @return the result of the callable + * @throws Exception if the callable throws + */ + T createProcessor(String processorId, Callable callable) throws Exception; + /** * Registers a {@link org.apache.camel.spi.EndpointStrategy callback} to allow you to do custom logic when an * {@link Endpoint} is about to be registered to the {@link org.apache.camel.spi.EndpointRegistry}. diff --git a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java index 5b1e7da577c3c..a10f58a4b4c37 100644 --- a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java +++ b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java @@ -205,6 +205,7 @@ import org.apache.camel.util.StringHelper; import org.apache.camel.util.TimeUtils; import org.apache.camel.util.URISupport; +import org.apache.camel.util.concurrent.ContextValue; import org.apache.camel.vault.VaultConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -233,8 +234,8 @@ public abstract class AbstractCamelContext extends BaseService private final Map languages = new ConcurrentHashMap<>(); private final Map dataformats = new ConcurrentHashMap<>(); private final List lifecycleStrategies = new CopyOnWriteArrayList<>(); - private final ThreadLocal isStartingRoutes = new ThreadLocal<>(); - private final ThreadLocal isLockModel = new ThreadLocal<>(); + private final ContextValue isStartingRoutes = ContextValue.newInstance("isStartingRoutes"); + private final ContextValue isLockModel = ContextValue.newInstance("isLockModel"); private final Map routeServices = new LinkedHashMap<>(); private final Map suspendedRouteServices = new LinkedHashMap<>(); private final InternalRouteStartupManager internalRouteStartupManager = new InternalRouteStartupManager(); @@ -1121,8 +1122,7 @@ public ServiceStatus getRouteStatus(String key) { } public boolean isStartingRoutes() { - Boolean answer = isStartingRoutes.get(); - return answer != null && answer; + return Boolean.TRUE.equals(isStartingRoutes.orElse(false)); } public void setStartingRoutes(boolean starting) { @@ -1134,8 +1134,7 @@ public void setStartingRoutes(boolean starting) { } public boolean isLockModel() { - Boolean answer = isLockModel.get(); - return answer != null && answer; + return Boolean.TRUE.equals(isLockModel.orElse(false)); } public void setLockModel(boolean lockModel) { diff --git a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java index 6e3cb2a5953c9..7c44e5c62cbd8 100644 --- a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java +++ b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.Lock; @@ -87,15 +88,16 @@ import org.apache.camel.support.startup.DefaultStartupStepRecorder; import org.apache.camel.util.StringHelper; import org.apache.camel.util.URISupport; +import org.apache.camel.util.concurrent.ContextValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; class DefaultCamelContextExtension implements ExtendedCamelContext { private final AbstractCamelContext camelContext; - private final ThreadLocal isCreateRoute = new ThreadLocal<>(); - private final ThreadLocal isCreateProcessor = new ThreadLocal<>(); - private final ThreadLocal isSetupRoutes = new ThreadLocal<>(); + private final ContextValue isCreateRoute = ContextValue.newInstance("isCreateRoute"); + private final ContextValue isCreateProcessor = ContextValue.newInstance("isCreateProcessor"); + private final ContextValue isSetupRoutes = ContextValue.newInstance("isSetupRoutes"); private final List interceptStrategies = new ArrayList<>(); private final Map factories = new ConcurrentHashMap<>(); private final Map bootstrapFactories = new ConcurrentHashMap<>(); @@ -318,18 +320,17 @@ public List getRouteStartupOrder() { @Override public boolean isSetupRoutes() { - Boolean answer = isSetupRoutes.get(); - return answer != null && answer; + return Boolean.TRUE.equals(isSetupRoutes.orElse(false)); } @Override public String getCreateRoute() { - return isCreateRoute.get(); + return isCreateRoute.orElse(null); } @Override public String getCreateProcessor() { - return isCreateProcessor.get(); + return isCreateProcessor.orElse(null); } @Override @@ -419,15 +420,35 @@ public void setRegistry(Registry registry) { } @Override + @Deprecated public void createRoute(String routeId) { if (routeId != null) { isCreateRoute.set(routeId); } else { - isSetupRoutes.remove(); + isCreateRoute.remove(); } } @Override + public void createRoute(String routeId, Runnable operation) { + ContextValue.where(isCreateRoute, routeId, operation); + } + + @Override + public T createRoute(String routeId, Callable callable) throws Exception { + return ContextValue.where(isCreateRoute, routeId, () -> { + try { + return callable.call(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + @Override + @Deprecated public void createProcessor(String processorId) { if (processorId != null) { isCreateProcessor.set(processorId); @@ -437,6 +458,25 @@ public void createProcessor(String processorId) { } @Override + public void createProcessor(String processorId, Runnable operation) { + ContextValue.where(isCreateProcessor, processorId, operation); + } + + @Override + public T createProcessor(String processorId, Callable callable) throws Exception { + return ContextValue.where(isCreateProcessor, processorId, () -> { + try { + return callable.call(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + @Override + @Deprecated public void setupRoutes(boolean done) { if (done) { isSetupRoutes.remove(); @@ -445,6 +485,24 @@ public void setupRoutes(boolean done) { } } + @Override + public void setupRoutes(Runnable operation) { + ContextValue.where(isSetupRoutes, true, operation); + } + + @Override + public T setupRoutes(Callable callable) throws Exception { + return ContextValue.where(isSetupRoutes, true, () -> { + try { + return callable.call(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + @Override public List getInterceptStrategies() { return interceptStrategies; diff --git a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultReactiveExecutor.java b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultReactiveExecutor.java index 596565f465105..939c553c7991e 100644 --- a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultReactiveExecutor.java +++ b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultReactiveExecutor.java @@ -26,7 +26,7 @@ import org.apache.camel.api.management.ManagedResource; import org.apache.camel.spi.ReactiveExecutor; import org.apache.camel.support.service.ServiceSupport; -import org.apache.camel.util.concurrent.NamedThreadLocal; +import org.apache.camel.util.concurrent.ContextValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,7 +44,7 @@ public class DefaultReactiveExecutor extends ServiceSupport implements ReactiveE private final LongAdder runningWorkers = new LongAdder(); private final LongAdder pendingTasks = new LongAdder(); - private final NamedThreadLocal workers = new NamedThreadLocal<>("CamelReactiveWorker", () -> { + private final ContextValue workers = ContextValue.newThreadLocal("CamelReactiveWorker", () -> { int number = createdWorkers.incrementAndGet(); return new Worker(number, DefaultReactiveExecutor.this); }); @@ -66,10 +66,7 @@ public void scheduleSync(Runnable runnable) { @Override public void scheduleQueue(Runnable runnable) { - if (LOG.isTraceEnabled()) { - LOG.trace("ScheduleQueue: {}", runnable); - } - workers.get().queue.add(runnable); + workers.get().scheduleQueue(runnable); } @Override @@ -120,7 +117,6 @@ private static class Worker { private final int number; private final DefaultReactiveExecutor executor; - private final boolean stats; private volatile Deque queue = new ArrayDeque<>(); private volatile Deque> back; private volatile boolean running; @@ -128,7 +124,6 @@ private static class Worker { public Worker(int number, DefaultReactiveExecutor executor) { this.number = number; this.executor = executor; - this.stats = executor != null && executor.isStatisticsEnabled(); } void schedule(Runnable runnable, boolean first, boolean main, boolean sync) { @@ -148,6 +143,14 @@ void schedule(Runnable runnable, boolean first, boolean main, boolean sync) { tryExecuteReactiveWork(runnable, sync); } + void scheduleQueue(Runnable runnable) { + if (LOG.isTraceEnabled()) { + LOG.trace("ScheduleQueue: {}", runnable); + } + queue.add(runnable); + incrementPendingTasks(); + } + private void executeMainFlow() { if (!queue.isEmpty()) { if (back == null) { @@ -204,25 +207,25 @@ private void doRun(Runnable polled) { } private void decrementRunningWorkers() { - if (stats) { + if (executor.statisticsEnabled) { executor.runningWorkers.decrement(); } } private void incrementRunningWorkers() { - if (stats) { + if (executor.statisticsEnabled) { executor.runningWorkers.increment(); } } private void incrementPendingTasks() { - if (stats) { + if (executor.statisticsEnabled) { executor.pendingTasks.increment(); } } private void decrementPendingTasks() { - if (stats) { + if (executor.statisticsEnabled) { executor.pendingTasks.decrement(); } } diff --git a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultCamelContext.java b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultCamelContext.java index 2a9ede673ad8f..de77eb7e8a2ed 100644 --- a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultCamelContext.java +++ b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultCamelContext.java @@ -84,7 +84,7 @@ import org.apache.camel.util.ObjectHelper; import org.apache.camel.util.OrderedLocationProperties; import org.apache.camel.util.StopWatch; -import org.apache.camel.util.concurrent.NamedThreadLocal; +import org.apache.camel.util.concurrent.ContextValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -96,7 +96,8 @@ public class DefaultCamelContext extends SimpleCamelContext implements ModelCame // global options that can be set on CamelContext as part of concurrent testing // which means options should be isolated via thread-locals and not a static instance // use a HashMap to store only JDK classes in the thread-local so there will not be any Camel classes leaking - private static final ThreadLocal> OPTIONS = new NamedThreadLocal<>("CamelContextOptions", HashMap::new); + private static final ContextValue> OPTIONS + = ContextValue.newThreadLocal("CamelContextOptions", HashMap::new); private static final String OPTION_NO_START = "OptionNoStart"; private static final String OPTION_DISABLE_JMX = "OptionDisableJMX"; private static final String OPTION_EXCLUDE_ROUTES = "OptionExcludeRoutes"; @@ -760,13 +761,19 @@ public void startRouteDefinitions(List routeDefinitions) throws = getCamelContextReference().getCamelContextExtension().getStartupStepRecorder(); StartupStep step = recorder.beginStep(Route.class, routeDefinition.getRouteId(), "Create Route"); - getCamelContextExtension().createRoute(routeDefinition.getRouteId()); - - Route route = model.getModelReifierFactory().createRoute(this, routeDefinition); - recorder.endStep(step); - - RouteService routeService = new RouteService(route); - startRouteService(routeService, true); + getCamelContextExtension().createRoute(routeDefinition.getRouteId(), () -> { + try { + Route route = model.getModelReifierFactory().createRoute(this, routeDefinition); + recorder.endStep(step); + + RouteService routeService = new RouteService(route); + startRouteService(routeService, true); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); } else { // Add the definition to the list of definitions to remove as the route is excluded if (routeDefinitionsToRemove == null) { @@ -790,7 +797,6 @@ public void startRouteDefinitions(List routeDefinitions) throws if (!alreadyStartingRoutes) { setStartingRoutes(false); } - getCamelContextExtension().createRoute(null); } } diff --git a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/ProcessorReifier.java b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/ProcessorReifier.java index bc4fa9ade67bd..7cc3307982023 100644 --- a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/ProcessorReifier.java +++ b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/ProcessorReifier.java @@ -809,9 +809,8 @@ protected Processor createProcessor(ProcessorDefinition output) throws Except StartupStep step = camelContext.getCamelContextExtension().getStartupStepRecorder().beginStep(ProcessorReifier.class, outputId, "Create processor"); - camelContext.getCamelContextExtension().createProcessor(outputId); - Processor processor = null; - try { + return camelContext.getCamelContextExtension().createProcessor(outputId, () -> { + Processor processor = null; // at first use custom factory final ProcessorFactory processorFactory = PluginHelper.getProcessorFactory(camelContext); if (processorFactory != null) { @@ -822,10 +821,8 @@ protected Processor createProcessor(ProcessorDefinition output) throws Except processor = reifier(route, output).createProcessor(); } camelContext.getCamelContextExtension().getStartupStepRecorder().endStep(step); - } finally { - camelContext.getCamelContextExtension().createProcessor(null); - } - return processor; + return processor; + }); } /** @@ -833,8 +830,7 @@ protected Processor createProcessor(ProcessorDefinition output) throws Except */ protected Channel makeProcessor() throws Exception { String outputId = definition.idOrCreate(camelContext.getCamelContextExtension().getContextPlugin(NodeIdFactory.class)); - camelContext.getCamelContextExtension().createProcessor(outputId); - try { + return camelContext.getCamelContextExtension().createProcessor(outputId, () -> { Processor processor = null; // allow any custom logic before we create the processor @@ -865,9 +861,7 @@ protected Channel makeProcessor() throws Exception { return null; } return wrapProcessor(processor); - } finally { - camelContext.getCamelContextExtension().createProcessor(null); - } + }); } /** diff --git a/core/camel-core-xml/src/main/java/org/apache/camel/core/xml/AbstractCamelContextFactoryBean.java b/core/camel-core-xml/src/main/java/org/apache/camel/core/xml/AbstractCamelContextFactoryBean.java index 0c9cd0154383d..6e1ec0844dd77 100644 --- a/core/camel-core-xml/src/main/java/org/apache/camel/core/xml/AbstractCamelContextFactoryBean.java +++ b/core/camel-core-xml/src/main/java/org/apache/camel/core/xml/AbstractCamelContextFactoryBean.java @@ -597,8 +597,15 @@ protected void setupRoutes() throws Exception { LOG.debug("Setting up routes"); // mark that we are setting up routes - getContext().getCamelContextExtension().setupRoutes(false); + getContext().getCamelContextExtension().setupRoutes(this::doSetupRoutes); + } + } + /** + * Internal method to do the actual route setup within the setupRoutes context. + */ + private void doSetupRoutes() { + try { // add route configurations initRouteConfigurationRefs(); getContext().addRouteConfigurations(getRouteConfigurations()); @@ -663,9 +670,8 @@ protected void setupRoutes() throws Exception { findRouteBuilders(); installRoutes(); - - // and we are now finished setting up the routes - getContext().getCamelContextExtension().setupRoutes(true); + } catch (Exception e) { + throw new RuntimeException(e); } } diff --git a/core/camel-util/pom.xml b/core/camel-util/pom.xml index 7740e9b33d707..782a65feb0869 100644 --- a/core/camel-util/pom.xml +++ b/core/camel-util/pom.xml @@ -319,5 +319,34 @@ + + java-25-sources + + [25,) + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin-version} + + + compile-java-25 + compile + + compile + + + 25 + ${project.basedir}/src/main/java25 + true + + + + + + + diff --git a/core/camel-util/src/main/java/org/apache/camel/util/concurrent/ContextValue.java b/core/camel-util/src/main/java/org/apache/camel/util/concurrent/ContextValue.java new file mode 100644 index 0000000000000..208a3cfda2593 --- /dev/null +++ b/core/camel-util/src/main/java/org/apache/camel/util/concurrent/ContextValue.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.util.concurrent; + +import java.util.NoSuchElementException; +import java.util.function.Supplier; + +/** + * A context value abstraction that provides thread-scoped data sharing. + *

+ * This interface provides a unified API for sharing data within a thread context, with implementations that use either + * {@link ThreadLocal} (for JDK 17+) or {@link java.lang.ScopedValue} (for JDK 21+ with virtual threads). + *

+ * The implementation is chosen automatically based on the JDK version and whether virtual threads are enabled via the + * {@code camel.threads.virtual.enabled} system property. + *

+ * Usage patterns: + *

+ *

+ * Important: When using {@link #newThreadLocal(String, Supplier)}, the values should be lightweight + * objects. Heavy objects stored in ThreadLocal can lead to memory leaks (if threads are pooled) and increased + * memory consumption (one instance per thread). Consider whether the object truly needs per-thread state, or if it can + * be shared or passed as a parameter instead. + *

+ * Example: + * + *

{@code
+ * private static final ContextValue ROUTE_ID = ContextValue.newInstance("routeId");
+ *
+ * // Bind a value for a scope
+ * ContextValue.where(ROUTE_ID, "myRoute", () -> {
+ *     // Code here can access ROUTE_ID.get()
+ *     processRoute();
+ * });
+ * }
+ * + * @param the type of value stored in this context + * @see java.lang.ThreadLocal + * @see java.lang.ScopedValue + */ +public interface ContextValue { + + /** + * Returns the value of this context variable for the current thread. + *

+ * For ScopedValue-based implementations (JDK 21+), this will throw {@link NoSuchElementException} if called outside + * a binding scope. For ThreadLocal-based implementations, this returns the value set via {@link #set(Object)} or + * {@code null} if not set. + * + * @return the current value + * @throws NoSuchElementException if no value is bound (ScopedValue implementation only) + */ + T get(); + + /** + * Returns the value of this context variable for the current thread, or the given default value if no value is + * bound. + * + * @param defaultValue the value to return if no value is bound + * @return the current value, or {@code defaultValue} if not bound + */ + T orElse(T defaultValue); + + /** + * Returns whether a value is currently bound for this context variable. + * + * @return {@code true} if a value is bound, {@code false} otherwise + */ + boolean isBound(); + + /** + * Sets the value for this context variable (ThreadLocal-based implementations only). + *

+ * This method is only supported by ThreadLocal-based implementations. For ScopedValue-based implementations, use + * {@link #where(ContextValue, Object, Runnable)} instead. + * + * @param value the value to set + * @throws UnsupportedOperationException if called on a ScopedValue-based implementation + */ + void set(T value); + + /** + * Removes the value for this context variable (ThreadLocal-based implementations only). + *

+ * This method is only supported by ThreadLocal-based implementations. + * + * @throws UnsupportedOperationException if called on a ScopedValue-based implementation + */ + void remove(); + + /** + * Returns the name of this context value (for debugging purposes). + * + * @return the name + */ + String name(); + + /** + * Creates a new context value with the given name. + *

+ * The implementation will use ScopedValue on JDK 21+ when virtual threads are enabled, otherwise it will use + * ThreadLocal. + * + * @param the type of value + * @param name the name for debugging purposes + * @return a new context value + */ + static ContextValue newInstance(String name) { + return ContextValueFactory.newInstance(name); + } + + /** + * Creates a new ThreadLocal-based context value with the given name. + *

+ * This always uses ThreadLocal, regardless of JDK version or virtual thread settings. Use this when you need + * mutable state that can be modified after initialization. + * + * @param the type of value + * @param name the name for debugging purposes + * @return a new ThreadLocal-based context value + */ + static ContextValue newThreadLocal(String name) { + return ContextValueFactory.newThreadLocal(name); + } + + /** + * Creates a new ThreadLocal-based context value with the given name and initial value supplier. + *

+ * This always uses ThreadLocal regardless of JDK version or virtual thread settings. The supplier is called to + * provide the initial value when {@link #get()} is called and no value has been set. + * + * @param the type of value + * @param name the name for debugging purposes + * @param supplier the supplier for the initial value + * @return a new ThreadLocal-based context value with initial value support + */ + static ContextValue newThreadLocal(String name, Supplier supplier) { + return ContextValueFactory.newThreadLocal(name, supplier); + } + + /** + * Executes the given operation with the context value bound to the specified value. + *

+ * The binding is only visible to the current thread and threads created within the operation (for ScopedValue + * implementations). + * + * @param the type of value + * @param the return type + * @param key the context value to bind + * @param value the value to bind + * @param operation the operation to execute + * @return the result of the operation + */ + static R where(ContextValue key, T value, Supplier operation) { + return ContextValueFactory.where(key, value, operation); + } + + /** + * Executes the given operation with the context value bound to the specified value. + * + * @param the type of value + * @param key the context value to bind + * @param value the value to bind + * @param operation the operation to execute + */ + static void where(ContextValue key, T value, Runnable operation) { + ContextValueFactory.where(key, value, operation); + } +} diff --git a/core/camel-util/src/main/java/org/apache/camel/util/concurrent/ContextValueFactory.java b/core/camel-util/src/main/java/org/apache/camel/util/concurrent/ContextValueFactory.java new file mode 100644 index 0000000000000..f3b991c291589 --- /dev/null +++ b/core/camel-util/src/main/java/org/apache/camel/util/concurrent/ContextValueFactory.java @@ -0,0 +1,140 @@ +/* + * 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.util.concurrent; + +import java.util.function.Supplier; + +/** + * Factory for creating {@link ContextValue} instances. + *

+ * This class is package-private and used internally by {@link ContextValue}. The implementation is overridden in Java + * 21+ to use ScopedValue when appropriate. + */ +class ContextValueFactory { + + /** + * Creates a new context value with the given name. + *

+ * This base implementation always uses ThreadLocal. The Java 21+ version may use ScopedValue when virtual threads + * are enabled. + */ + static ContextValue newInstance(String name) { + return new ThreadLocalContextValue<>(name); + } + + /** + * Creates a new ThreadLocal-based context value with the given name. + *

+ * This always uses ThreadLocal, regardless of JDK version. + */ + static ContextValue newThreadLocal(String name) { + return new ThreadLocalContextValue<>(name); + } + + /** + * Creates a new ThreadLocal-based context value with the given name and initial value supplier. + *

+ * This always uses ThreadLocal, regardless of JDK version. + */ + static ContextValue newThreadLocal(String name, Supplier supplier) { + return new ThreadLocalContextValue<>(name, supplier); + } + + /** + * Executes the given operation with the context value bound to the specified value. + */ + static R where(ContextValue key, T value, Supplier operation) { + if (key instanceof ThreadLocalContextValue tlKey) { + T oldValue = tlKey.get(); + try { + tlKey.set(value); + return operation.get(); + } finally { + if (oldValue != null) { + tlKey.set(oldValue); + } else { + tlKey.remove(); + } + } + } + throw new IllegalArgumentException("Unsupported ContextValue type: " + key.getClass()); + } + + /** + * Executes the given operation with the context value bound to the specified value. + */ + static void where(ContextValue key, T value, Runnable operation) { + where(key, value, () -> { + operation.run(); + return null; + }); + } + + /** + * ThreadLocal-based implementation of ContextValue. + */ + static class ThreadLocalContextValue implements ContextValue { + private final String name; + private final ThreadLocal threadLocal; + + ThreadLocalContextValue(String name) { + this.name = name; + this.threadLocal = new ThreadLocal<>(); + } + + ThreadLocalContextValue(String name, Supplier supplier) { + this.name = name; + this.threadLocal = ThreadLocal.withInitial(supplier); + } + + @Override + public T get() { + return threadLocal.get(); + } + + @Override + public T orElse(T defaultValue) { + T value = threadLocal.get(); + return value != null ? value : defaultValue; + } + + @Override + public boolean isBound() { + return threadLocal.get() != null; + } + + @Override + public void set(T value) { + threadLocal.set(value); + } + + @Override + public void remove() { + threadLocal.remove(); + } + + @Override + public String name() { + return name; + } + + @Override + public String toString() { + return "ContextValue[" + name + "]"; + } + } +} diff --git a/core/camel-util/src/main/java/org/apache/camel/util/concurrent/NamedThreadLocal.java b/core/camel-util/src/main/java/org/apache/camel/util/concurrent/NamedThreadLocal.java index 8ca41278f89ae..7a29362d603e9 100644 --- a/core/camel-util/src/main/java/org/apache/camel/util/concurrent/NamedThreadLocal.java +++ b/core/camel-util/src/main/java/org/apache/camel/util/concurrent/NamedThreadLocal.java @@ -20,7 +20,12 @@ /** * A {@link ThreadLocal} with an assigned name that makes introspection and debugging easier. + * + * @deprecated Use {@link ContextValue#newThreadLocal(String)} or {@link ContextValue#newThreadLocal(String, Supplier)} + * instead. The ContextValue API provides better abstraction and will automatically use ScopedValue when + * running with virtual threads on Java 21+. */ +@Deprecated(since = "4.17.0") public final class NamedThreadLocal extends ThreadLocal { private final String name; diff --git a/core/camel-util/src/main/java25/org/apache/camel/util/concurrent/ContextValueFactory.java b/core/camel-util/src/main/java25/org/apache/camel/util/concurrent/ContextValueFactory.java new file mode 100644 index 0000000000000..d6ce66128e380 --- /dev/null +++ b/core/camel-util/src/main/java25/org/apache/camel/util/concurrent/ContextValueFactory.java @@ -0,0 +1,220 @@ +/* + * 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.util.concurrent; + +import java.util.NoSuchElementException; +import java.util.function.Supplier; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Factory for creating {@link ContextValue} instances. + *

+ * This Java 25+ version uses ScopedValue when virtual threads are enabled, otherwise falls back to ThreadLocal. + */ +class ContextValueFactory { + + private static final Logger LOG = LoggerFactory.getLogger(ContextValueFactory.class); + private static final boolean USE_SCOPED_VALUES = shouldUseScopedValues(); + + static { + if (USE_SCOPED_VALUES) { + LOG.info("ContextValue will use ScopedValue for virtual thread optimization"); + } else { + LOG.debug("ContextValue will use ThreadLocal"); + } + } + + private static boolean shouldUseScopedValues() { + // Only use ScopedValue when virtual threads are enabled + // ScopedValue is immutable and designed for the "pass context through call chain" pattern + return ThreadType.current() == ThreadType.VIRTUAL; + } + + /** + * Creates a new context value with the given name. + *

+ * Uses ScopedValue when virtual threads are enabled, otherwise ThreadLocal. + */ + static ContextValue newInstance(String name) { + if (USE_SCOPED_VALUES) { + return new ScopedValueContextValue<>(name); + } + return new ThreadLocalContextValue<>(name); + } + + /** + * Creates a new ThreadLocal-based context value with the given name. + *

+ * This always uses ThreadLocal, regardless of virtual thread settings. + */ + static ContextValue newThreadLocal(String name) { + return new ThreadLocalContextValue<>(name); + } + + /** + * Creates a new ThreadLocal-based context value with the given name and initial value supplier. + *

+ * This always uses ThreadLocal, regardless of virtual thread settings. + */ + static ContextValue newThreadLocal(String name, Supplier supplier) { + return new ThreadLocalContextValue<>(name, supplier); + } + + /** + * Executes the given operation with the context value bound to the specified value. + */ + static R where(ContextValue key, T value, Supplier operation) { + if (key instanceof ScopedValueContextValue svKey) { + // In JDK 25+, ScopedValue.where() returns a Carrier that has get() method + return ScopedValue.where(svKey.scopedValue, value).get(operation); + } else if (key instanceof ThreadLocalContextValue tlKey) { + T oldValue = tlKey.get(); + try { + tlKey.set(value); + return operation.get(); + } finally { + if (oldValue != null) { + tlKey.set(oldValue); + } else { + tlKey.remove(); + } + } + } + throw new IllegalArgumentException("Unsupported ContextValue type: " + key.getClass()); + } + + /** + * Executes the given operation with the context value bound to the specified value. + */ + static void where(ContextValue key, T value, Runnable operation) { + if (key instanceof ScopedValueContextValue svKey) { + // In JDK 25+, ScopedValue.where() returns a Carrier that has run() method + ScopedValue.where(svKey.scopedValue, value).run(operation); + } else { + where(key, value, () -> { + operation.run(); + return null; + }); + } + } + + /** + * ScopedValue-based implementation of ContextValue (JDK 25+). + */ + static class ScopedValueContextValue implements ContextValue { + private final String name; + final ScopedValue scopedValue; + + ScopedValueContextValue(String name) { + this.name = name; + this.scopedValue = ScopedValue.newInstance(); + } + + @Override + public T get() { + return scopedValue.get(); + } + + @Override + public T orElse(T defaultValue) { + return scopedValue.orElse(defaultValue); + } + + @Override + public boolean isBound() { + return scopedValue.isBound(); + } + + @Override + public void set(T value) { + throw new UnsupportedOperationException( + "ScopedValue is immutable. Use ContextValue.where() to bind values."); + } + + @Override + public void remove() { + throw new UnsupportedOperationException( + "ScopedValue is immutable. Values are automatically unbound when leaving the scope."); + } + + @Override + public String name() { + return name; + } + + @Override + public String toString() { + return "ContextValue[" + name + ",ScopedValue]"; + } + } + + /** + * ThreadLocal-based implementation of ContextValue. + */ + static class ThreadLocalContextValue implements ContextValue { + private final String name; + private final ThreadLocal threadLocal; + + ThreadLocalContextValue(String name) { + this.name = name; + this.threadLocal = new ThreadLocal<>(); + } + + ThreadLocalContextValue(String name, Supplier supplier) { + this.name = name; + this.threadLocal = ThreadLocal.withInitial(supplier); + } + + @Override + public T get() { + return threadLocal.get(); + } + + @Override + public T orElse(T defaultValue) { + T value = threadLocal.get(); + return value != null ? value : defaultValue; + } + + @Override + public boolean isBound() { + return threadLocal.get() != null; + } + + @Override + public void set(T value) { + threadLocal.set(value); + } + + @Override + public void remove() { + threadLocal.remove(); + } + + @Override + public String name() { + return name; + } + + @Override + public String toString() { + return "ContextValue[" + name + ",ThreadLocal]"; + } + } +} diff --git a/core/camel-util/src/test/java/org/apache/camel/util/concurrent/ContextValueTest.java b/core/camel-util/src/test/java/org/apache/camel/util/concurrent/ContextValueTest.java new file mode 100644 index 0000000000000..e7d55c1f83e23 --- /dev/null +++ b/core/camel-util/src/test/java/org/apache/camel/util/concurrent/ContextValueTest.java @@ -0,0 +1,135 @@ +/* + * 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.util.concurrent; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class ContextValueTest { + + @Test + public void testBasicUsage() { + ContextValue routeId = ContextValue.newInstance("routeId"); + + // Initially not bound + assertFalse(routeId.isBound()); + assertNull(routeId.orElse(null)); + assertEquals("default", routeId.orElse("default")); + + // Bind a value + ContextValue.where(routeId, "myRoute", () -> { + assertTrue(routeId.isBound()); + assertEquals("myRoute", routeId.get()); + assertEquals("myRoute", routeId.orElse("default")); + }); + + // After scope, not bound anymore (for ScopedValue) or still bound (for ThreadLocal) + // We can't assert this reliably as it depends on the implementation + } + + @Test + public void testNestedScopes() { + ContextValue routeId = ContextValue.newInstance("routeId"); + + ContextValue.where(routeId, "outer", () -> { + assertEquals("outer", routeId.get()); + + ContextValue.where(routeId, "inner", () -> { + assertEquals("inner", routeId.get()); + }); + + // After inner scope, should be back to outer + assertEquals("outer", routeId.get()); + }); + } + + @Test + public void testMultipleContextValues() { + ContextValue routeId = ContextValue.newInstance("routeId"); + ContextValue exchangeId = ContextValue.newInstance("exchangeId"); + + ContextValue.where(routeId, "route1", () -> { + ContextValue.where(exchangeId, "exchange1", () -> { + assertEquals("route1", routeId.get()); + assertEquals("exchange1", exchangeId.get()); + }); + }); + } + + @Test + public void testThreadLocalContextValue() { + // ThreadLocal-based context values support mutation + ContextValue mutableValue = ContextValue.newThreadLocal("mutable"); + + assertFalse(mutableValue.isBound()); + + mutableValue.set("value1"); + assertTrue(mutableValue.isBound()); + assertEquals("value1", mutableValue.get()); + + mutableValue.set("value2"); + assertEquals("value2", mutableValue.get()); + + mutableValue.remove(); + assertFalse(mutableValue.isBound()); + } + + @Test + public void testWhereWithSupplier() { + ContextValue routeId = ContextValue.newInstance("routeId"); + + String result = ContextValue.where(routeId, "myRoute", () -> { + return "Result: " + routeId.get(); + }); + + assertEquals("Result: myRoute", result); + } + + @Test + public void testThreadIsolation() throws Exception { + ContextValue routeId = ContextValue.newInstance("routeId"); + + // Set value in main thread + ContextValue.where(routeId, "mainThread", () -> { + assertEquals("mainThread", routeId.get()); + + // Create a new thread - it should not see the value (for ScopedValue) + // or see null (for ThreadLocal without inheritance) + Thread thread = new Thread(() -> { + // The value should not be visible in the new thread + assertNull(routeId.orElse(null)); + }); + thread.start(); + try { + thread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + // Main thread should still see its value + assertEquals("mainThread", routeId.get()); + }); + } + + @Test + public void testName() { + ContextValue value = ContextValue.newInstance("testName"); + assertEquals("testName", value.name()); + assertTrue(value.toString().contains("testName")); + } +} From 6d33161897803a6ea0d00d5aaa41d3652b76c8a2 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 7 Jan 2026 12:17:18 +0100 Subject: [PATCH 2/8] Add load tests for virtual threads performance comparison Add two disabled load test classes that can be run manually to compare performance between platform threads and virtual threads: - VirtualThreadsLoadTest: Uses SEDA with concurrent consumers to test throughput with simulated I/O delays - VirtualThreadsWithThreadsDSLLoadTest: Uses threads() DSL to exercise the ContextValue/ScopedValue code paths Tests are disabled by default and configurable via system properties: - loadtest.messages: Number of messages to process (default: 5000) - loadtest.producers: Number of producer threads (default: 50) - loadtest.consumers: Number of concurrent consumers (default: 100) - loadtest.delay: Simulated I/O delay in ms (default: 5-10) Run with: mvn test -Dtest=VirtualThreadsLoadTest \ -Djunit.jupiter.conditions.deactivate='org.junit.*DisabledCondition' \ -Dcamel.threads.virtual.enabled=true --- .../processor/VirtualThreadsLoadTest.java | 163 ++++++++++++++++++ .../VirtualThreadsWithThreadsDSLLoadTest.java | 144 ++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 core/camel-core/src/test/java/org/apache/camel/processor/VirtualThreadsLoadTest.java create mode 100644 core/camel-core/src/test/java/org/apache/camel/processor/VirtualThreadsWithThreadsDSLLoadTest.java diff --git a/core/camel-core/src/test/java/org/apache/camel/processor/VirtualThreadsLoadTest.java b/core/camel-core/src/test/java/org/apache/camel/processor/VirtualThreadsLoadTest.java new file mode 100644 index 0000000000000..fac56aec72904 --- /dev/null +++ b/core/camel-core/src/test/java/org/apache/camel/processor/VirtualThreadsLoadTest.java @@ -0,0 +1,163 @@ +/* + * 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.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.LongAdder; + +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.util.StopWatch; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Load test to compare performance of platform threads vs virtual threads. + *

+ * This test is disabled by default as it's meant to be run manually for benchmarking. + *

+ * Run with platform threads (default): + * + *

+ * mvn test -Dtest=VirtualThreadsLoadTest -pl core/camel-core
+ * 
+ *

+ * Run with virtual threads (JDK 21+): + * + *

+ * mvn test -Dtest=VirtualThreadsLoadTest -pl core/camel-core -Dcamel.threads.virtual.enabled=true
+ * 
+ */ +@Disabled("Manual load test - run explicitly for benchmarking") +public class VirtualThreadsLoadTest extends ContextTestSupport { + + private static final Logger LOG = LoggerFactory.getLogger(VirtualThreadsLoadTest.class); + + // Configuration - adjust these for your environment + // With 200 consumers and 5ms delay, theoretical max throughput = 200 * 1000/5 = 40,000 msg/sec + private static final int TOTAL_MESSAGES = Integer.getInteger("loadtest.messages", 5_000); + private static final int CONCURRENT_PRODUCERS = Integer.getInteger("loadtest.producers", 50); + private static final int CONCURRENT_CONSUMERS = Integer.getInteger("loadtest.consumers", 100); + private static final int SIMULATED_IO_DELAY_MS = Integer.getInteger("loadtest.delay", 5); + + private final LongAdder processedCount = new LongAdder(); + private CountDownLatch completionLatch; + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext context = super.createCamelContext(); + // Log whether virtual threads are enabled + boolean virtualThreads = "true".equalsIgnoreCase( + System.getProperty("camel.threads.virtual.enabled", "false")); + LOG.info("Virtual threads enabled: {}", virtualThreads); + return context; + } + + @Test + public void testHighConcurrencyWithSimulatedIO() throws Exception { + completionLatch = new CountDownLatch(TOTAL_MESSAGES); + processedCount.reset(); + + System.out.println("Starting load test: " + TOTAL_MESSAGES + " messages, " + + CONCURRENT_PRODUCERS + " producers, " + CONCURRENT_CONSUMERS + " consumers, " + + SIMULATED_IO_DELAY_MS + "ms I/O delay"); + + StopWatch watch = new StopWatch(); + + // Create producer threads - use virtual threads when available for producers too + ExecutorService producerPool; + try { + producerPool = (ExecutorService) Executors.class + .getMethod("newVirtualThreadPerTaskExecutor").invoke(null); + System.out.println("Using virtual threads for producers"); + } catch (Exception e) { + producerPool = Executors.newFixedThreadPool(CONCURRENT_PRODUCERS); + System.out.println("Using platform threads for producers"); + } + + for (int i = 0; i < TOTAL_MESSAGES; i++) { + final int msgNum = i; + producerPool.submit(() -> { + try { + template.sendBody("seda:start", "Message-" + msgNum); + } catch (Exception e) { + LOG.error("Error sending message", e); + } + }); + } + + // Wait for all messages to be processed + boolean completed = completionLatch.await(5, TimeUnit.MINUTES); + + long elapsed = watch.taken(); + producerPool.shutdown(); + + // Calculate metrics + long processed = processedCount.sum(); + double throughput = (processed * 1000.0) / elapsed; + double avgLatency = elapsed / (double) processed; + + // Use System.out for guaranteed visibility in test output + System.out.println(); + System.out.println("=== Load Test Results ==="); + System.out.println("Completed: " + (completed ? "YES" : "NO (timeout)")); + System.out.println("Messages processed: " + processed); + System.out.println("Total time: " + elapsed + " ms"); + System.out.println("Throughput: " + String.format("%.2f", throughput) + " msg/sec"); + System.out.println("Average latency: " + String.format("%.2f", avgLatency) + " ms/msg"); + System.out.println("Virtual threads: " + System.getProperty("camel.threads.virtual.enabled", "false")); + System.out.println(); + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + // Route with concurrent consumers and simulated I/O delay + // Use larger queue size to avoid blocking + from("seda:start?concurrentConsumers=" + CONCURRENT_CONSUMERS + "&size=" + (TOTAL_MESSAGES + 1000)) + .routeId("loadTestRoute") + .process(new SimulatedIOProcessor()) + .process(exchange -> { + processedCount.increment(); + completionLatch.countDown(); + }); + } + }; + } + + /** + * Processor that simulates I/O delay (e.g., database call, HTTP request). This is where virtual threads should show + * significant improvement - platform threads block during sleep, while virtual threads yield. + */ + private static class SimulatedIOProcessor implements Processor { + @Override + public void process(Exchange exchange) throws Exception { + // Simulate blocking I/O operation + Thread.sleep(SIMULATED_IO_DELAY_MS); + } + } +} diff --git a/core/camel-core/src/test/java/org/apache/camel/processor/VirtualThreadsWithThreadsDSLLoadTest.java b/core/camel-core/src/test/java/org/apache/camel/processor/VirtualThreadsWithThreadsDSLLoadTest.java new file mode 100644 index 0000000000000..3a3f4997ed44c --- /dev/null +++ b/core/camel-core/src/test/java/org/apache/camel/processor/VirtualThreadsWithThreadsDSLLoadTest.java @@ -0,0 +1,144 @@ +/* + * 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.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.LongAdder; + +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.util.StopWatch; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Load test using the threads() DSL to directly exercise the thread pool creation which uses ContextValue/ScopedValue + * for the "create processor" context. + *

+ * This test is disabled by default as it's meant to be run manually for benchmarking. + *

+ * Run with platform threads (default): + * + *

+ * mvn test -Dtest=VirtualThreadsWithThreadsDSLLoadTest -pl core/camel-core
+ * 
+ *

+ * Run with virtual threads (JDK 21+): + * + *

+ * mvn test -Dtest=VirtualThreadsWithThreadsDSLLoadTest -pl core/camel-core -Dcamel.threads.virtual.enabled=true
+ * 
+ */ +@Disabled("Manual load test - run explicitly for benchmarking") +public class VirtualThreadsWithThreadsDSLLoadTest extends ContextTestSupport { + + private static final Logger LOG = LoggerFactory.getLogger(VirtualThreadsWithThreadsDSLLoadTest.class); + + // Configuration - can be overridden via system properties + private static final int TOTAL_MESSAGES = Integer.getInteger("loadtest.messages", 5_000); + private static final int CONCURRENT_PRODUCERS = Integer.getInteger("loadtest.producers", 50); + private static final int THREAD_POOL_SIZE = Integer.getInteger("loadtest.poolSize", 20); + private static final int MAX_POOL_SIZE = Integer.getInteger("loadtest.maxPoolSize", 100); + private static final int SIMULATED_IO_DELAY_MS = Integer.getInteger("loadtest.delay", 10); + + private final LongAdder processedCount = new LongAdder(); + private CountDownLatch completionLatch; + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext context = super.createCamelContext(); + boolean virtualThreads = "true".equalsIgnoreCase( + System.getProperty("camel.threads.virtual.enabled", "false")); + LOG.info("Virtual threads enabled: {}", virtualThreads); + return context; + } + + @Test + public void testThreadsDSLWithSimulatedIO() throws Exception { + completionLatch = new CountDownLatch(TOTAL_MESSAGES); + processedCount.reset(); + + LOG.info("Starting threads() DSL load test: {} messages, {} producers, pool {}-{}, {}ms I/O delay", + TOTAL_MESSAGES, CONCURRENT_PRODUCERS, THREAD_POOL_SIZE, MAX_POOL_SIZE, SIMULATED_IO_DELAY_MS); + + StopWatch watch = new StopWatch(); + + ExecutorService producerPool = Executors.newFixedThreadPool(CONCURRENT_PRODUCERS); + for (int i = 0; i < TOTAL_MESSAGES; i++) { + final int msgNum = i; + producerPool.submit(() -> { + try { + template.sendBody("direct:start", "Message-" + msgNum); + } catch (Exception e) { + LOG.error("Error sending message", e); + } + }); + } + + boolean completed = completionLatch.await(5, TimeUnit.MINUTES); + + long elapsed = watch.taken(); + producerPool.shutdown(); + + long processed = processedCount.sum(); + double throughput = (processed * 1000.0) / elapsed; + + // Use System.out for guaranteed visibility in test output + System.out.println(); + System.out.println("=== threads() DSL Load Test Results ==="); + System.out.println("Completed: " + (completed ? "YES" : "NO (timeout)")); + System.out.println("Messages processed: " + processed); + System.out.println("Total time: " + elapsed + " ms"); + System.out.println("Throughput: " + String.format("%.2f", throughput) + " msg/sec"); + System.out.println("Virtual threads: " + System.getProperty("camel.threads.virtual.enabled", "false")); + System.out.println(); + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + // Route using threads() DSL - this exercises ContextValue for createProcessor + from("direct:start") + .routeId("threadsDSLLoadTest") + .threads(THREAD_POOL_SIZE, MAX_POOL_SIZE) + .threadName("loadTest") + .process(new SimulatedIOProcessor()) + .process(exchange -> { + processedCount.increment(); + completionLatch.countDown(); + }); + } + }; + } + + private static class SimulatedIOProcessor implements Processor { + @Override + public void process(Exchange exchange) throws Exception { + Thread.sleep(SIMULATED_IO_DELAY_MS); + } + } +} From 0ede6f1dac45a8fd005ba063fab2ed2d43b5d622 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 9 Jan 2026 11:10:22 +0100 Subject: [PATCH 3/8] Refactor SedaConsumer to use template method pattern for extensibility Extract template method hooks in SedaConsumer to allow subclasses to customize polling behavior without duplicating the entire doRun() loop: - beforePoll(): Called before polling, returns true to proceed or false to skip this iteration. Allows acquiring resources like permits. - afterPollEmpty(): Called when poll returns no message. Allows releasing resources. - processPolledExchange(Exchange): Processes the polled exchange. Default is inline processing; can be overridden to dispatch to another thread. Also made these methods protected for subclass access: - createExecutor(int poolSize): Creates the executor service - setupTasks(): Sets up thread pool and tasks - shutdownExecutor(): Shuts down executors - isShutdownPending()/setShutdownPending(): Access shutdown state - pollTimeout field: Made protected ThreadPerTaskSedaConsumer now simply overrides these hooks instead of duplicating the entire polling loop, reducing code from 223 to 158 lines and improving maintainability. --- .../camel/component/seda/SedaConsumer.java | 100 +++++++++-- .../seda/ThreadPerTaskSedaConsumer.java | 157 ++++++++++++++++++ 2 files changed, 241 insertions(+), 16 deletions(-) create mode 100644 components/camel-seda/src/main/java/org/apache/camel/component/seda/ThreadPerTaskSedaConsumer.java diff --git a/components/camel-seda/src/main/java/org/apache/camel/component/seda/SedaConsumer.java b/components/camel-seda/src/main/java/org/apache/camel/component/seda/SedaConsumer.java index 6efe2ef8af084..845fd90455138 100644 --- a/components/camel-seda/src/main/java/org/apache/camel/component/seda/SedaConsumer.java +++ b/components/camel-seda/src/main/java/org/apache/camel/component/seda/SedaConsumer.java @@ -53,13 +53,25 @@ public class SedaConsumer extends DefaultConsumer implements Runnable, ShutdownA private volatile boolean shutdownPending; private volatile boolean forceShutdown; private ExecutorService executor; - private final int pollTimeout; + protected final int pollTimeout; public SedaConsumer(SedaEndpoint endpoint, Processor processor) { super(endpoint, processor); this.pollTimeout = endpoint.getPollTimeout(); } + protected int getPollTimeout() { + return pollTimeout; + } + + protected boolean isShutdownPending() { + return shutdownPending; + } + + protected void setShutdownPending(boolean shutdownPending) { + this.shutdownPending = shutdownPending; + } + @Override public SedaEndpoint getEndpoint() { return (SedaEndpoint) super.getEndpoint(); @@ -174,6 +186,11 @@ protected void doRun() { Exchange exchange = null; try { + // hook for subclasses to prepare before polling (e.g., acquire permits) + if (!beforePoll()) { + continue; + } + // use the end user configured poll timeout exchange = queue.poll(pollTimeout, TimeUnit.MILLISECONDS); if (LOG.isTraceEnabled()) { @@ -182,20 +199,20 @@ protected void doRun() { } if (exchange != null) { try { - final Exchange original = exchange; - // prepare the exchange before sending to consumer - final Exchange prepared = prepareExchange(exchange); - // callback to be executed when sending to consumer and processing is done - AsyncCallback callback = doneSync -> onProcessingDone(original, prepared); - // process the exchange - sendToConsumers(prepared, callback); + // process the exchange (subclasses can override to dispatch to another thread) + processPolledExchange(exchange); } catch (Exception e) { getExceptionHandler().handleException("Error processing exchange", exchange, e); } - } else if (shutdownPending && queue.isEmpty()) { - LOG.trace("Shutdown is pending, so this consumer thread is breaking out because the task queue is empty."); - // we want to shutdown so break out if there queue is empty - break; + } else { + // hook for subclasses to cleanup after empty poll (e.g., release permits) + afterPollEmpty(); + if (shutdownPending && queue.isEmpty()) { + LOG.trace( + "Shutdown is pending, so this consumer thread is breaking out because the task queue is empty."); + // we want to shutdown so break out if there queue is empty + break; + } } } catch (InterruptedException e) { LOG.debug("Sleep interrupted, are we stopping? {}", isStopping() || isStopped()); @@ -210,6 +227,42 @@ protected void doRun() { } } + /** + * Hook called before polling the queue. Subclasses can override to acquire resources (e.g., permits). + * + * @return true to proceed with polling, false to skip this poll iteration + * @throws InterruptedException if interrupted while acquiring resources + */ + protected boolean beforePoll() throws InterruptedException { + return true; + } + + /** + * Hook called when poll returned no exchange. Subclasses can override to release resources (e.g., permits). + */ + protected void afterPollEmpty() { + // nothing by default + } + + /** + * Process a polled exchange. Subclasses can override to dispatch to another thread. + * + * @param exchange the exchange to process + */ + protected void processPolledExchange(Exchange exchange) { + final Exchange original = exchange; + // prepare the exchange before sending to consumer + final Exchange prepared = prepareExchange(exchange); + // callback to be executed when sending to consumer and processing is done + AsyncCallback callback = doneSync -> onProcessingDone(original, prepared); + // process the exchange + try { + sendToConsumers(prepared, callback); + } catch (Exception e) { + getExceptionHandler().handleException("Error processing exchange", exchange, e); + } + } + /** * Strategy to invoke when the exchange is done being processed. *

@@ -332,23 +385,38 @@ protected void doShutdown() throws Exception { shutdownExecutor(); } - private void shutdownExecutor() { + /** + * Shuts down the executor service. + */ + protected void shutdownExecutor() { if (executor != null) { getEndpoint().getCamelContext().getExecutorServiceManager().shutdownNow(executor); executor = null; } } + /** + * Creates the executor service used for consumer threads. + *

+ * Subclasses can override this method to provide a different executor, such as one using virtual threads. + * + * @param poolSize the number of concurrent consumers + * @return the executor service + */ + protected ExecutorService createExecutor(int poolSize) { + return getEndpoint().getCamelContext().getExecutorServiceManager() + .newFixedThreadPool(this, getEndpoint().getEndpointUri(), poolSize); + } + /** * Setup the thread pool and ensures tasks gets executed (if needed) */ - private void setupTasks() { + protected void setupTasks() { int poolSize = getEndpoint().getConcurrentConsumers(); // create thread pool if needed if (executor == null) { - executor = getEndpoint().getCamelContext().getExecutorServiceManager().newFixedThreadPool(this, - getEndpoint().getEndpointUri(), poolSize); + executor = createExecutor(poolSize); } // submit needed number of tasks diff --git a/components/camel-seda/src/main/java/org/apache/camel/component/seda/ThreadPerTaskSedaConsumer.java b/components/camel-seda/src/main/java/org/apache/camel/component/seda/ThreadPerTaskSedaConsumer.java new file mode 100644 index 0000000000000..ff879ac75daca --- /dev/null +++ b/components/camel-seda/src/main/java/org/apache/camel/component/seda/ThreadPerTaskSedaConsumer.java @@ -0,0 +1,157 @@ +/* + * 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.seda; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.LongAdder; + +import org.apache.camel.AsyncCallback; +import org.apache.camel.Exchange; +import org.apache.camel.Processor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A SEDA consumer that spawns a new thread/task for each message instead of using a fixed pool of long-running consumer + * threads. + *

+ * This consumer model is optimized for virtual threads (JDK 21+) where creating threads is very cheap, but it also + * works with platform threads. The key differences from {@link SedaConsumer} are: + *

    + *
  • Uses a cached thread pool instead of a fixed pool
  • + *
  • A single coordinator thread polls the queue
  • + *
  • Each message is processed in its own task/thread
  • + *
  • The concurrentConsumers setting becomes a concurrency limit (0 = unlimited)
  • + *
+ *

+ * When virtual threads are enabled via {@code camel.threads.virtual.enabled=true}, the cached thread pool will use + * {@code Executors.newThreadPerTaskExecutor()}, providing optimal scaling for I/O-bound workloads. + */ +public class ThreadPerTaskSedaConsumer extends SedaConsumer { + + private static final Logger LOG = LoggerFactory.getLogger(ThreadPerTaskSedaConsumer.class); + + private final int maxConcurrentTasks; + private final LongAdder activeTasks = new LongAdder(); + + private volatile ExecutorService taskExecutor; + private volatile Semaphore concurrencyLimiter; + + public ThreadPerTaskSedaConsumer(SedaEndpoint endpoint, Processor processor) { + super(endpoint, processor); + // Use concurrentConsumers as the max concurrent tasks limit + // 0 means unlimited (the most common case for virtual threads) + this.maxConcurrentTasks = endpoint.getConcurrentConsumers(); + } + + @Override + protected ExecutorService createExecutor(int poolSize) { + // Create a single-thread executor for the coordinator + // The actual work is done by taskExecutor + return getEndpoint().getCamelContext().getExecutorServiceManager() + .newSingleThreadExecutor(this, getEndpoint().getEndpointUri() + "-coordinator"); + } + + @Override + protected void setupTasks() { + // Create task executor - uses virtual threads when enabled + taskExecutor = getEndpoint().getCamelContext().getExecutorServiceManager() + .newCachedThreadPool(this, getEndpoint().getEndpointUri() + "-task"); + + // Create concurrency limiter if max is specified and > 0 + if (maxConcurrentTasks > 0) { + concurrencyLimiter = new Semaphore(maxConcurrentTasks); + LOG.debug("Using concurrency limit of {} for thread-per-task consumer", maxConcurrentTasks); + } + + // Call parent to create the coordinator executor and start it + super.setupTasks(); + + LOG.info("Started thread-per-task SEDA consumer for {} (maxConcurrent={})", + getEndpoint().getEndpointUri(), maxConcurrentTasks > 0 ? maxConcurrentTasks : "unlimited"); + } + + @Override + protected void shutdownExecutor() { + super.shutdownExecutor(); + if (taskExecutor != null) { + getEndpoint().getCamelContext().getExecutorServiceManager().shutdown(taskExecutor); + taskExecutor = null; + } + } + + @Override + protected boolean beforePoll() throws InterruptedException { + // Acquire permit if using concurrency limiter (blocks if at limit) + if (concurrencyLimiter != null) { + return concurrencyLimiter.tryAcquire(pollTimeout, TimeUnit.MILLISECONDS); + } + return true; + } + + @Override + protected void afterPollEmpty() { + // Release permit if we acquired one + if (concurrencyLimiter != null) { + concurrencyLimiter.release(); + } + } + + @Override + protected void processPolledExchange(Exchange exchange) { + // Dispatch to task executor for processing + taskExecutor.execute(() -> { + activeTasks.increment(); + try { + // Prepare the exchange + Exchange prepared = prepareExchange(exchange); + + // Process asynchronously + AsyncCallback callback = doneSync -> { + if (exchange.getException() != null) { + getExceptionHandler().handleException("Error processing exchange", exchange, + exchange.getException()); + } + }; + sendToConsumers(prepared, callback); + } catch (Exception e) { + getExceptionHandler().handleException("Error processing exchange", exchange, e); + } finally { + activeTasks.decrement(); + if (concurrencyLimiter != null) { + concurrencyLimiter.release(); + } + } + }); + } + + /** + * Returns the current number of active processing tasks. + */ + public long getActiveTaskCount() { + return activeTasks.sum(); + } + + /** + * Returns the maximum concurrent tasks allowed (0 means unlimited). + */ + public int getMaxConcurrentTasks() { + return maxConcurrentTasks; + } +} From bed233458a9d49a68b7de623ea95a4a74a430c87 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 12 Jan 2026 13:34:55 +0100 Subject: [PATCH 4/8] Add virtualThreadPerTask option to SEDA endpoint Add a new 'virtualThreadPerTask' option to SedaEndpoint that enables the ThreadPerTaskSedaConsumer. When enabled, spawns a new thread for each message instead of using a fixed pool of consumer threads. This model is optimized for virtual threads (JDK 21+) where thread creation is very cheap, making it ideal for I/O-bound workloads. The concurrentConsumers option becomes a limit on max concurrent tasks (0 means unlimited). Changes: - Add virtualThreadPerTask property to SedaEndpoint with getter/setter - Update createNewConsumer() to return ThreadPerTaskSedaConsumer when virtualThreadPerTask is enabled - Update VirtualThreadsLoadTest to support testing with the new mode - Add ThreadPerTaskSedaConsumerTest for unit testing the feature - Regenerate endpoint configurers and metadata files --- .../seda/SedaEndpointConfigurer.java | 6 ++ .../seda/SedaEndpointUriFactory.java | 3 +- .../org/apache/camel/component/seda/seda.json | 21 ++--- .../camel/component/seda/SedaEndpoint.java | 24 ++++++ .../stub/StubEndpointUriFactory.java | 3 +- .../org/apache/camel/component/stub/stub.json | 21 ++--- .../seda/ThreadPerTaskSedaConsumerTest.java | 82 +++++++++++++++++++ .../processor/VirtualThreadsLoadTest.java | 22 ++++- 8 files changed, 158 insertions(+), 24 deletions(-) create mode 100644 core/camel-core/src/test/java/org/apache/camel/component/seda/ThreadPerTaskSedaConsumerTest.java diff --git a/components/camel-seda/src/generated/java/org/apache/camel/component/seda/SedaEndpointConfigurer.java b/components/camel-seda/src/generated/java/org/apache/camel/component/seda/SedaEndpointConfigurer.java index ec2b70431cffd..9d762e51bf6c8 100644 --- a/components/camel-seda/src/generated/java/org/apache/camel/component/seda/SedaEndpointConfigurer.java +++ b/components/camel-seda/src/generated/java/org/apache/camel/component/seda/SedaEndpointConfigurer.java @@ -56,6 +56,8 @@ public boolean configure(CamelContext camelContext, Object obj, String name, Obj case "queue": target.setQueue(property(camelContext, java.util.concurrent.BlockingQueue.class, value)); return true; case "size": target.setSize(property(camelContext, int.class, value)); return true; case "timeout": target.setTimeout(property(camelContext, java.time.Duration.class, value).toMillis()); return true; + case "virtualthreadpertask": + case "virtualThreadPerTask": target.setVirtualThreadPerTask(property(camelContext, boolean.class, value)); return true; case "waitfortasktocomplete": case "waitForTaskToComplete": target.setWaitForTaskToComplete(property(camelContext, org.apache.camel.WaitForTaskToComplete.class, value)); return true; default: return false; @@ -98,6 +100,8 @@ public Class getOptionType(String name, boolean ignoreCase) { case "queue": return java.util.concurrent.BlockingQueue.class; case "size": return int.class; case "timeout": return long.class; + case "virtualthreadpertask": + case "virtualThreadPerTask": return boolean.class; case "waitfortasktocomplete": case "waitForTaskToComplete": return org.apache.camel.WaitForTaskToComplete.class; default: return null; @@ -141,6 +145,8 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) { case "queue": return target.getQueue(); case "size": return target.getSize(); case "timeout": return target.getTimeout(); + case "virtualthreadpertask": + case "virtualThreadPerTask": return target.isVirtualThreadPerTask(); case "waitfortasktocomplete": case "waitForTaskToComplete": return target.getWaitForTaskToComplete(); default: return null; diff --git a/components/camel-seda/src/generated/java/org/apache/camel/component/seda/SedaEndpointUriFactory.java b/components/camel-seda/src/generated/java/org/apache/camel/component/seda/SedaEndpointUriFactory.java index a1fbc3d27d6a6..de0217a87588b 100644 --- a/components/camel-seda/src/generated/java/org/apache/camel/component/seda/SedaEndpointUriFactory.java +++ b/components/camel-seda/src/generated/java/org/apache/camel/component/seda/SedaEndpointUriFactory.java @@ -23,7 +23,7 @@ public class SedaEndpointUriFactory extends org.apache.camel.support.component.E private static final Set SECRET_PROPERTY_NAMES; private static final Map MULTI_VALUE_PREFIXES; static { - Set props = new HashSet<>(20); + Set props = new HashSet<>(21); props.add("blockWhenFull"); props.add("bridgeErrorHandler"); props.add("browseLimit"); @@ -43,6 +43,7 @@ public class SedaEndpointUriFactory extends org.apache.camel.support.component.E props.add("queue"); props.add("size"); props.add("timeout"); + props.add("virtualThreadPerTask"); props.add("waitForTaskToComplete"); PROPERTY_NAMES = Collections.unmodifiableSet(props); SECRET_PROPERTY_NAMES = Collections.emptySet(); diff --git a/components/camel-seda/src/generated/resources/META-INF/org/apache/camel/component/seda/seda.json b/components/camel-seda/src/generated/resources/META-INF/org/apache/camel/component/seda/seda.json index 25a33322d8bb3..019ed74f3edf0 100644 --- a/components/camel-seda/src/generated/resources/META-INF/org/apache/camel/component/seda/seda.json +++ b/components/camel-seda/src/generated/resources/META-INF/org/apache/camel/component/seda/seda.json @@ -46,15 +46,16 @@ "multipleConsumers": { "index": 7, "kind": "parameter", "displayName": "Multiple Consumers", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Specifies whether multiple consumers are allowed. If enabled, you can use SEDA for Publish-Subscribe messaging. That is, you can send a message to the SEDA queue and have each consumer receive a copy of the message. When enabled, this option should be specified on every consumer endpoint." }, "pollTimeout": { "index": 8, "kind": "parameter", "displayName": "Poll Timeout", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 1000, "description": "The timeout (in milliseconds) used when polling. When a timeout occurs, the consumer can check whether it is allowed to continue running. Setting a lower value allows the consumer to react more quickly upon shutdown." }, "purgeWhenStopping": { "index": 9, "kind": "parameter", "displayName": "Purge When Stopping", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether to purge the task queue when stopping the consumer\/route. This allows to stop faster, as any pending messages on the queue is discarded." }, - "timeout": { "index": 10, "kind": "parameter", "displayName": "Timeout", "group": "producer", "label": "producer", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "30000", "description": "Timeout before a SEDA producer will stop waiting for an asynchronous task to complete. You can disable timeout by using 0 or a negative value." }, - "waitForTaskToComplete": { "index": 11, "kind": "parameter", "displayName": "Wait For Task To Complete", "group": "producer", "label": "producer", "required": false, "type": "enum", "javaType": "org.apache.camel.WaitForTaskToComplete", "enum": [ "Never", "IfReplyExpected", "Always" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "IfReplyExpected", "description": "Option to specify whether the caller should wait for the async task to complete or not before continuing. The following three options are supported: Always, Never or IfReplyExpected. The first two values are self-explanatory. The last value, IfReplyExpected, will only wait if the message is Request Reply based. The default option is IfReplyExpected." }, - "blockWhenFull": { "index": 12, "kind": "parameter", "displayName": "Block When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will block until the queue's capacity is no longer exhausted. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will instead block and wait until the message can be accepted." }, - "discardIfNoConsumers": { "index": 13, "kind": "parameter", "displayName": "Discard If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should discard the message (do not add the message to the queue), when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, - "discardWhenFull": { "index": 14, "kind": "parameter", "displayName": "Discard When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will be discarded. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will give up sending and continue, meaning that the message was not sent to the SEDA queue." }, - "failIfNoConsumers": { "index": 15, "kind": "parameter", "displayName": "Fail If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should fail by throwing an exception, when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, - "lazyStartProducer": { "index": 16, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, - "offerTimeout": { "index": 17, "kind": "parameter", "displayName": "Offer Timeout", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Offer timeout can be added to the block case when queue is full. You can disable timeout by using 0 or a negative value." }, - "browseLimit": { "index": 18, "kind": "parameter", "displayName": "Browse Limit", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 100, "description": "Maximum number of messages to keep in memory available for browsing. Use 0 for unlimited." }, - "queue": { "index": 19, "kind": "parameter", "displayName": "Queue", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.concurrent.BlockingQueue", "deprecated": false, "autowired": false, "secret": false, "description": "Define the queue instance which will be used by the endpoint" } + "virtualThreadPerTask": { "index": 10, "kind": "parameter", "displayName": "Virtual Thread Per Task", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "If enabled, spawns a new virtual thread for each message instead of using a fixed pool of consumer threads. This model is optimized for virtual threads (JDK 21) and I\/O-bound workloads where creating threads is cheap. The concurrentConsumers option becomes a limit on max concurrent tasks (0 = unlimited). Requires virtual threads to be enabled via camel.threads.virtual.enabled=true." }, + "timeout": { "index": 11, "kind": "parameter", "displayName": "Timeout", "group": "producer", "label": "producer", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "30000", "description": "Timeout before a SEDA producer will stop waiting for an asynchronous task to complete. You can disable timeout by using 0 or a negative value." }, + "waitForTaskToComplete": { "index": 12, "kind": "parameter", "displayName": "Wait For Task To Complete", "group": "producer", "label": "producer", "required": false, "type": "enum", "javaType": "org.apache.camel.WaitForTaskToComplete", "enum": [ "Never", "IfReplyExpected", "Always" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "IfReplyExpected", "description": "Option to specify whether the caller should wait for the async task to complete or not before continuing. The following three options are supported: Always, Never or IfReplyExpected. The first two values are self-explanatory. The last value, IfReplyExpected, will only wait if the message is Request Reply based. The default option is IfReplyExpected." }, + "blockWhenFull": { "index": 13, "kind": "parameter", "displayName": "Block When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will block until the queue's capacity is no longer exhausted. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will instead block and wait until the message can be accepted." }, + "discardIfNoConsumers": { "index": 14, "kind": "parameter", "displayName": "Discard If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should discard the message (do not add the message to the queue), when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, + "discardWhenFull": { "index": 15, "kind": "parameter", "displayName": "Discard When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will be discarded. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will give up sending and continue, meaning that the message was not sent to the SEDA queue." }, + "failIfNoConsumers": { "index": 16, "kind": "parameter", "displayName": "Fail If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should fail by throwing an exception, when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, + "lazyStartProducer": { "index": 17, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, + "offerTimeout": { "index": 18, "kind": "parameter", "displayName": "Offer Timeout", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Offer timeout can be added to the block case when queue is full. You can disable timeout by using 0 or a negative value." }, + "browseLimit": { "index": 19, "kind": "parameter", "displayName": "Browse Limit", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 100, "description": "Maximum number of messages to keep in memory available for browsing. Use 0 for unlimited." }, + "queue": { "index": 20, "kind": "parameter", "displayName": "Queue", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.concurrent.BlockingQueue", "deprecated": false, "autowired": false, "secret": false, "description": "Define the queue instance which will be used by the endpoint" } } } diff --git a/components/camel-seda/src/main/java/org/apache/camel/component/seda/SedaEndpoint.java b/components/camel-seda/src/main/java/org/apache/camel/component/seda/SedaEndpoint.java index 5e78f36803299..5385748e8fb7b 100644 --- a/components/camel-seda/src/main/java/org/apache/camel/component/seda/SedaEndpoint.java +++ b/components/camel-seda/src/main/java/org/apache/camel/component/seda/SedaEndpoint.java @@ -99,6 +99,12 @@ public class SedaEndpoint extends DefaultEndpoint implements AsyncEndpoint, Brow description = "The timeout (in milliseconds) used when polling. When a timeout occurs, the consumer can check whether it is" + " allowed to continue running. Setting a lower value allows the consumer to react more quickly upon shutdown.") private int pollTimeout = 1000; + @UriParam(label = "consumer,advanced", + description = "If enabled, spawns a new virtual thread for each message instead of using a fixed pool of consumer threads. " + + "This model is optimized for virtual threads (JDK 21+) and I/O-bound workloads where creating threads is cheap. " + + "The concurrentConsumers option becomes a limit on max concurrent tasks (0 = unlimited). " + + "Requires virtual threads to be enabled via camel.threads.virtual.enabled=true.") + private boolean virtualThreadPerTask; @UriParam(label = "producer", defaultValue = "IfReplyExpected", description = "Option to specify whether the caller should wait for the async task to complete or not before continuing. The" @@ -203,6 +209,9 @@ public Consumer createConsumer(Processor processor) throws Exception { } protected SedaConsumer createNewConsumer(Processor processor) { + if (virtualThreadPerTask) { + return new ThreadPerTaskSedaConsumer(this, processor); + } return new SedaConsumer(this, processor); } @@ -526,6 +535,21 @@ public void setPollTimeout(int pollTimeout) { this.pollTimeout = pollTimeout; } + @ManagedAttribute + public boolean isVirtualThreadPerTask() { + return virtualThreadPerTask; + } + + /** + * If enabled, spawns a new virtual thread for each message instead of using a fixed pool of consumer threads. This + * model is optimized for virtual threads (JDK 21+) and I/O-bound workloads where creating threads is cheap. The + * concurrentConsumers option becomes a limit on max concurrent tasks (0 = unlimited). Requires virtual threads to + * be enabled via camel.threads.virtual.enabled=true. + */ + public void setVirtualThreadPerTask(boolean virtualThreadPerTask) { + this.virtualThreadPerTask = virtualThreadPerTask; + } + @ManagedAttribute public boolean isPurgeWhenStopping() { return purgeWhenStopping; diff --git a/components/camel-stub/src/generated/java/org/apache/camel/component/stub/StubEndpointUriFactory.java b/components/camel-stub/src/generated/java/org/apache/camel/component/stub/StubEndpointUriFactory.java index 60dc193c0f116..0f3b8b320f1e9 100644 --- a/components/camel-stub/src/generated/java/org/apache/camel/component/stub/StubEndpointUriFactory.java +++ b/components/camel-stub/src/generated/java/org/apache/camel/component/stub/StubEndpointUriFactory.java @@ -23,7 +23,7 @@ public class StubEndpointUriFactory extends org.apache.camel.support.component.E private static final Set SECRET_PROPERTY_NAMES; private static final Map MULTI_VALUE_PREFIXES; static { - Set props = new HashSet<>(20); + Set props = new HashSet<>(21); props.add("blockWhenFull"); props.add("bridgeErrorHandler"); props.add("browseLimit"); @@ -43,6 +43,7 @@ public class StubEndpointUriFactory extends org.apache.camel.support.component.E props.add("queue"); props.add("size"); props.add("timeout"); + props.add("virtualThreadPerTask"); props.add("waitForTaskToComplete"); PROPERTY_NAMES = Collections.unmodifiableSet(props); SECRET_PROPERTY_NAMES = Collections.emptySet(); diff --git a/components/camel-stub/src/generated/resources/META-INF/org/apache/camel/component/stub/stub.json b/components/camel-stub/src/generated/resources/META-INF/org/apache/camel/component/stub/stub.json index e20e188728ca4..60ff8957b83fe 100644 --- a/components/camel-stub/src/generated/resources/META-INF/org/apache/camel/component/stub/stub.json +++ b/components/camel-stub/src/generated/resources/META-INF/org/apache/camel/component/stub/stub.json @@ -48,15 +48,16 @@ "multipleConsumers": { "index": 7, "kind": "parameter", "displayName": "Multiple Consumers", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Specifies whether multiple consumers are allowed. If enabled, you can use SEDA for Publish-Subscribe messaging. That is, you can send a message to the SEDA queue and have each consumer receive a copy of the message. When enabled, this option should be specified on every consumer endpoint." }, "pollTimeout": { "index": 8, "kind": "parameter", "displayName": "Poll Timeout", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 1000, "description": "The timeout (in milliseconds) used when polling. When a timeout occurs, the consumer can check whether it is allowed to continue running. Setting a lower value allows the consumer to react more quickly upon shutdown." }, "purgeWhenStopping": { "index": 9, "kind": "parameter", "displayName": "Purge When Stopping", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether to purge the task queue when stopping the consumer\/route. This allows to stop faster, as any pending messages on the queue is discarded." }, - "timeout": { "index": 10, "kind": "parameter", "displayName": "Timeout", "group": "producer", "label": "producer", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "30000", "description": "Timeout before a SEDA producer will stop waiting for an asynchronous task to complete. You can disable timeout by using 0 or a negative value." }, - "waitForTaskToComplete": { "index": 11, "kind": "parameter", "displayName": "Wait For Task To Complete", "group": "producer", "label": "producer", "required": false, "type": "enum", "javaType": "org.apache.camel.WaitForTaskToComplete", "enum": [ "Never", "IfReplyExpected", "Always" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "IfReplyExpected", "description": "Option to specify whether the caller should wait for the async task to complete or not before continuing. The following three options are supported: Always, Never or IfReplyExpected. The first two values are self-explanatory. The last value, IfReplyExpected, will only wait if the message is Request Reply based. The default option is IfReplyExpected." }, - "blockWhenFull": { "index": 12, "kind": "parameter", "displayName": "Block When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will block until the queue's capacity is no longer exhausted. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will instead block and wait until the message can be accepted." }, - "discardIfNoConsumers": { "index": 13, "kind": "parameter", "displayName": "Discard If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should discard the message (do not add the message to the queue), when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, - "discardWhenFull": { "index": 14, "kind": "parameter", "displayName": "Discard When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will be discarded. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will give up sending and continue, meaning that the message was not sent to the SEDA queue." }, - "failIfNoConsumers": { "index": 15, "kind": "parameter", "displayName": "Fail If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should fail by throwing an exception, when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, - "lazyStartProducer": { "index": 16, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, - "offerTimeout": { "index": 17, "kind": "parameter", "displayName": "Offer Timeout", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Offer timeout can be added to the block case when queue is full. You can disable timeout by using 0 or a negative value." }, - "browseLimit": { "index": 18, "kind": "parameter", "displayName": "Browse Limit", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 100, "description": "Maximum number of messages to keep in memory available for browsing. Use 0 for unlimited." }, - "queue": { "index": 19, "kind": "parameter", "displayName": "Queue", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.concurrent.BlockingQueue", "deprecated": false, "autowired": false, "secret": false, "description": "Define the queue instance which will be used by the endpoint" } + "virtualThreadPerTask": { "index": 10, "kind": "parameter", "displayName": "Virtual Thread Per Task", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "If enabled, spawns a new virtual thread for each message instead of using a fixed pool of consumer threads. This model is optimized for virtual threads (JDK 21) and I\/O-bound workloads where creating threads is cheap. The concurrentConsumers option becomes a limit on max concurrent tasks (0 = unlimited). Requires virtual threads to be enabled via camel.threads.virtual.enabled=true." }, + "timeout": { "index": 11, "kind": "parameter", "displayName": "Timeout", "group": "producer", "label": "producer", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "30000", "description": "Timeout before a SEDA producer will stop waiting for an asynchronous task to complete. You can disable timeout by using 0 or a negative value." }, + "waitForTaskToComplete": { "index": 12, "kind": "parameter", "displayName": "Wait For Task To Complete", "group": "producer", "label": "producer", "required": false, "type": "enum", "javaType": "org.apache.camel.WaitForTaskToComplete", "enum": [ "Never", "IfReplyExpected", "Always" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "IfReplyExpected", "description": "Option to specify whether the caller should wait for the async task to complete or not before continuing. The following three options are supported: Always, Never or IfReplyExpected. The first two values are self-explanatory. The last value, IfReplyExpected, will only wait if the message is Request Reply based. The default option is IfReplyExpected." }, + "blockWhenFull": { "index": 13, "kind": "parameter", "displayName": "Block When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will block until the queue's capacity is no longer exhausted. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will instead block and wait until the message can be accepted." }, + "discardIfNoConsumers": { "index": 14, "kind": "parameter", "displayName": "Discard If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should discard the message (do not add the message to the queue), when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, + "discardWhenFull": { "index": 15, "kind": "parameter", "displayName": "Discard When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will be discarded. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will give up sending and continue, meaning that the message was not sent to the SEDA queue." }, + "failIfNoConsumers": { "index": 16, "kind": "parameter", "displayName": "Fail If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should fail by throwing an exception, when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, + "lazyStartProducer": { "index": 17, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, + "offerTimeout": { "index": 18, "kind": "parameter", "displayName": "Offer Timeout", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Offer timeout can be added to the block case when queue is full. You can disable timeout by using 0 or a negative value." }, + "browseLimit": { "index": 19, "kind": "parameter", "displayName": "Browse Limit", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 100, "description": "Maximum number of messages to keep in memory available for browsing. Use 0 for unlimited." }, + "queue": { "index": 20, "kind": "parameter", "displayName": "Queue", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.concurrent.BlockingQueue", "deprecated": false, "autowired": false, "secret": false, "description": "Define the queue instance which will be used by the endpoint" } } } diff --git a/core/camel-core/src/test/java/org/apache/camel/component/seda/ThreadPerTaskSedaConsumerTest.java b/core/camel-core/src/test/java/org/apache/camel/component/seda/ThreadPerTaskSedaConsumerTest.java new file mode 100644 index 0000000000000..f1725fdc63778 --- /dev/null +++ b/core/camel-core/src/test/java/org/apache/camel/component/seda/ThreadPerTaskSedaConsumerTest.java @@ -0,0 +1,82 @@ +/* + * 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.seda; + +import org.apache.camel.ContextTestSupport; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.junit.jupiter.api.Test; + +/** + * Test for the virtualThreadPerTask mode of SEDA consumer + */ +public class ThreadPerTaskSedaConsumerTest extends ContextTestSupport { + + @Test + public void testVirtualThreadPerTask() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:result"); + mock.expectedMessageCount(10); + + for (int i = 0; i < 10; i++) { + template.sendBody("seda:test?virtualThreadPerTask=true", "Message " + i); + } + + mock.assertIsSatisfied(); + } + + @Test + public void testVirtualThreadPerTaskWithConcurrencyLimit() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:limited"); + mock.expectedMessageCount(5); + + for (int i = 0; i < 5; i++) { + template.sendBody("seda:limited?virtualThreadPerTask=true&concurrentConsumers=2", "Message " + i); + } + + mock.assertIsSatisfied(); + } + + @Test + public void testVirtualThreadPerTaskHighThroughput() throws Exception { + int messageCount = 100; + MockEndpoint mock = getMockEndpoint("mock:throughput"); + mock.expectedMessageCount(messageCount); + + for (int i = 0; i < messageCount; i++) { + template.sendBody("seda:throughput?virtualThreadPerTask=true", "Message " + i); + } + + mock.assertIsSatisfied(); + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("seda:test?virtualThreadPerTask=true") + .to("mock:result"); + + from("seda:limited?virtualThreadPerTask=true&concurrentConsumers=2") + .to("mock:limited"); + + from("seda:throughput?virtualThreadPerTask=true") + .to("mock:throughput"); + } + }; + } +} diff --git a/core/camel-core/src/test/java/org/apache/camel/processor/VirtualThreadsLoadTest.java b/core/camel-core/src/test/java/org/apache/camel/processor/VirtualThreadsLoadTest.java index fac56aec72904..291b2b5a6e8a5 100644 --- a/core/camel-core/src/test/java/org/apache/camel/processor/VirtualThreadsLoadTest.java +++ b/core/camel-core/src/test/java/org/apache/camel/processor/VirtualThreadsLoadTest.java @@ -49,6 +49,12 @@ *

  * mvn test -Dtest=VirtualThreadsLoadTest -pl core/camel-core -Dcamel.threads.virtual.enabled=true
  * 
+ *

+ * Run with virtual threads and thread-per-task mode (optimal for virtual threads): + * + *

+ * mvn test -Dtest=VirtualThreadsLoadTest -pl core/camel-core -Dcamel.threads.virtual.enabled=true -Dloadtest.virtualThreadPerTask=true
+ * 
*/ @Disabled("Manual load test - run explicitly for benchmarking") public class VirtualThreadsLoadTest extends ContextTestSupport { @@ -61,6 +67,9 @@ public class VirtualThreadsLoadTest extends ContextTestSupport { private static final int CONCURRENT_PRODUCERS = Integer.getInteger("loadtest.producers", 50); private static final int CONCURRENT_CONSUMERS = Integer.getInteger("loadtest.consumers", 100); private static final int SIMULATED_IO_DELAY_MS = Integer.getInteger("loadtest.delay", 5); + // When true, uses virtualThreadPerTask mode which spawns a new thread per message + // This is optimal for virtual threads where thread creation is cheap + private static final boolean VIRTUAL_THREAD_PER_TASK = Boolean.getBoolean("loadtest.virtualThreadPerTask"); private final LongAdder processedCount = new LongAdder(); private CountDownLatch completionLatch; @@ -82,7 +91,8 @@ public void testHighConcurrencyWithSimulatedIO() throws Exception { System.out.println("Starting load test: " + TOTAL_MESSAGES + " messages, " + CONCURRENT_PRODUCERS + " producers, " + CONCURRENT_CONSUMERS + " consumers, " - + SIMULATED_IO_DELAY_MS + "ms I/O delay"); + + SIMULATED_IO_DELAY_MS + "ms I/O delay" + + (VIRTUAL_THREAD_PER_TASK ? ", virtualThreadPerTask=true" : "")); StopWatch watch = new StopWatch(); @@ -128,6 +138,7 @@ public void testHighConcurrencyWithSimulatedIO() throws Exception { System.out.println("Throughput: " + String.format("%.2f", throughput) + " msg/sec"); System.out.println("Average latency: " + String.format("%.2f", avgLatency) + " ms/msg"); System.out.println("Virtual threads: " + System.getProperty("camel.threads.virtual.enabled", "false")); + System.out.println("Thread-per-task mode: " + VIRTUAL_THREAD_PER_TASK); System.out.println(); } @@ -138,7 +149,14 @@ protected RouteBuilder createRouteBuilder() { public void configure() { // Route with concurrent consumers and simulated I/O delay // Use larger queue size to avoid blocking - from("seda:start?concurrentConsumers=" + CONCURRENT_CONSUMERS + "&size=" + (TOTAL_MESSAGES + 1000)) + String sedaOptions = "concurrentConsumers=" + CONCURRENT_CONSUMERS + + "&size=" + (TOTAL_MESSAGES + 1000); + if (VIRTUAL_THREAD_PER_TASK) { + // Use thread-per-task mode - optimal for virtual threads + // concurrentConsumers becomes a concurrency limit + sedaOptions += "&virtualThreadPerTask=true"; + } + from("seda:start?" + sedaOptions) .routeId("loadTestRoute") .process(new SimulatedIOProcessor()) .process(exchange -> { From e90c94a551070bbcfd5ed36119479c7280981cf1 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 12 Jan 2026 16:05:40 +0100 Subject: [PATCH 5/8] Fix exception wrapping in AbstractCamelContextFactoryBean Rethrow RuntimeException (and subclasses like IllegalArgumentException) directly without wrapping. Only wrap checked exceptions in RuntimeException. This fixes test failures where tests expected IllegalArgumentException but were receiving RuntimeException wrapping IllegalArgumentException. --- .../apache/camel/core/xml/AbstractCamelContextFactoryBean.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/camel-core-xml/src/main/java/org/apache/camel/core/xml/AbstractCamelContextFactoryBean.java b/core/camel-core-xml/src/main/java/org/apache/camel/core/xml/AbstractCamelContextFactoryBean.java index 6e1ec0844dd77..edf78befc8cf2 100644 --- a/core/camel-core-xml/src/main/java/org/apache/camel/core/xml/AbstractCamelContextFactoryBean.java +++ b/core/camel-core-xml/src/main/java/org/apache/camel/core/xml/AbstractCamelContextFactoryBean.java @@ -670,6 +670,8 @@ private void doSetupRoutes() { findRouteBuilders(); installRoutes(); + } catch (RuntimeException e) { + throw e; } catch (Exception e) { throw new RuntimeException(e); } From c58ea65778db7f563bfa53f571b829695bb0bc27 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 12 Jan 2026 21:44:18 +0100 Subject: [PATCH 6/8] Regen --- .../apache/camel/catalog/components/seda.json | 21 +++++----- .../apache/camel/catalog/components/stub.json | 21 +++++----- .../dsl/SedaEndpointBuilderFactory.java | 40 +++++++++++++++++++ .../dsl/StubEndpointBuilderFactory.java | 40 +++++++++++++++++++ 4 files changed, 102 insertions(+), 20 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/seda.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/seda.json index 25a33322d8bb3..019ed74f3edf0 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/seda.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/seda.json @@ -46,15 +46,16 @@ "multipleConsumers": { "index": 7, "kind": "parameter", "displayName": "Multiple Consumers", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Specifies whether multiple consumers are allowed. If enabled, you can use SEDA for Publish-Subscribe messaging. That is, you can send a message to the SEDA queue and have each consumer receive a copy of the message. When enabled, this option should be specified on every consumer endpoint." }, "pollTimeout": { "index": 8, "kind": "parameter", "displayName": "Poll Timeout", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 1000, "description": "The timeout (in milliseconds) used when polling. When a timeout occurs, the consumer can check whether it is allowed to continue running. Setting a lower value allows the consumer to react more quickly upon shutdown." }, "purgeWhenStopping": { "index": 9, "kind": "parameter", "displayName": "Purge When Stopping", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether to purge the task queue when stopping the consumer\/route. This allows to stop faster, as any pending messages on the queue is discarded." }, - "timeout": { "index": 10, "kind": "parameter", "displayName": "Timeout", "group": "producer", "label": "producer", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "30000", "description": "Timeout before a SEDA producer will stop waiting for an asynchronous task to complete. You can disable timeout by using 0 or a negative value." }, - "waitForTaskToComplete": { "index": 11, "kind": "parameter", "displayName": "Wait For Task To Complete", "group": "producer", "label": "producer", "required": false, "type": "enum", "javaType": "org.apache.camel.WaitForTaskToComplete", "enum": [ "Never", "IfReplyExpected", "Always" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "IfReplyExpected", "description": "Option to specify whether the caller should wait for the async task to complete or not before continuing. The following three options are supported: Always, Never or IfReplyExpected. The first two values are self-explanatory. The last value, IfReplyExpected, will only wait if the message is Request Reply based. The default option is IfReplyExpected." }, - "blockWhenFull": { "index": 12, "kind": "parameter", "displayName": "Block When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will block until the queue's capacity is no longer exhausted. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will instead block and wait until the message can be accepted." }, - "discardIfNoConsumers": { "index": 13, "kind": "parameter", "displayName": "Discard If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should discard the message (do not add the message to the queue), when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, - "discardWhenFull": { "index": 14, "kind": "parameter", "displayName": "Discard When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will be discarded. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will give up sending and continue, meaning that the message was not sent to the SEDA queue." }, - "failIfNoConsumers": { "index": 15, "kind": "parameter", "displayName": "Fail If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should fail by throwing an exception, when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, - "lazyStartProducer": { "index": 16, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, - "offerTimeout": { "index": 17, "kind": "parameter", "displayName": "Offer Timeout", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Offer timeout can be added to the block case when queue is full. You can disable timeout by using 0 or a negative value." }, - "browseLimit": { "index": 18, "kind": "parameter", "displayName": "Browse Limit", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 100, "description": "Maximum number of messages to keep in memory available for browsing. Use 0 for unlimited." }, - "queue": { "index": 19, "kind": "parameter", "displayName": "Queue", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.concurrent.BlockingQueue", "deprecated": false, "autowired": false, "secret": false, "description": "Define the queue instance which will be used by the endpoint" } + "virtualThreadPerTask": { "index": 10, "kind": "parameter", "displayName": "Virtual Thread Per Task", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "If enabled, spawns a new virtual thread for each message instead of using a fixed pool of consumer threads. This model is optimized for virtual threads (JDK 21) and I\/O-bound workloads where creating threads is cheap. The concurrentConsumers option becomes a limit on max concurrent tasks (0 = unlimited). Requires virtual threads to be enabled via camel.threads.virtual.enabled=true." }, + "timeout": { "index": 11, "kind": "parameter", "displayName": "Timeout", "group": "producer", "label": "producer", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "30000", "description": "Timeout before a SEDA producer will stop waiting for an asynchronous task to complete. You can disable timeout by using 0 or a negative value." }, + "waitForTaskToComplete": { "index": 12, "kind": "parameter", "displayName": "Wait For Task To Complete", "group": "producer", "label": "producer", "required": false, "type": "enum", "javaType": "org.apache.camel.WaitForTaskToComplete", "enum": [ "Never", "IfReplyExpected", "Always" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "IfReplyExpected", "description": "Option to specify whether the caller should wait for the async task to complete or not before continuing. The following three options are supported: Always, Never or IfReplyExpected. The first two values are self-explanatory. The last value, IfReplyExpected, will only wait if the message is Request Reply based. The default option is IfReplyExpected." }, + "blockWhenFull": { "index": 13, "kind": "parameter", "displayName": "Block When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will block until the queue's capacity is no longer exhausted. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will instead block and wait until the message can be accepted." }, + "discardIfNoConsumers": { "index": 14, "kind": "parameter", "displayName": "Discard If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should discard the message (do not add the message to the queue), when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, + "discardWhenFull": { "index": 15, "kind": "parameter", "displayName": "Discard When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will be discarded. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will give up sending and continue, meaning that the message was not sent to the SEDA queue." }, + "failIfNoConsumers": { "index": 16, "kind": "parameter", "displayName": "Fail If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should fail by throwing an exception, when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, + "lazyStartProducer": { "index": 17, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, + "offerTimeout": { "index": 18, "kind": "parameter", "displayName": "Offer Timeout", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Offer timeout can be added to the block case when queue is full. You can disable timeout by using 0 or a negative value." }, + "browseLimit": { "index": 19, "kind": "parameter", "displayName": "Browse Limit", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 100, "description": "Maximum number of messages to keep in memory available for browsing. Use 0 for unlimited." }, + "queue": { "index": 20, "kind": "parameter", "displayName": "Queue", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.concurrent.BlockingQueue", "deprecated": false, "autowired": false, "secret": false, "description": "Define the queue instance which will be used by the endpoint" } } } diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/stub.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/stub.json index e20e188728ca4..60ff8957b83fe 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/stub.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/stub.json @@ -48,15 +48,16 @@ "multipleConsumers": { "index": 7, "kind": "parameter", "displayName": "Multiple Consumers", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Specifies whether multiple consumers are allowed. If enabled, you can use SEDA for Publish-Subscribe messaging. That is, you can send a message to the SEDA queue and have each consumer receive a copy of the message. When enabled, this option should be specified on every consumer endpoint." }, "pollTimeout": { "index": 8, "kind": "parameter", "displayName": "Poll Timeout", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 1000, "description": "The timeout (in milliseconds) used when polling. When a timeout occurs, the consumer can check whether it is allowed to continue running. Setting a lower value allows the consumer to react more quickly upon shutdown." }, "purgeWhenStopping": { "index": 9, "kind": "parameter", "displayName": "Purge When Stopping", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether to purge the task queue when stopping the consumer\/route. This allows to stop faster, as any pending messages on the queue is discarded." }, - "timeout": { "index": 10, "kind": "parameter", "displayName": "Timeout", "group": "producer", "label": "producer", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "30000", "description": "Timeout before a SEDA producer will stop waiting for an asynchronous task to complete. You can disable timeout by using 0 or a negative value." }, - "waitForTaskToComplete": { "index": 11, "kind": "parameter", "displayName": "Wait For Task To Complete", "group": "producer", "label": "producer", "required": false, "type": "enum", "javaType": "org.apache.camel.WaitForTaskToComplete", "enum": [ "Never", "IfReplyExpected", "Always" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "IfReplyExpected", "description": "Option to specify whether the caller should wait for the async task to complete or not before continuing. The following three options are supported: Always, Never or IfReplyExpected. The first two values are self-explanatory. The last value, IfReplyExpected, will only wait if the message is Request Reply based. The default option is IfReplyExpected." }, - "blockWhenFull": { "index": 12, "kind": "parameter", "displayName": "Block When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will block until the queue's capacity is no longer exhausted. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will instead block and wait until the message can be accepted." }, - "discardIfNoConsumers": { "index": 13, "kind": "parameter", "displayName": "Discard If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should discard the message (do not add the message to the queue), when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, - "discardWhenFull": { "index": 14, "kind": "parameter", "displayName": "Discard When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will be discarded. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will give up sending and continue, meaning that the message was not sent to the SEDA queue." }, - "failIfNoConsumers": { "index": 15, "kind": "parameter", "displayName": "Fail If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should fail by throwing an exception, when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, - "lazyStartProducer": { "index": 16, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, - "offerTimeout": { "index": 17, "kind": "parameter", "displayName": "Offer Timeout", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Offer timeout can be added to the block case when queue is full. You can disable timeout by using 0 or a negative value." }, - "browseLimit": { "index": 18, "kind": "parameter", "displayName": "Browse Limit", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 100, "description": "Maximum number of messages to keep in memory available for browsing. Use 0 for unlimited." }, - "queue": { "index": 19, "kind": "parameter", "displayName": "Queue", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.concurrent.BlockingQueue", "deprecated": false, "autowired": false, "secret": false, "description": "Define the queue instance which will be used by the endpoint" } + "virtualThreadPerTask": { "index": 10, "kind": "parameter", "displayName": "Virtual Thread Per Task", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "If enabled, spawns a new virtual thread for each message instead of using a fixed pool of consumer threads. This model is optimized for virtual threads (JDK 21) and I\/O-bound workloads where creating threads is cheap. The concurrentConsumers option becomes a limit on max concurrent tasks (0 = unlimited). Requires virtual threads to be enabled via camel.threads.virtual.enabled=true." }, + "timeout": { "index": 11, "kind": "parameter", "displayName": "Timeout", "group": "producer", "label": "producer", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "30000", "description": "Timeout before a SEDA producer will stop waiting for an asynchronous task to complete. You can disable timeout by using 0 or a negative value." }, + "waitForTaskToComplete": { "index": 12, "kind": "parameter", "displayName": "Wait For Task To Complete", "group": "producer", "label": "producer", "required": false, "type": "enum", "javaType": "org.apache.camel.WaitForTaskToComplete", "enum": [ "Never", "IfReplyExpected", "Always" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "IfReplyExpected", "description": "Option to specify whether the caller should wait for the async task to complete or not before continuing. The following three options are supported: Always, Never or IfReplyExpected. The first two values are self-explanatory. The last value, IfReplyExpected, will only wait if the message is Request Reply based. The default option is IfReplyExpected." }, + "blockWhenFull": { "index": 13, "kind": "parameter", "displayName": "Block When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will block until the queue's capacity is no longer exhausted. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will instead block and wait until the message can be accepted." }, + "discardIfNoConsumers": { "index": 14, "kind": "parameter", "displayName": "Discard If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should discard the message (do not add the message to the queue), when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, + "discardWhenFull": { "index": 15, "kind": "parameter", "displayName": "Discard When Full", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether a thread that sends messages to a full SEDA queue will be discarded. By default, an exception will be thrown stating that the queue is full. By enabling this option, the calling thread will give up sending and continue, meaning that the message was not sent to the SEDA queue." }, + "failIfNoConsumers": { "index": 16, "kind": "parameter", "displayName": "Fail If No Consumers", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should fail by throwing an exception, when sending to a queue with no active consumers. Only one of the options discardIfNoConsumers and failIfNoConsumers can be enabled at the same time." }, + "lazyStartProducer": { "index": 17, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, + "offerTimeout": { "index": 18, "kind": "parameter", "displayName": "Offer Timeout", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Offer timeout can be added to the block case when queue is full. You can disable timeout by using 0 or a negative value." }, + "browseLimit": { "index": 19, "kind": "parameter", "displayName": "Browse Limit", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 100, "description": "Maximum number of messages to keep in memory available for browsing. Use 0 for unlimited." }, + "queue": { "index": 20, "kind": "parameter", "displayName": "Queue", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.concurrent.BlockingQueue", "deprecated": false, "autowired": false, "secret": false, "description": "Define the queue instance which will be used by the endpoint" } } } diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SedaEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SedaEndpointBuilderFactory.java index 5c049daf4bf17..ac80fc03e90d1 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SedaEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SedaEndpointBuilderFactory.java @@ -379,6 +379,46 @@ default AdvancedSedaEndpointConsumerBuilder purgeWhenStopping(String purgeWhenSt doSetProperty("purgeWhenStopping", purgeWhenStopping); return this; } + /** + * If enabled, spawns a new virtual thread for each message instead of + * using a fixed pool of consumer threads. This model is optimized for + * virtual threads (JDK 21) and I/O-bound workloads where creating + * threads is cheap. The concurrentConsumers option becomes a limit on + * max concurrent tasks (0 = unlimited). Requires virtual threads to be + * enabled via camel.threads.virtual.enabled=true. + * + * The option is a: boolean type. + * + * Default: false + * Group: consumer (advanced) + * + * @param virtualThreadPerTask the value to set + * @return the dsl builder + */ + default AdvancedSedaEndpointConsumerBuilder virtualThreadPerTask(boolean virtualThreadPerTask) { + doSetProperty("virtualThreadPerTask", virtualThreadPerTask); + return this; + } + /** + * If enabled, spawns a new virtual thread for each message instead of + * using a fixed pool of consumer threads. This model is optimized for + * virtual threads (JDK 21) and I/O-bound workloads where creating + * threads is cheap. The concurrentConsumers option becomes a limit on + * max concurrent tasks (0 = unlimited). Requires virtual threads to be + * enabled via camel.threads.virtual.enabled=true. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: consumer (advanced) + * + * @param virtualThreadPerTask the value to set + * @return the dsl builder + */ + default AdvancedSedaEndpointConsumerBuilder virtualThreadPerTask(String virtualThreadPerTask) { + doSetProperty("virtualThreadPerTask", virtualThreadPerTask); + return this; + } /** * Maximum number of messages to keep in memory available for browsing. * Use 0 for unlimited. diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/StubEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/StubEndpointBuilderFactory.java index 7310f05bb1de4..0109ab69a4ae6 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/StubEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/StubEndpointBuilderFactory.java @@ -379,6 +379,46 @@ default AdvancedStubEndpointConsumerBuilder purgeWhenStopping(String purgeWhenSt doSetProperty("purgeWhenStopping", purgeWhenStopping); return this; } + /** + * If enabled, spawns a new virtual thread for each message instead of + * using a fixed pool of consumer threads. This model is optimized for + * virtual threads (JDK 21) and I/O-bound workloads where creating + * threads is cheap. The concurrentConsumers option becomes a limit on + * max concurrent tasks (0 = unlimited). Requires virtual threads to be + * enabled via camel.threads.virtual.enabled=true. + * + * The option is a: boolean type. + * + * Default: false + * Group: consumer (advanced) + * + * @param virtualThreadPerTask the value to set + * @return the dsl builder + */ + default AdvancedStubEndpointConsumerBuilder virtualThreadPerTask(boolean virtualThreadPerTask) { + doSetProperty("virtualThreadPerTask", virtualThreadPerTask); + return this; + } + /** + * If enabled, spawns a new virtual thread for each message instead of + * using a fixed pool of consumer threads. This model is optimized for + * virtual threads (JDK 21) and I/O-bound workloads where creating + * threads is cheap. The concurrentConsumers option becomes a limit on + * max concurrent tasks (0 = unlimited). Requires virtual threads to be + * enabled via camel.threads.virtual.enabled=true. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: consumer (advanced) + * + * @param virtualThreadPerTask the value to set + * @return the dsl builder + */ + default AdvancedStubEndpointConsumerBuilder virtualThreadPerTask(String virtualThreadPerTask) { + doSetProperty("virtualThreadPerTask", virtualThreadPerTask); + return this; + } /** * Maximum number of messages to keep in memory available for browsing. * Use 0 for unlimited. From 5b8a2377758ece60f196c62a1b0937cc666d2c75 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 15 Jan 2026 04:51:50 +0100 Subject: [PATCH 7/8] Add comprehensive Virtual Threads documentation This commit adds a new documentation page covering virtual threads support in Apache Camel, including: - Introduction to virtual threads and why they matter for integration - How to enable virtual threads globally in Camel - Components with virtual thread support (SEDA, Jetty, Platform HTTP, etc.) - SEDA deep dive with two execution models comparison (traditional vs virtualThreadPerTask) including a Mermaid diagram - Backpressure and flow control mechanisms - Context propagation with ContextValue (ThreadLocal vs ScopedValue) - Best practices and performance considerations - Complete code examples for common use cases The article is added to the navigation under Architecture, after Threading Model. --- docs/user-manual/modules/ROOT/nav.adoc | 1 + .../modules/ROOT/pages/virtual-threads.adoc | 852 ++++++++++++++++++ 2 files changed, 853 insertions(+) create mode 100644 docs/user-manual/modules/ROOT/pages/virtual-threads.adoc diff --git a/docs/user-manual/modules/ROOT/nav.adoc b/docs/user-manual/modules/ROOT/nav.adoc index 37a204df317af..1fd1235352a77 100644 --- a/docs/user-manual/modules/ROOT/nav.adoc +++ b/docs/user-manual/modules/ROOT/nav.adoc @@ -93,6 +93,7 @@ ** xref:template-engines.adoc[Template Engines] ** xref:transformer.adoc[Transformer] ** xref:threading-model.adoc[Threading Model] +** xref:virtual-threads.adoc[Virtual Threads] ** xref:tracer.adoc[Tracer] ** xref:type-converter.adoc[Type Converter] ** xref:uris.adoc[URIs] diff --git a/docs/user-manual/modules/ROOT/pages/virtual-threads.adoc b/docs/user-manual/modules/ROOT/pages/virtual-threads.adoc new file mode 100644 index 0000000000000..3a97a15000551 --- /dev/null +++ b/docs/user-manual/modules/ROOT/pages/virtual-threads.adoc @@ -0,0 +1,852 @@ += Virtual Threads in Apache Camel + +This guide covers using virtual threads (Project Loom) with Apache Camel for improved performance in I/O-bound integration workloads. + +== Introduction + +=== What Are Virtual Threads? + +Virtual threads, introduced as a preview in JDK 19 and finalized in JDK 21 (https://openjdk.org/jeps/444[JEP 444]), are lightweight threads managed by the JVM rather than the operating system. They enable writing concurrent code in the familiar thread-per-request style while achieving the scalability of asynchronous programming. + +==== Key Characteristics + +[cols="1,1,1"] +|=== +| Aspect | Platform Threads | Virtual Threads + +| *Managed by* +| Operating system +| JVM + +| *Memory footprint* +| ~1 MB stack +| ~1 KB (grows as needed) + +| *Creation cost* +| Expensive (kernel call) +| Cheap (object allocation) + +| *Max practical count* +| Thousands +| Millions + +| *Blocking behavior* +| Blocks OS thread +| Parks, frees carrier thread +|=== + +==== Why Virtual Threads Matter for Integration + +Integration workloads are typically *I/O-bound* - waiting for HTTP responses, database queries, message broker acknowledgments, or file operations. With platform threads, each blocked operation holds an expensive OS thread hostage. With virtual threads: + +* *I/O waits don't waste resources* - When a virtual thread blocks on I/O, it "parks" and its carrier thread can run other virtual threads +* *Massive concurrency becomes practical* - Handle thousands of concurrent requests without thread pool exhaustion +* *Simple programming model* - Write straightforward blocking code instead of complex reactive chains + +=== Requirements + +* *JDK 21+* for virtual threads +* *JDK 25+* for ScopedValue optimizations (optional, provides better performance with context propagation) + +== Enabling Virtual Threads in Camel + +Virtual threads are *opt-in* in Apache Camel. When enabled, Camel's thread pool factory automatically creates virtual threads instead of platform threads for compatible operations. + +=== Global Configuration + +==== System Property + +[source,bash] +---- +java -Dcamel.threads.virtual.enabled=true -jar myapp.jar +---- + +==== Application Properties (Spring Boot / Quarkus) + +[source,properties] +---- +camel.threads.virtual.enabled=true +---- + +==== Programmatic Configuration + +For custom setups, the thread type is determined at JVM startup based on the system property. Camel's `ThreadType.current()` returns either `PLATFORM` or `VIRTUAL`. + +=== What Changes When Enabled + +When virtual threads are enabled, Camel's `DefaultThreadPoolFactory` (JDK 21+ variant) changes behavior: + +[cols="1,1,1"] +|=== +| Thread Pool Type | Platform Mode | Virtual Mode + +| `newCachedThreadPool()` +| `Executors.newCachedThreadPool()` +| `Executors.newThreadPerTaskExecutor()` + +| `newThreadPool()` (poolSize > 1) +| `ThreadPoolExecutor` +| `Executors.newThreadPerTaskExecutor()` + +| `newScheduledThreadPool()` +| `ScheduledThreadPoolExecutor` +| `Executors.newScheduledThreadPool(0, factory)` +|=== + +[NOTE] +==== +Single-threaded executors and scheduled tasks still use platform threads, as virtual threads are optimized for concurrent I/O-bound work, not scheduled or sequential tasks. +==== + +== Components with Virtual Thread Support + +Camel components benefit from virtual threads in different ways depending on their architecture. + +=== Automatic Support (Thread Pool Based) + +These components use Camel's `ExecutorServiceManager` and automatically benefit from virtual threads when enabled: + +[cols="1,2"] +|=== +| Component | How It Benefits + +| *SEDA / VM* +| Consumer threads become virtual; with `virtualThreadPerTask=true`, each message gets its own virtual thread + +| *Direct-VM* +| Cross-context calls use virtual threads for async processing + +| *Threads DSL* +| `.threads()` EIP uses virtual thread pools + +| *Async Processors* +| Components using `AsyncProcessor` with thread pools +|=== + +=== HTTP Server Components + +HTTP server components can be configured to use virtual threads for request handling: + +==== Jetty + +Jetty 12+ supports virtual threads via `VirtualThreadPool`. Configure a custom thread pool: + +[source,java] +---- +import org.eclipse.jetty.util.thread.VirtualThreadPool; + +JettyHttpComponent jetty = context.getComponent("jetty", JettyHttpComponent.class); + +// Create Jetty's VirtualThreadPool for request handling +VirtualThreadPool virtualThreadPool = new VirtualThreadPool(); +virtualThreadPool.setName("CamelJettyVirtual"); +jetty.setThreadPool(virtualThreadPool); +---- + +Or in Spring configuration: + +[source,xml] +---- + + + + + + + +---- + +==== Platform HTTP (Vert.x) + +The camel-platform-http-vertx component uses Vert.x's event loop model. Virtual threads aren't directly applicable, but you can offload blocking work: + +[source,java] +---- +from("platform-http:/api/orders") + .threads() // Offload to virtual thread pool + .to("jpa:Order"); // Blocking JPA operation +---- + +==== Undertow + +Undertow can use virtual threads via XNIO worker configuration. Check Undertow documentation for JDK 21+ virtual thread support. + +=== Messaging Components + +[cols="1,2"] +|=== +| Component | Virtual Thread Usage + +| *Kafka* +| Consumer thread pools benefit from virtual threads for high-concurrency scenarios + +| *JMS* +| Session handling and message listeners can use virtual thread pools + +| *AMQP* +| Connection handling benefits from virtual threads +|=== + +=== Database Components + +Virtual threads shine with blocking database operations: + +[source,java] +---- +// With virtual threads, these blocking calls don't waste platform threads +from("seda:process?virtualThreadPerTask=true&concurrentConsumers=500") + .to("jpa:Order") // Blocking JDBC under the hood + .to("sql:SELECT * FROM inventory WHERE id = :#${body.itemId}") + .to("mongodb:orders"); +---- + +== SEDA Deep Dive: Two Execution Models + +The SEDA (Staged Event-Driven Architecture) component in Apache Camel provides asynchronous, in-memory messaging between routes. With the introduction of virtual threads, SEDA now supports two distinct execution models, each optimized for different scenarios. + +=== Traditional Model: Fixed Consumer Pool + +The default SEDA consumer model uses a *fixed pool of long-running consumer threads* that continuously poll the queue for messages. + +==== How It Works + +1. When the consumer starts, it creates `concurrentConsumers` threads (default: 1) +2. Each thread runs in an infinite loop, polling the queue with a configurable timeout +3. When a message arrives, the thread processes it and then polls again +4. Threads are reused across many messages + +==== Configuration + +[source,java] +---- +from("seda:orders?concurrentConsumers=10") + .process(this::processOrder) + .to("direct:fulfillment"); +---- + +==== Best For + +* CPU-bound processing where thread creation overhead matters +* Scenarios with predictable, steady throughput +* When you need precise control over thread pool sizing +* Platform threads (JDK < 21 or virtual threads disabled) + +=== Virtual Thread Per Task Model + +The `virtualThreadPerTask` mode uses a fundamentally different approach: *spawn a new thread for each message*. + +==== How It Works + +1. A single coordinator thread polls the queue +2. For each message, a new task is submitted to a cached thread pool +3. When virtual threads are enabled, `Executors.newThreadPerTaskExecutor()` is used +4. Each message gets its own lightweight virtual thread +5. The `concurrentConsumers` option becomes a *concurrency limit* (0 = unlimited) + +==== Configuration + +[source,java] +---- +from("seda:orders?virtualThreadPerTask=true&concurrentConsumers=100") + .process(this::processOrder) // I/O-bound operation + .to("direct:fulfillment"); +---- + +==== Best For + +* I/O-bound workloads (database calls, HTTP requests, file operations) +* Highly variable throughput with bursty traffic +* Scenarios requiring massive concurrency (thousands of concurrent messages) +* Virtual threads (JDK 21+ with `camel.threads.virtual.enabled=true`) + +=== Architecture Comparison + +[cols="1,1,1"] +|=== +| Aspect | Traditional (Fixed Pool) | Virtual Thread Per Task + +| *Thread creation* +| Once at startup +| Per message + +| *Thread count* +| Fixed (`concurrentConsumers`) +| Dynamic (bounded by limit) + +| *Queue polling* +| All threads poll +| Single coordinator polls + +| *Message dispatch* +| Direct in polling thread +| Submitted to task executor + +| *Optimal for* +| CPU-bound, platform threads +| I/O-bound, virtual threads + +| *Memory overhead* +| Higher (platform threads ~1MB) +| Lower (virtual threads ~1KB) +|=== + +==== Visual Comparison + +[mermaid] +---- +flowchart TB + subgraph traditional["Traditional Model (Fixed Pool)"] + direction TB + Q1[("SEDA Queue")] + C1["Consumer Thread 1"] + C2["Consumer Thread 2"] + C3["Consumer Thread N"] + P1["Process Message"] + + Q1 -->|"poll()"| C1 + Q1 -->|"poll()"| C2 + Q1 -->|"poll()"| C3 + C1 --> P1 + C2 --> P1 + C3 --> P1 + end + + subgraph virtual["Virtual Thread Per Task Model"] + direction TB + Q2[("SEDA Queue")] + COORD["Coordinator Thread"] + SEM{{"Semaphore (concurrency limit)"}} + VT1["Virtual Thread 1"] + VT2["Virtual Thread 2"] + VTN["Virtual Thread N"] + P2["Process Message"] + + Q2 -->|"poll()"| COORD + COORD -->|"acquire"| SEM + SEM -->|"spawn"| VT1 + SEM -->|"spawn"| VT2 + SEM -->|"spawn"| VTN + VT1 --> P2 + VT2 --> P2 + VTN --> P2 + end +---- + +=== Enabling Virtual Threads + +To use virtual threads in Camel, you need JDK 21+ and must enable them via configuration: + +==== Application Properties + +[source,properties] +---- +camel.threads.virtual.enabled=true +---- + +==== System Property + +[source,bash] +---- +java -Dcamel.threads.virtual.enabled=true -jar myapp.jar +---- + +When enabled, Camel's `DefaultThreadPoolFactory` automatically uses `Executors.newThreadPerTaskExecutor()` for cached thread pools, creating virtual threads instead of platform threads. + +=== Backpressure and Flow Control + +When using virtual threads with high concurrency, proper backpressure is essential to prevent overwhelming downstream systems. SEDA provides multiple layers of backpressure control. + +==== Layer 1: Queue-Based Backpressure (Producer Side) + +The SEDA queue itself acts as a buffer with configurable size: + +[source,java] +---- +// Queue holds up to 10,000 messages +from("seda:orders?size=10000") +---- + +When the queue is full, producers can be configured to: + +[cols="1,2,1"] +|=== +| Option | Behavior | Use Case + +| `blockWhenFull=true` +| Producer blocks until space available +| Synchronous callers that can wait + +| `blockWhenFull=true&offerTimeout=5000` +| Block up to 5 seconds, then fail +| Timeout-based flow control + +| `discardWhenFull=true` +| Silently drop the message +| Fire-and-forget, lossy acceptable + +| (default) +| Throw `IllegalStateException` +| Fail-fast, caller handles retry +|=== + +Example with blocking and timeout: + +[source,java] +---- +// Producer blocks up to 10 seconds when queue is full +from("direct:incoming") + .to("seda:processing?size=5000&blockWhenFull=true&offerTimeout=10000"); +---- + +==== Layer 2: Concurrency Limiting (Consumer Side) + +In `virtualThreadPerTask` mode, the `concurrentConsumers` parameter controls maximum concurrent processing tasks: + +[source,java] +---- +// Max 200 concurrent virtual threads processing messages +from("seda:orders?virtualThreadPerTask=true&concurrentConsumers=200") + .to("http://downstream-service/api"); +---- + +This uses a `Semaphore` internally to gate message dispatch, ensuring you don't overwhelm downstream services even with thousands of queued messages. + +==== Layer 3: Combination Strategy + +For robust production systems, combine both: + +[source,java] +---- +// Producer side: buffer up to 10,000, block if full (with timeout) +from("rest:post:/orders") + .to("seda:order-queue?size=10000&blockWhenFull=true&offerTimeout=30000"); + +// Consumer side: process with virtual threads, max 500 concurrent +from("seda:order-queue?virtualThreadPerTask=true&concurrentConsumers=500") + .to("http://inventory-service/check") + .to("http://payment-service/process") + .to("jpa:Order"); +---- + +This configuration: + +* Buffers up to 10,000 orders in memory +* Blocks REST callers for up to 30 seconds if buffer is full +* Processes with up to 500 concurrent virtual threads +* Protects downstream HTTP services from overload + +==== Backpressure Comparison + +[cols="1,1,1"] +|=== +| Mechanism | Controls | Location + +| `size` +| Queue capacity (message buffer) +| Between producer and consumer + +| `blockWhenFull` / `offerTimeout` +| Producer blocking behavior +| Producer side + +| `concurrentConsumers` (traditional) +| Fixed thread pool size +| Consumer side + +| `concurrentConsumers` (virtualThreadPerTask) +| Max concurrent tasks (semaphore) +| Consumer side +|=== + +=== Example: High-Throughput Order Processing + +[source,java] +---- +public class OrderProcessingRoute extends RouteBuilder { + @Override + public void configure() { + // Receive orders via REST, queue them for async processing + // Block callers if queue is full (with 30s timeout) + rest("/orders") + .post() + .to("seda:incoming-orders?size=10000&blockWhenFull=true&offerTimeout=30000"); + + // Process with virtual threads - each order gets its own thread + // Limit to 500 concurrent to protect downstream services + from("seda:incoming-orders?virtualThreadPerTask=true&concurrentConsumers=500") + .routeId("order-processor") + .log("Processing order ${body.orderId} on ${threadName}") + .to("http://inventory-service/check") // I/O - virtual thread parks + .to("http://payment-service/process") // I/O - virtual thread parks + .to("jpa:Order") // I/O - virtual thread parks + .to("direct:send-confirmation"); + } +} +---- + +=== Performance Characteristics + +With virtual threads and I/O-bound workloads, you can expect: + +* *Higher throughput*: Virtual threads don't block OS threads during I/O waits +* *Better resource utilization*: Thousands of concurrent operations with minimal memory +* *Lower latency under load*: No thread pool exhaustion or queuing delays +* *Simpler scaling*: Just increase concurrency limit, no thread pool tuning + +==== Benchmark + +Run the included load test to compare models: + +[source,bash] +---- +# Platform threads, fixed pool +mvn test -Dtest=VirtualThreadsLoadTest -pl core/camel-core + +# Virtual threads, fixed pool +mvn test -Dtest=VirtualThreadsLoadTest -pl core/camel-core \ + -Dcamel.threads.virtual.enabled=true + +# Virtual threads, thread-per-task (optimal) +mvn test -Dtest=VirtualThreadsLoadTest -pl core/camel-core \ + -Dcamel.threads.virtual.enabled=true \ + -Dloadtest.virtualThreadPerTask=true +---- + +== Context Propagation with ContextValue + +One challenge with virtual threads is *context propagation* - passing contextual data (like transaction IDs, tenant info, or user credentials) through the call chain. Traditional `ThreadLocal` works but has limitations with virtual threads. + +=== The Problem with ThreadLocal + +`ThreadLocal` has issues in virtual thread environments: + +* *Memory overhead*: Each virtual thread needs its own copy +* *Inheritance complexity*: Values must be explicitly inherited to child threads +* *No automatic cleanup*: Risk of leaks if values aren't removed +* *No scoping*: Values persist until explicitly removed + +=== Introducing ContextValue + +Apache Camel provides the `ContextValue` abstraction that automatically chooses the optimal implementation based on JDK version and configuration: + +[cols="1,1,1"] +|=== +| JDK Version | Virtual Threads Enabled | Implementation + +| JDK 17-24 +| N/A +| ThreadLocal + +| JDK 21-24 +| Yes +| ThreadLocal (ScopedValue not yet stable) + +| JDK 25+ +| Yes +| *ScopedValue* + +| JDK 25+ +| No +| ThreadLocal +|=== + +=== ScopedValue Benefits (JDK 25+) + +https://openjdk.org/jeps/487[JEP 487: Scoped Values] provides: + +* *Immutability*: Values cannot be changed within a scope (safer) +* *Automatic inheritance*: Child virtual threads inherit values automatically +* *Automatic cleanup*: Values are unbound when leaving scope (no leaks) +* *Better performance*: Optimized for the structured concurrency model + +=== Using ContextValue + +==== Basic Usage + +[source,java] +---- +import org.apache.camel.util.concurrent.ContextValue; + +// Create a context value (picks ScopedValue or ThreadLocal automatically) +private static final ContextValue TENANT_ID = ContextValue.newInstance("tenantId"); + +// Bind a value for a scope +ContextValue.where(TENANT_ID, "acme-corp", () -> { + // Code here can access TENANT_ID.get() + processRequest(); + return result; +}); + +// Inside processRequest(), on any thread in the scope: +public void processRequest() { + String tenant = TENANT_ID.get(); // Returns "acme-corp" + // ... process with tenant context +} +---- + +==== When to Use ThreadLocal vs ContextValue + +[source,java] +---- +// Use ContextValue.newInstance() for READ-ONLY context passing +private static final ContextValue REQUEST_CTX = ContextValue.newInstance("requestCtx"); + +// Use ContextValue.newThreadLocal() when you need MUTABLE state +private static final ContextValue COUNTER = ContextValue.newThreadLocal("counter", Counter::new); +---- + +==== Integration with Camel Internals + +Camel uses `ContextValue` internally for various purposes: + +[source,java] +---- +// Example: Passing context during route creation +private static final ContextValue> CREATE_PROCESSOR + = ContextValue.newInstance("CreateProcessor"); + +// When creating processors, bind the context +ContextValue.where(CREATE_PROCESSOR, this, () -> { + return createOutputsProcessor(routeContext); +}); + +// Child code can access the current processor being created +ProcessorDefinition current = CREATE_PROCESSOR.orElse(null); +---- + +=== Migration from ThreadLocal + +If you have existing code using `ThreadLocal`, migration is straightforward: + +[source,java] +---- +// Before: ThreadLocal +private static final ThreadLocal CURRENT_USER = new ThreadLocal<>(); + +public void handleRequest(User user) { + CURRENT_USER.set(user); + try { + processRequest(); + } finally { + CURRENT_USER.remove(); + } +} + +// After: ContextValue +private static final ContextValue CURRENT_USER = ContextValue.newInstance("currentUser"); + +public void handleRequest(User user) { + ContextValue.where(CURRENT_USER, user, this::processRequest); +} +---- + +The `ContextValue` version is cleaner and automatically handles cleanup. + +== Best Practices and Performance Considerations + +=== When to Use Virtual Threads + +[cols="1,1"] +|=== +| Good Fit ✓ | Poor Fit ✗ + +| HTTP client calls +| CPU-intensive computation + +| Database queries (JDBC) +| Tight loops with no I/O + +| File I/O operations +| Real-time/low-latency systems + +| Message broker operations +| Native code (JNI) that blocks + +| Calling external services +| Code holding locks for long periods +|=== + +=== Configuration Guidelines + +==== Start Conservative + +[source,properties] +---- +# Start with virtual threads disabled, benchmark, then enable +camel.threads.virtual.enabled=false + +# When enabling, test thoroughly +camel.threads.virtual.enabled=true +---- + +==== SEDA Tuning + +[source,java] +---- +// For I/O-bound: use virtualThreadPerTask with high concurrency limit +from("seda:io-bound?virtualThreadPerTask=true&concurrentConsumers=1000") + +// For CPU-bound: stick with traditional model, tune pool size +from("seda:cpu-bound?concurrentConsumers=4") // ~number of CPU cores +---- + +==== Avoid Pinning + +Virtual threads "pin" to carrier threads when: + +* Inside `synchronized` blocks +* During native method calls + +Prefer `ReentrantLock` over `synchronized`: + +[source,java] +---- +// Avoid: can pin virtual thread +synchronized (lock) { + doBlockingOperation(); +} + +// Prefer: virtual thread can unmount +lock.lock(); +try { + doBlockingOperation(); +} finally { + lock.unlock(); +} +---- + +=== Monitoring and Debugging + +==== Thread Names + +Virtual threads created by Camel have descriptive names: + +[source,text] +---- +VirtualThread[#123]/Camel (camel-1) thread #5 - seda://orders +---- + +==== JFR Events + +JDK Flight Recorder captures virtual thread events: + +[source,bash] +---- +# Record virtual thread events +java -XX:StartFlightRecording=filename=recording.jfr,settings=default \ + -Dcamel.threads.virtual.enabled=true \ + -jar myapp.jar +---- + +==== Detecting Pinning + +[source,bash] +---- +# Log when virtual threads pin (JDK 21+) +java -Djdk.tracePinnedThreads=short \ + -Dcamel.threads.virtual.enabled=true \ + -jar myapp.jar +---- + +== Complete Examples + +=== Example 1: High-Concurrency REST API + +[source,java] +---- +public class RestApiRoute extends RouteBuilder { + @Override + public void configure() { + // REST endpoint receives requests + rest("/api") + .post("/orders") + .to("seda:process-order"); + + // Process with virtual threads - handle 1000s of concurrent requests + from("seda:process-order?virtualThreadPerTask=true&concurrentConsumers=2000") + .routeId("order-processor") + // Each step may block on I/O - virtual threads park efficiently + .to("http://inventory-service/reserve") + .to("http://payment-service/charge") + .to("jpa:Order?persistenceUnit=orders") + .to("kafka:order-events"); + } +} +---- + +=== Example 2: Parallel Enrichment with Virtual Threads + +[source,java] +---- +public class ParallelEnrichmentRoute extends RouteBuilder { + @Override + public void configure() { + from("direct:enrich") + .multicast() + .parallelProcessing() + .executorService(virtualThreadExecutor()) // Use virtual threads + .to("direct:enrichFromUserService", + "direct:enrichFromOrderHistory", + "direct:enrichFromRecommendations") + .end() + .to("direct:aggregate"); + } + + private ExecutorService virtualThreadExecutor() { + return getCamelContext() + .getExecutorServiceManager() + .newCachedThreadPool(this, "enrichment"); + // When camel.threads.virtual.enabled=true, this returns a virtual thread executor + } +} +---- + +=== Example 3: Context Propagation Across Routes + +[source,java] +---- +public class TenantAwareRoute extends RouteBuilder { + + private static final ContextValue TENANT_ID = ContextValue.newInstance("tenantId"); + + @Override + public void configure() { + from("platform-http:/api/{tenant}/orders") + .process(exchange -> { + String tenant = exchange.getMessage().getHeader("tenant", String.class); + // Bind tenant for the entire processing scope + ContextValue.where(TENANT_ID, tenant, () -> { + exchange.setProperty("tenantId", tenant); + return null; + }); + }) + .to("seda:process?virtualThreadPerTask=true"); + + from("seda:process?virtualThreadPerTask=true&concurrentConsumers=500") + .process(exchange -> { + // Access tenant ID in any processor + String tenant = TENANT_ID.orElse("default"); + log.info("Processing for tenant: {}", tenant); + }) + .toD("jpa:Order?persistenceUnit=${exchangeProperty.tenantId}"); + } +} +---- + +== Summary + +Virtual threads in Apache Camel provide: + +* *Simplified concurrency* - Write blocking code without callback hell +* *Improved scalability* - Handle thousands of concurrent I/O operations +* *Reduced resource consumption* - Lightweight threads use less memory +* *Better throughput* - No thread pool exhaustion under load + +To get started: + +1. Upgrade to JDK 21+ +2. Add `camel.threads.virtual.enabled=true` to your configuration +3. For SEDA components, consider `virtualThreadPerTask=true` for I/O-bound workloads +4. Monitor with `-Djdk.tracePinnedThreads=short` to detect issues + +For advanced context propagation needs, especially on JDK 25+, use `ContextValue` instead of raw `ThreadLocal`. From f1c50b3bb91d87e58f383efd0bc267cbf1916595 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 19 Feb 2026 13:18:56 +0100 Subject: [PATCH 8/8] Fix ContextValue scoping issues with KameletProcessor and JDK 25 compilation The ContextValue abstraction properly scopes createRoute/createProcessor ThreadLocals, but KameletEndpoint.doInit() relied on a leaked ThreadLocal value from a bug in the old createRoute(null) which cleared isSetupRoutes instead of isCreateRoute. Fix by capturing the route/processor context in KameletReifier (within the createProcessor scope) and passing them to KameletProcessor, which restores them during doInit() so the endpoint can inherit error handlers correctly. Also fix JDK 25 ScopedValue.Carrier API: use call() instead of get(). Co-Authored-By: Claude Opus 4.6 --- .../component/kamelet/KameletProcessor.java | 29 ++++++++++++++++++- .../component/kamelet/KameletReifier.java | 8 ++++- .../util/concurrent/ContextValueFactory.java | 3 +- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletProcessor.java b/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletProcessor.java index 80cd3fe9b53f1..0f4012e7c7c6e 100644 --- a/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletProcessor.java +++ b/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletProcessor.java @@ -43,6 +43,8 @@ public class KameletProcessor extends BaseProcessorSupport private final String name; private final AsyncProcessor processor; + private final String parentRouteId; + private final String parentProcessorId; private KameletProducer producer; private KameletComponent component; private CamelContext camelContext; @@ -50,9 +52,16 @@ public class KameletProcessor extends BaseProcessorSupport private String routeId; public KameletProcessor(CamelContext camelContext, String name, Processor processor) throws Exception { + this(camelContext, name, processor, null, null); + } + + public KameletProcessor(CamelContext camelContext, String name, Processor processor, + String parentRouteId, String parentProcessorId) throws Exception { this.camelContext = camelContext; this.name = name; this.processor = AsyncProcessorConverterHelper.convert(processor); + this.parentRouteId = parentRouteId; + this.parentProcessorId = parentProcessorId; } @ManagedAttribute(description = "Kamelet name (templateId/routeId?options)") @@ -118,7 +127,25 @@ public String getTraceLabel() { @Override protected void doInit() throws Exception { this.component = camelContext.getComponent("kamelet", KameletComponent.class); - this.producer = (KameletProducer) camelContext.getEndpoint("kamelet://" + name).createAsyncProducer(); + + // set the route/processor context so the KameletEndpoint.doInit() can pick them up + // these were captured during construction (within the createProcessor scope) + if (parentRouteId != null) { + camelContext.getCamelContextExtension().createRoute(parentRouteId); + } + if (parentProcessorId != null) { + camelContext.getCamelContextExtension().createProcessor(parentProcessorId); + } + try { + this.producer = (KameletProducer) camelContext.getEndpoint("kamelet://" + name).createAsyncProducer(); + } finally { + if (parentRouteId != null) { + camelContext.getCamelContextExtension().createRoute(null); + } + if (parentProcessorId != null) { + camelContext.getCamelContextExtension().createProcessor(null); + } + } ServiceHelper.initService(processor, producer); diff --git a/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletReifier.java b/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletReifier.java index dd544eacc95c5..587b2e144d87b 100644 --- a/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletReifier.java +++ b/components/camel-kamelet/src/main/java/org/apache/camel/component/kamelet/KameletReifier.java @@ -40,8 +40,14 @@ public Processor createProcessor() throws Exception { // wrap in uow String outputId = definition.idOrCreate(camelContext.getCamelContextExtension().getContextPlugin(NodeIdFactory.class)); final Processor childProcessor = processor; + // capture route/processor context now (within createProcessor scope) for later use by KameletProcessor + final String parentRouteId = camelContext.getCamelContextExtension().getCreateRoute(); return camelContext.getCamelContextExtension().createProcessor(outputId, () -> { - Processor answer = new KameletProcessor(camelContext, parseString(definition.getName()), childProcessor); + final String parentProcessorId = camelContext.getCamelContextExtension().getCreateProcessor(); + Processor answer + = new KameletProcessor( + camelContext, parseString(definition.getName()), childProcessor, + parentRouteId, parentProcessorId); if (answer instanceof DisabledAware da) { da.setDisabled(isDisabled(camelContext, definition)); } diff --git a/core/camel-util/src/main/java25/org/apache/camel/util/concurrent/ContextValueFactory.java b/core/camel-util/src/main/java25/org/apache/camel/util/concurrent/ContextValueFactory.java index d6ce66128e380..8575a1f917055 100644 --- a/core/camel-util/src/main/java25/org/apache/camel/util/concurrent/ContextValueFactory.java +++ b/core/camel-util/src/main/java25/org/apache/camel/util/concurrent/ContextValueFactory.java @@ -81,8 +81,7 @@ static ContextValue newThreadLocal(String name, Supplier supplier) { */ static R where(ContextValue key, T value, Supplier operation) { if (key instanceof ScopedValueContextValue svKey) { - // In JDK 25+, ScopedValue.where() returns a Carrier that has get() method - return ScopedValue.where(svKey.scopedValue, value).get(operation); + return ScopedValue.where(svKey.scopedValue, value).call(operation::get); } else if (key instanceof ThreadLocalContextValue tlKey) { T oldValue = tlKey.get(); try {