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);
+    }
+}

Reply via email to