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 2efea47304e1 CAMEL-24687: camel-quickjs - recycle an engine once its
WebAssembly memory or evaluation count is exhausted (#26308)
2efea47304e1 is described below
commit 2efea47304e1e7f485a70403927a46c2738cfe1b
Author: Federico Mariani <[email protected]>
AuthorDate: Fri Sep 11 15:57:22 2026 +0200
CAMEL-24687: camel-quickjs - recycle an engine once its WebAssembly memory
or evaluation count is exhausted (#26308)
* CAMEL-24687: camel-quickjs - recycle an engine once its WebAssembly
memory or evaluation count is exhausted
QuickJS4J evaluates a module per invocation and QuickJS keeps evaluated
modules until its context
is freed, so an engine grows by about 12 KB per evaluation. Under an
8-thread load test a 1 GB heap
filled in 22 seconds (919 MB live after a full GC) and the JVM died with
OutOfMemoryError. A worker
thread's engine is now closed and recreated once its linear memory exceeds
engineMaxMemory (64 MB)
or after engineMaxEvaluations (50,000) evaluations; the memory is observed
through the Endive
memory factory.
* CAMEL-24687: camel-quickjs - count throwing evaluations towards
recycling, declare endive, document the properties
The exhaustion check ran on the success path only, so a script that throws
grew the engine without
ever recycling it (measured by the reviewer: +5.6 KB per failed
evaluation). The check now runs in
the finally block. run.endive:runtime is declared explicitly (aligned with
camel-wasm's endive
version), discard only closes an engine it removed from the queue, the
limits are volatile, and
the docs show how to set them with camel.language.quickjs.* properties.
---
.../camel/catalog/docs/quickjs-language.adoc | 17 +++
components/camel-quickjs/pom.xml | 6 +
.../src/main/docs/quickjs-language.adoc | 17 +++
.../camel/language/quickjs/QuickjsHelper.java | 9 +-
.../camel/language/quickjs/QuickjsLanguage.java | 76 +++++++++++-
.../quickjs/QuickjsEngineRecyclingTest.java | 138 +++++++++++++++++++++
6 files changed, 259 insertions(+), 4 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickjs-language.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickjs-language.adoc
index fc6baf58c54f..75e9e2158e18 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickjs-language.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickjs-language.adoc
@@ -99,6 +99,23 @@ As a predicate (`.when().quickjs(...)` or
`.filter().quickjs(...)`), the result
boolean with Camel's standard `ObjectHelper.evaluateValuePredicate` rules: a
`Boolean` is used
directly; the strings `true`/`false` are parsed; any other non-empty, non-null
value is true.
+== Engine lifecycle
+
+Every worker thread owns one QuickJS engine, created on first use and closed
when the language stops.
+QuickJS keeps every module it has evaluated until its context is freed, and
QuickJS4J evaluates a
+module per call, so an engine grows with every evaluation. The language
therefore recycles a
+thread's engine once its WebAssembly memory exceeds `engineMaxMemory` (64 MB)
or it has run
+`engineMaxEvaluations` (50,000) evaluations. Both are properties of
`QuickjsLanguage` and can be set like any
+language option, for example in `application.properties`:
+
+[source,properties]
+----
+camel.language.quickjs.engineMaxMemory = 134217728
+camel.language.quickjs.engineMaxEvaluations = 100000
+----
+
+or programmatically through `((QuickjsLanguage)
context.resolveLanguage("quickjs")).setEngineMaxMemory(...)`.
+
== Security
JavaScript runs in the QuickJS4J sandbox. The runtime does not expose Java
classes, reflection,
diff --git a/components/camel-quickjs/pom.xml b/components/camel-quickjs/pom.xml
index b7c6809f1f4e..618eb2490f61 100644
--- a/components/camel-quickjs/pom.xml
+++ b/components/camel-quickjs/pom.xml
@@ -45,6 +45,12 @@
<artifactId>quickjs4j</artifactId>
<version>${quickjs4j-version}</version>
</dependency>
+ <!-- the WebAssembly memory of an engine is observed through the
endive runtime API -->
+ <dependency>
+ <groupId>run.endive</groupId>
+ <artifactId>runtime</artifactId>
+ <version>${endive-version}</version>
+ </dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
diff --git a/components/camel-quickjs/src/main/docs/quickjs-language.adoc
b/components/camel-quickjs/src/main/docs/quickjs-language.adoc
index fc6baf58c54f..75e9e2158e18 100644
--- a/components/camel-quickjs/src/main/docs/quickjs-language.adoc
+++ b/components/camel-quickjs/src/main/docs/quickjs-language.adoc
@@ -99,6 +99,23 @@ As a predicate (`.when().quickjs(...)` or
`.filter().quickjs(...)`), the result
boolean with Camel's standard `ObjectHelper.evaluateValuePredicate` rules: a
`Boolean` is used
directly; the strings `true`/`false` are parsed; any other non-empty, non-null
value is true.
+== Engine lifecycle
+
+Every worker thread owns one QuickJS engine, created on first use and closed
when the language stops.
+QuickJS keeps every module it has evaluated until its context is freed, and
QuickJS4J evaluates a
+module per call, so an engine grows with every evaluation. The language
therefore recycles a
+thread's engine once its WebAssembly memory exceeds `engineMaxMemory` (64 MB)
or it has run
+`engineMaxEvaluations` (50,000) evaluations. Both are properties of
`QuickjsLanguage` and can be set like any
+language option, for example in `application.properties`:
+
+[source,properties]
+----
+camel.language.quickjs.engineMaxMemory = 134217728
+camel.language.quickjs.engineMaxEvaluations = 100000
+----
+
+or programmatically through `((QuickjsLanguage)
context.resolveLanguage("quickjs")).setEngineMaxMemory(...)`.
+
== Security
JavaScript runs in the QuickJS4J sandbox. The runtime does not expose Java
classes, reflection,
diff --git
a/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsHelper.java
b/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsHelper.java
index 3353de412896..0ee17537a417 100644
---
a/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsHelper.java
+++
b/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsHelper.java
@@ -41,6 +41,8 @@ import org.apache.camel.Message;
import org.apache.camel.RuntimeCamelException;
import org.apache.camel.StreamCache;
import org.apache.camel.util.StringHelper;
+import run.endive.runtime.ByteArrayMemory;
+import run.endive.runtime.Memory;
/**
* Helpers for evaluating JavaScript with QuickJS4J using JSON-serializable
Exchange bindings.
@@ -99,10 +101,15 @@ final class QuickjsHelper {
private QuickjsHelper() {
}
- static Engine newEngine(ByteArrayOutputStream stderr) {
+ static Engine newEngine(ByteArrayOutputStream stderr, Memory[] memory) {
return Engine.builder()
.withStdout(new DiscardingOutputStream())
.withStderr(stderr)
+ .withMemoryFactory(limits -> {
+ // keep a handle on the WebAssembly linear memory so the
language can watch it grow
+ memory[0] = new ByteArrayMemory(limits);
+ return memory[0];
+ })
.addInvokables(Invokables.builder(MODULE_NAME)
.add(evalFunction())
.build())
diff --git
a/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsLanguage.java
b/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsLanguage.java
index be3c3bd702cf..97a3a2c85730 100644
---
a/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsLanguage.java
+++
b/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsLanguage.java
@@ -34,6 +34,7 @@ import org.apache.camel.Service;
import org.apache.camel.spi.ScriptingLanguage;
import org.apache.camel.spi.annotations.Language;
import org.apache.camel.support.TypedLanguageSupport;
+import run.endive.runtime.Memory;
/**
* Camel expression language for JavaScript via <a
href="https://github.com/roastedroot/quickjs4j">QuickJS4J</a>.
@@ -47,6 +48,15 @@ import org.apache.camel.support.TypedLanguageSupport;
@Language("quickjs")
public class QuickjsLanguage extends TypedLanguageSupport implements
ScriptingLanguage, Service {
+ /**
+ * Every evaluation executes a module in the QuickJS runtime, and QuickJS
keeps evaluated modules until its context
+ * is freed, so an engine grows with every evaluation (about 12 KB each).
An engine is therefore recycled once its
+ * WebAssembly memory exceeds {@link #getEngineMaxMemory()} or it has run
{@link #getEngineMaxEvaluations()}
+ * evaluations: it is closed and the thread creates a fresh one on its
next evaluation.
+ */
+ private volatile long engineMaxMemory = 64L * 1024 * 1024;
+ private volatile int engineMaxEvaluations = 50_000;
+
private final AtomicInteger generation = new AtomicInteger();
private final ConcurrentLinkedQueue<Engine> engines = new
ConcurrentLinkedQueue<>();
private final ThreadLocal<EngineState> engine = new ThreadLocal<>();
@@ -138,7 +148,54 @@ public class QuickjsLanguage extends TypedLanguageSupport
implements ScriptingLa
} finally {
// Drop this evaluation's WASI stderr so a reused Engine cannot
accumulate it.
state.stderr.reset();
+ // a script that throws has still evaluated (and QuickJS kept) its
module: count it as well
+ if (state.exhausted(engineMaxMemory, engineMaxEvaluations)) {
+ discard(state);
+ }
+ }
+ }
+
+ /**
+ * Closes the calling thread's engine; the next evaluation on this thread
creates a fresh one.
+ */
+ private void discard(EngineState state) {
+ if (engine.get() == state) {
+ engine.remove();
}
+ // stop() may have polled this engine off the queue already and closed
it: only the remover closes
+ if (engines.remove(state.engine)) {
+ closeUnpublished(state.engine);
+ }
+ }
+
+ public long getEngineMaxMemory() {
+ return engineMaxMemory;
+ }
+
+ /**
+ * Recycle a worker thread's engine once its WebAssembly memory exceeds
this many bytes (default 64 MB).
+ */
+ public void setEngineMaxMemory(long engineMaxMemory) {
+ this.engineMaxMemory = engineMaxMemory;
+ }
+
+ public int getEngineMaxEvaluations() {
+ return engineMaxEvaluations;
+ }
+
+ /**
+ * Recycle a worker thread's engine after this many evaluations (default
50,000).
+ */
+ public void setEngineMaxEvaluations(int engineMaxEvaluations) {
+ this.engineMaxEvaluations = engineMaxEvaluations;
+ }
+
+ /**
+ * WebAssembly memory of the calling thread's engine, in bytes (for tests).
+ */
+ long engineMemory() {
+ EngineState state = engine.get();
+ return state == null ? 0 : state.memoryBytes();
}
private EngineState currentEngine() {
@@ -155,7 +212,8 @@ public class QuickjsLanguage extends TypedLanguageSupport
implements ScriptingLa
return state;
}
ByteArrayOutputStream stderr = new ByteArrayOutputStream();
- Engine created = QuickjsHelper.newEngine(stderr);
+ Memory[] memory = new Memory[1];
+ Engine created = QuickjsHelper.newEngine(stderr, memory);
if (generation.get() != gen) {
closeUnpublished(created);
return currentEngine(attempt + 1);
@@ -164,7 +222,7 @@ public class QuickjsLanguage extends TypedLanguageSupport
implements ScriptingLa
try {
if (generation.get() == gen) {
engines.add(created);
- state = new EngineState(gen, created, stderr);
+ state = new EngineState(gen, created, stderr, memory[0]);
engine.set(state);
return state;
}
@@ -211,11 +269,23 @@ public class QuickjsLanguage extends TypedLanguageSupport
implements ScriptingLa
private final int generation;
private final Engine engine;
private final ByteArrayOutputStream stderr;
+ private final Memory memory;
+ private int evaluations;
- private EngineState(int generation, Engine engine,
ByteArrayOutputStream stderr) {
+ private EngineState(int generation, Engine engine,
ByteArrayOutputStream stderr, Memory memory) {
this.generation = generation;
this.engine = engine;
this.stderr = stderr;
+ this.memory = memory;
+ }
+
+ long memoryBytes() {
+ return memory == null ? 0 : (long) memory.pages() *
Memory.PAGE_SIZE;
+ }
+
+ boolean exhausted(long maxMemory, int maxEvaluations) {
+ evaluations++;
+ return evaluations >= maxEvaluations || memoryBytes() > maxMemory;
}
}
}
diff --git
a/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsEngineRecyclingTest.java
b/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsEngineRecyclingTest.java
new file mode 100644
index 000000000000..55dd3234b8ed
--- /dev/null
+++
b/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsEngineRecyclingTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.quickjs;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.ExpressionEvaluationException;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * QuickJS keeps every evaluated module, so an engine grows with each
evaluation; the language recycles it.
+ */
+class QuickjsEngineRecyclingTest {
+
+ private static CamelContext context;
+ private static QuickjsLanguage language;
+
+ @BeforeAll
+ static void startContext() {
+ context = new DefaultCamelContext();
+ context.start();
+ language = (QuickjsLanguage) context.resolveLanguage("quickjs");
+ }
+
+ @AfterAll
+ static void stopContext() {
+ context.stop();
+ }
+
+ private static Exchange exchange(Object body) {
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getMessage().setBody(body);
+ return exchange;
+ }
+
+ @Test
+ void engineMemoryGrowsWithEvaluations() {
+ Exchange exchange = exchange(1);
+ language.createExpression("body + 1").evaluate(exchange,
Integer.class);
+ long before = language.engineMemory();
+ assertThat(before).isPositive();
+ for (int i = 0; i < 2000; i++) {
+ language.createExpression("body + " + i).evaluate(exchange,
Integer.class);
+ }
+ assertThat(language.engineMemory()).isGreaterThan(before);
+ }
+
+ @Test
+ void engineIsRecycledAfterMaxEvaluations() {
+ Exchange exchange = exchange(1);
+ int max = language.getEngineMaxEvaluations();
+ long maxMemory = language.getEngineMaxMemory();
+ try {
+ // start from no engine on this thread: whatever an earlier test
left behind is discarded here
+ language.setEngineMaxMemory(1);
+ language.createExpression("body").evaluate(exchange,
Integer.class);
+ language.setEngineMaxMemory(maxMemory);
+ assertThat(language.trackedEngineCount()).isZero();
+ language.setEngineMaxEvaluations(10);
+ for (int i = 0; i < 9; i++) {
+ assertThat(language.createExpression("body + " +
i).evaluate(exchange, Integer.class)).isEqualTo(1 + i);
+ assertThat(language.trackedEngineCount()).isEqualTo(1);
+ }
+ // the 10th evaluation exhausts the engine: it is closed and the
thread has none until the next call
+ assertThat(language.createExpression("body +
9").evaluate(exchange, Integer.class)).isEqualTo(10);
+ assertThat(language.trackedEngineCount()).isZero();
+ assertThat(language.createExpression("body +
100").evaluate(exchange, Integer.class)).isEqualTo(101);
+ assertThat(language.trackedEngineCount()).isEqualTo(1);
+ } finally {
+ language.setEngineMaxEvaluations(max);
+ language.setEngineMaxMemory(maxMemory);
+ }
+ }
+
+ @Test
+ void throwingScriptsCountTowardsRecycling() {
+ Exchange exchange = exchange(1);
+ int max = language.getEngineMaxEvaluations();
+ long maxMemory = language.getEngineMaxMemory();
+ try {
+ language.setEngineMaxMemory(1);
+ language.createExpression("body").evaluate(exchange,
Integer.class);
+ language.setEngineMaxMemory(maxMemory);
+ assertThat(language.trackedEngineCount()).isZero();
+ language.setEngineMaxEvaluations(10);
+ for (int i = 0; i < 9; i++) {
+ assertThatThrownBy(() -> language.createExpression("notDefined
+ 1").evaluate(exchange, Object.class))
+ .isInstanceOf(ExpressionEvaluationException.class);
+ assertThat(language.trackedEngineCount()).isEqualTo(1);
+ }
+ // the 10th failed evaluation exhausts the engine like a
successful one would
+ assertThatThrownBy(() -> language.createExpression("notDefined +
1").evaluate(exchange, Object.class))
+ .isInstanceOf(ExpressionEvaluationException.class);
+ assertThat(language.trackedEngineCount()).isZero();
+ assertThat(language.createExpression("body +
1").evaluate(exchange, Integer.class)).isEqualTo(2);
+ assertThat(language.trackedEngineCount()).isEqualTo(1);
+ } finally {
+ language.setEngineMaxEvaluations(max);
+ language.setEngineMaxMemory(maxMemory);
+ }
+ }
+
+ @Test
+ void engineIsRecycledAfterMaxMemory() {
+ Exchange exchange = exchange(1);
+ long max = language.getEngineMaxMemory();
+ try {
+ language.setEngineMaxMemory(1);
+ language.createExpression("body").evaluate(exchange,
Integer.class);
+ assertThat(language.trackedEngineCount()).isZero();
+ } finally {
+ language.setEngineMaxMemory(max);
+ }
+ assertThat(language.createExpression("body + 1").evaluate(exchange,
Integer.class)).isEqualTo(2);
+ assertThat(language.trackedEngineCount()).isEqualTo(1);
+ }
+}