This is an automated email from the ASF dual-hosted git repository.
Croway pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 6919a3f8d837 CAMEL-24682: camel-groovy - resolve the language and
shell factory once, keep the compiled script per expression, lazy binding
6919a3f8d837 is described below
commit 6919a3f8d83762e8b777f7a0d546ebe8375fd22e
Author: Federico Mariani <[email protected]>
AuthorDate: Mon Sep 14 12:23:42 2026 +0200
CAMEL-24682: camel-groovy - resolve the language and shell factory once,
keep the compiled script per expression, lazy binding
---
.../camel/language/groovy/GroovyExpression.java | 258 ++++++++++++++++++---
.../camel/language/groovy/GroovyLanguage.java | 58 ++++-
.../language/groovy/GroovyCompileOnceTest.java | 125 ++++++++++
.../groovy/GroovyExpressionBindingTest.java | 255 ++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 11 +
5 files changed, 674 insertions(+), 33 deletions(-)
diff --git
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
index 8456e767cba7..ee22fb79d927 100644
---
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
+++
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
@@ -16,20 +16,27 @@
*/
package org.apache.camel.language.groovy;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import groovy.lang.Binding;
import groovy.lang.GroovyShell;
import groovy.lang.Script;
+import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
+import org.apache.camel.Message;
+import org.apache.camel.RuntimeCamelException;
import org.apache.camel.attachment.AttachmentMessage;
import org.apache.camel.attachment.DefaultAttachmentMessage;
import org.apache.camel.support.ExchangeHelper;
import org.apache.camel.support.ExpressionSupport;
-import org.apache.camel.support.ObjectHelper;
+import org.apache.camel.support.LanguageHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -39,6 +46,11 @@ public class GroovyExpression extends ExpressionSupport {
private final String text;
+ // the language and shell factory of the CamelContext, resolved once
instead of on every evaluation
+ private volatile Resolved resolved;
+ // the compiled script of this expression, so a cache eviction in the
language does not force a recompilation
+ private volatile CompiledScript compiled;
+
public GroovyExpression(String text) {
this.text = text;
}
@@ -53,6 +65,12 @@ public class GroovyExpression extends ExpressionSupport {
return "groovy: " + text;
}
+ @Override
+ public void init(CamelContext context) {
+ super.init(context);
+ resolve(context);
+ }
+
@Override
public <T> T evaluate(Exchange exchange, Class<T> type) {
Map<String, Object> globalVariables = new HashMap<>();
@@ -83,41 +101,227 @@ public class GroovyExpression extends ExpressionSupport {
@SuppressWarnings("unchecked")
protected Script instantiateScript(Exchange exchange, Map<String, Object>
globalVariables) {
- // Get the script from the cache, or create a new instance
- GroovyLanguage language = (GroovyLanguage)
exchange.getContext().resolveLanguage("groovy");
- Set<GroovyShellFactory> shellFactories =
exchange.getContext().getRegistry().findByType(GroovyShellFactory.class);
- GroovyShellFactory shellFactory = null;
+ Resolved r = resolve(exchange.getContext());
+ GroovyShellFactory shellFactory = r.shellFactory;
String fileName = null;
- if (shellFactories.size() == 1) {
- shellFactory = shellFactories.iterator().next();
+ if (shellFactory != null) {
fileName = shellFactory.getFileName(exchange);
globalVariables.putAll(shellFactory.getVariables(exchange));
}
- final String key = fileName != null ? fileName + text : text;
- Class<Script> scriptClass = language.getScriptFromCache(key);
- if (scriptClass == null) {
- // prefer to use classloader from groovy script compiler, and if
not fallback to app context
- ClassLoader cl =
exchange.getContext().getCamelContextExtension().getContextPlugin(GroovyScriptClassLoader.class);
- GroovyShell shell = shellFactory != null ?
shellFactory.createGroovyShell(exchange)
- : cl != null ? new GroovyShell(cl) : new GroovyShell();
- scriptClass = fileName != null
- ? shell.getClassLoader().parseClass(text, fileName) :
shell.getClassLoader().parseClass(text);
- language.addScriptToCache(key, scriptClass);
+
+ int generation = r.language.getGeneration();
+ CompiledScript c = compiled;
+ if (c == null || c.generation != generation || c.context != r.context
|| c.language != r.language
+ || !Objects.equals(c.fileName, fileName)) {
+ // Get the script from the cache, or create a new instance
+ final String key = fileName != null ? fileName + text : text;
+ final String name = fileName;
+ Class<Script> scriptClass = r.language.getOrCompile(key, () -> {
+ // prefer to use classloader from groovy script compiler, and
if not fallback to app context
+ ClassLoader cl
+ =
exchange.getContext().getCamelContextExtension().getContextPlugin(GroovyScriptClassLoader.class);
+ GroovyShell shell = shellFactory != null ?
shellFactory.createGroovyShell(exchange)
+ : cl != null ? new GroovyShell(cl) : new GroovyShell();
+ return name != null
+ ? shell.getClassLoader().parseClass(text, name) :
shell.getClassLoader().parseClass(text);
+ });
+ c = new CompiledScript(r.context, r.language, generation,
fileName, scriptClass, constructor(scriptClass));
+ compiled = c;
}
// New instance of the script
- return ObjectHelper.newInstance(scriptClass, Script.class);
+ return c.newInstance();
+ }
+
+ private static MethodHandle constructor(Class<Script> scriptClass) {
+ try {
+ return MethodHandles.publicLookup().findConstructor(scriptClass,
MethodType.methodType(void.class));
+ } catch (NoSuchMethodException | IllegalAccessException e) {
+ throw new RuntimeCamelException(e);
+ }
}
protected Binding createBinding(Exchange exchange, Map<String, Object>
globalVariables) {
- Map<String, Object> map = new HashMap<>(globalVariables);
- ExchangeHelper.populateVariableMap(exchange, map, true);
- AttachmentMessage am = new
DefaultAttachmentMessage(exchange.getMessage());
- if (am.hasAttachments()) {
- map.put("attachments", am.getAttachments());
- } else {
- map.put("attachments", Collections.EMPTY_MAP);
+ return new ExchangeBinding(exchange, globalVariables);
+ }
+
+ private Resolved resolve(CamelContext context) {
+ Resolved r = resolved;
+ if (r == null || r.context != context) {
+ GroovyLanguage language = (GroovyLanguage)
context.resolveLanguage("groovy");
+ Set<GroovyShellFactory> shellFactories =
context.getRegistry().findByType(GroovyShellFactory.class);
+ GroovyShellFactory shellFactory = shellFactories.size() == 1 ?
shellFactories.iterator().next() : null;
+ r = new Resolved(context, language, shellFactory);
+ resolved = r;
+ }
+ return r;
+ }
+
+ private record Resolved(CamelContext context, GroovyLanguage language,
GroovyShellFactory shellFactory) {
+ }
+
+ /**
+ * The compiled class of the script with its no-arg constructor: a method
handle bound once is cheaper to invoke
+ * than {@code Class.getDeclaredConstructor().newInstance()}, which copies
the constructor on every call.
+ */
+ private record CompiledScript(
+ CamelContext context, GroovyLanguage language, int generation,
String fileName, Class<Script> scriptClass,
+ MethodHandle constructor) {
+
+ Script newInstance() {
+ try {
+ return (Script) constructor.invoke();
+ } catch (Error e) {
+ throw e;
+ } catch (Throwable e) {
+ throw new RuntimeCamelException(e);
+ }
+ }
+ }
+
+ /**
+ * Binding with the same variables as {@link
ExchangeHelper#populateVariableMap(Exchange, Map, boolean)} plus
+ * attachments and log.
+ * <p>
+ * The body, the headers, the exception and the out message are read when
the binding is created. The values that
+ * are costly to create (the copy of the exchange properties, the variable
repository and the attachment message)
+ * are created the first time the script uses them (through the variable
or {@code binding.variables}), so they
+ * reflect the exchange at that moment: a property set by the script
before it reads {@code exchangeProperties} is
+ * visible. Once created a value is kept for the rest of the evaluation.
+ * <p>
+ * A global variable of the {@link GroovyShellFactory} is hidden by the
exchange variable with the same name, except
+ * {@code out} and {@code response} when the exchange has no out message,
as they are then not exposed.
+ */
+ private static final class ExchangeBinding extends Binding {
+
+ private static final Set<String> EXCHANGE_VARIABLES = Set.of(
+ "body", "header", "headers", "variable", "variables",
"exception", "in", "request", "exchange",
+ "exchangeProperty", "exchangeProperties", "out", "response",
"camelContext", "attachments", "log");
+
+ private final Exchange exchange;
+ private final Message in;
+ private final Object body;
+ private final Map<String, Object> headers;
+ private final Exception exception;
+ private final Message out;
+ private Map<String, Object> exchangeProperties;
+ private Map<String, Object> exchangeVariables;
+ private Map<?, ?> attachments;
+ private boolean materialized;
+
+ ExchangeBinding(Exchange exchange, Map<String, Object>
globalVariables) {
+ super(new HashMap<>());
+ this.exchange = exchange;
+ this.in = exchange.getIn();
+ this.body = in.getBody();
+ this.headers = in.getHeaders();
+ this.exception = LanguageHelper.exception(exchange);
+ this.out = ExchangeHelper.isOutCapable(exchange) ?
exchange.getMessage() : null;
+ if (!globalVariables.isEmpty()) {
+ Map<String, Object> variables = super.getVariables();
+ // the exchange variables take precedence over global
variables with the same name
+ globalVariables.forEach((k, v) -> {
+ if (!isExposed(k)) {
+ variables.put(k, v);
+ }
+ });
+ }
+ }
+
+ @Override
+ public Object getVariable(String name) {
+ if (materialized || super.getVariables().containsKey(name) ||
!isExposed(name)) {
+ // throws MissingPropertyException when the variable does not
exist
+ return super.getVariable(name);
+ }
+ return exchangeVariable(name);
+ }
+
+ @Override
+ public boolean hasVariable(String name) {
+ if (!materialized && isExposed(name)) {
+ return true;
+ }
+ return super.hasVariable(name);
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes")
+ public Map getVariables() {
+ if (!materialized) {
+ Map<String, Object> variables = super.getVariables();
+ for (String name : EXCHANGE_VARIABLES) {
+ // variables set by the script win
+ if (isExposed(name) && !variables.containsKey(name)) {
+ variables.put(name, exchangeVariable(name));
+ }
+ }
+ materialized = true;
+ }
+ return super.getVariables();
+ }
+
+ @Override
+ public void removeVariable(String name) {
+ getVariables();
+ super.removeVariable(name);
+ }
+
+ /**
+ * Whether the name is an exchange variable of this binding: out and
response only exist when the exchange has
+ * an out message.
+ */
+ private boolean isExposed(String name) {
+ if (!EXCHANGE_VARIABLES.contains(name)) {
+ return false;
+ }
+ return out != null || !("out".equals(name) ||
"response".equals(name));
+ }
+
+ /**
+ * The value of an exchange variable.
+ */
+ private Object exchangeVariable(String name) {
+ switch (name) {
+ case "body":
+ return body;
+ case "header":
+ case "headers":
+ return headers;
+ case "variable":
+ case "variables":
+ if (exchangeVariables == null) {
+ exchangeVariables = exchange.getVariables();
+ }
+ return exchangeVariables;
+ case "exception":
+ return exception;
+ case "in":
+ case "request":
+ return in;
+ case "exchange":
+ return exchange;
+ case "exchangeProperty":
+ case "exchangeProperties":
+ if (exchangeProperties == null) {
+ exchangeProperties = exchange.getAllProperties();
+ }
+ return exchangeProperties;
+ case "out":
+ case "response":
+ return out;
+ case "camelContext":
+ return exchange.getContext();
+ case "attachments":
+ if (attachments == null) {
+ AttachmentMessage am = new
DefaultAttachmentMessage(exchange.getMessage());
+ attachments = am.hasAttachments() ?
am.getAttachments() : Collections.emptyMap();
+ }
+ return attachments;
+ case "log":
+ return LOG;
+ default:
+ return null;
+ }
}
- map.put("log", LOG);
- return new Binding(map);
}
}
diff --git
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyLanguage.java
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyLanguage.java
index 627b57fc0741..05ddabd9c5c5 100644
---
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyLanguage.java
+++
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyLanguage.java
@@ -19,6 +19,10 @@ package org.apache.camel.language.groovy;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
import groovy.lang.Binding;
import groovy.lang.GroovyShell;
@@ -58,6 +62,16 @@ public class GroovyLanguage extends TypedLanguageSupport
implements ScriptingLan
*/
private final Map<String, GroovyClassService> scriptCache;
+ /**
+ * Incremented whenever the script cache is cleared, so expressions
holding a compiled script know it is stale.
+ */
+ private final AtomicInteger generation = new AtomicInteger();
+
+ /**
+ * The scripts being compiled, so concurrent cache misses of the same
script compile it once.
+ */
+ private final ConcurrentMap<String, Object> compileLocks = new
ConcurrentHashMap<>();
+
private EventNotifier notifier;
private GroovyLanguage(Map<String, GroovyClassService> scriptCache,
boolean loadExternalResource) {
@@ -66,7 +80,9 @@ public class GroovyLanguage extends TypedLanguageSupport
implements ScriptingLan
}
public GroovyLanguage() {
- this(LRUCacheFactory.newLRUSoftCache(16, 1000, true), true);
+ // do not remove the class of an evicted script
(stopOnEviction=false): a GroovyExpression may still hold and run
+ // it. Classes are removed when the language stops or the cache is
cleared on reload.
+ this(LRUCacheFactory.newLRUSoftCache(16, 1000, false), true);
}
@Override
@@ -87,6 +103,7 @@ public class GroovyLanguage extends TypedLanguageSupport
implements ScriptingLan
public void stop() {
ServiceHelper.stopService(scriptCache.values());
scriptCache.clear();
+ generation.incrementAndGet();
if (notifier != null) {
getCamelContext().getManagementStrategy().removeEventNotifier(notifier);
notifier = null;
@@ -101,6 +118,7 @@ public class GroovyLanguage extends TypedLanguageSupport
implements ScriptingLan
if (event instanceof CamelEvent.CamelContextReloadingEvent ||
event instanceof CamelEvent.RouteReloadedEvent) {
ServiceHelper.stopService(scriptCache.values());
scriptCache.clear();
+ generation.incrementAndGet();
}
}
@@ -155,14 +173,13 @@ public class GroovyLanguage extends TypedLanguageSupport
implements ScriptingLan
if (loadExternalResource) {
script = loadResource(script);
}
- Class<Script> clazz = getScriptFromCache(script);
- if (clazz == null) {
+ final String text = script;
+ Class<Script> clazz = getOrCompile(text, () -> {
// prefer to use classloader from groovy script compiler, and if
not fallback to app context
ClassLoader cl =
getCamelContext().getCamelContextExtension().getContextPlugin(GroovyScriptClassLoader.class);
GroovyShell shell = cl != null ? new GroovyShell(cl) : new
GroovyShell();
- clazz = shell.getClassLoader().parseClass(script);
- addScriptToCache(script, clazz);
- }
+ return shell.getClassLoader().parseClass(text);
+ });
Script gs = ObjectHelper.newInstance(clazz, Script.class);
if (bindings != null) {
gs.setBinding(new Binding(bindings));
@@ -193,6 +210,10 @@ public class GroovyLanguage extends TypedLanguageSupport
implements ScriptingLan
return validateExpression(expression);
}
+ int getGeneration() {
+ return generation.get();
+ }
+
Class<Script> getScriptFromCache(String script) {
final GroovyClassService cached = scriptCache.get(script);
if (cached == null) {
@@ -205,6 +226,31 @@ public class GroovyLanguage extends TypedLanguageSupport
implements ScriptingLan
scriptCache.put(script, new GroovyClassService(scriptClass));
}
+ /**
+ * Gets the compiled class of the script from the cache, compiling and
caching it on a miss. Concurrent misses of
+ * the same key compile the script once: the callers wait for the
compilation in flight and then find it in the
+ * cache. The cache hit path does not take a lock.
+ */
+ Class<Script> getOrCompile(String key, Supplier<Class<Script>> compiler) {
+ Class<Script> clazz = getScriptFromCache(key);
+ if (clazz != null) {
+ return clazz;
+ }
+ Object lock = compileLocks.computeIfAbsent(key, k -> new Object());
+ try {
+ synchronized (lock) {
+ clazz = getScriptFromCache(key);
+ if (clazz == null) {
+ clazz = compiler.get();
+ addScriptToCache(key, clazz);
+ }
+ return clazz;
+ }
+ } finally {
+ compileLocks.remove(key, lock);
+ }
+ }
+
public static class Builder {
private final Map<String, GroovyClassService> cache = new HashMap<>();
diff --git
a/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyCompileOnceTest.java
b/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyCompileOnceTest.java
new file mode 100644
index 000000000000..aff7688692a6
--- /dev/null
+++
b/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyCompileOnceTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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.language.groovy;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import groovy.lang.GroovyClassLoader;
+import groovy.lang.GroovyCodeSource;
+import groovy.lang.GroovyShell;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.Expression;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Concurrent cache misses of the same script compile it once.
+ */
+public class GroovyCompileOnceTest {
+
+ private static final int THREADS = 8;
+
+ private CamelContext context;
+ private final AtomicInteger compilations = new AtomicInteger();
+ // counts down when a thread has reached the script compilation path
+ private final CountDownLatch arrived = new CountDownLatch(THREADS);
+
+ @BeforeEach
+ public void setUp() {
+ context = new DefaultCamelContext();
+ context.getRegistry().bind("shellFactory", new GroovyShellFactory() {
+ @Override
+ public GroovyShell createGroovyShell(Exchange exchange) {
+ return new GroovyShell() {
+ @Override
+ public GroovyClassLoader getClassLoader() {
+ return new CountingClassLoader();
+ }
+ };
+ }
+
+ @Override
+ public Map<String, Object> getVariables(Exchange exchange) {
+ // called by every evaluation before the cache lookup
+ arrived.countDown();
+ return Map.of();
+ }
+ });
+ context.start();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ context.stop();
+ }
+
+ @Test
+ public void testConcurrentMissesCompileOnce() throws Exception {
+ Expression expression =
context.resolveLanguage("groovy").createExpression("getClass()");
+ expression.init(context);
+
+ ExecutorService pool = Executors.newFixedThreadPool(THREADS);
+ try {
+ List<Future<Class<?>>> futures = new ArrayList<>();
+ for (int i = 0; i < THREADS; i++) {
+ futures.add(pool.submit(() -> {
+ Exchange exchange = new DefaultExchange(context);
+ return expression.evaluate(exchange, Class.class);
+ }));
+ }
+ Class<?> first = futures.get(0).get(30, TimeUnit.SECONDS);
+ for (Future<Class<?>> f : futures) {
+ assertSame(first, f.get(30, TimeUnit.SECONDS));
+ }
+ } finally {
+ pool.shutdownNow();
+ }
+ assertEquals(1, compilations.get());
+ }
+
+ private final class CountingClassLoader extends GroovyClassLoader {
+
+ @Override
+ public Class parseClass(GroovyCodeSource codeSource, boolean
shouldCacheSource) {
+ try {
+ // every thread has missed the cache before the first
compilation finishes
+ assertTrue(arrived.await(30, TimeUnit.SECONDS));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ compilations.incrementAndGet();
+ return super.parseClass(codeSource, shouldCacheSource);
+ }
+ }
+}
diff --git
a/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyExpressionBindingTest.java
b/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyExpressionBindingTest.java
new file mode 100644
index 000000000000..50f3ced73ed5
--- /dev/null
+++
b/components/camel-groovy/src/test/java/org/apache/camel/language/groovy/GroovyExpressionBindingTest.java
@@ -0,0 +1,255 @@
+/*
+ * 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.language.groovy;
+
+import java.util.Map;
+
+import groovy.lang.GroovyShell;
+import groovy.lang.Script;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.ExchangePattern;
+import org.apache.camel.Expression;
+import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The binding of a groovy expression exposes the exchange variables, and the
compiled script of an expression is reused
+ * until the language cache is cleared.
+ */
+public class GroovyExpressionBindingTest {
+
+ private CamelContext context;
+ private Exchange exchange;
+
+ @BeforeEach
+ public void setUp() {
+ context = new DefaultCamelContext();
+ context.start();
+ exchange = new DefaultExchange(context);
+ exchange.getIn().setBody("World");
+ exchange.getIn().setHeader("name", "James");
+ exchange.setProperty("myProperty", "myValue");
+ }
+
+ @AfterEach
+ public void tearDown() {
+ context.stop();
+ }
+
+ private Object evaluate(String script) {
+ Expression expression =
context.resolveLanguage("groovy").createExpression(script);
+ expression.init(context);
+ return expression.evaluate(exchange, Object.class);
+ }
+
+ @Test
+ public void testExchangeVariables() {
+ assertEquals("World", evaluate("body"));
+ assertEquals("James", evaluate("header.name"));
+ assertEquals("James", evaluate("headers.name"));
+ assertEquals("myValue", evaluate("exchangeProperty.myProperty"));
+ assertEquals("myValue", evaluate("exchangeProperties.myProperty"));
+ assertSame(exchange, evaluate("exchange"));
+ assertSame(exchange.getIn(), evaluate("request"));
+ assertSame(context, evaluate("camelContext"));
+ assertEquals(Boolean.TRUE, evaluate("attachments.isEmpty()"));
+ assertEquals(Boolean.TRUE, evaluate("log != null"));
+ }
+
+ @Test
+ public void testOutOnlyWhenOutCapable() {
+ assertEquals(Boolean.FALSE,
evaluate("binding.hasVariable('response')"));
+ exchange.setPattern(ExchangePattern.InOut);
+ assertEquals(Boolean.TRUE,
evaluate("binding.hasVariable('response')"));
+ assertEquals("World", evaluate("response.body"));
+ }
+
+ @Test
+ public void testBodyIsReadWhenTheBindingIsCreated() {
+ assertEquals("World", evaluate("exchange.in.body = 'Changed'; body"));
+ assertEquals("Changed", exchange.getIn().getBody());
+ }
+
+ @Test
+ public void testScriptVariables() {
+ assertEquals(10, evaluate("x = 5; x * 2"));
+ assertEquals("Bye", evaluate("body = 'Bye'; body"));
+ assertEquals("World", exchange.getIn().getBody());
+ }
+
+ @Test
+ public void testBindingVariables() {
+ assertEquals(Boolean.TRUE, evaluate(
+ "binding.variables.keySet().containsAll(['body', 'header',
'headers', 'variable', 'variables', 'exception',"
+ + " 'in', 'request', 'exchange',
'exchangeProperty', 'exchangeProperties',"
+ + " 'camelContext', 'attachments',
'log'])"));
+ assertEquals("World", evaluate("binding.variables.body"));
+ assertEquals("Bye", evaluate("body = 'Bye'; binding.variables.body"));
+ }
+
+ @Test
+ public void testSnapshotIsKeptWhenBindingVariablesIsUsed() {
+ assertEquals("World", evaluate("exchange.in.body = 'Changed';
binding.variables; body"));
+ // the previous script changed the exchange body, the next binding
takes its snapshot from there
+ exchange.getIn().setBody("World");
+ assertEquals("World", evaluate("exchange.in.body = 'Again';
binding.variables.body"));
+ assertEquals(Boolean.TRUE, evaluate("def p = exchangeProperties;
binding.variables; p.is(exchangeProperties)"));
+ assertEquals(Boolean.TRUE,
evaluate("exchangeProperties.is(binding.variables.exchangeProperties)"));
+ }
+
+ @Test
+ public void testExchangePropertiesAreASnapshot() {
+ assertEquals("myValue",
+ evaluate("def p = exchangeProperties;
exchange.setProperty('myProperty', 'other'); p.myProperty"));
+ assertEquals("other", exchange.getProperty("myProperty"));
+ }
+
+ @Test
+ public void testRemoveVariable() {
+ assertEquals(Boolean.FALSE, evaluate("binding.removeVariable('body');
binding.hasVariable('body')"));
+ }
+
+ @Test
+ public void testScriptWritesExchangeVariables() {
+ assertEquals("bar", evaluate("variables.foo = 'bar'; variable.foo"));
+ assertEquals("bar", exchange.getVariable("foo"));
+ }
+
+ @Test
+ public void testAttachmentsAreCreatedOncePerEvaluation() {
+ assertEquals(Boolean.TRUE, evaluate("attachments.is(attachments) &&
attachments.is(binding.variables.attachments)"));
+ }
+
+ @Test
+ public void testSubclassCanAddGlobalVariables() {
+ GroovyExpression expression = new GroovyExpression("answer + 1") {
+ @Override
+ protected Script instantiateScript(Exchange exchange, Map<String,
Object> globalVariables) {
+ globalVariables.put("answer", 41);
+ return super.instantiateScript(exchange, globalVariables);
+ }
+ };
+ expression.init(context);
+ assertEquals(42, expression.evaluate(exchange, Integer.class));
+ }
+
+ @Test
+ public void testUnknownVariable() {
+ Exception e = assertThrows(Exception.class, () ->
evaluate("doesNotExist"));
+ assertTrue(e.getMessage().contains("doesNotExist"), e.getMessage());
+ }
+
+ @Test
+ public void testShellFactoryVariables() {
+ context.getRegistry().bind("shellFactory", new GroovyShellFactory() {
+ @Override
+ public GroovyShell createGroovyShell(Exchange exchange) {
+ return new GroovyShell();
+ }
+
+ @Override
+ public Map<String, Object> getVariables(Exchange exchange) {
+ return Map.of("greeting", "Hello", "body", "not used");
+ }
+ });
+ // exchange variables take precedence over global variables with the
same name
+ assertEquals("Hello World", evaluate("greeting + ' ' + body"));
+ }
+
+ @Test
+ public void testShellFactoryOutVariableOnInOnlyExchange() {
+ context.getRegistry().bind("shellFactory", new GroovyShellFactory() {
+ @Override
+ public GroovyShell createGroovyShell(Exchange exchange) {
+ return new GroovyShell();
+ }
+
+ @Override
+ public Map<String, Object> getVariables(Exchange exchange) {
+ return Map.of("out", "x", "response", "y");
+ }
+ });
+ // the exchange has no out message, so the global variables are not
hidden
+ assertEquals("x", evaluate("out"));
+ assertEquals("y", evaluate("response"));
+ assertEquals(Boolean.TRUE, evaluate("binding.hasVariable('out')"));
+ assertEquals("x", evaluate("binding.variables.out"));
+ // with an out message the exchange variables take precedence
+ exchange.setPattern(ExchangePattern.InOut);
+ assertSame(exchange.getMessage(), evaluate("out"));
+ assertSame(exchange.getMessage(),
evaluate("binding.variables.response"));
+ }
+
+ @Test
+ public void testNewScriptInstancePerEvaluation() {
+ Expression expression =
context.resolveLanguage("groovy").createExpression("this");
+ expression.init(context);
+ Script first = expression.evaluate(exchange, Script.class);
+ Script second = expression.evaluate(exchange, Script.class);
+ assertNotSame(first, second);
+ assertSame(first.getClass(), second.getClass());
+ }
+
+ @Test
+ public void testScriptConstructorError() {
+ // a @Field initializer runs in the constructor of the script class
+ RuntimeCamelException e = assertThrows(RuntimeCamelException.class,
+ () -> evaluate("@groovy.transform.Field String boom = { throw
new IllegalStateException('boom') }(); 'x'"));
+ Throwable cause = e;
+ while (cause != null && !(cause instanceof IllegalStateException)) {
+ cause = cause.getCause();
+ }
+ assertNotNull(cause);
+ assertEquals("boom", cause.getMessage());
+ }
+
+ @Test
+ public void testCompiledScriptIsReusedUntilTheCacheIsCleared() {
+ GroovyLanguage language = (GroovyLanguage)
context.resolveLanguage("groovy");
+ Expression expression = language.createExpression("getClass()");
+ expression.init(context);
+ Class<?> first = expression.evaluate(exchange, Class.class);
+
+ Expression dynamic = language.createExpression("body.toUpperCase() +
'-' + headers.name.size()");
+ dynamic.init(context);
+ assertEquals("WORLD-5", dynamic.evaluate(exchange, String.class));
+
+ // more distinct scripts than the language cache holds
+ for (int i = 0; i < 1100; i++) {
+ language.createExpression("getClass() // " + i).evaluate(exchange,
Class.class);
+ }
+ assertSame(first, expression.evaluate(exchange, Class.class));
+ // the evicted class is still usable with dynamic dispatch
+ assertEquals("WORLD-5", dynamic.evaluate(exchange, String.class));
+
+ // the cache is cleared when the language stops (and on reload in dev
profile)
+ language.stop();
+ assertNotSame(first, expression.evaluate(exchange, Class.class));
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index cd72c9a7634a..33e585b81506 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -105,6 +105,17 @@ The `route-topology` developer console (used by `camel cmd
route-topology` and t
includes routes created by Kamelets, as these are an implementation detail of
the Kamelet and were already hidden
from the route console. Set the `kamelets=true` option (or `--kamelets` on the
CLI) to include them.
+=== camel-groovy
+
+A `GroovyShellFactory` is now looked up in the registry once per
`CamelContext`, when the first groovy expression is
+evaluated, instead of on every evaluation. A factory bound to the registry
after that point is no longer used; bind
+it before the context starts.
+
+The `exchangeProperties`, `exchangeProperty`, `variables`, `variable` and
`attachments` script variables are now read
+from the exchange the first time the script uses them instead of being copied
before the script runs. A script that
+changes a property or variable through `exchange` and then reads one of these
variables for the first time now sees
+its own change where it previously saw the state from before the script ran.
+
=== camel-dynamic-router
The `dynamic-router-control` endpoint no longer takes the subscription
`predicate`, or the