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 74ab8a253a12 CAMEL-24689: camel-javascript - share one GraalJS Engine,
cache the Source, materialize results (#26310)
74ab8a253a12 is described below
commit 74ab8a253a12fe4b135fceb3815283f9a13266f4
Author: Federico Mariani <[email protected]>
AuthorDate: Mon Sep 14 12:48:17 2026 +0200
CAMEL-24689: camel-javascript - share one GraalJS Engine, cache the Source,
materialize results (#26310)
* perf: camel-javascript - share one GraalJS Engine per language instance
JavaScriptHelper.newContext() built every polyglot Context without an
Engine, so each evaluation created a private engine, re-parsed the
script and allocated roughly 860 KB; a simple concat expression ran at
about 3,100 ops/s and a single route message allocated 1.7 MB.
JavaScriptLanguage is now a Service owning a single Engine, created
lazily on first use and closed when the CamelContext stops the
language. Contexts are still created per evaluation with the same
access settings as before (allowIO, HostAccess.ALL, host class lookup,
PolyglotAccess.NONE), but with .engine(shared) so parsed and compiled
code is reused across them. The engine.WarnInterpreterOnly=false option
moves to the engine builder (engine options cannot be set on a context
that uses an explicit engine); one INFO line is logged at engine
creation when the runtime is interpreter-only.
JavaScriptExpression keeps a reference to the language that created it
and, when created directly, resolves it from the exchange on first use,
following the camel-python3 pattern.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
(cherry picked from commit ea58a7cc93b0f5a473b13b9d1e718d402afabbb1)
* perf: camel-javascript - cache the parsed Source per script text
Both JavaScriptExpression.evaluate and the ScriptingLanguage entry point
rebuilt the polyglot Source on every evaluation. A Source is the key the
shared engine uses to reuse parsed and compiled code, so a fresh
instance each time defeats that sharing and re-parses the script.
The language now keeps an LRU soft cache of Source per distinct script
text (16 initial, 1000 max, same as camel-python3), built once with the
same name and application/javascript+module mime type as before, and
cleared when the language stops.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
(cherry picked from commit 322a1305330b68f3a5ae438f6a72812771c097fc)
* perf: camel-javascript - materialize guest values before closing the
Context
Value.as(Object.class) returns Context-bound proxies (PolyglotMap,
PolyglotList) for JS objects and arrays. Since the per-evaluation
Context is closed when evaluate() returns, any script returning an
object literal, an array, a JS Map/Set or a Date handed the route a
value that threw "The Context is already closed" on first access.
Guest values are now copied into plain Java types while the Context is
still open: arrays become List, objects and JS Map become Map, JS Set
becomes Set, Date becomes Instant; nested values are copied recursively
and cycles are handled. Primitives, strings and host objects (for
example a body returned as-is) are returned exactly as before, and
functions are still left to as(Object.class). The ScriptingLanguage
entry point applies the same conversion, falling back to the Camel type
converter when the materialized value is not of the requested type.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
(cherry picked from commit ce0de5adecf9a254a9d7fabd9d3573df22b5d9eb)
* CAMEL-24689: camel-javascript - upgrade guide entry, fail on impossible
conversions, javadoc and cache flags
The materialized results change the types a script hands back
(LinkedHashMap, ArrayList,
LinkedHashSet, Instant instead of a polyglot Value bound to a closed
Context) and newContext() is
deprecated: both are in the 4.23 upgrade guide. The generic evaluate now
uses mandatoryConvertTo,
so an impossible conversion fails as o.as(resultType) did instead of
returning null. The class
javadoc says the engine is built when the language starts, the Set
detection is documented as a
heuristic on the meta object name, and the Source cache no longer asks to
stop evicted entries.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
---------
Co-authored-by: Claude Fable 5.1 <[email protected]>
---
components/camel-javascript/pom.xml | 5 +
.../camel/language/js/JavaScriptExpression.java | 31 +++-
.../apache/camel/language/js/JavaScriptHelper.java | 53 +++++-
.../camel/language/js/JavaScriptLanguage.java | 206 +++++++++++++++++++--
.../js/JavaScriptResultMaterializationTest.java | 137 ++++++++++++++
.../language/js/JavaScriptSharedEngineTest.java | 127 +++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 13 ++
7 files changed, 548 insertions(+), 24 deletions(-)
diff --git a/components/camel-javascript/pom.xml
b/components/camel-javascript/pom.xml
index 30e2235d32f6..19a253c4df8e 100644
--- a/components/camel-javascript/pom.xml
+++ b/components/camel-javascript/pom.xml
@@ -67,6 +67,11 @@
<type>test-jar</type>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest</artifactId>
diff --git
a/components/camel-javascript/src/main/java/org/apache/camel/language/js/JavaScriptExpression.java
b/components/camel-javascript/src/main/java/org/apache/camel/language/js/JavaScriptExpression.java
index b675f70fd831..5d360a053574 100644
---
a/components/camel-javascript/src/main/java/org/apache/camel/language/js/JavaScriptExpression.java
+++
b/components/camel-javascript/src/main/java/org/apache/camel/language/js/JavaScriptExpression.java
@@ -19,19 +19,22 @@ package org.apache.camel.language.js;
import org.apache.camel.Exchange;
import org.apache.camel.support.ExpressionSupport;
import org.graalvm.polyglot.Context;
-import org.graalvm.polyglot.Source;
import org.graalvm.polyglot.Value;
-import static org.graalvm.polyglot.Source.newBuilder;
-
public class JavaScriptExpression extends ExpressionSupport {
private final String expressionString;
private final Class<?> type;
+ private volatile JavaScriptLanguage language;
public JavaScriptExpression(String expressionString, Class<?> type) {
+ this(expressionString, type, null);
+ }
+
+ JavaScriptExpression(String expressionString, Class<?> type,
JavaScriptLanguage language) {
this.expressionString = expressionString;
this.type = type;
+ this.language = language;
}
public static JavaScriptExpression js(String expression) {
@@ -46,7 +49,8 @@ public class JavaScriptExpression extends ExpressionSupport {
@SuppressWarnings("unchecked")
@Override
public <T> T evaluate(Exchange exchange, Class<T> type) {
- try (Context cx = JavaScriptHelper.newContext()) {
+ JavaScriptLanguage lang = language(exchange);
+ try (Context cx = lang.newContext()) {
Value b = cx.getBindings("js");
b.putMember("exchange", exchange);
@@ -57,10 +61,8 @@ public class JavaScriptExpression extends ExpressionSupport {
b.putMember("properties", exchange.getAllProperties());
b.putMember("body", exchange.getMessage().getBody());
- Source source = newBuilder("js", expressionString, "Unnamed")
- .mimeType("application/javascript+module").buildLiteral();
- Value o = cx.eval(source);
- Object answer = o != null ? o.as(Object.class) : null;
+ Value o = cx.eval(lang.source(expressionString));
+ Object answer = JavaScriptLanguage.materialize(o);
if (type == Object.class) {
return (T) answer;
}
@@ -68,6 +70,19 @@ public class JavaScriptExpression extends ExpressionSupport {
}
}
+ /**
+ * The language owning the shared engine. Expressions created through the
language already have it; expressions
+ * created directly (for example via {@link #js(String)}) resolve it from
the exchange on first use.
+ */
+ private JavaScriptLanguage language(Exchange exchange) {
+ JavaScriptLanguage lang = language;
+ if (lang == null) {
+ lang = (JavaScriptLanguage)
exchange.getContext().resolveLanguage("js");
+ language = lang;
+ }
+ return lang;
+ }
+
public Class<?> getType() {
return type;
}
diff --git
a/components/camel-javascript/src/main/java/org/apache/camel/language/js/JavaScriptHelper.java
b/components/camel-javascript/src/main/java/org/apache/camel/language/js/JavaScriptHelper.java
index 207bbc3a686a..16361e80dc80 100644
---
a/components/camel-javascript/src/main/java/org/apache/camel/language/js/JavaScriptHelper.java
+++
b/components/camel-javascript/src/main/java/org/apache/camel/language/js/JavaScriptHelper.java
@@ -17,21 +17,66 @@
package org.apache.camel.language.js;
import org.graalvm.polyglot.Context;
+import org.graalvm.polyglot.Engine;
import org.graalvm.polyglot.HostAccess;
import org.graalvm.polyglot.PolyglotAccess;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+/**
+ * Factory for the GraalJS {@link Engine} and {@link Context} used by the
JavaScript language.
+ */
public final class JavaScriptHelper {
+ private static final Logger LOG =
LoggerFactory.getLogger(JavaScriptHelper.class);
+
private JavaScriptHelper() {
}
+ /**
+ * Creates the {@link Engine} shared by all contexts of one {@link
JavaScriptLanguage} instance. Sharing the engine
+ * lets GraalJS reuse parsed sources and compiled code across the
per-evaluation contexts instead of re-parsing
+ * every script and allocating a private engine on each evaluation.
+ */
+ public static Engine newEngine() {
+ Engine engine = Engine.newBuilder("js")
+ .option("engine.WarnInterpreterOnly", "false")
+ .build();
+ if (Engine.supportsCompilation()) {
+ LOG.debug("Created GraalJS engine ({}) with JIT compilation
support", engine.getImplementationName());
+ } else {
+ LOG.info("Created GraalJS engine in interpreter-only mode ({});
scripts are not JIT compiled."
+ + " Run on a GraalVM JDK or add the Truffle compiler
runtime to the module path for better performance",
+ engine.getImplementationName());
+ }
+ return engine;
+ }
+
+ /**
+ * Builds a per-evaluation context backed by the given shared engine. Each
context is isolated from the others; only
+ * the parsed and compiled code is shared through the engine.
+ */
+ public static Context newContext(Engine engine) {
+ return configure(Context.newBuilder("js").engine(engine)).build();
+ }
+
+ /**
+ * Builds a context with its own private engine.
+ *
+ * @deprecated use {@link #newContext(Engine)} with a shared engine so
parsed and compiled code is reused
+ */
+ @Deprecated(since = "4.23.0")
public static Context newContext() {
- final Context.Builder contextBuilder = Context.newBuilder("js")
+ return configure(Context.newBuilder("js"))
+ .option("engine.WarnInterpreterOnly", "false")
+ .build();
+ }
+
+ private static Context.Builder configure(Context.Builder builder) {
+ return builder
.allowIO(true)
.allowHostAccess(HostAccess.ALL)
.allowHostClassLookup(s -> true)
- .allowPolyglotAccess(PolyglotAccess.NONE)
- .option("engine.WarnInterpreterOnly", "false");
- return contextBuilder.build();
+ .allowPolyglotAccess(PolyglotAccess.NONE);
}
}
diff --git
a/components/camel-javascript/src/main/java/org/apache/camel/language/js/JavaScriptLanguage.java
b/components/camel-javascript/src/main/java/org/apache/camel/language/js/JavaScriptLanguage.java
index e0d9e41575c2..228353ef3444 100644
---
a/components/camel-javascript/src/main/java/org/apache/camel/language/js/JavaScriptLanguage.java
+++
b/components/camel-javascript/src/main/java/org/apache/camel/language/js/JavaScriptLanguage.java
@@ -16,21 +16,67 @@
*/
package org.apache.camel.language.js;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
import org.apache.camel.Expression;
+import org.apache.camel.NoTypeConversionAvailableException;
import org.apache.camel.Predicate;
+import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.Service;
import org.apache.camel.spi.ScriptingLanguage;
import org.apache.camel.spi.annotations.Language;
+import org.apache.camel.support.LRUCacheFactory;
import org.apache.camel.support.TypedLanguageSupport;
import org.graalvm.polyglot.Context;
+import org.graalvm.polyglot.Engine;
import org.graalvm.polyglot.Source;
import org.graalvm.polyglot.Value;
-import static org.graalvm.polyglot.Source.newBuilder;
-
+/**
+ * Camel expression language for JavaScript via <a
href="https://www.graalvm.org/javascript/">GraalJS</a>.
+ * <p>
+ * One {@link Engine} is shared by all evaluations of a language instance so
parsed and compiled scripts are reused; a
+ * fresh {@link Context} is still created per evaluation so scripts stay
isolated from each other. The engine is created
+ * when the language is started (a language is started as soon as the {@code
CamelContext} resolves it, so the GraalJS
+ * engine build happens at route startup rather than on the first message) and
closed when it is stopped; the
+ * {@code engine()} accessor also builds it on demand for an expression used
before start.
+ */
@Language("js")
-public class JavaScriptLanguage extends TypedLanguageSupport implements
ScriptingLanguage {
+public class JavaScriptLanguage extends TypedLanguageSupport implements
ScriptingLanguage, Service {
+
+ // Source is not a Service, so nothing is stopped on eviction
+ private final Map<String, Source> sourceCache =
LRUCacheFactory.newLRUSoftCache(16, 1000, false);
+ private final Lock engineLock = new ReentrantLock();
+ private volatile Engine engine;
+
+ @Override
+ public void start() {
+ engine();
+ }
+
+ @Override
+ public void stop() {
+ sourceCache.clear();
+ Engine toClose;
+ engineLock.lock();
+ try {
+ toClose = engine;
+ engine = null;
+ } finally {
+ engineLock.unlock();
+ }
+ if (toClose != null) {
+ toClose.close();
+ }
+ }
@Override
public Predicate createPredicate(String expression) {
@@ -45,14 +91,150 @@ public class JavaScriptLanguage extends
TypedLanguageSupport implements Scriptin
@Override
public <T> T evaluate(String script, Map<String, Object> bindings,
Class<T> resultType) {
script = loadResource(script);
- try (Context cx = JavaScriptHelper.newContext()) {
- Value b = cx.getBindings("js");
- bindings.forEach(b::putMember);
- Source source = newBuilder("js", script, "Unnamed")
- .mimeType("application/javascript+module").buildLiteral();
- Value o = cx.eval(source);
- Object answer = o != null ? o.as(resultType) : null;
- return resultType.cast(answer);
+ try (Context cx = newContext()) {
+ if (bindings != null) {
+ Value b = cx.getBindings("js");
+ bindings.forEach(b::putMember);
+ }
+ Value o = cx.eval(source(script));
+ Object answer = materialize(o);
+ if (answer == null || resultType == Object.class ||
resultType.isInstance(answer)) {
+ return resultType.cast(answer);
+ }
+ if (getCamelContext() != null) {
+ try {
+ // fail loudly on an impossible conversion, as
o.as(resultType) did before the result was materialized
+ return
getCamelContext().getTypeConverter().mandatoryConvertTo(resultType, answer);
+ } catch (NoTypeConversionAvailableException e) {
+ throw RuntimeCamelException.wrapRuntimeCamelException(e);
+ }
+ }
+ return resultType.cast(o.as(resultType));
+ }
+ }
+
+ /**
+ * Copies a guest {@link Value} into ordinary Java types so the result
remains usable after the per-evaluation
+ * {@link Context} is closed: JS arrays become {@link List}, JS objects
and {@code Map} become {@link Map}, JS
+ * {@code Set} becomes {@link Set}, and {@code Date} becomes {@link
java.time.Instant}. Nested values are copied
+ * recursively. Primitives, strings and host objects are returned as
before; other guest objects such as functions
+ * are left to {@link Value#as(Class) value.as(Object.class)}.
+ */
+ static Object materialize(Value value) {
+ return materialize(value, new HashMap<>());
+ }
+
+ private static Object materialize(Value value, Map<Value, Object> seen) {
+ if (value == null || value.isNull()) {
+ return null;
+ }
+ if (value.isHostObject()) {
+ return value.asHostObject();
+ }
+ if (value.isProxyObject()) {
+ return value.asProxyObject();
+ }
+ if (value.isBoolean() || value.isNumber() || value.isString() ||
value.canExecute()) {
+ return value.as(Object.class);
+ }
+ if (value.isInstant()) {
+ return value.asInstant();
+ }
+ Object existing = seen.get(value);
+ if (existing != null) {
+ return existing;
+ }
+ if (value.hasArrayElements()) {
+ int size = Math.toIntExact(value.getArraySize());
+ List<Object> list = new ArrayList<>(size);
+ seen.put(value, list);
+ for (int i = 0; i < size; i++) {
+ list.add(materialize(value.getArrayElement(i), seen));
+ }
+ return list;
+ }
+ if (value.hasHashEntries()) {
+ Map<Object, Object> map = new LinkedHashMap<>();
+ seen.put(value, map);
+ Value entries = value.getHashEntriesIterator();
+ while (entries.hasIteratorNextElement()) {
+ Value entry = entries.getIteratorNextElement();
+ map.put(materialize(entry.getArrayElement(0), seen),
materialize(entry.getArrayElement(1), seen));
+ }
+ return map;
+ }
+ if (value.hasIterator() && isJsSet(value)) {
+ Set<Object> set = new LinkedHashSet<>();
+ seen.put(value, set);
+ Value iterator = value.getIterator();
+ while (iterator.hasIteratorNextElement()) {
+ set.add(materialize(iterator.getIteratorNextElement(), seen));
+ }
+ return set;
+ }
+ if (value.hasMembers()) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ seen.put(value, map);
+ for (String key : value.getMemberKeys()) {
+ map.put(key, materialize(value.getMember(key), seen));
+ }
+ return map;
+ }
+ return value.as(Object.class);
+ }
+
+ /**
+ * Whether the value is a JavaScript {@code Set}. The polyglot API has no
type test for it, so this is a heuristic
+ * on the meta object's simple name: a script-defined {@code class Set}
matches too, and a {@code WeakSet} or a
+ * subclass does not (they are then materialized as a list of their
iterator).
+ */
+ private static boolean isJsSet(Value value) {
+ Value meta = value.getMetaObject();
+ if (meta == null || !meta.isMetaObject()) {
+ return false;
+ }
+ try {
+ return "Set".equals(meta.getMetaSimpleName());
+ } catch (UnsupportedOperationException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Creates a per-evaluation {@link Context} backed by the shared {@link
Engine}.
+ */
+ Context newContext() {
+ return JavaScriptHelper.newContext(engine());
+ }
+
+ /**
+ * Returns the {@link Source} for the script text, building it once per
distinct script so the shared engine can
+ * reuse its parsed and compiled form across contexts.
+ */
+ Source source(String script) {
+ Source cached = sourceCache.get(script);
+ if (cached != null) {
+ return cached;
+ }
+ Source created = Source.newBuilder("js", script, "Unnamed")
+ .mimeType("application/javascript+module").buildLiteral();
+ sourceCache.put(script, created);
+ return created;
+ }
+
+ private Engine engine() {
+ Engine existing = engine;
+ if (existing != null) {
+ return existing;
+ }
+ engineLock.lock();
+ try {
+ if (engine == null) {
+ engine = JavaScriptHelper.newEngine();
+ }
+ return engine;
+ } finally {
+ engineLock.unlock();
}
}
@@ -62,6 +244,6 @@ public class JavaScriptLanguage extends TypedLanguageSupport
implements Scriptin
* @return the corresponding {@code JavaScriptExpression}
*/
private JavaScriptExpression createJavaScriptExpression(String expression,
Class<?> type) {
- return new JavaScriptExpression(loadResource(expression), type);
+ return new JavaScriptExpression(loadResource(expression), type, this);
}
}
diff --git
a/components/camel-javascript/src/test/java/org/apache/camel/language/js/JavaScriptResultMaterializationTest.java
b/components/camel-javascript/src/test/java/org/apache/camel/language/js/JavaScriptResultMaterializationTest.java
new file mode 100644
index 000000000000..ae57072dc3c0
--- /dev/null
+++
b/components/camel-javascript/src/test/java/org/apache/camel/language/js/JavaScriptResultMaterializationTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.js;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+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 org.junit.jupiter.api.condition.DisabledIfSystemProperty;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
+
+/**
+ * Guest values are copied into plain Java types before the per-evaluation
Context is closed, so JS objects and arrays
+ * returned by a script remain usable by the rest of the route.
+ */
+@DisabledIfSystemProperty(named = "os.arch", matches = "(?i)(s390x|ppc64le)")
+class JavaScriptResultMaterializationTest {
+
+ private CamelContext context;
+ private JavaScriptLanguage language;
+
+ @BeforeEach
+ void setUp() {
+ context = new DefaultCamelContext();
+ context.start();
+ language = (JavaScriptLanguage) context.resolveLanguage("js");
+ }
+
+ @AfterEach
+ void tearDown() {
+ context.stop();
+ }
+
+ @Test
+ void objectLiteralBecomesMap() {
+ Object result = evaluate("({a: 1, b: 'x'})");
+ assertThat(result).isInstanceOf(Map.class);
+ assertThat(asMap(result)).containsExactly(entry("a", 1), entry("b",
"x"));
+ }
+
+ @Test
+ void arrayBecomesList() {
+ Object result = evaluate("[1, 'two', true]");
+ assertThat(result).isInstanceOf(List.class);
+ assertThat(asList(result)).containsExactly(1, "two", true);
+ }
+
+ @Test
+ void nestedStructuresAreCopiedRecursively() {
+ Object result = evaluate("({items: [{k: 'v'}, [1, 2]], n: null})");
+ assertThat(result).isInstanceOf(Map.class);
+ Map<String, Object> map = asMap(result);
+ assertThat(map).containsKeys("items", "n");
+ assertThat(map.get("n")).isNull();
+ List<Object> items = asList(map.get("items"));
+ assertThat(items).hasSize(2);
+ assertThat(asMap(items.get(0))).containsExactly(entry("k", "v"));
+ assertThat(asList(items.get(1))).containsExactly(1, 2);
+ }
+
+ @Test
+ void jsMapSetAndDateAreConverted() {
+ assertThat(asMap(evaluate("new Map([['k', 1], ['j',
2]])"))).containsExactly(entry("k", 1), entry("j", 2));
+ assertThat(asSet(evaluate("new Set([1, 2, 2])"))).containsExactly(1,
2);
+ assertThat(evaluate("new Date(0)")).isEqualTo(Instant.EPOCH);
+ }
+
+ @Test
+ void primitivesAndHostObjectsAreUnchanged() {
+ List<String> body = List.of("h");
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getMessage().setBody(body);
+ assertThat(language.createExpression("body").evaluate(exchange,
Object.class)).isSameAs(body);
+ assertThat(evaluate("2 + 3")).isEqualTo(5);
+ assertThat(evaluate("1.5")).isEqualTo(1.5d);
+ assertThat(evaluate("'str'")).isEqualTo("str");
+ assertThat(evaluate("true")).isEqualTo(true);
+ assertThat(evaluate("undefined")).isNull();
+ }
+
+ @Test
+ void scriptingLanguageEntryPointMaterializesToo() {
+ Map<String, Object> bindings = Map.of("n", 2);
+ Map<String, Object> map = asMap(language.evaluate("({n: n, list: [n, n
* 2]})", bindings, Map.class));
+ assertThat(map).containsKeys("n", "list");
+ assertThat(map.get("n")).isEqualTo(2);
+ assertThat(asList(map.get("list"))).containsExactly(2, 4);
+ assertThat(asList(language.evaluate("[1, 2, 3]", bindings,
List.class))).containsExactly(1, 2, 3);
+ assertThat(language.evaluate("n + 1", bindings,
String.class)).isEqualTo("3");
+ }
+
+ private Object evaluate(String script) {
+ return language.createExpression(script).evaluate(new
DefaultExchange(context), Object.class);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String, Object> asMap(Object value) {
+ assertThat(value).isInstanceOf(Map.class);
+ return (Map<String, Object>) value;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List<Object> asList(Object value) {
+ assertThat(value).isInstanceOf(List.class);
+ return (List<Object>) value;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Set<Object> asSet(Object value) {
+ assertThat(value).isInstanceOf(Set.class);
+ return (Set<Object>) value;
+ }
+}
diff --git
a/components/camel-javascript/src/test/java/org/apache/camel/language/js/JavaScriptSharedEngineTest.java
b/components/camel-javascript/src/test/java/org/apache/camel/language/js/JavaScriptSharedEngineTest.java
new file mode 100644
index 000000000000..d61ba80c7723
--- /dev/null
+++
b/components/camel-javascript/src/test/java/org/apache/camel/language/js/JavaScriptSharedEngineTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.js;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+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.spi.Language;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledIfSystemProperty;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The GraalJS {@link org.graalvm.polyglot.Engine} is shared per language
instance while every evaluation still gets its
+ * own {@link org.graalvm.polyglot.Context}.
+ */
+@DisabledIfSystemProperty(named = "os.arch", matches = "(?i)(s390x|ppc64le)")
+class JavaScriptSharedEngineTest {
+
+ private static final int THREADS = 8;
+ private static final int ITERATIONS = 25;
+
+ @Test
+ void concurrentEvaluationsShareTheEngine() throws Exception {
+ try (CamelContext context = new DefaultCamelContext()) {
+ context.start();
+ Expression expression =
context.resolveLanguage("js").createExpression("'Hello ' + body + ' ' +
headers.n");
+
+ ExecutorService pool = Executors.newFixedThreadPool(THREADS);
+ try {
+ List<Future<List<String>>> futures = new ArrayList<>();
+ for (int t = 0; t < THREADS; t++) {
+ final int thread = t;
+ futures.add(pool.submit((Callable<List<String>>) () -> {
+ List<String> results = new ArrayList<>();
+ for (int i = 0; i < ITERATIONS; i++) {
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getMessage().setBody("t" + thread);
+ exchange.getMessage().setHeader("n", i);
+ results.add(expression.evaluate(exchange,
String.class));
+ }
+ return results;
+ }));
+ }
+ for (int t = 0; t < THREADS; t++) {
+ List<String> results = futures.get(t).get(60,
TimeUnit.SECONDS);
+ assertThat(results).hasSize(ITERATIONS);
+ for (int i = 0; i < ITERATIONS; i++) {
+ assertThat(results.get(i)).isEqualTo("Hello t" + t + "
" + i);
+ }
+ }
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+ }
+
+ @Test
+ void languageWorksAgainAfterCamelContextRestart() throws Exception {
+ try (CamelContext context = new DefaultCamelContext()) {
+ context.start();
+ Language language = context.resolveLanguage("js");
+ Expression expression = language.createExpression("body + '!'");
+ assertThat(evaluate(context, expression,
"first")).isEqualTo("first!");
+
+ context.stop();
+ context.start();
+
+ // the expression created before the restart still works (the
engine is re-created lazily)
+ assertThat(evaluate(context, expression,
"second")).isEqualTo("second!");
+ // and so does a freshly resolved language
+ Expression fresh =
context.resolveLanguage("js").createExpression("body + '?'");
+ assertThat(evaluate(context, fresh, "third")).isEqualTo("third?");
+ }
+ }
+
+ @Test
+ void eachCamelContextHasItsOwnLanguageAndEngine() throws Exception {
+ try (CamelContext one = new DefaultCamelContext(); CamelContext two =
new DefaultCamelContext()) {
+ one.start();
+ two.start();
+ Language languageOne = one.resolveLanguage("js");
+ Language languageTwo = two.resolveLanguage("js");
+ assertThat(languageOne).isNotSameAs(languageTwo);
+
+ Expression expressionOne = languageOne.createExpression("body + '
one'");
+ Expression expressionTwo = languageTwo.createExpression("body + '
two'");
+ assertThat(evaluate(one, expressionOne, "a")).isEqualTo("a one");
+ assertThat(evaluate(two, expressionTwo, "b")).isEqualTo("b two");
+
+ // stopping one context closes only its engine; the other language
keeps working
+ one.stop();
+ assertThat(evaluate(two, expressionTwo, "c")).isEqualTo("c two");
+ }
+ }
+
+ private static String evaluate(CamelContext context, Expression
expression, String body) {
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getMessage().setBody(body);
+ return expression.evaluate(exchange, String.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 33e585b81506..de9321756174 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
@@ -651,6 +651,19 @@ id being a dynamic top-level JSON key. Anything that
parses this console's raw J
(custom tooling, or scripts calling the dev console HTTP endpoint) must be
updated to the new
shape. The `camel get variable` CLI command has already been updated
accordingly.
+=== camel-javascript
+
+The `js` language now shares one GraalJS `Engine` per language instance (a
`Context` is still created per
+evaluation), so the engine is built when the `CamelContext` starts instead of
on every evaluation.
+
+Values returned by a script are now converted to plain Java objects before the
script's `Context` is closed. Previously
+a JavaScript object, array, `Map`, `Set` or `Date` came back as a polyglot
`Value` bound to a `Context` that had already
+been closed, and reading it failed. A script now returns a `LinkedHashMap` for
an object or a JavaScript `Map`, an
+`ArrayList` for an array, a `LinkedHashSet` for a `Set` and a
`java.time.Instant` for a `Date`. Code that handled the
+polyglot `Value` itself needs to work with these types instead.
+
+`JavaScriptHelper.newContext()` is deprecated: contexts are created by the
language from its shared engine.
+
=== camel-jbang
The `--runtime` option of `camel run` has a new default value `jbang`, which
is the existing in-process