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

Reply via email to