perf: 优化 JavaScript 代码节点执行与错误定位

- 复用 Graal Engine 与分段有界 Source 缓存,保持执行 Context 隔离

- 增加并发超时取消、脚本行列错误及参数解析复用
This commit is contained in:
2026-08-03 11:17:44 +08:00
parent fd3d9ad419
commit f0a5aacc92
6 changed files with 669 additions and 18 deletions

View File

@@ -20,6 +20,37 @@ import com.easyagents.flow.core.node.CodeNode;
import java.util.Map;
/**
* 代码节点运行时引擎。
*/
public interface CodeRuntimeEngine {
/**
* 执行代码节点。
*
* @param code 解析模板后的代码
* @param node 当前代码节点
* @param chain 当前工作流
* @return 节点输出
*/
Map<String, Object> execute(String code, CodeNode node, Chain chain);
/**
* 使用已经解析的节点参数执行代码。
*
* <p>默认委托旧接口,保持自定义运行时引擎兼容。内置引擎可覆盖该方法,
* 避免重复解析节点参数。</p>
*
* @param code 解析模板后的代码
* @param node 当前代码节点
* @param chain 当前工作流
* @param parameterValues 已解析的节点参数
* @return 节点输出
*/
default Map<String, Object> execute(String code,
CodeNode node,
Chain chain,
Map<String, Object> parameterValues) {
return execute(code, node, chain);
}
}

View File

@@ -20,16 +20,23 @@ import com.easyagents.flow.core.code.impl.JavascriptRuntimeEngine;
import java.util.ArrayList;
import java.util.List;
/**
* 管理代码节点运行时引擎提供者。
*/
public class CodeRuntimeEngineManager {
public List<CodeRuntimeEngineProvider> providers = new ArrayList<>();
/**
* JavaScript 引擎实例,可在业务启动阶段配置超时后替换。
*/
private volatile JavascriptRuntimeEngine javascriptRuntimeEngine;
private static class ManagerHolder {
private static final CodeRuntimeEngineManager INSTANCE = new CodeRuntimeEngineManager();
}
private CodeRuntimeEngineManager() {
JavascriptRuntimeEngine javascriptRuntimeEngine = new JavascriptRuntimeEngine();
javascriptRuntimeEngine = new JavascriptRuntimeEngine();
providers.add(engineId -> {
if ("js".equals(engineId) || "javascript".equals(engineId)) {
return javascriptRuntimeEngine;
@@ -38,18 +45,50 @@ public class CodeRuntimeEngineManager {
});
}
/**
* 获取全局代码运行时管理器。
*
* @return 代码运行时管理器
*/
public static CodeRuntimeEngineManager getInstance() {
return ManagerHolder.INSTANCE;
}
/**
* 配置 JavaScript 代码执行超时。
*
* @param timeoutMs 超时时间0 表示关闭超时
* @throws IllegalArgumentException 超时时间为负数时抛出
*/
public void configureJavascriptRuntimeEngine(long timeoutMs) {
javascriptRuntimeEngine =
new JavascriptRuntimeEngine(timeoutMs);
}
/**
* 注册代码运行时提供者。
*
* @param provider 代码运行时提供者
*/
public void registerProvider(CodeRuntimeEngineProvider provider) {
providers.add(provider);
}
/**
* 移除代码运行时提供者。
*
* @param provider 代码运行时提供者
*/
public void removeProvider(CodeRuntimeEngineProvider provider) {
providers.remove(provider);
}
/**
* 按引擎标识获取代码运行时。
*
* @param engineId 引擎标识
* @return 匹配的代码运行时;未找到时返回 {@code null}
*/
public CodeRuntimeEngine getCodeRuntimeEngine(Object engineId) {
for (CodeRuntimeEngineProvider provider : providers) {
CodeRuntimeEngine codeRuntimeEngine = provider.getCodeRuntimeEngine(engineId);

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
* <p>
* Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl-3.0.txt
* <p>
* 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 com.easyagents.flow.core.code.impl;
/**
* 可直接反馈给工作流使用者的 JavaScript 执行异常。
*/
public class JavascriptExecutionException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* 创建 JavaScript 执行异常。
*
* @param message 面向使用者的错误信息
* @param cause 原始执行异常
*/
public JavascriptExecutionException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -23,11 +23,23 @@ import com.easyagents.flow.core.code.CodeRuntimeEngine;
import com.easyagents.flow.core.node.CodeNode;
import com.easyagents.flow.core.util.graalvm.JsInteropUtils;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Engine;
import org.graalvm.polyglot.HostAccess;
import org.graalvm.polyglot.PolyglotException;
import org.graalvm.polyglot.Source;
import org.graalvm.polyglot.SourceSection;
import org.graalvm.polyglot.Value;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 基于 GraalVM 的 JavaScript 代码节点执行器。
@@ -38,26 +50,93 @@ import java.util.Map;
*/
public class JavascriptRuntimeEngine implements CodeRuntimeEngine {
// 使用 Context.Builder 构建上下文,线程安全
private static final Context.Builder CONTEXT_BUILDER = Context.newBuilder("js")
private static final int SOURCE_CACHE_LIMIT = 512;
private static final int SOURCE_CACHE_SEGMENT_COUNT = 16;
private static final int SOURCE_CACHE_SEGMENT_LIMIT =
SOURCE_CACHE_LIMIT / SOURCE_CACHE_SEGMENT_COUNT;
private static final int MAX_CACHEABLE_SOURCE_CHARS = 16 * 1024;
private static final int TIMEOUT_EXECUTOR_THREADS =
Math.max(2, Math.min(
Runtime.getRuntime().availableProcessors(), 8));
private static final String SOURCE_NAME =
"workflow-code-node.js";
private static final AtomicInteger TIMEOUT_THREAD_SEQUENCE =
new AtomicInteger();
/**
* Engine 跨隔离 Context 共享编译缓存。
*/
private static final Engine ENGINE = Engine.newBuilder()
.option("engine.WarnInterpreterOnly", "false")
.allowHostAccess(HostAccess.ALL) // 允许访问 Java 对象的方法和字段
.allowHostClassLookup(className -> false) // 禁止动态加载任意 Java 类
.option("js.ecmascript-version", "2021"); // 使用较新的 ECMAScript 版本
.build();
private static final Source RESULT_INIT_SOURCE =
Source.create("js", "var _result = {};");
private static final List<Map<String, Source>> SOURCE_CACHE_SEGMENTS =
createSourceCacheSegments();
private static final ScheduledThreadPoolExecutor
TIMEOUT_EXECUTOR = createTimeoutExecutor();
/**
* 执行 JavaScript 代码并返回节点输出
* 单次 JavaScript 执行超时0 表示关闭
*/
private final long timeoutMs;
/**
* 创建不启用超时的 JavaScript 执行器。
*/
public JavascriptRuntimeEngine() {
this(0L);
}
/**
* 创建 JavaScript 执行器。
*
* @param timeoutMs 单次执行超时0 表示关闭
* @throws IllegalArgumentException 超时时间为负数时抛出
*/
public JavascriptRuntimeEngine(long timeoutMs) {
if (timeoutMs < 0L) {
throw new IllegalArgumentException(
"JavaScript 执行超时时间不能为负数");
}
this.timeoutMs = timeoutMs;
}
/**
* 执行 JavaScript 代码并返回节点输出,兼容直接调用旧接口的场景。
*
* @param code 用户代码
* @param node 当前代码节点
* @param chain 当前工作流
* @return 代码节点输出
* @throws RuntimeException JavaScript 执行失败或 main 返回值不是对象时抛出
* @throws JavascriptExecutionException JavaScript 执行失败时抛出
*/
@Override
public Map<String, Object> execute(String code, CodeNode node, Chain chain) {
try (Context context = CONTEXT_BUILDER.build()) {
Map<String, Object> parameterValues =
chain.getExecutionState().resolveParameters(node);
return execute(code, node, chain, parameterValues);
}
/**
* 使用已经解析的节点参数执行 JavaScript 代码。
*
* @param code 用户代码
* @param node 当前代码节点
* @param chain 当前工作流
* @param parameterValues 已解析的节点参数
* @return 代码节点输出
* @throws JavascriptExecutionException JavaScript 执行失败时抛出
*/
@Override
public Map<String, Object> execute(String code,
CodeNode node,
Chain chain,
Map<String, Object> parameterValues) {
Context context = createContext();
AtomicBoolean timedOut = new AtomicBoolean(false);
ScheduledFuture<?> timeoutFuture =
scheduleTimeout(context, timedOut);
try (Context ignored = context) {
Value bindings = context.getBindings("js");
ChainState chainState =
chain.getExecutionState();
@@ -72,9 +151,6 @@ public class JavascriptRuntimeEngine implements CodeRuntimeEngine {
}
});
// 注入参数
Map<String, Object> parameterValues =
chainState.resolveParameters(node);
if (parameterValues != null) {
for (Map.Entry<String, Object> entry : parameterValues.entrySet()) {
bindings.putMember(entry.getKey(), JsInteropUtils.wrapJavaValueForJS(context, entry.getValue()));
@@ -82,20 +158,230 @@ public class JavascriptRuntimeEngine implements CodeRuntimeEngine {
}
// 在 JS 中创建 _result 对象
context.eval("js", "var _result = {};");
context.eval(RESULT_INIT_SOURCE);
// 注入 _chain 和 _context
bindings.putMember("_chain", chain);
bindings.putMember("_state", nodeState);
// 执行用户脚本
context.eval("js", code);
context.eval(source(code, node));
return resolveResult(context, bindings, parameterValues);
} catch (PolyglotException e) {
throw executionException(e, timedOut.get());
} catch (Exception e) {
throw new RuntimeException("Polyglot JS 脚本执行失败: " + e.getMessage(), e);
if (timedOut.get()) {
throw timeoutException(e);
}
throw new JavascriptExecutionException(
"JavaScript 执行失败:" + safeMessage(e),
e);
} finally {
if (timeoutFuture != null) {
timeoutFuture.cancel(false);
}
}
}
/**
* 创建一次隔离的 JavaScript Context。
*
* @return 新的 JavaScript Context
*/
private Context createContext() {
return Context.newBuilder("js")
.engine(ENGINE)
.allowHostAccess(HostAccess.ALL)
.allowHostClassLookup(className -> false)
.option("js.ecmascript-version", "2021")
.build();
}
/**
* 获取可复用的用户脚本 Source。
*
* <p>模板化脚本可能随输入生成大量不同源码,因此不进入共享缓存。
* 超长脚本同样跳过缓存,限制源码与编译元数据占用。</p>
*
* @param code 解析模板后的代码
* @param node 当前代码节点
* @return GraalVM Source
*/
private Source source(String code, CodeNode node) {
String originalCode =
node == null ? null : node.getCode();
boolean dynamicTemplate = originalCode != null
&& originalCode.contains("{{");
if (dynamicTemplate
|| code.length() > MAX_CACHEABLE_SOURCE_CHARS) {
return Source.newBuilder("js", code, SOURCE_NAME)
.cached(false)
.buildLiteral();
}
Map<String, Source> sourceCache =
SOURCE_CACHE_SEGMENTS.get(
Math.floorMod(
code.hashCode(),
SOURCE_CACHE_SEGMENT_COUNT));
synchronized (sourceCache) {
return sourceCache.computeIfAbsent(
code,
script -> Source.newBuilder(
"js",
script,
SOURCE_NAME)
.cached(true)
.buildLiteral());
}
}
/**
* 创建分段有界的 Source 缓存。
*
* <p>每个分段独立维护 LRU 淘汰,避免所有工作流竞争同一把锁,
* 同时保证总缓存条目不超过全局上限。</p>
*
* @return Source 缓存分段
*/
private static List<Map<String, Source>>
createSourceCacheSegments() {
List<Map<String, Source>> segments =
new ArrayList<>(SOURCE_CACHE_SEGMENT_COUNT);
for (int index = 0;
index < SOURCE_CACHE_SEGMENT_COUNT;
index++) {
segments.add(new LinkedHashMap<>(
SOURCE_CACHE_SEGMENT_LIMIT + 1,
0.75F,
true) {
@Override
protected boolean removeEldestEntry(
Map.Entry<String, Source> eldest) {
return size()
> SOURCE_CACHE_SEGMENT_LIMIT;
}
});
}
return Collections.unmodifiableList(segments);
}
/**
* 安排单次执行超时。
*
* @param context 待取消的 Context
* @param timedOut 超时状态
* @return 超时任务;关闭超时时返回 {@code null}
*/
private ScheduledFuture<?> scheduleTimeout(
Context context,
AtomicBoolean timedOut) {
if (timeoutMs <= 0L) {
return null;
}
return TIMEOUT_EXECUTOR.schedule(() -> {
timedOut.set(true);
context.close(true);
}, timeoutMs, TimeUnit.MILLISECONDS);
}
/**
* 将 GraalVM 异常转换为用户可定位的脚本异常。
*
* @param error GraalVM 异常
* @param timedOut 是否由超时任务取消
* @return JavaScript 执行异常
*/
private JavascriptExecutionException executionException(
PolyglotException error,
boolean timedOut) {
if (timedOut) {
return timeoutException(error);
}
String errorType = error.isSyntaxError()
? "JavaScript 语法错误"
: "JavaScript 执行失败";
SourceSection location = sourceLocation(error);
String locationText = location == null
? ""
: "(第 " + location.getStartLine()
+ " 行,第 " + location.getStartColumn() + " 列)";
return new JavascriptExecutionException(
errorType + locationText + "" + safeMessage(error),
error);
}
/**
* 获取异常对应的用户脚本位置。
*
* <p>部分 GraalVM 版本不会在运行时异常上直接设置位置,
* 此时从首个 guest stack frame 补取。</p>
*
* @param error GraalVM 异常
* @return 用户脚本位置;无法定位时返回 {@code null}
*/
private SourceSection sourceLocation(PolyglotException error) {
SourceSection location = error.getSourceLocation();
if (location != null) {
return location;
}
for (PolyglotException.StackFrame frame
: error.getPolyglotStackTrace()) {
if (frame.isGuestFrame()
&& frame.getSourceLocation() != null) {
return frame.getSourceLocation();
}
}
return null;
}
/**
* 创建 JavaScript 执行超时异常。
*
* @param cause 超时取消产生的异常
* @return JavaScript 执行异常
*/
private JavascriptExecutionException timeoutException(
Throwable cause) {
return new JavascriptExecutionException(
"JavaScript 执行超时(" + timeoutMs + "ms",
cause);
}
/**
* 获取稳定的异常消息。
*
* @param error 原始异常
* @return 非空异常消息
*/
private String safeMessage(Throwable error) {
String message = error.getMessage();
return message == null || message.isBlank()
? error.getClass().getSimpleName()
: message;
}
/**
* 创建超时调度器。
*
* @return 有界多线程守护调度器
*/
private static ScheduledThreadPoolExecutor
createTimeoutExecutor() {
ScheduledThreadPoolExecutor executor =
new ScheduledThreadPoolExecutor(
TIMEOUT_EXECUTOR_THREADS,
task -> {
Thread thread = new Thread(
task,
"javascript-runtime-timeout-"
+ TIMEOUT_THREAD_SEQUENCE
.incrementAndGet());
thread.setDaemon(true);
return thread;
});
executor.setRemoveOnCancelPolicy(true);
return executor;
}
/**

View File

@@ -63,7 +63,11 @@ public class CodeNode extends BaseNode {
if (codeRuntimeEngine == null) {
throw new IllegalArgumentException("code runtime engine not found: " + this.engine);
}
return codeRuntimeEngine.execute(newCode, this, chain);
return codeRuntimeEngine.execute(
newCode,
this,
chain,
parameterValues);
}
}

View File

@@ -6,14 +6,25 @@ import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository;
import com.easyagents.flow.core.code.impl.JavascriptExecutionException;
import com.easyagents.flow.core.code.impl.JavascriptRuntimeEngine;
import com.easyagents.flow.core.node.CodeNode;
import org.junit.Assert;
import org.junit.Test;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
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.stream.Collectors;
/**
@@ -82,6 +93,252 @@ public class JavascriptRuntimeEngineTest {
}
}
/**
* 验证语法错误包含可定位的脚本行列信息。
*/
@Test
public void shouldExposeSyntaxErrorLocation() {
CodeNode node = codeNode("const answer = ;");
Chain chain = chain(Collections.emptyMap());
try {
node.execute(chain);
Assert.fail("JavaScript 语法错误应执行失败");
} catch (JavascriptExecutionException exception) {
Assert.assertTrue(exception.getMessage().contains(
"JavaScript 语法错误"));
Assert.assertTrue(exception.getMessage().contains("第 1 行"));
}
}
/**
* 验证运行时异常包含原始错误和脚本位置。
*/
@Test
public void shouldExposeRuntimeErrorLocation() {
CodeNode node = codeNode("throw new Error('boom');");
Chain chain = chain(Collections.emptyMap());
try {
node.execute(chain);
Assert.fail("JavaScript 运行时异常应执行失败");
} catch (JavascriptExecutionException exception) {
Assert.assertTrue(exception.getMessage().contains(
"JavaScript 执行失败"));
Assert.assertTrue(exception.getMessage().contains("第 1 行"));
Assert.assertTrue(exception.getMessage().contains("boom"));
}
}
/**
* 验证无限循环会在配置时间内被取消。
*/
@Test
public void shouldCancelExecutionAfterTimeout() {
JavascriptRuntimeEngine engine =
new JavascriptRuntimeEngine(100L);
CodeNode node = codeNode("while (true) {}");
Chain chain = chain(Collections.emptyMap());
long startedAt = System.currentTimeMillis();
try {
engine.execute(
node.getCode(),
node,
chain,
Collections.emptyMap());
Assert.fail("JavaScript 超时应执行失败");
} catch (JavascriptExecutionException exception) {
Assert.assertTrue(exception.getMessage().contains(
"JavaScript 执行超时100ms"));
Assert.assertTrue(
"超时取消应在合理时间内完成",
System.currentTimeMillis() - startedAt < 3000L);
}
}
/**
* 验证负数超时不会静默关闭执行保护。
*/
@Test
public void shouldRejectNegativeTimeout() {
try {
new JavascriptRuntimeEngine(-1L);
Assert.fail("负数超时应被拒绝");
} catch (IllegalArgumentException exception) {
Assert.assertTrue(exception.getMessage().contains(
"不能为负数"));
}
}
/**
* 验证多个无限循环可并发触发超时取消。
*
* @throws Exception 并发任务执行失败时抛出
*/
@Test
public void shouldCancelConcurrentExecutionsAfterTimeout()
throws Exception {
int executionCount = 8;
JavascriptRuntimeEngine engine =
new JavascriptRuntimeEngine(100L);
ExecutorService executor =
Executors.newFixedThreadPool(executionCount);
CountDownLatch ready =
new CountDownLatch(executionCount);
CountDownLatch start = new CountDownLatch(1);
List<Future<String>> futures =
new ArrayList<>(executionCount);
try {
for (int index = 0;
index < executionCount;
index++) {
final int executionIndex = index;
futures.add(executor.submit(() -> {
CodeNode node = codeNode("while (true) {}");
node.setId("timeout-node-" + executionIndex);
Chain chain = chain(Collections.emptyMap());
ready.countDown();
start.await();
try {
engine.execute(
node.getCode(),
node,
chain,
Collections.emptyMap());
return "未触发超时";
} catch (JavascriptExecutionException exception) {
return exception.getMessage();
}
}));
}
Assert.assertTrue(
"并发执行未及时就绪",
ready.await(2L, TimeUnit.SECONDS));
long startedAt = System.currentTimeMillis();
start.countDown();
for (Future<String> future : futures) {
Assert.assertTrue(
future.get(3L, TimeUnit.SECONDS)
.contains("JavaScript 执行超时100ms"));
}
Assert.assertTrue(
"并发超时取消应在合理时间内完成",
System.currentTimeMillis() - startedAt < 3000L);
} finally {
start.countDown();
executor.shutdownNow();
}
}
/**
* 验证分段 Source 缓存在并发未命中时保持结果正确。
*
* @throws Exception 并发任务执行失败时抛出
*/
@Test
public void shouldExecuteCacheMissesConcurrently()
throws Exception {
int executionCount = 32;
JavascriptRuntimeEngine engine =
new JavascriptRuntimeEngine();
ExecutorService executor =
Executors.newFixedThreadPool(8);
List<Future<Long>> futures =
new ArrayList<>(executionCount);
try {
for (int index = 0;
index < executionCount;
index++) {
final int expected = index;
futures.add(executor.submit(() -> {
CodeNode node = codeNode(
"_result.value = " + expected + ";");
node.setId("cache-node-" + expected);
Map<String, Object> result = engine.execute(
node.getCode(),
node,
chain(Collections.emptyMap()),
Collections.emptyMap());
return ((Number) result.get("value"))
.longValue();
}));
}
for (int index = 0;
index < executionCount;
index++) {
Assert.assertEquals(
index,
futures.get(index)
.get(3L, TimeUnit.SECONDS)
.longValue());
}
} finally {
executor.shutdownNow();
}
}
/**
* 验证分段缓存总条目数始终受上限约束。
*
* @throws Exception 反射访问缓存失败时抛出
*/
@Test
@SuppressWarnings("unchecked")
public void shouldKeepSourceCacheBounded()
throws Exception {
JavascriptRuntimeEngine engine =
new JavascriptRuntimeEngine();
Method sourceMethod = JavascriptRuntimeEngine.class
.getDeclaredMethod(
"source",
String.class,
CodeNode.class);
sourceMethod.setAccessible(true);
CodeNode node = codeNode("");
for (int index = 0; index < 1024; index++) {
String code = "_result.value = " + index + ";";
node.setCode(code);
sourceMethod.invoke(engine, code, node);
}
Field cacheField = JavascriptRuntimeEngine.class
.getDeclaredField("SOURCE_CACHE_SEGMENTS");
cacheField.setAccessible(true);
List<Map<String, Object>> segments =
(List<Map<String, Object>>) cacheField.get(null);
int cachedSourceCount = segments.stream()
.mapToInt(Map::size)
.sum();
Assert.assertTrue(cachedSourceCount <= 512);
Assert.assertTrue(
segments.stream()
.allMatch(segment ->
segment.size() <= 32));
}
/**
* 验证共享 Engine 下不同执行仍保持全局变量隔离。
*/
@Test
public void shouldKeepExecutionContextsIsolated() {
CodeNode node = codeNode(String.join("\n",
"if (typeof globalCounter === 'undefined') {",
" globalCounter = 0;",
"}",
"globalCounter += 1;",
"_result.counter = globalCounter;"));
Map<String, Object> first =
node.execute(chain(Collections.emptyMap()));
Map<String, Object> second =
node.execute(chain(Collections.emptyMap()));
Assert.assertEquals(1L, first.get("counter"));
Assert.assertEquals(1L, second.get("counter"));
}
/**
* 创建 JavaScript 代码节点。
*