perf: 优化 JavaScript 代码节点执行与错误定位
- 复用 Graal Engine 与分段有界 Source 缓存,保持执行 Context 隔离 - 增加并发超时取消、脚本行列错误及参数解析复用
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 代码节点。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user