perf: 收敛工作流状态与高 IO 节点开销

- 落地 Redis 版本状态、触发租约和定义缓存

- 优化数据批写、插件请求、文件下载与审计日志

- 补齐循环范围校验、轮询兼容和专项测试
This commit is contained in:
2026-07-29 00:47:48 +08:00
parent 5ee6065017
commit 1ae8a22afe
103 changed files with 15600 additions and 532 deletions

View File

@@ -120,6 +120,11 @@
<artifactId>spring-boot-actuator</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
<version>1.15.7</version>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>

View File

@@ -14,6 +14,7 @@ import org.slf4j.LoggerFactory;
import tech.easyflow.ai.easyagents.CustomMultipartFile;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.node.TemporaryFileMultipartFile;
import tech.easyflow.ai.mapper.PluginMapper;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.common.ai.plugin.NestedParamConverter;
@@ -23,10 +24,13 @@ import tech.easyflow.common.ai.plugin.PluginParamConverter;
import tech.easyflow.common.filestorage.FileStorageManager;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.util.SpringContextUtil;
import com.easyagents.flow.core.util.IoBulkhead;
import java.io.*;
import java.lang.reflect.Array;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
public class PluginTool extends BaseTool {
@@ -36,6 +40,8 @@ public class PluginTool extends BaseTool {
private String name;
private String description;
private Parameter[] parameters;
private transient PluginItem pluginItemSnapshot;
private transient Plugin pluginSnapshot;
private static final Logger logger = LoggerFactory.getLogger(PluginTool.class);
public PluginTool() {
@@ -43,9 +49,21 @@ public class PluginTool extends BaseTool {
}
public PluginTool(PluginItem pluginItem) {
this(pluginItem, null);
}
/**
* 使用已加载实体快照创建插件工具。
*
* @param pluginItem 插件项快照
* @param plugin 插件快照,可为空
*/
public PluginTool(PluginItem pluginItem, Plugin plugin) {
this.name = pluginItem.getEnglishName();
this.description = pluginItem.getDescription();
this.pluginToolId = pluginItem.getId();
this.pluginItemSnapshot = pluginItem;
this.pluginSnapshot = plugin;
this.parameters = getDefaultParameters(pluginItem.getInputData());
}
@@ -80,18 +98,7 @@ public class PluginTool extends BaseTool {
}
private Parameter[] getDefaultParameters(String inputData) {
PluginItemService pluginToolService = SpringContextUtil.getBean(PluginItemService.class);
QueryWrapper queryAiPluginToolWrapper = QueryWrapper.create()
.select("*")
.from("tb_plugin_item")
.where("id = ? ", this.pluginToolId);
PluginItem pluginItem = pluginToolService.getMapper().selectOneByQuery(queryAiPluginToolWrapper);
List<Map<String, Object>> dataList = null;
if (pluginItem == null || pluginItem.getInputData() == null){
dataList = getDataList(inputData);
} else {
dataList = getDataList(pluginItem.getInputData());
}
List<Map<String, Object>> dataList = getDataList(inputData);
Parameter[] params = new Parameter[dataList.size()];
for (int i = 0; i < dataList.size(); i++) {
Map<String, Object> item = dataList.get(i);
@@ -147,14 +154,16 @@ public class PluginTool extends BaseTool {
}
public Object runPluginTool(Map<String, Object> argsMap, String inputData, BigInteger pluginId){
PluginItemService pluginToolService = SpringContextUtil.getBean(PluginItemService.class);
QueryWrapper queryAiPluginToolWrapper = QueryWrapper.create()
.select("*")
.from("tb_plugin_item")
.where("id = ? ", pluginId);
PluginItem pluginItem = pluginToolService.getMapper().selectOneByQuery(queryAiPluginToolWrapper);
PluginItem pluginItem = pluginItemSnapshot != null
&& Objects.equals(pluginItemSnapshot.getId(), pluginId)
? pluginItemSnapshot
: loadPluginItem(pluginId);
String method = pluginItem.getRequestMethod().toUpperCase();
Plugin plugin = getAiPlugin(pluginItem.getPluginId());
Plugin plugin = pluginSnapshot != null
&& Objects.equals(
pluginSnapshot.getId(), pluginItem.getPluginId())
? pluginSnapshot
: getAiPlugin(pluginItem.getPluginId());
String url;
if (!StrUtil.isEmpty(pluginItem.getBasePath())) {
@@ -200,6 +209,8 @@ public class PluginTool extends BaseTool {
List<PluginParam> pathParams = new ArrayList<>();
Map<String, Object> nestedParams = NestedParamConverter.convertToNestedParamMap(pluginParams);
List<Path> temporaryFiles = new ArrayList<>();
try {
// 遍历嵌套参数
for (Map.Entry<String, Object> entry : nestedParams.entrySet()) {
String paramName = entry.getKey();
@@ -234,15 +245,37 @@ public class PluginTool extends BaseTool {
// 如果是文件类型
if (originalParam.getType().equals("File")){
try {
FileStorageService fileStorageService = SpringContextUtil.getBean(FileStorageManager.class);
InputStream inputStream = fileStorageService.readStream((String)originalParam.getDefaultValue());
requestParam.setType("MultipartFile");
byte[] bytes = inputStreamToBytes(inputStream);
String contentType = FileTypeUtil.getType(new ByteArrayInputStream(bytes));
String fileUrl = (String) originalParam.getDefaultValue();
FileStorageService fileStorageService =
SpringContextUtil.getBean(
FileStorageManager.class);
String fileUrl =
(String) originalParam.getDefaultValue();
int lastSlashIndex = fileUrl.lastIndexOf("/");
String fileName = fileUrl.substring(lastSlashIndex + 1);
requestParam.setDefaultValue(new CustomMultipartFile(bytes, originalParam.getName(), fileName, contentType));
String fileName =
fileUrl.substring(lastSlashIndex + 1);
Path temporaryFile = Files.createTempFile(
"easyflow-plugin-", ".upload");
try (IoBulkhead.Permit ignored =
IoBulkhead.storage().acquire(
"storage:plugin-read");
InputStream inputStream =
fileStorageService.readStream(fileUrl);
OutputStream outputStream =
Files.newOutputStream(temporaryFile)) {
copyBounded(
inputStream,
outputStream,
Long.getLong(
"easyflow.plugin.file.max-bytes",
256L * 1024L * 1024L));
}
temporaryFiles.add(temporaryFile);
requestParam.setType("MultipartFile");
requestParam.setDefaultValue(
new TemporaryFileMultipartFile(
fileName,
temporaryFile,
null));
} catch (IOException e) {
throw new RuntimeException(e);
}
@@ -283,6 +316,59 @@ public class PluginTool extends BaseTool {
logger.error(result.get("error").toString());
}
return result;
} finally {
for (Path temporaryFile : temporaryFiles) {
try {
Files.deleteIfExists(temporaryFile);
} catch (IOException cleanupError) {
logger.warn(
"清理插件上传临时文件失败path={}",
temporaryFile,
cleanupError);
}
}
}
}
/**
* 按 ID 加载插件项。
*
* @param pluginId 插件项 ID
* @return 插件项
*/
private PluginItem loadPluginItem(BigInteger pluginId) {
PluginItemService pluginToolService =
SpringContextUtil.getBean(PluginItemService.class);
QueryWrapper query = QueryWrapper.create()
.select("*")
.from("tb_plugin_item")
.where("id = ? ", pluginId);
return pluginToolService.getMapper().selectOneByQuery(query);
}
/**
* 使用固定缓冲区复制文件并校验实际字节数。
*
* @param inputStream 输入流
* @param outputStream 输出流
* @param maxBytes 最大字节数
* @throws IOException 读取失败或超限
*/
private void copyBounded(
InputStream inputStream,
OutputStream outputStream,
long maxBytes) throws IOException {
byte[] buffer = new byte[64 * 1024];
long total = 0L;
int read;
while ((read = inputStream.read(buffer)) != -1) {
total += read;
if (maxBytes > 0L && total > maxBytes) {
throw new IOException(
"插件文件超过字节上限: " + maxBytes);
}
outputStream.write(buffer, 0, read);
}
}
// 辅助方法:根据参数名查找原始参数定义

View File

@@ -3,6 +3,7 @@ package tech.easyflow.ai.easyagentsflow.code;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.code.CodeRuntimeEngine;
import com.easyagents.flow.core.node.CodeNode;
import com.easyagents.flow.core.util.StringUtil;
@@ -218,20 +219,24 @@ public class PythonRuntimeEngine implements CodeRuntimeEngine {
private Map<String, Object> buildContext(Chain chain, CodeNode node) {
Map<String, Object> context = new HashMap<>();
ChainState chainState =
chain.getExecutionState();
Map<String, Object> all = chain.getState().getMemory();
Map<String, Object> all =
chainState.getMemory();
all.forEach((key, value) -> {
if (!key.contains(".")) {
context.put(key, value);
}
});
Map<String, Object> parameterValues = chain.getState().resolveParameters(node);
Map<String, Object> parameterValues =
chainState.resolveParameters(node);
if (parameterValues != null && !parameterValues.isEmpty()) {
context.putAll(parameterValues);
}
context.put("_env", chain.getState().getEnvMap());
context.put("_env", chainState.getEnvMap());
return context;
}

View File

@@ -1,9 +1,13 @@
package tech.easyflow.ai.easyagentsflow.config;
import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository;
import com.easyagents.flow.core.chain.repository.ChainDefinitionSnapshotRepository;
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
import com.easyagents.flow.core.chain.repository.LoopResultRepository;
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.easyagents.flow.core.chain.runtime.TriggerScheduler;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tech.easyflow.ai.easyagentsflow.listener.ChainErrorListenerForSave;
@@ -11,8 +15,10 @@ import tech.easyflow.ai.easyagentsflow.listener.ChainEventListenerForSave;
import tech.easyflow.ai.easyagentsflow.listener.NodeErrorListenerForSave;
import javax.annotation.Resource;
import java.time.Duration;
@Configuration
@EnableConfigurationProperties(WorkflowExecutionBudgetProperties.class)
public class ChainExecutorConfig {
@Resource
@@ -22,14 +28,51 @@ public class ChainExecutorConfig {
@Resource
private NodeStateRepository nodeStateRepository;
@Resource
private LoopResultRepository loopResultRepository;
@Resource
private ChainDefinitionSnapshotRepository chainDefinitionSnapshotRepository;
@Resource
private TriggerScheduler triggerScheduler;
@Resource
private ChainEventListenerForSave chainEventListenerForSave;
@Resource
private WorkflowExecutionBudgetProperties workflowExecutionBudgetProperties;
@Resource
private WorkflowRuntimeProperties workflowRuntimeProperties;
@Bean(name = "chainExecutor")
public ChainExecutor chainExecutor() {
ChainExecutor chainExecutor = new ChainExecutor(chainDefinitionRepository,
chainStateRepository,
nodeStateRepository);
nodeStateRepository,
loopResultRepository,
chainDefinitionSnapshotRepository,
triggerScheduler,
workflowExecutionBudgetProperties.toExecutionBudget());
int laneMaxDepth = workflowRuntimeProperties
.getChildWorkflowLaneMaxDepth();
int budgetMaxDepth = workflowExecutionBudgetProperties
.getMaxNestedDepth();
if (laneMaxDepth <= 0
|| budgetMaxDepth <= 0
|| budgetMaxDepth > laneMaxDepth) {
throw new IllegalStateException(
"easyflow.workflow.execution-budget.max-nested-depth "
+ "must be positive and not exceed "
+ "easyflow.workflow.runtime."
+ "child-workflow-lane-max-depth");
}
Duration pollInterval = workflowRuntimeProperties
.getChildWorkflowPollInterval();
long pollMillis = pollInterval == null
? 500L
: Math.max(100L, pollInterval.toMillis());
chainExecutor.configureChildWorkflowRuntime(
Math.max(1, workflowRuntimeProperties
.getChildWorkflowRootPermits()),
pollMillis,
laneMaxDepth);
saveStepsListeners(chainExecutor);

View File

@@ -0,0 +1,144 @@
package tech.easyflow.ai.easyagentsflow.config;
import com.easyagents.flow.core.chain.runtime.ExecutionBudget;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* 工作流执行资源保护预算配置。
*/
@ConfigurationProperties(prefix = "easyflow.workflow.execution-budget")
public class WorkflowExecutionBudgetProperties {
private long maxIterations = ExecutionBudget.DEFAULT_MAX_ITERATIONS;
private Duration maxDuration = Duration.ofMillis(ExecutionBudget.DEFAULT_MAX_DURATION_MILLIS);
private long maxChildExecutions = ExecutionBudget.DEFAULT_MAX_CHILD_EXECUTIONS;
private long maxAccumulatedBytes = ExecutionBudget.DEFAULT_MAX_ACCUMULATED_BYTES;
private int maxNestedDepth = ExecutionBudget.DEFAULT_MAX_NESTED_DEPTH;
private long maxHotStateBytes = ExecutionBudget.DEFAULT_MAX_HOT_STATE_BYTES;
/**
* 转换为工作流引擎使用的不可变执行预算。
*
* @return 执行预算
*/
public ExecutionBudget toExecutionBudget() {
long maxDurationMillis = maxDuration == null ? 0L : maxDuration.toMillis();
return new ExecutionBudget(
maxIterations,
maxDurationMillis,
maxChildExecutions,
maxAccumulatedBytes,
maxNestedDepth,
maxHotStateBytes);
}
/**
* 获取单个执行实例允许的最大循环次数。
*
* @return 最大循环次数
*/
public long getMaxIterations() {
return maxIterations;
}
/**
* 设置单个执行实例允许的最大循环次数。
*
* @param maxIterations 最大循环次数;小于等于零表示不限制
*/
public void setMaxIterations(long maxIterations) {
this.maxIterations = maxIterations;
}
/**
* 获取单个执行实例允许的最大运行时间。
*
* @return 最大运行时间
*/
public Duration getMaxDuration() {
return maxDuration;
}
/**
* 设置单个执行实例允许的最大运行时间。
*
* @param maxDuration 最大运行时间
*/
public void setMaxDuration(Duration maxDuration) {
this.maxDuration = maxDuration;
}
/**
* 获取允许调度的最大子节点执行次数。
*
* @return 最大子节点执行次数
*/
public long getMaxChildExecutions() {
return maxChildExecutions;
}
/**
* 设置允许调度的最大子节点执行次数。
*
* @param maxChildExecutions 最大子节点执行次数;小于等于零表示不限制
*/
public void setMaxChildExecutions(long maxChildExecutions) {
this.maxChildExecutions = maxChildExecutions;
}
/**
* 获取允许累计的结果估算字节数。
*
* @return 最大累计结果字节数
*/
public long getMaxAccumulatedBytes() {
return maxAccumulatedBytes;
}
/**
* 设置允许累计的结果估算字节数。
*
* @param maxAccumulatedBytes 最大累计结果字节数;小于等于零表示不限制
*/
public void setMaxAccumulatedBytes(long maxAccumulatedBytes) {
this.maxAccumulatedBytes = maxAccumulatedBytes;
}
/**
* 获取循环允许的最大嵌套深度。
*
* @return 最大嵌套深度
*/
public int getMaxNestedDepth() {
return maxNestedDepth;
}
/**
* 设置循环允许的最大嵌套深度。
*
* @param maxNestedDepth 最大嵌套深度;小于等于零表示不限制
*/
public void setMaxNestedDepth(int maxNestedDepth) {
this.maxNestedDepth = maxNestedDepth;
}
/**
* 获取单个热状态允许的最大估算字节数。
*
* @return 最大热状态字节数
*/
public long getMaxHotStateBytes() {
return maxHotStateBytes;
}
/**
* 设置单个热状态允许的最大估算字节数。
*
* @param maxHotStateBytes 最大热状态字节数;小于等于零表示不限制
*/
public void setMaxHotStateBytes(long maxHotStateBytes) {
this.maxHotStateBytes = maxHotStateBytes;
}
}

View File

@@ -0,0 +1,93 @@
package tech.easyflow.ai.easyagentsflow.config;
import com.easyagents.flow.core.util.IoBulkhead;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.ToDoubleFunction;
/**
* 将工作流各类 I/O 隔离器运行状态接入 Micrometer。
*/
@Component
public class WorkflowIoBulkheadMetrics {
/**
* 注册工作流 I/O 隔离器指标。
*
* @param meterRegistry Micrometer 注册表
* @param properties 工作流 I/O 配置
*/
public WorkflowIoBulkheadMetrics(
MeterRegistry meterRegistry,
WorkflowIoProperties properties) {
IoBulkhead.configure(
properties.getHttp().toSettings(),
properties.getDataset().toSettings(),
properties.getStorage().toSettings(),
properties.getDocumentParse().toSettings(),
properties.getResponseAggregation().toSettings());
Map<String, IoBulkhead> lanes = new LinkedHashMap<>();
lanes.put("http", IoBulkhead.shared());
lanes.put("dataset", IoBulkhead.dataset());
lanes.put("storage", IoBulkhead.storage());
lanes.put("document_parse", IoBulkhead.documentParse());
lanes.put(
"response_aggregation",
IoBulkhead.responseAggregation());
lanes.forEach((lane, bulkhead) -> registerLane(
meterRegistry, lane, bulkhead));
}
/**
* 注册一个资源 lane 的核心容量和等待指标。
*
* @param registry 指标注册表
* @param lane lane 名
* @param bulkhead 隔离器
*/
private void registerLane(
MeterRegistry registry,
String lane,
IoBulkhead bulkhead) {
gauge(registry, lane, bulkhead, "in_flight",
snapshot -> snapshot.inFlightCount());
gauge(registry, lane, bulkhead, "acquired_total",
snapshot -> snapshot.acquiredCount());
gauge(registry, lane, bulkhead, "rejected_total",
snapshot -> snapshot.rejectedCount());
gauge(registry, lane, bulkhead, "wait_nanos_total",
snapshot -> snapshot.totalWaitNanos());
gauge(registry, lane, bulkhead, "available_permits",
snapshot -> snapshot.availableGlobalPermits());
gauge(registry, lane, bulkhead, "tracked_targets",
snapshot -> snapshot.trackedTargetCount());
}
/**
* 注册从快照读取的 Gauge。
*
* @param registry 指标注册表
* @param lane lane 名
* @param bulkhead 隔离器
* @param metric 指标后缀
* @param valueFunction 快照取值函数
*/
private void gauge(
MeterRegistry registry,
String lane,
IoBulkhead bulkhead,
String metric,
ToDoubleFunction<IoBulkhead.Snapshot> valueFunction) {
Gauge.builder(
"easyflow.workflow.io." + metric,
bulkhead,
value -> valueFunction.applyAsDouble(
value.snapshot()))
.tag("lane", lane)
.register(registry);
}
}

View File

@@ -0,0 +1,236 @@
package tech.easyflow.ai.easyagentsflow.config;
import com.easyagents.flow.core.util.IoBulkhead;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* 工作流阻塞 I/O 隔离配置。
*
* <p>全部参数均提供宽松默认值,应用无需新增配置即可保持现有业务行为。</p>
*/
@ConfigurationProperties(prefix = "easyflow.workflow.io")
public class WorkflowIoProperties {
private Lane http = new Lane(64, 16, Duration.ofSeconds(1), 1_024);
private Lane dataset = new Lane(32, 8, Duration.ofSeconds(1), 512);
private Lane storage = new Lane(24, 12, Duration.ofSeconds(2), 256);
private Lane documentParse = new Lane(8, 4, Duration.ofSeconds(2), 128);
private Lane responseAggregation =
new Lane(8, 4, Duration.ofSeconds(2), 1_024);
/**
* 获取 HTTP 隔离配置。
*
* @return HTTP 配置
*/
public Lane getHttp() {
return http;
}
/**
* 设置 HTTP 隔离配置。
*
* @param http HTTP 配置
*/
public void setHttp(Lane http) {
this.http = http;
}
/**
* 获取数据集隔离配置。
*
* @return 数据集配置
*/
public Lane getDataset() {
return dataset;
}
/**
* 设置数据集隔离配置。
*
* @param dataset 数据集配置
*/
public void setDataset(Lane dataset) {
this.dataset = dataset;
}
/**
* 获取对象存储隔离配置。
*
* @return 对象存储配置
*/
public Lane getStorage() {
return storage;
}
/**
* 设置对象存储隔离配置。
*
* @param storage 对象存储配置
*/
public void setStorage(Lane storage) {
this.storage = storage;
}
/**
* 获取文档解析隔离配置。
*
* @return 文档解析配置
*/
public Lane getDocumentParse() {
return documentParse;
}
/**
* 设置文档解析隔离配置。
*
* @param documentParse 文档解析配置
*/
public void setDocumentParse(Lane documentParse) {
this.documentParse = documentParse;
}
/**
* 获取响应聚合隔离配置。
*
* @return 响应聚合配置
*/
public Lane getResponseAggregation() {
return responseAggregation;
}
/**
* 设置响应聚合隔离配置。
*
* @param responseAggregation 响应聚合配置
*/
public void setResponseAggregation(Lane responseAggregation) {
this.responseAggregation = responseAggregation;
}
/**
* 单类阻塞 I/O 的容量配置。
*/
public static class Lane {
private int maxConcurrency;
private int perTargetMaxConcurrency;
private Duration acquireTimeout;
private int maxTrackedTargets;
/**
* 创建供 Spring 绑定使用的空配置对象。
*/
public Lane() {
}
/**
* 创建带默认值的隔离配置。
*
* @param maxConcurrency 总并发
* @param perTargetMaxConcurrency 单目标并发
* @param acquireTimeout 许可等待时间
* @param maxTrackedTargets 最大目标数
*/
public Lane(
int maxConcurrency,
int perTargetMaxConcurrency,
Duration acquireTimeout,
int maxTrackedTargets) {
this.maxConcurrency = maxConcurrency;
this.perTargetMaxConcurrency = perTargetMaxConcurrency;
this.acquireTimeout = acquireTimeout;
this.maxTrackedTargets = maxTrackedTargets;
}
/**
* 转换为引擎配置并完成启动期校验。
*
* @return 引擎隔离配置
* @throws IllegalArgumentException 配置值无效时抛出
*/
public IoBulkhead.Settings toSettings() {
return new IoBulkhead.Settings(
maxConcurrency,
perTargetMaxConcurrency,
acquireTimeout,
maxTrackedTargets);
}
/**
* 获取总并发。
*
* @return 总并发
*/
public int getMaxConcurrency() {
return maxConcurrency;
}
/**
* 设置总并发。
*
* @param maxConcurrency 总并发
*/
public void setMaxConcurrency(int maxConcurrency) {
this.maxConcurrency = maxConcurrency;
}
/**
* 获取单目标并发。
*
* @return 单目标并发
*/
public int getPerTargetMaxConcurrency() {
return perTargetMaxConcurrency;
}
/**
* 设置单目标并发。
*
* @param perTargetMaxConcurrency 单目标并发
*/
public void setPerTargetMaxConcurrency(
int perTargetMaxConcurrency) {
this.perTargetMaxConcurrency =
perTargetMaxConcurrency;
}
/**
* 获取许可等待时间。
*
* @return 等待时间
*/
public Duration getAcquireTimeout() {
return acquireTimeout;
}
/**
* 设置许可等待时间。
*
* @param acquireTimeout 等待时间
*/
public void setAcquireTimeout(Duration acquireTimeout) {
this.acquireTimeout = acquireTimeout;
}
/**
* 获取最大目标数。
*
* @return 最大目标数
*/
public int getMaxTrackedTargets() {
return maxTrackedTargets;
}
/**
* 设置最大目标数。
*
* @param maxTrackedTargets 最大目标数
*/
public void setMaxTrackedTargets(int maxTrackedTargets) {
this.maxTrackedTargets = maxTrackedTargets;
}
}
}

View File

@@ -0,0 +1,291 @@
package tech.easyflow.ai.easyagentsflow.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* 工作流调度运行时配置。
*/
@ConfigurationProperties(prefix = "easyflow.workflow.runtime")
public class WorkflowRuntimeProperties {
private Duration triggerScanInterval = Duration.ofSeconds(5);
private int schedulerThreads = 2;
private int workerCoreSize = 32;
private int workerMaxSize = 256;
private int workerQueueCapacity = 256;
private int childWorkflowLaneMaxDepth = 32;
private int childWorkflowLaneMaxThreads = 16;
private int childWorkflowRootPermits = 32;
private Duration childWorkflowPollInterval =
Duration.ofMillis(500);
private int dataWriteBatchSize = 200;
private long downloadMaxBytes = 2L * 1024L * 1024L * 1024L;
private int definitionCacheMaxEntries = 512;
private long definitionCacheMaxBytes =
256L * 1024L * 1024L;
private Duration definitionCacheExpireAfterAccess = Duration.ofMinutes(30);
/**
* 获取触发器补偿扫描间隔。
*
* @return 扫描间隔
*/
public Duration getTriggerScanInterval() {
return triggerScanInterval;
}
/**
* 设置触发器补偿扫描间隔。
*
* @param triggerScanInterval 扫描间隔
*/
public void setTriggerScanInterval(Duration triggerScanInterval) {
this.triggerScanInterval = triggerScanInterval;
}
/**
* 获取调度线程数。
*
* @return 调度线程数
*/
public int getSchedulerThreads() {
return schedulerThreads;
}
/**
* 设置调度线程数。
*
* @param schedulerThreads 调度线程数
*/
public void setSchedulerThreads(int schedulerThreads) {
this.schedulerThreads = schedulerThreads;
}
/**
* 获取工作线程核心数。
*
* @return 核心线程数
*/
public int getWorkerCoreSize() {
return workerCoreSize;
}
/**
* 设置工作线程核心数。
*
* @param workerCoreSize 核心线程数
*/
public void setWorkerCoreSize(int workerCoreSize) {
this.workerCoreSize = workerCoreSize;
}
/**
* 获取工作线程最大数。
*
* @return 最大线程数
*/
public int getWorkerMaxSize() {
return workerMaxSize;
}
/**
* 设置工作线程最大数。
*
* @param workerMaxSize 最大线程数
*/
public void setWorkerMaxSize(int workerMaxSize) {
this.workerMaxSize = workerMaxSize;
}
/**
* 获取工作队列容量。
*
* @return 队列容量
*/
public int getWorkerQueueCapacity() {
return workerQueueCapacity;
}
/**
* 设置工作队列容量。
*
* @param workerQueueCapacity 队列容量
*/
public void setWorkerQueueCapacity(int workerQueueCapacity) {
this.workerQueueCapacity = workerQueueCapacity;
}
/**
* 获取子工作流独立通道覆盖的最大嵌套深度。
*
* @return 最大深度
*/
public int getChildWorkflowLaneMaxDepth() {
return childWorkflowLaneMaxDepth;
}
/**
* 设置子工作流独立通道覆盖的最大嵌套深度。
*
* @param childWorkflowLaneMaxDepth 最大深度
*/
public void setChildWorkflowLaneMaxDepth(
int childWorkflowLaneMaxDepth) {
this.childWorkflowLaneMaxDepth =
childWorkflowLaneMaxDepth;
}
/**
* 获取每个子工作流深度通道的最大线程数。
*
* @return 最大线程数
*/
public int getChildWorkflowLaneMaxThreads() {
return childWorkflowLaneMaxThreads;
}
/**
* 设置每个子工作流深度通道的最大线程数。
*
* @param childWorkflowLaneMaxThreads 最大线程数
*/
public void setChildWorkflowLaneMaxThreads(
int childWorkflowLaneMaxThreads) {
this.childWorkflowLaneMaxThreads =
childWorkflowLaneMaxThreads;
}
/**
* 获取根级同步子工作流并发许可数。
*
* @return 并发许可数
*/
public int getChildWorkflowRootPermits() {
return childWorkflowRootPermits;
}
/**
* 设置根级同步子工作流并发许可数。
*
* @param childWorkflowRootPermits 并发许可数
*/
public void setChildWorkflowRootPermits(
int childWorkflowRootPermits) {
this.childWorkflowRootPermits =
childWorkflowRootPermits;
}
/**
* 获取同步等待持久终态的轮询间隔。
*
* @return 轮询间隔
*/
public Duration getChildWorkflowPollInterval() {
return childWorkflowPollInterval;
}
/**
* 设置同步等待持久终态的轮询间隔。
*
* @param childWorkflowPollInterval 轮询间隔
*/
public void setChildWorkflowPollInterval(
Duration childWorkflowPollInterval) {
this.childWorkflowPollInterval =
childWorkflowPollInterval;
}
/**
* 获取数据写入节点单批最大行数。
*
* @return 单批最大行数
*/
public int getDataWriteBatchSize() {
return dataWriteBatchSize;
}
/**
* 设置数据写入节点单批最大行数。
*
* @param dataWriteBatchSize 单批最大行数
*/
public void setDataWriteBatchSize(int dataWriteBatchSize) {
this.dataWriteBatchSize = dataWriteBatchSize;
}
/**
* 获取下载节点允许的最大文件字节数。
*
* @return 最大文件字节数,小于等于 0 表示不限制
*/
public long getDownloadMaxBytes() {
return downloadMaxBytes;
}
/**
* 设置下载节点允许的最大文件字节数。
*
* @param downloadMaxBytes 最大文件字节数,小于等于 0 表示不限制
*/
public void setDownloadMaxBytes(long downloadMaxBytes) {
this.downloadMaxBytes = downloadMaxBytes;
}
/**
* 获取本地编译定义缓存最大条目数。
*
* @return 最大条目数
*/
public int getDefinitionCacheMaxEntries() {
return definitionCacheMaxEntries;
}
/**
* 设置本地编译定义缓存最大条目数。
*
* @param definitionCacheMaxEntries 最大条目数
*/
public void setDefinitionCacheMaxEntries(int definitionCacheMaxEntries) {
this.definitionCacheMaxEntries = definitionCacheMaxEntries;
}
/**
* 获取本地编译定义缓存的最大估算字节数。
*
* @return 最大字节数
*/
public long getDefinitionCacheMaxBytes() {
return definitionCacheMaxBytes;
}
/**
* 设置本地编译定义缓存的最大估算字节数。
*
* @param definitionCacheMaxBytes 最大字节数
*/
public void setDefinitionCacheMaxBytes(
long definitionCacheMaxBytes) {
this.definitionCacheMaxBytes =
definitionCacheMaxBytes;
}
/**
* 获取编译定义缓存访问过期时间。
*
* @return 访问过期时间
*/
public Duration getDefinitionCacheExpireAfterAccess() {
return definitionCacheExpireAfterAccess;
}
/**
* 设置编译定义缓存访问过期时间。
*
* @param definitionCacheExpireAfterAccess 访问过期时间
*/
public void setDefinitionCacheExpireAfterAccess(Duration definitionCacheExpireAfterAccess) {
this.definitionCacheExpireAfterAccess = definitionCacheExpireAfterAccess;
}
}

View File

@@ -0,0 +1,100 @@
package tech.easyflow.ai.easyagentsflow.config;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.easyagents.flow.core.chain.runtime.TriggerScheduler;
import com.easyagents.flow.core.chain.runtime.TriggerStore;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 工作流持久化触发调度器配置。
*/
@Configuration
@EnableConfigurationProperties({
WorkflowRuntimeProperties.class,
WorkflowIoProperties.class
})
public class WorkflowTriggerSchedulerConfig {
/**
* 创建使用 Redis 触发器仓储的独立调度器。
*
* @param triggerStore 持久化触发器仓储
* @param properties 调度运行时配置
* @return 工作流触发调度器
*/
@Bean(destroyMethod = "shutdown")
public TriggerScheduler workflowTriggerScheduler(
TriggerStore triggerStore, WorkflowRuntimeProperties properties) {
int schedulerThreads = Math.max(1, properties.getSchedulerThreads());
int workerCoreSize = Math.max(1, properties.getWorkerCoreSize());
int workerMaxSize = Math.max(workerCoreSize, properties.getWorkerMaxSize());
int queueCapacity = Math.max(1, properties.getWorkerQueueCapacity());
Duration scanInterval = properties.getTriggerScanInterval();
long scanIntervalMillis = scanInterval == null ? 5000L : Math.max(1000L, scanInterval.toMillis());
ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor(
schedulerThreads, namedThreadFactory("workflow-trigger-scheduler"));
scheduler.setRemoveOnCancelPolicy(true);
ThreadPoolExecutor worker = new ThreadPoolExecutor(
workerCoreSize,
workerMaxSize,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(queueCapacity),
namedThreadFactory("workflow-node-worker"),
new ThreadPoolExecutor.AbortPolicy());
TriggerScheduler triggerScheduler = new TriggerScheduler(
triggerStore, scheduler, worker, scanIntervalMillis);
int childLaneMaxDepth = Math.max(
1, properties.getChildWorkflowLaneMaxDepth());
int childLaneMaxThreads = Math.max(
1, properties.getChildWorkflowLaneMaxThreads());
/*
* 不同嵌套深度使用独立小通道。depth N 的 WorkflowNode 即使全部同步等待,
* depth N+1 的触发器仍有独立容量,不会形成同池递归饥饿。
*/
for (int depth = 1; depth <= childLaneMaxDepth; depth++) {
ThreadPoolExecutor childWorkflowWorker =
new ThreadPoolExecutor(
0,
childLaneMaxThreads,
60L,
TimeUnit.SECONDS,
new SynchronousQueue<>(),
namedThreadFactory(
"workflow-child-" + depth),
new ThreadPoolExecutor.AbortPolicy());
childWorkflowWorker.allowCoreThreadTimeOut(true);
triggerScheduler.registerWorker(
ChainExecutor.childExecutionLane(depth),
childWorkflowWorker);
}
return triggerScheduler;
}
/**
* 创建带稳定前缀的守护线程工厂。
*
* @param prefix 线程名前缀
* @return 线程工厂
*/
private ThreadFactory namedThreadFactory(String prefix) {
AtomicInteger sequence = new AtomicInteger();
return task -> {
Thread thread = new Thread(task, prefix + "-" + sequence.incrementAndGet());
thread.setDaemon(true);
return thread;
};
}
}

View File

@@ -0,0 +1,9 @@
package tech.easyflow.ai.easyagentsflow.event;
/**
* 工作流定义内容或发布快照发生变化的本地事件。
*
* @param workflowId 工作流 ID
*/
public record WorkflowDefinitionChangedEvent(String workflowId) {
}

View File

@@ -0,0 +1,261 @@
package tech.easyflow.ai.easyagentsflow.event;
import com.alibaba.fastjson2.JSON;
import com.easyagents.flow.core.chain.repository.LoopInputReference;
import com.easyagents.flow.core.chain.repository.LoopResultReference;
import com.easyagents.flow.core.chain.repository.LoopResultRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.common.mq.core.MQConsumerHandler;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQSubscription;
import tech.easyflow.common.mq.config.MQProperties;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 工作流执行审计事件消费者。
*/
@Component
public class WorkflowExecutionAuditConsumer implements MQConsumerHandler {
private final WorkflowExecResultService workflowExecResultService;
private final WorkflowExecStepService workflowExecStepService;
private final MQProperties mqProperties;
private final LoopResultRepository loopResultRepository;
/**
* 创建工作流执行审计事件消费者。
*
* @param workflowExecResultService 工作流执行记录服务
* @param workflowExecStepService 节点执行步骤服务
* @param mqProperties MQ 配置
* @param loopResultRepository 循环与大型查询结果仓储
*/
public WorkflowExecutionAuditConsumer(WorkflowExecResultService workflowExecResultService,
WorkflowExecStepService workflowExecStepService,
MQProperties mqProperties,
LoopResultRepository loopResultRepository) {
this.workflowExecResultService = workflowExecResultService;
this.workflowExecStepService = workflowExecStepService;
this.mqProperties = mqProperties;
this.loopResultRepository =
loopResultRepository;
}
/**
* {@inheritDoc}
*/
@Override
public MQSubscription subscription() {
MQSubscription subscription = new MQSubscription();
subscription.setTopic(WorkflowExecutionAuditMqConstants.TOPIC);
subscription.setConsumerGroup(WorkflowExecutionAuditMqConstants.CONSUMER_GROUP);
subscription.setShardCount(Math.max(
1,
mqProperties.getRedis().getChatPersistShardCount()));
subscription.setBatchEnabled(true);
return subscription;
}
/**
* {@inheritDoc}
*/
@Override
public void handle(List<MQMessage> messages) {
if (messages == null || messages.isEmpty()) {
return;
}
for (MQMessage message : messages) {
WorkflowExecutionAuditEvent event = JSON.parseObject(
message.getBody(), WorkflowExecutionAuditEvent.class);
if (event == null || event.getType() == null) {
throw new IllegalArgumentException("Invalid workflow execution audit event");
}
apply(event);
}
}
/**
* 按事件顺序幂等写入执行记录。
*
* @param event 审计事件
*/
private void apply(WorkflowExecutionAuditEvent event) {
switch (event.getType()) {
case CHAIN_STARTED -> createExecution(event);
case CHAIN_ENDED -> finishExecution(event);
case NODE_STARTED -> createStep(event);
case NODE_ENDED -> finishStep(event);
default -> throw new IllegalArgumentException(
"Unsupported workflow execution audit event: " + event.getType());
}
}
/**
* 创建工作流执行记录。
*
* @param event 启动事件
*/
private void createExecution(WorkflowExecutionAuditEvent event) {
WorkflowExecResult incoming = requireResult(event);
try {
workflowExecResultService.save(incoming);
} catch (DuplicateKeyException ignored) {
// MQ 至少一次投递下的重复启动事件按 exec_key 幂等处理。
}
}
/**
* 完成工作流执行记录。
*
* @param event 结束事件
*/
private void finishExecution(WorkflowExecutionAuditEvent event) {
WorkflowExecResult incoming = requireResult(event);
incoming.setOutput(resolveAuditOutput(
incoming.getOutput()));
if (workflowExecResultService.updateByExecKey(incoming) != 1) {
throw new IllegalStateException(
"Unable to update workflow execution record: " + incoming.getExecKey());
}
}
/**
* 创建节点执行步骤。
*
* @param event 节点启动事件
*/
private void createStep(WorkflowExecutionAuditEvent event) {
WorkflowExecStep incoming = requireStep(event);
WorkflowExecResult record = workflowExecResultService.getByExecKey(event.getInstanceId());
if (record == null) {
throw new IllegalStateException(
"Workflow execution record not found: " + event.getInstanceId());
}
incoming.setRecordId(record.getId());
incoming.setInput(resolveAuditOutput(
incoming.getInput()));
try {
workflowExecStepService.save(incoming);
} catch (DuplicateKeyException ignored) {
// 同一基础设施触发器恢复后重复投递时按稳定 exec_key 幂等处理。
}
}
/**
* 完成节点执行步骤。
*
* @param event 节点结束事件
*/
private void finishStep(WorkflowExecutionAuditEvent event) {
WorkflowExecStep incoming = requireStep(event);
incoming.setOutput(resolveAuditOutput(
incoming.getOutput()));
if (workflowExecStepService.updateByExecKey(incoming) != 1) {
throw new IllegalStateException(
"Unable to update workflow execution step: " + incoming.getExecKey());
}
}
/**
* 获取事件中的工作流执行记录。
*
* @param event 审计事件
* @return 工作流执行记录
*/
private WorkflowExecResult requireResult(WorkflowExecutionAuditEvent event) {
if (event.getResult() == null || event.getResult().getExecKey() == null) {
throw new IllegalArgumentException("Workflow execution audit result is required");
}
return event.getResult();
}
/**
* 获取事件中的节点执行步骤。
*
* @param event 审计事件
* @return 节点执行步骤
*/
private WorkflowExecStep requireStep(WorkflowExecutionAuditEvent event) {
if (event.getStep() == null || event.getStep().getExecKey() == null) {
throw new IllegalArgumentException("Workflow execution audit step is required");
}
return event.getStep();
}
/**
* 在审计消费线程还原轻量引用,保持执行记录既有完整 JSON 语义。
*
* @param output 可能包含内部引用的 JSON
* @return 已还原的完整 JSON
*/
private String resolveAuditOutput(
String output) {
if (output == null || output.isBlank()) {
return output;
}
Object parsed = JSON.parse(output);
Object references = restoreReferences(parsed);
Object resolved =
loopResultRepository.resolveReferences(
references);
return JSON.toJSONString(resolved);
}
/**
* 将异步消息中的稳定引用标记恢复为引擎引用对象。
*
* @param value JSON 值
* @return 引擎可解析值
*/
private Object restoreReferences(Object value) {
if (value instanceof Map<?, ?> map) {
if ("easyflow.loop-input.v1".equals(
map.get("referenceType"))
&& map.get("resultId") != null
&& map.get("itemCount")
instanceof Number) {
return new LoopInputReference(
String.valueOf(
map.get("resultId")),
((Number) map.get(
"itemCount")).intValue());
}
if ("easyflow.loop-result.v1".equals(
map.get("referenceType"))
&& map.get("resultId") != null
&& map.get("iterationCount")
instanceof Number
&& map.get("outputName") != null) {
return new LoopResultReference(
String.valueOf(
map.get("resultId")),
((Number) map.get(
"iterationCount"))
.intValue(),
String.valueOf(
map.get("outputName")));
}
Map<Object, Object> restored =
new LinkedHashMap<>();
map.forEach((key, item) ->
restored.put(
key,
restoreReferences(item)));
return restored;
}
if (value instanceof List<?> list) {
return list.stream()
.map(this::restoreReferences)
.toList();
}
return value;
}
}

View File

@@ -0,0 +1,158 @@
package tech.easyflow.ai.easyagentsflow.event;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
import java.io.Serializable;
import java.util.Date;
/**
* 工作流执行记录异步持久化事件。
*/
public class WorkflowExecutionAuditEvent implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 审计事件类型。
*/
public enum Type {
CHAIN_STARTED,
CHAIN_ENDED,
NODE_STARTED,
NODE_ENDED
}
/**
* 事件幂等 ID。
*/
private String eventId;
/**
* 工作流实例 ID同时作为同实例事件顺序键。
*/
private String instanceId;
/**
* 事件类型。
*/
private Type type;
/**
* 事件发生时间。
*/
private Date occurredAt;
/**
* 工作流执行记录快照。
*/
private WorkflowExecResult result;
/**
* 节点执行步骤快照。
*/
private WorkflowExecStep step;
/**
* 获取事件幂等 ID。
*
* @return 事件幂等 ID
*/
public String getEventId() {
return eventId;
}
/**
* 设置事件幂等 ID。
*
* @param eventId 事件幂等 ID
*/
public void setEventId(String eventId) {
this.eventId = eventId;
}
/**
* 获取工作流实例 ID。
*
* @return 工作流实例 ID
*/
public String getInstanceId() {
return instanceId;
}
/**
* 设置工作流实例 ID。
*
* @param instanceId 工作流实例 ID
*/
public void setInstanceId(String instanceId) {
this.instanceId = instanceId;
}
/**
* 获取事件类型。
*
* @return 事件类型
*/
public Type getType() {
return type;
}
/**
* 设置事件类型。
*
* @param type 事件类型
*/
public void setType(Type type) {
this.type = type;
}
/**
* 获取事件发生时间。
*
* @return 事件发生时间
*/
public Date getOccurredAt() {
return occurredAt;
}
/**
* 设置事件发生时间。
*
* @param occurredAt 事件发生时间
*/
public void setOccurredAt(Date occurredAt) {
this.occurredAt = occurredAt;
}
/**
* 获取工作流执行记录快照。
*
* @return 工作流执行记录快照
*/
public WorkflowExecResult getResult() {
return result;
}
/**
* 设置工作流执行记录快照。
*
* @param result 工作流执行记录快照
*/
public void setResult(WorkflowExecResult result) {
this.result = result;
}
/**
* 获取节点执行步骤快照。
*
* @return 节点执行步骤快照
*/
public WorkflowExecStep getStep() {
return step;
}
/**
* 设置节点执行步骤快照。
*
* @param step 节点执行步骤快照
*/
public void setStep(WorkflowExecStep step) {
this.step = step;
}
}

View File

@@ -0,0 +1,19 @@
package tech.easyflow.ai.easyagentsflow.event;
/**
* 工作流执行审计 MQ 常量。
*/
public final class WorkflowExecutionAuditMqConstants {
/**
* 工作流执行审计主题。
*/
public static final String TOPIC = "workflow-execution-audit";
/**
* 工作流执行审计消费组。
*/
public static final String CONSUMER_GROUP = "workflow-execution-audit-writer";
private WorkflowExecutionAuditMqConstants() {
}
}

View File

@@ -0,0 +1,713 @@
package tech.easyflow.ai.easyagentsflow.event;
import com.alibaba.fastjson2.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import tech.easyflow.common.mq.core.MQDeadLetterService;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQProducer;
import javax.annotation.PreDestroy;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Date;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 工作流执行审计事件生产者。
*
* <p>少量固定 lane 保证同一实例 FIFO跨 lane 并行发送和重试,避免单个异常
* 实例阻塞全部工作流。全局条数与字节预算共同约束本地重试内存。</p>
*/
@Service
public class WorkflowExecutionAuditProducer {
private static final Logger log =
LoggerFactory.getLogger(WorkflowExecutionAuditProducer.class);
private static final int DEFAULT_LANE_COUNT = 8;
private static final int MAX_LOCAL_BACKLOG = 10_000;
private static final long MAX_MESSAGE_BYTES =
64L * 1024L * 1024L;
private static final long MAX_BACKLOG_BYTES =
512L * 1024L * 1024L;
private static final long SHUTDOWN_FLUSH_MILLIS =
TimeUnit.SECONDS.toMillis(5L);
private static final int MAX_DRAIN_BATCH = 256;
private static final int MAX_SEND_ATTEMPTS = 16;
private static final long MAX_RETRY_DELAY_MILLIS =
TimeUnit.MINUTES.toMillis(1);
private final MQProducer mqProducer;
private final MQDeadLetterService deadLetterService;
private final List<DeliveryLane> lanes;
private final Object admissionLock =
new Object();
private final ScheduledExecutorService retryExecutor;
private final AtomicBoolean closed =
new AtomicBoolean();
private final int maxLocalBacklog;
private final long maxMessageBytes;
private final long maxBacklogBytes;
private final long shutdownFlushMillis;
private int backlogCount;
private long backlogBytes;
/**
* 创建工作流执行审计事件生产者。
*
* @param mqProducer 通用 MQ 生产者
* @param deadLetterService 通用 MQ 死信服务
*/
@Autowired
public WorkflowExecutionAuditProducer(
MQProducer mqProducer,
MQDeadLetterService deadLetterService) {
this(
mqProducer,
deadLetterService,
DEFAULT_LANE_COUNT,
MAX_LOCAL_BACKLOG,
MAX_MESSAGE_BYTES,
MAX_BACKLOG_BYTES,
SHUTDOWN_FLUSH_MILLIS);
}
/**
* 创建带测试预算的工作流审计生产者。
*
* @param mqProducer 通用 MQ 生产者
* @param deadLetterService 通用 MQ 死信服务
* @param laneCount 固定发送 lane 数
* @param maxLocalBacklog 最大本地积压条数
* @param maxMessageBytes 单条消息最大字节数
* @param maxBacklogBytes 本地积压最大总字节数
* @param shutdownFlushMillis 关闭时最大收口时间
*/
WorkflowExecutionAuditProducer(
MQProducer mqProducer,
MQDeadLetterService deadLetterService,
int laneCount,
int maxLocalBacklog,
long maxMessageBytes,
long maxBacklogBytes,
long shutdownFlushMillis) {
this.mqProducer = mqProducer;
this.deadLetterService = deadLetterService;
this.maxLocalBacklog =
Math.max(1, maxLocalBacklog);
this.maxMessageBytes =
Math.max(1L, maxMessageBytes);
this.maxBacklogBytes =
Math.max(this.maxMessageBytes,
maxBacklogBytes);
this.shutdownFlushMillis =
Math.max(0L, shutdownFlushMillis);
int effectiveLaneCount =
Math.max(1, laneCount);
this.lanes =
new ArrayList<>(effectiveLaneCount);
for (int index = 0;
index < effectiveLaneCount;
index++) {
lanes.add(new DeliveryLane());
}
this.retryExecutor =
Executors.newScheduledThreadPool(
Math.min(
effectiveLaneCount, 4),
runnable -> {
Thread thread =
new Thread(
runnable,
"workflow-audit-producer-retry");
thread.setDaemon(true);
return thread;
});
for (DeliveryLane lane : lanes) {
retryExecutor.scheduleWithFixedDelay(
() -> drainReadyBatch(
lane, false),
10L,
10L,
TimeUnit.MILLISECONDS);
}
}
/**
* 投递工作流执行审计事件。
*
* <p>同一实例稳定落在同一发送 lane 和 MQ 分片并保持事件顺序;不同 lane
* 独立发送和退避。</p>
*
* @param event 工作流执行审计事件
* @return Redis Stream 记录 ID或本地排队标识
*/
public String send(
WorkflowExecutionAuditEvent event) {
if (closed.get()) {
throw new IllegalStateException(
"Workflow audit producer is closed");
}
if (event == null
|| event.getType() == null) {
throw new IllegalArgumentException(
"Workflow execution audit event is required");
}
Date occurredAt =
event.getOccurredAt() == null
? new Date()
: event.getOccurredAt();
event.setOccurredAt(occurredAt);
MQMessage message =
new MQMessage();
message.setMessageId(event.getEventId());
message.setTopic(
WorkflowExecutionAuditMqConstants.TOPIC);
message.setKey(event.getInstanceId());
message.setCreatedAt(occurredAt);
message.setBody(
JSON.toJSONString(event));
long messageBytes =
messageBytes(message);
ensureMessageSize(message, messageBytes);
DeliveryLane lane =
laneFor(message.getKey());
synchronized (lane.lock) {
if (closed.get()) {
throw new IllegalStateException(
"Workflow audit producer is closed");
}
if (lane.sending
|| !lane.backlog.isEmpty()) {
enqueueLast(
lane,
new PendingDelivery(
message,
0,
0L,
messageBytes));
return queuedId(message);
}
lane.sending = true;
}
try {
return mqProducer.send(message);
} catch (RuntimeException sendError) {
boolean closing;
synchronized (lane.lock) {
closing = closed.get();
if (!closing) {
enqueueFirst(
lane,
new PendingDelivery(
message,
1,
System.currentTimeMillis()
+ retryDelayMillis(1),
messageBytes));
}
}
if (closing) {
try {
deadLetterService.deadLetter(
message,
"producer closed during send failure");
} catch (RuntimeException deadLetterError) {
sendError.addSuppressed(
deadLetterError);
}
throw new IllegalStateException(
"Workflow audit producer closed during send",
sendError);
}
log.warn(
"工作流审计 MQ 暂时不可用事件已进入有界重试队列eventId={}",
message.getMessageId());
return queuedId(message);
} finally {
synchronized (lane.lock) {
lane.sending = false;
}
}
}
/**
* 在单个 lane 中按原顺序批量重试。
*
* @param lane 发送 lane
* @param ignoreRetryTime 关闭收口时是否忽略退避时间
*/
private void drainReadyBatch(
DeliveryLane lane,
boolean ignoreRetryTime) {
for (int index = 0;
index < MAX_DRAIN_BATCH;
index++) {
if (!drainOne(
lane, ignoreRetryTime)) {
return;
}
}
}
/**
* 重试一个 lane 的队首消息。
*
* @param lane 发送 lane
* @param ignoreRetryTime 是否忽略退避时间
* @return 队首已移除且可继续排空时为 {@code true}
*/
private boolean drainOne(
DeliveryLane lane,
boolean ignoreRetryTime) {
PendingDelivery pending;
synchronized (lane.lock) {
if (lane.sending
|| lane.backlog.isEmpty()) {
return false;
}
pending = lane.backlog.peekFirst();
if (pending == null
|| (!ignoreRetryTime
&& pending.nextAttemptAtMillis()
> System.currentTimeMillis())) {
return false;
}
lane.sending = true;
}
boolean removed = false;
try {
mqProducer.send(pending.message());
removed = removeHead(
lane, pending);
} catch (RuntimeException sendError) {
int nextAttempt =
pending.attempt() + 1;
if (nextAttempt
>= MAX_SEND_ATTEMPTS) {
if (deadLetter(
pending.message(),
sendError)) {
removed = removeHead(
lane, pending);
} else {
replaceHead(
lane,
pending,
pending.retryAt(
nextAttempt,
System.currentTimeMillis()
+ MAX_RETRY_DELAY_MILLIS));
}
} else {
replaceHead(
lane,
pending,
pending.retryAt(
nextAttempt,
System.currentTimeMillis()
+ retryDelayMillis(
nextAttempt)));
}
} finally {
synchronized (lane.lock) {
lane.sending = false;
}
}
return removed;
}
/**
* 将耗尽生产重试的事件写入通用死信流。
*
* @param message MQ 消息
* @param failure 最终发送异常
* @return 死信写入成功时为 {@code true}
*/
private boolean deadLetter(
MQMessage message,
RuntimeException failure) {
try {
deadLetterService.deadLetter(
message,
"producer send attempts exhausted: "
+ failure.getClass().getName()
+ ": "
+ failure.getMessage());
return true;
} catch (RuntimeException deadLetterError) {
log.error(
"工作流审计生产失败且死信写入失败eventId={}",
message.getMessageId(),
deadLetterError);
return false;
}
}
/**
* 删除仍位于 lane 队首的消息并归还全局积压预算。
*
* @param lane 发送 lane
* @param expected 期望队首
* @return 成功删除时为 {@code true}
*/
private boolean removeHead(
DeliveryLane lane,
PendingDelivery expected) {
synchronized (lane.lock) {
if (lane.backlog.peekFirst()
!= expected) {
return false;
}
lane.backlog.removeFirst();
releaseAdmission(expected);
return true;
}
}
/**
* 原子替换仍位于 lane 队首的消息。
*
* @param lane 发送 lane
* @param expected 当前队首
* @param replacement 替换项
*/
private void replaceHead(
DeliveryLane lane,
PendingDelivery expected,
PendingDelivery replacement) {
synchronized (lane.lock) {
if (lane.backlog.peekFirst()
== expected) {
lane.backlog.removeFirst();
lane.backlog.addFirst(
replacement);
}
}
}
/**
* 入队到指定 lane 尾部。
*
* @param lane 发送 lane
* @param pending 待投递事件
*/
private void enqueueLast(
DeliveryLane lane,
PendingDelivery pending) {
reserveAdmission(pending);
lane.backlog.addLast(pending);
}
/**
* 入队到指定 lane 头部。
*
* @param lane 发送 lane
* @param pending 待投递事件
*/
private void enqueueFirst(
DeliveryLane lane,
PendingDelivery pending) {
reserveAdmission(pending);
lane.backlog.addFirst(pending);
}
/**
* 预占全局积压条数和字节预算。
*
* @param pending 待入队消息
*/
private void reserveAdmission(
PendingDelivery pending) {
boolean rejected;
synchronized (admissionLock) {
rejected = backlogCount
>= maxLocalBacklog
|| pending.messageBytes()
> maxBacklogBytes
- backlogBytes;
if (!rejected) {
backlogCount++;
backlogBytes +=
pending.messageBytes();
}
}
if (rejected) {
deadLetterService.deadLetter(
pending.message(),
"producer retry queue is full");
throw new IllegalStateException(
"Workflow audit producer retry queue is full");
}
}
/**
* 归还一条积压消息占用的全局预算。
*
* @param pending 已移除消息
*/
private void releaseAdmission(
PendingDelivery pending) {
synchronized (admissionLock) {
backlogCount =
Math.max(0, backlogCount - 1);
backlogBytes =
Math.max(
0L,
backlogBytes
- pending.messageBytes());
}
}
/**
* 校验单条消息字节上限。
*
* @param message MQ 消息
* @param bytes 消息估算字节数
*/
private void ensureMessageSize(
MQMessage message,
long bytes) {
if (bytes <= maxMessageBytes) {
return;
}
deadLetterService.deadLetter(
message,
"producer message exceeds byte limit");
throw new IllegalArgumentException(
"Workflow audit message exceeds byte limit");
}
/**
* 估算 MQ 消息本地持有字节数。
*
* @param message MQ 消息
* @return UTF-8 负载和关键元数据字节数
*/
private long messageBytes(
MQMessage message) {
return utf8Bytes(message.getBody())
+ utf8Bytes(message.getMessageId())
+ utf8Bytes(message.getKey())
+ 128L;
}
/**
* 计算字符串 UTF-8 字节数。
*
* @param value 字符串
* @return 字节数
*/
private long utf8Bytes(String value) {
if (value == null) {
return 0L;
}
long bytes = 0L;
for (int index = 0;
index < value.length();
index++) {
char current =
value.charAt(index);
if (current <= 0x7F) {
bytes++;
} else if (current <= 0x7FF) {
bytes += 2L;
} else if (Character.isHighSurrogate(
current)
&& index + 1 < value.length()
&& Character.isLowSurrogate(
value.charAt(index + 1))) {
bytes += 4L;
index++;
} else {
bytes += 3L;
}
}
return bytes;
}
/**
* 按稳定 key 选择固定发送 lane。
*
* @param key 工作流实例键
* @return 发送 lane
*/
private DeliveryLane laneFor(String key) {
int hash = key == null
? 0
: key.hashCode();
return lanes.get(
Math.floorMod(hash, lanes.size()));
}
/**
* 构造本地排队返回标识。
*
* @param message 已排队消息
* @return 排队标识
*/
private String queuedId(
MQMessage message) {
return "queued:"
+ message.getMessageId();
}
/**
* 计算生产者重试退避。
*
* @param attempt 已失败次数
* @return 退避毫秒数
*/
private long retryDelayMillis(int attempt) {
int shift =
Math.min(
16,
Math.max(0, attempt - 1));
return Math.min(
MAX_RETRY_DELAY_MILLIS,
100L << shift);
}
/**
* 关闭生产者重试线程,并在有界时间内发送或死信收口积压事件。
*/
@PreDestroy
public void close() {
if (!closed.compareAndSet(
false, true)) {
return;
}
retryExecutor.shutdownNow();
long deadline =
System.nanoTime()
+ TimeUnit.MILLISECONDS.toNanos(
shutdownFlushMillis);
while (hasBacklog()
&& System.nanoTime() < deadline) {
boolean progressed = false;
for (DeliveryLane lane : lanes) {
int before = laneSize(lane);
drainReadyBatch(lane, true);
progressed |= laneSize(lane)
< before;
}
if (!progressed) {
try {
Thread.sleep(10L);
} catch (InterruptedException error) {
Thread.currentThread()
.interrupt();
break;
}
}
}
deadLetterRemainingBacklog();
}
/**
* 判断是否仍有本地积压消息。
*
* @return 有积压时为 {@code true}
*/
private boolean hasBacklog() {
synchronized (admissionLock) {
return backlogCount > 0;
}
}
/**
* 获取 lane 当前积压条数。
*
* @param lane 发送 lane
* @return 积压条数
*/
private int laneSize(DeliveryLane lane) {
synchronized (lane.lock) {
return lane.backlog.size();
}
}
/**
* 将关闭期限后剩余消息转入死信,避免进程内静默丢失。
*/
private void deadLetterRemainingBacklog() {
for (DeliveryLane lane : lanes) {
List<PendingDelivery> remaining =
new ArrayList<>();
synchronized (lane.lock) {
while (!lane.backlog.isEmpty()) {
PendingDelivery pending =
lane.backlog.removeFirst();
remaining.add(pending);
releaseAdmission(pending);
}
}
for (PendingDelivery pending :
remaining) {
try {
deadLetterService.deadLetter(
pending.message(),
"producer shutdown flush timeout");
} catch (RuntimeException error) {
log.error(
"工作流审计关闭收口死信失败eventId={}",
pending.message()
.getMessageId(),
error);
}
}
}
}
/**
* 一条有序待投递审计消息。
*
* @param message MQ 消息
* @param attempt 已失败次数
* @param nextAttemptAtMillis 下次允许重试时间
* @param messageBytes 本地持有字节数
*/
private record PendingDelivery(
MQMessage message,
int attempt,
long nextAttemptAtMillis,
long messageBytes) {
/**
* 创建下一次重试记录。
*
* @param nextAttempt 下一次尝试次数
* @param retryAt 下次允许发送时间
* @return 保留原消息与字节大小的新记录
*/
private PendingDelivery retryAt(
int nextAttempt,
long retryAt) {
return new PendingDelivery(
message,
nextAttempt,
retryAt,
messageBytes);
}
}
/**
* 一个独立 FIFO 发送 lane。
*/
private static final class DeliveryLane {
private final Object lock =
new Object();
private final Deque<PendingDelivery> backlog =
new ArrayDeque<>();
private boolean sending;
}
}

View File

@@ -1,28 +1,27 @@
package tech.easyflow.ai.easyagentsflow.listener;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSON;
import com.easyagents.flow.core.chain.*;
import com.easyagents.flow.core.chain.event.*;
import com.easyagents.flow.core.chain.listener.ChainEventListener;
import com.easyagents.flow.core.chain.repository.NodeStateField;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditEvent;
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditProducer;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.easyagentsflow.support.WorkflowExecutionStepKey;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.utils.WorkFlowUtil;
import javax.annotation.Resource;
import java.util.Date;
import java.util.EnumSet;
@Component
public class ChainEventListenerForSave implements ChainEventListener {
@@ -35,7 +34,7 @@ public class ChainEventListenerForSave implements ChainEventListener {
@Resource
private WorkflowExecResultService workflowExecResultService;
@Resource
private WorkflowExecStepService workflowExecStepService;
private WorkflowExecutionAuditProducer auditProducer;
@Override
public void onEvent(Event event, Chain chain) {
@@ -60,19 +59,19 @@ public class ChainEventListenerForSave implements ChainEventListener {
}
private void handleChainStartEvent(ChainStartEvent event, Chain chain) {
log.info("ChainStartEvent: {}", event);
ChainDefinition definition = chain.getDefinition();
ChainState state = chain.getState();
log.info(
"workflow event type=chain-started, instanceId={}, definitionId={}, variableCount={}",
state.getInstanceId(),
definition == null ? null : definition.getId(),
event.getVariables() == null ? 0 : event.getVariables().size());
Workflow workflow = resolveWorkflow(definition);
if (workflow == null) {
log.error("ChainStartEvent: workflow not found, definitionId={}", definition.getId());
return;
}
String instanceId = state.getInstanceId();
WorkflowExecResult existed = workflowExecResultService.getByExecKey(instanceId);
if (existed != null) {
return;
}
WorkflowExecResult record = new WorkflowExecResult();
record.setExecKey(instanceId);
record.setWorkflowId(workflow.getId());
@@ -84,102 +83,157 @@ public class ChainEventListenerForSave implements ChainEventListener {
record.setStatus(state.getStatus().getValue());
record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain));
record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString());
// 启动记录保留同步确认,避免执行接口返回后立即查询时记录尚不可见。
try {
workflowExecResultService.save(record);
} catch (DuplicateKeyException e) {
// 多节点重试时可能并发写同一 exec_key,按幂等处理。
log.debug("exec result already exists, execKey={}", instanceId, e);
} catch (DuplicateKeyException duplicate) {
// 重复启动或恢复按 exec_key 幂等处理。
log.debug("exec result already exists, execKey={}", instanceId, duplicate);
}
}
private void handleChainEndEvent(ChainEndEvent event, Chain chain) {
log.info("ChainEndEvent: {}", event);
ChainState state = chain.getState();
String instanceId = state.getInstanceId();
WorkflowExecResult record = workflowExecResultService.getByExecKey(instanceId);
if (record == null) {
log.error("ChainEndEvent: record not found: {}", instanceId);
} else {
record.setEndTime(new Date());
record.setStatus(state.getStatus().getValue());
record.setOutput(JSON.toJSONString(state.getExecuteResult()));
ExceptionSummary error = state.getError();
if (error != null) {
record.setErrorInfo(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
}
workflowExecResultService.updateById(record);
log.info(
"workflow event type=chain-ended, instanceId={}, status={}",
instanceId,
state.getStatus());
WorkflowExecResult record = new WorkflowExecResult();
record.setExecKey(instanceId);
record.setEndTime(new Date());
record.setStatus(state.getStatus().getValue());
// 大型引用由审计消费者异步还原,避免阻塞工作流终态提交。
record.setOutput(JSON.toJSONString(
state.getExecuteResult()));
ExceptionSummary error = state.getError();
if (error != null) {
record.setErrorInfo(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
}
sendAuditEvent(
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
instanceId + ":chain-ended",
instanceId,
record,
null);
}
private void handleNodeStartEvent(NodeStartEvent event, Chain chain) {
log.info("NodeStartEvent: {}", event);
Node node = event.getNode();
ChainState ancestorState = findAncestorState(chain.getState(), chain);
String auditInstanceId =
event.getAuditInstanceId();
ChainState ancestorState =
StrUtil.isBlank(auditInstanceId)
|| auditInstanceId.equals(
chain.getStateInstanceId())
? chain.getExecutionState()
: chain.getChainStateRepository()
.load(auditInstanceId);
if (ancestorState == null) {
throw new IllegalStateException(
"Workflow audit state not found: "
+ auditInstanceId);
}
String instanceId = ancestorState.getInstanceId();
NodeState nodeState = chain.getNodeState(node.getId());
String execKey = IdUtil.fastSimpleUUID();
chain.updateNodeStateSafely(node.getId(), state -> {
state.getMemory().put("executeId", execKey);
return EnumSet.of(NodeStateField.MEMORY);
});
WorkflowExecResult record = workflowExecResultService.getByExecKey(instanceId);
if (record == null) {
log.error("NodeStartEvent: record not found: {}", instanceId);
} else {
WorkflowExecStep step = new WorkflowExecStep();
step.setRecordId(record.getId());
step.setExecKey(execKey);
step.setNodeId(node.getId());
step.setNodeName(node.getName());
step.setInput(JSON.toJSONString(ancestorState.resolveParameters(node)));
step.setNodeData(JSON.toJSONString(node));
step.setStartTime(new Date());
step.setStatus(nodeState.getStatus().getValue());
workflowExecStepService.save(step);
NodeStatus nodeStatus = event.getStatus();
if (nodeStatus == null) {
NodeState nodeState = chain.getNodeState(node.getId());
nodeStatus = nodeState.getStatus();
}
log.info(
"workflow event type=node-started, instanceId={}, nodeId={}, nodeType={}, status={}",
instanceId,
node.getId(),
node.getClass().getSimpleName(),
nodeStatus);
String execKey = currentStepExecKey(
event.getExecutionAttemptKey(),
chain,
node);
WorkflowExecStep step = new WorkflowExecStep();
step.setExecKey(execKey);
step.setNodeId(node.getId());
step.setNodeName(node.getName());
// 业务线程保留大型引用,完整审计输入由 MQ 消费线程异步还原。
step.setInput(JSON.toJSONString(
ancestorState
.resolveParametersPreservingReferences(
node)));
step.setNodeData(JSON.toJSONString(node));
step.setStartTime(new Date());
step.setStatus(nodeStatus.getValue());
sendAuditEvent(
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
execKey + ":started",
instanceId,
null,
step);
}
private void handleNodeEndEvent(NodeEndEvent event, Chain chain) {
log.info("NodeEndEvent: {}", event);
Node node = event.getNode();
NodeState nodeState = chain.getNodeState(node.getId());
String execKey = nodeState.getMemory().get("executeId").toString();
WorkflowExecStep step = workflowExecStepService.getByExecKey(execKey);
if (step == null) {
log.error("NodeEndEvent: step not found: {}", execKey);
} else {
step.setOutput(JSON.toJSONString(event.getResult()));
step.setEndTime(new Date());
step.setStatus(nodeState.getStatus().getValue());
ExceptionSummary error = nodeState.getError();
if (error != null) {
step.setErrorInfo(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
}
workflowExecStepService.updateById(step);
String auditInstanceId =
chain.getAuditInstanceId();
NodeState legacyNodeState = null;
NodeStatus nodeStatus =
event.getStatus();
if (nodeStatus == null) {
// 兼容旧版引擎未携带不可变终态的事件。
legacyNodeState =
chain.getNodeState(
node.getId());
nodeStatus =
legacyNodeState.getStatus();
}
log.info(
"workflow event type=node-ended, instanceId={}, nodeId={}, nodeType={}, status={}, resultFieldCount={}",
auditInstanceId,
node.getId(),
node.getClass().getSimpleName(),
nodeStatus,
event.getResult() == null ? 0 : event.getResult().size());
String execKey = currentStepExecKey(
event.getExecutionAttemptKey(),
chain,
node);
WorkflowExecStep step = new WorkflowExecStep();
step.setExecKey(execKey);
// 节点线程只投递轻量引用,完整执行记录仍由审计消费者透明还原。
step.setOutput(JSON.toJSONString(
event.getResult()));
step.setEndTime(new Date());
step.setStatus(nodeStatus.getValue());
ExceptionSummary error =
event.getError() == null
? (legacyNodeState == null
? null
: legacyNodeState.getError())
: new ExceptionSummary(
event.getError());
if (error != null) {
step.setErrorInfo(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
}
sendAuditEvent(
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
execKey + ":ended",
auditInstanceId,
null,
step);
}
private void handleChainStatusChangeEvent(ChainStatusChangeEvent event, Chain chain) {
log.info("ChainStatusChangeEvent: {}", event);
log.info(
"workflow event type=chain-status-changed, instanceId={}, status={}",
chain.getStateInstanceId(),
event.getStatus());
}
private void handleChainResumeEvent(ChainResumeEvent event, Chain chain) {
log.info("ChainResumeEvent: {}", event);
}
/**
* 递归查找顶级状态
*/
private ChainState findAncestorState(ChainState state, Chain chain) {
String parentInstanceId = state.getParentInstanceId();
if (StrUtil.isEmpty(parentInstanceId)) {
return state;
}
ChainState chainState = chain.getChainStateRepository().load(parentInstanceId);
return findAncestorState(chainState, chain);
log.info(
"workflow event type=chain-resumed, instanceId={}",
chain.getStateInstanceId());
}
/**
@@ -203,4 +257,60 @@ public class ChainEventListenerForSave implements ChainEventListener {
return null;
}
}
/**
* 生成节点本次业务尝试的稳定执行步骤键。
*
* @param capturedAttemptKey 事件创建时捕获的业务尝试键
* @param chain 当前工作流
* @param node 当前节点
* @return 长度固定的执行步骤键
*/
private String currentStepExecKey(
String capturedAttemptKey,
Chain chain,
Node node) {
String attemptKey = capturedAttemptKey;
if (StrUtil.isBlank(attemptKey)) {
// 兼容旧版引擎直接构造、尚未携带不可变尝试键的事件。
NodeState nodeState =
chain.getNodeState(node.getId());
attemptKey = nodeState == null
? null
: nodeState
.getExecutionAttemptKey();
}
String execKey =
WorkflowExecutionStepKey.resolve(
attemptKey);
if (StrUtil.isBlank(execKey)) {
throw new IllegalStateException(
"Workflow execution attempt key is unavailable: " + node.getId());
}
return execKey;
}
/**
* 投递工作流执行审计事件。
*
* @param type 事件类型
* @param eventId 事件幂等 ID
* @param instanceId 顶级工作流实例 ID
* @param result 工作流执行记录快照
* @param step 节点执行步骤快照
*/
private void sendAuditEvent(WorkflowExecutionAuditEvent.Type type,
String eventId,
String instanceId,
WorkflowExecResult result,
WorkflowExecStep step) {
WorkflowExecutionAuditEvent auditEvent = new WorkflowExecutionAuditEvent();
auditEvent.setType(type);
auditEvent.setEventId(eventId);
auditEvent.setInstanceId(instanceId);
auditEvent.setOccurredAt(new Date());
auditEvent.setResult(result);
auditEvent.setStep(step);
auditProducer.send(auditEvent);
}
}

View File

@@ -57,6 +57,22 @@ public class BaseRepository {
return clazz.cast(value);
}
/**
* 删除并校验工作流运行状态缓存。
*
* @param key 缓存键
*/
protected void removeCache(String key) {
CacheResult result = cache.REMOVE(key);
CacheResultCode resultCode = result.getResultCode();
if (resultCode == CacheResultCode.NOT_EXISTS || resultCode == CacheResultCode.EXPIRED) {
return;
}
if (!result.isSuccess()) {
throw cacheOperationException("删除", key, result);
}
}
/**
* 构建包含缓存操作上下文的异常。
*

View File

@@ -20,14 +20,29 @@ public class ChainDefinitionRepositoryImpl implements ChainDefinitionRepository
private ChainParser chainParser;
@Resource
private WorkflowDatacenterContentService workflowDatacenterContentService;
@Resource
private WorkflowDefinitionCache workflowDefinitionCache;
@Override
public ChainDefinition getChainDefinitionById(String id) {
return workflowDefinitionCache.get(id, () -> loadAndCompile(id));
}
/**
* 从持久层加载工作流并编译定义。
*
* @param id 定义 ID
* @return 已编译工作流定义
*/
private ChainDefinition loadAndCompile(String id) {
boolean publishedDefinition = PublishedWorkflowDefinitionIds.isPublished(id);
String workflowId = PublishedWorkflowDefinitionIds.unwrap(id);
Workflow workflow = publishedDefinition
? workflowService.getPublishedById(new java.math.BigInteger(workflowId))
: workflowService.getById(workflowId);
if (workflow == null) {
throw new IllegalStateException("Workflow not found: " + workflowId);
}
String json = workflowDatacenterContentService.prepareContent(workflow.getContent());
ChainDefinition chainDefinition = chainParser.parse(json);
chainDefinition.setId(id);

View File

@@ -0,0 +1,158 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.repository.ChainDefinitionSnapshotRepository;
import org.springframework.stereotype.Component;
import tech.easyflow.common.cache.VersionedObjectStore;
import tech.easyflow.common.constant.CacheKey;
import javax.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.Collections;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;
/**
* 基于 Redis 版本对象存储的工作流实例定义快照仓储。
*/
@Component
public class ChainDefinitionSnapshotRepositoryImpl extends BaseRepository
implements ChainDefinitionSnapshotRepository {
/**
* 快照由工作流终态显式删除;长 TTL 仅用于异常中断后的孤儿兜底清理。
*/
private static final Duration SNAPSHOT_TTL = Duration.ofDays(7);
private static final Map<ChainDefinition, String> CONTENT_HASH_CACHE =
Collections.synchronizedMap(new WeakHashMap<>());
@Resource
private VersionedObjectStore versionedObjectStore;
/**
* {@inheritDoc}
*/
@Override
public void save(String instanceId, ChainDefinition definition) {
String contentHash = contentHash(definition);
String immutableContentKey = contentKey(contentHash);
versionedObjectStore.createIfAbsent(
immutableContentKey,
definition,
0L,
SNAPSHOT_TTL);
// 每个新实例都延长不可变内容寿命,保证引用 TTL 内不会悬空。
versionedObjectStore.refreshExpirations(
List.of(immutableContentKey), SNAPSHOT_TTL);
versionedObjectStore.createIfAbsent(
stateKey(instanceId),
contentHash,
0L,
SNAPSHOT_TTL);
}
/**
* {@inheritDoc}
*/
@Override
public ChainDefinition load(String instanceId) {
Object reference = versionedObjectStore.load(
stateKey(instanceId), Object.class);
if (reference instanceof String) {
ChainDefinition snapshot = versionedObjectStore.load(
contentKey((String) reference),
ChainDefinition.class);
if (snapshot != null) {
return snapshot;
}
} else if (reference instanceof ChainDefinition) {
// 兼容 XL12 之前按实例保存完整定义的运行中实例。
return (ChainDefinition) reference;
}
ChainDefinition legacy = getCache(legacyKey(instanceId), ChainDefinition.class);
if (legacy != null) {
save(instanceId, legacy);
}
return legacy;
}
/**
* {@inheritDoc}
*/
@Override
public void remove(String instanceId) {
versionedObjectStore.deleteAll(List.of(stateKey(instanceId)));
removeCache(legacyKey(instanceId));
}
/**
* 构建定义快照缓存键。
*
* @param instanceId 工作流实例 ID
* @return 缓存键
*/
private String legacyKey(String instanceId) {
return CacheKey.CHAIN_DEFINITION_SNAPSHOT_CACHE_KEY + instanceId;
}
/**
* 构建不会随短期运行状态 TTL 漂移的快照键。
*
* @param instanceId 工作流实例 ID
* @return Redis 快照键
*/
private String stateKey(String instanceId) {
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:definition";
}
/**
* 构建按定义内容复用的快照键。
*
* @param contentHash 定义内容摘要
* @return Redis 内容键
*/
private String contentKey(String contentHash) {
return CacheKey.CHAIN_DEFINITION_SNAPSHOT_CACHE_KEY
+ "content:"
+ contentHash;
}
/**
* 计算定义序列化内容摘要;同一编译定义对象仅计算一次。
*
* @param definition 编译后的定义
* @return SHA-256 十六进制摘要
*/
private String contentHash(ChainDefinition definition) {
if (definition == null) {
throw new IllegalArgumentException(
"Chain definition required");
}
String cached = CONTENT_HASH_CACHE.get(definition);
if (cached != null) {
return cached;
}
try (ByteArrayOutputStream output =
new ByteArrayOutputStream();
ObjectOutputStream objects =
new ObjectOutputStream(output)) {
objects.writeObject(definition);
objects.flush();
String calculated = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256")
.digest(output.toByteArray()));
CONTENT_HASH_CACHE.put(definition, calculated);
return calculated;
} catch (IOException | NoSuchAlgorithmException error) {
throw new IllegalStateException(
"Failed to hash chain definition", error);
}
}
}

View File

@@ -1,32 +1,447 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.repository.ChainLock;
import com.easyagents.flow.core.chain.repository.ChainStateField;
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
import org.springframework.stereotype.Component;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.cache.VersionedFields;
import tech.easyflow.common.cache.VersionedObjectStore;
import tech.easyflow.common.constant.CacheKey;
import javax.annotation.Resource;
import java.io.Serializable;
import java.time.Duration;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 基于 Redis 字段化 CAS、认领守卫和短期实例锁的工作流状态仓储。
*/
@Component
public class ChainStateRepositoryImpl extends BaseRepository implements ChainStateRepository {
@Override
public ChainState load(String instanceId) {
String key = CacheKey.CHAIN_CACHE_KEY + instanceId;
ChainState chainState = getCache(key, ChainState.class);
if (chainState == null) {
chainState = new ChainState();
chainState.setInstanceId(instanceId);
putCache(key, chainState);
}
return chainState;
private static final Duration STATE_TTL = Duration.ofDays(3);
private static final Duration MIGRATION_MARKER_TTL = Duration.ofDays(4);
private static final Duration FENCING_COUNTER_TTL = Duration.ofDays(4);
private static final Duration MIN_LOCK_LEASE = Duration.ofSeconds(30);
private static final ScheduledThreadPoolExecutor LOCK_RENEW_EXECUTOR =
createLockRenewExecutor();
private final Set<String> legacyInstances = java.util.concurrent.ConcurrentHashMap.newKeySet();
@Resource
private RedisLockExecutor redisLockExecutor;
@Resource
private VersionedObjectStore versionedObjectStore;
/**
* 创建会主动移除已取消任务的锁续期线程池。
*
* <p>绝大多数实例锁仅持有数毫秒;开启 remove-on-cancel 可避免高吞吐场景下,
* 已取消的十秒延迟续期任务在队列中短时堆积。</p>
*
* @return 小型多线程锁续期调度器
*/
private static ScheduledThreadPoolExecutor createLockRenewExecutor() {
ScheduledThreadPoolExecutor executor =
new ScheduledThreadPoolExecutor(2, new LockRenewThreadFactory());
executor.setRemoveOnCancelPolicy(true);
executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
return executor;
}
/**
* {@inheritDoc}
*/
@Override
public ChainState load(String instanceId) {
String stateKey = stateKey(instanceId);
VersionedFields snapshot = versionedObjectStore.loadFields(stateKey);
if (WorkflowStateFields.isFieldFormat(snapshot)) {
legacyInstances.remove(instanceId);
return WorkflowStateFields.decodeChain(snapshot);
}
if (snapshot != null) {
ChainState payloadState = versionedObjectStore.load(stateKey, ChainState.class);
if (payloadState != null) {
rewritePayloadState(stateKey, payloadState);
return payloadState;
}
}
if (hasMigrationMarker(instanceId)) {
legacyInstances.remove(instanceId);
return null;
}
String legacyKey = CacheKey.CHAIN_CACHE_KEY + instanceId;
ChainState legacyState = getCache(legacyKey, ChainState.class);
if (legacyState == null) {
return null;
}
// 活跃旧实例继续沿用旧写路径,避免滚动升级期间两个格式同时推进。
if (legacyState.getStatus() == null || !legacyState.getStatus().isTerminal()) {
legacyInstances.add(instanceId);
return legacyState;
}
migrateTerminalLegacyState(instanceId, legacyKey, legacyState);
VersionedFields migrated = versionedObjectStore.loadFields(stateKey);
if (!WorkflowStateFields.isFieldFormat(migrated)) {
throw new IllegalStateException("Workflow state migration failed: " + instanceId);
}
return WorkflowStateFields.decodeChain(migrated);
}
/**
* {@inheritDoc}
*/
@Override
public Long loadVersion(String instanceId) {
if (!legacyInstances.contains(instanceId)) {
Long version = versionedObjectStore.loadVersion(stateKey(instanceId));
if (version != null) {
return version;
}
}
ChainState state = load(instanceId);
return state == null ? null : state.getVersion();
}
/**
* {@inheritDoc}
*/
@Override
public ChainState create(String instanceId) {
ChainState existing = load(instanceId);
if (existing != null) {
return existing;
}
ChainState created = new ChainState();
created.setInstanceId(instanceId);
if (versionedObjectStore.createFieldsIfAbsent(
stateKey(instanceId),
WorkflowStateFields.allChainFields(created),
created.getVersion(),
STATE_TTL)) {
touchMigrationMarker(instanceId);
return created;
}
VersionedFields concurrent = versionedObjectStore.loadFields(stateKey(instanceId));
if (!WorkflowStateFields.isFieldFormat(concurrent)) {
throw new IllegalStateException("Unable to initialize workflow state: " + instanceId);
}
return WorkflowStateFields.decodeChain(concurrent);
}
/**
* {@inheritDoc}
*/
@Override
public boolean tryUpdate(ChainState newState, EnumSet<ChainStateField> fields) {
String key = CacheKey.CHAIN_CACHE_KEY + newState.getInstanceId();
putCache(key, newState);
return true;
return tryUpdate(newState, fields, 0L);
}
/**
* {@inheritDoc}
*/
@Override
public boolean tryUpdate(
ChainState newState, EnumSet<ChainStateField> fields, long fencingToken) {
return tryUpdate(newState, fields, fencingToken, null, 0L);
}
/**
* {@inheritDoc}
*/
@Override
public boolean tryUpdate(
ChainState newState,
EnumSet<ChainStateField> fields,
long lockFencingToken,
String claimId,
long claimGeneration) {
String instanceId = newState.getInstanceId();
if (legacyInstances.contains(instanceId)) {
String legacyKey = CacheKey.CHAIN_CACHE_KEY + instanceId;
putCache(legacyKey, newState);
if (newState.getStatus() != null && newState.getStatus().isTerminal()) {
migrateTerminalLegacyState(instanceId, legacyKey, newState);
}
return true;
}
long newVersion = newState.getVersion();
if (newVersion <= 0L) {
throw new IllegalArgumentException("newState.version must be positive");
}
requireClaimId(claimId, claimGeneration);
boolean updated;
if (lockFencingToken > 0L) {
String claimGuardKey = claimGeneration > 0L
? executionGuardKey(instanceId, claimId)
: lockFenceKey(instanceId);
long effectiveClaimGeneration = claimGeneration > 0L
? claimGeneration
: lockFencingToken;
updated = versionedObjectStore.compareAndSetFieldsAndRefresh(
stateKey(instanceId),
newVersion - 1L,
WorkflowStateFields.chainFields(newState, fields),
newVersion,
lockFenceKey(instanceId),
lockFencingToken,
claimGuardKey,
effectiveClaimGeneration,
STATE_TTL,
markerKey(instanceId),
MIGRATION_MARKER_TTL);
} else {
updated = versionedObjectStore.compareAndSetFields(
stateKey(instanceId),
newVersion - 1L,
WorkflowStateFields.chainFields(newState, fields),
newVersion,
STATE_TTL);
}
return updated;
}
/**
* 将当前 Redis Hash 中的旧完整 payload 原地改写为字段化状态。
*
* @param stateKey 状态键
* @param state 旧完整状态
*/
private void rewritePayloadState(String stateKey, ChainState state) {
boolean rewritten = versionedObjectStore.rewriteAsFields(
stateKey,
state.getVersion(),
WorkflowStateFields.allChainFields(state),
STATE_TTL);
if (rewritten) {
touchMigrationMarker(state.getInstanceId());
}
}
/**
* 在旧实例终态后完成受控迁移并留下 tombstone。
*
* @param instanceId 实例 ID
* @param legacyKey 旧 JetCache 键
* @param state 终态状态
*/
private void migrateTerminalLegacyState(
String instanceId, String legacyKey, ChainState state) {
boolean created = versionedObjectStore.createFieldsIfAbsent(
stateKey(instanceId),
WorkflowStateFields.allChainFields(state),
state.getVersion(),
STATE_TTL);
VersionedFields existing = versionedObjectStore.loadFields(stateKey(instanceId));
if (!created && !WorkflowStateFields.isFieldFormat(existing)) {
throw new IllegalStateException("Workflow state migration conflict: " + instanceId);
}
touchMigrationMarker(instanceId);
removeCache(legacyKey);
legacyInstances.remove(instanceId);
}
/**
* 判断实例是否已经切换到字段化格式。
*
* @param instanceId 实例 ID
* @return 已存在迁移 tombstone 时为 {@code true}
*/
private boolean hasMigrationMarker(String instanceId) {
return versionedObjectStore.loadFields(markerKey(instanceId)) != null;
}
/**
* 创建或刷新迁移 tombstone防止新状态过期后复活旧缓存。
*
* @param instanceId 实例 ID
*/
private void touchMigrationMarker(String instanceId) {
Map<String, Serializable> marker = Map.of(
WorkflowStateFields.FORMAT_FIELD, WorkflowStateFields.FORMAT_VERSION);
if (!versionedObjectStore.createFieldsIfAbsent(
markerKey(instanceId), marker, 0L, MIGRATION_MARKER_TTL)) {
versionedObjectStore.compareAndSetFields(
markerKey(instanceId), 0L, marker, 0L, MIGRATION_MARKER_TTL);
}
}
/**
* 构建工作流状态 CAS key。
*
* @param instanceId 工作流实例 ID
* @return Redis 状态 key
*/
private String stateKey(String instanceId) {
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:chain";
}
/**
* 构建状态格式 tombstone 键。
*
* @param instanceId 实例 ID
* @return Redis marker 键
*/
private String markerKey(String instanceId) {
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:format";
}
/**
* 构建本次触发器认领的执行守卫键。
*
* @param instanceId 工作流实例 ID
* @param claimId 触发器 ID
* @return 执行守卫键
*/
private String executionGuardKey(String instanceId, String claimId) {
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:claim:" + claimId;
}
/**
* 校验分布式提交所需的 claim ID。
*
* @param claimId 触发器 ID
* @param fencingToken 当前认领 token
*/
private void requireClaimId(String claimId, long fencingToken) {
if (fencingToken > 0L && (claimId == null || claimId.trim().isEmpty())) {
throw new IllegalArgumentException("claimId is required with fencingToken");
}
}
/**
* 获取工作流实例级 Redis 分布式锁。
*
* @param instanceId 工作流实例 ID
* @param timeout 等待锁的最大时间
* @param unit 时间单位
* @return 分布式锁句柄
*/
@Override
public ChainLock getLock(String instanceId, long timeout, TimeUnit unit) {
if (instanceId == null || instanceId.trim().isEmpty()) {
throw new IllegalArgumentException("instanceId must not be blank");
}
if (redisLockExecutor == null) {
throw new IllegalStateException("RedisLockExecutor is not configured");
}
Duration waitTimeout = Duration.ofMillis(Math.max(1L, unit.toMillis(timeout)));
Duration leaseTimeout = waitTimeout.compareTo(MIN_LOCK_LEASE) > 0 ? waitTimeout : MIN_LOCK_LEASE;
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquireFenced(
CacheKey.CHAIN_LOCK_KEY + "{" + instanceId + "}",
lockFenceKey(instanceId),
waitTimeout,
leaseTimeout,
FENCING_COUNTER_TTL);
if (handle == null) {
return new RedisChainLock(null, 0L);
}
return new RedisChainLock(handle, handle.getFencingToken());
}
/**
* 构建实例锁 fencing token 键。
*
* @param instanceId 工作流实例 ID
* @return fencing token 哈希键
*/
private String lockFenceKey(String instanceId) {
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:fence";
}
/**
* Redis 工作流锁适配器。
*/
private static final class RedisChainLock implements ChainLock {
private final RedisLockExecutor.LockHandle handle;
private final long fencingToken;
private final AtomicBoolean valid = new AtomicBoolean(true);
private final ScheduledFuture<?> renewTask;
/**
* 创建锁适配器。
*
* @param handle Redis 锁句柄;为空表示未获取
* @param fencingToken 本次锁持有期的 fencing token
*/
private RedisChainLock(
RedisLockExecutor.LockHandle handle, long fencingToken) {
this.handle = handle;
this.fencingToken = fencingToken;
this.renewTask = handle == null ? null : LOCK_RENEW_EXECUTOR.scheduleWithFixedDelay(
() -> {
if (!handle.renew()) {
valid.set(false);
}
},
MIN_LOCK_LEASE.toMillis() / 3L,
MIN_LOCK_LEASE.toMillis() / 3L,
TimeUnit.MILLISECONDS);
}
/**
* {@inheritDoc}
*/
@Override
public boolean isAcquired() {
return handle != null && valid.get();
}
/**
* {@inheritDoc}
*/
@Override
public boolean isValid() {
return isAcquired();
}
/**
* {@inheritDoc}
*/
@Override
public long getFencingToken() {
return fencingToken;
}
/**
* {@inheritDoc}
*/
@Override
public void close() {
valid.set(false);
if (renewTask != null) {
renewTask.cancel(false);
}
if (handle != null) {
handle.release();
}
}
}
/**
* 创建守护型工作流状态锁续期线程。
*/
private static final class LockRenewThreadFactory implements ThreadFactory {
/**
* {@inheritDoc}
*/
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable, "workflow-state-lock-renew");
thread.setDaemon(true);
return thread;
}
}
}

View File

@@ -1,33 +1,320 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.NodeState;
import com.easyagents.flow.core.chain.repository.NodeStateField;
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
import org.springframework.stereotype.Component;
import tech.easyflow.common.cache.VersionedObjectStore;
import tech.easyflow.common.cache.VersionedFields;
import tech.easyflow.common.constant.CacheKey;
import javax.annotation.Resource;
import java.time.Duration;
import java.util.EnumSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* 基于 Redis 原子版本存储的节点状态仓储。
*/
@Component
public class NodeStateRepositoryImpl extends BaseRepository implements NodeStateRepository {
private static final Duration STATE_TTL = Duration.ofDays(3);
@Resource
private VersionedObjectStore versionedObjectStore;
private final ConcurrentMap<String, Boolean> legacyFormats = new ConcurrentHashMap<>();
/**
* {@inheritDoc}
*/
@Override
public NodeState load(String instanceId, String nodeId) {
String key = CacheKey.NODE_CACHE_KEY + instanceId + ":" + nodeId;
NodeState nodeState = getCache(key, NodeState.class);
if (nodeState == null) {
nodeState = new NodeState();
nodeState.setChainInstanceId(instanceId);
nodeState.setNodeId(nodeId);
putCache(key, nodeState);
String stateKey = stateKey(instanceId, nodeId);
VersionedFields snapshot = versionedObjectStore.loadFields(stateKey);
if (WorkflowStateFields.isFieldFormat(snapshot)) {
return WorkflowStateFields.decodeNode(snapshot);
}
return nodeState;
if (snapshot != null) {
NodeState payloadState = versionedObjectStore.load(stateKey, NodeState.class);
if (payloadState != null) {
WorkflowStateFields.normalizeNode(
payloadState);
versionedObjectStore.rewriteAsFields(
stateKey,
payloadState.getVersion(),
WorkflowStateFields.allNodeFields(payloadState),
STATE_TTL);
return payloadState;
}
}
String legacyKey = legacyStateKey(instanceId, nodeId);
NodeState legacyState = getCache(legacyKey, NodeState.class);
if (legacyState == null) {
return null;
}
WorkflowStateFields.normalizeNode(
legacyState);
if (isLegacyInstance(instanceId)) {
return legacyState;
}
versionedObjectStore.createFieldsIfAbsent(
stateKey,
WorkflowStateFields.allNodeFields(legacyState),
legacyState.getVersion(),
STATE_TTL);
VersionedFields migrated = versionedObjectStore.loadFields(stateKey);
if (!WorkflowStateFields.isFieldFormat(migrated)) {
throw new IllegalStateException(
"Workflow node state migration failed: " + instanceId + "/" + nodeId);
}
removeCache(legacyKey);
return WorkflowStateFields.decodeNode(migrated);
}
/**
* {@inheritDoc}
*/
@Override
public NodeState create(String instanceId, String nodeId, long chainStateVersion) {
return create(instanceId, nodeId, chainStateVersion, 0L);
}
/**
* {@inheritDoc}
*/
@Override
public NodeState create(
String instanceId,
String nodeId,
long chainStateVersion,
long fencingToken) {
return create(instanceId, nodeId, chainStateVersion, fencingToken, null, 0L);
}
/**
* {@inheritDoc}
*/
@Override
public NodeState create(
String instanceId,
String nodeId,
long chainStateVersion,
long lockFencingToken,
String claimId,
long claimGeneration) {
NodeState existing = load(instanceId, nodeId);
if (existing != null) {
return existing;
}
NodeState created = new NodeState();
created.setChainInstanceId(instanceId);
created.setNodeId(nodeId);
if (isLegacyInstance(instanceId)) {
putCache(legacyStateKey(instanceId, nodeId), created);
return created;
}
requireClaimId(claimId, claimGeneration);
String claimGuardKey = claimGeneration > 0L
? executionGuardKey(instanceId, claimId)
: lockFenceKey(instanceId);
long effectiveClaimGeneration = claimGeneration > 0L
? claimGeneration
: lockFencingToken;
boolean createdNow = lockFencingToken > 0L
? versionedObjectStore.createFieldsIfAbsent(
stateKey(instanceId, nodeId),
WorkflowStateFields.allNodeFields(created),
created.getVersion(),
chainStateKey(instanceId),
chainStateVersion,
lockFenceKey(instanceId),
lockFencingToken,
claimGuardKey,
effectiveClaimGeneration,
STATE_TTL)
: versionedObjectStore.createFieldsIfAbsent(
stateKey(instanceId, nodeId),
WorkflowStateFields.allNodeFields(created),
created.getVersion(),
chainStateKey(instanceId),
chainStateVersion,
STATE_TTL);
if (createdNow) {
return created;
}
VersionedFields concurrent = versionedObjectStore.loadFields(
stateKey(instanceId, nodeId));
return WorkflowStateFields.isFieldFormat(concurrent)
? WorkflowStateFields.decodeNode(concurrent)
: null;
}
/**
* {@inheritDoc}
*/
@Override
public boolean tryUpdate(NodeState newState, EnumSet<NodeStateField> fields, long chainStateVersion) {
String key = CacheKey.NODE_CACHE_KEY + newState.getChainInstanceId() + ":" + newState.getNodeId();
putCache(key, newState);
return true;
return tryUpdate(newState, fields, chainStateVersion, 0L);
}
/**
* {@inheritDoc}
*/
@Override
public boolean tryUpdate(
NodeState newState,
EnumSet<NodeStateField> fields,
long chainStateVersion,
long fencingToken) {
return tryUpdate(
newState, fields, chainStateVersion, fencingToken, null, 0L);
}
/**
* {@inheritDoc}
*/
@Override
public boolean tryUpdate(
NodeState newState,
EnumSet<NodeStateField> fields,
long chainStateVersion,
long lockFencingToken,
String claimId,
long claimGeneration) {
String instanceId = newState.getChainInstanceId();
if (isLegacyInstance(instanceId)) {
putCache(legacyStateKey(instanceId, newState.getNodeId()), newState);
return true;
}
long newVersion = newState.getVersion();
if (newVersion <= 0L) {
throw new IllegalArgumentException("newState.version must be positive");
}
requireClaimId(claimId, claimGeneration);
String claimGuardKey = claimGeneration > 0L
? executionGuardKey(instanceId, claimId)
: lockFenceKey(instanceId);
long effectiveClaimGeneration = claimGeneration > 0L
? claimGeneration
: lockFencingToken;
return lockFencingToken > 0L
? versionedObjectStore.compareAndSetFields(
stateKey(instanceId, newState.getNodeId()),
newVersion - 1L,
WorkflowStateFields.nodeFields(newState, fields),
newVersion,
chainStateKey(instanceId),
chainStateVersion,
lockFenceKey(instanceId),
lockFencingToken,
claimGuardKey,
effectiveClaimGeneration,
STATE_TTL)
: versionedObjectStore.compareAndSetFields(
stateKey(instanceId, newState.getNodeId()),
newVersion - 1L,
WorkflowStateFields.nodeFields(newState, fields),
newVersion,
chainStateKey(instanceId),
chainStateVersion,
STATE_TTL);
}
/**
* 判断实例是否仍由旧 JetCache 状态推进。
*
* @param instanceId 实例 ID
* @return 活跃旧格式实例时为 {@code true}
*/
private boolean isLegacyInstance(String instanceId) {
return legacyFormats.computeIfAbsent(instanceId, this::detectLegacyInstance);
}
/**
* 从持久化状态检测实例格式。
*
* @param instanceId 实例 ID
* @return 活跃旧格式实例时为 {@code true}
*/
private boolean detectLegacyInstance(String instanceId) {
VersionedFields chainSnapshot = versionedObjectStore.loadFields(
chainStateKey(instanceId));
if (WorkflowStateFields.isFieldFormat(chainSnapshot)) {
return false;
}
VersionedFields marker = versionedObjectStore.loadFields(
CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:format");
if (marker != null) {
return false;
}
return getCache(CacheKey.CHAIN_CACHE_KEY + instanceId, ChainState.class) != null;
}
/**
* 构建旧节点状态键。
*
* @param instanceId 实例 ID
* @param nodeId 节点 ID
* @return JetCache 键
*/
private String legacyStateKey(String instanceId, String nodeId) {
return CacheKey.NODE_CACHE_KEY + instanceId + ":" + nodeId;
}
/**
* 构建节点状态 CAS key。
*
* @param instanceId 工作流实例 ID
* @param nodeId 节点 ID
* @return Redis 状态 key
*/
private String stateKey(String instanceId, String nodeId) {
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:node:" + nodeId;
}
/**
* 构建节点状态提交所依赖的工作流状态 key。
*
* @param instanceId 工作流实例 ID
* @return Redis 工作流状态 key
*/
private String chainStateKey(String instanceId) {
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:chain";
}
/**
* 构建实例锁 fencing token 键。
*
* @param instanceId 工作流实例 ID
* @return fencing token 键
*/
private String lockFenceKey(String instanceId) {
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:fence";
}
/**
* 构建实例 fencing token 键。
*
* @param instanceId 工作流实例 ID
* @return fencing token 键
*/
private String executionGuardKey(String instanceId, String claimId) {
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:claim:" + claimId;
}
/**
* 校验分布式提交所需的 claim ID。
*
* @param claimId 触发器 ID
* @param fencingToken 当前认领 token
*/
private void requireClaimId(String claimId, long fencingToken) {
if (fencingToken > 0L && (claimId == null || claimId.trim().isEmpty())) {
throw new IllegalArgumentException("claimId is required with fencingToken");
}
}
}

View File

@@ -0,0 +1,785 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.runtime.Trigger;
import com.easyagents.flow.core.chain.runtime.TriggerClaimLostException;
import com.easyagents.flow.core.chain.runtime.TriggerStore;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tech.easyflow.common.constant.CacheKey;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 基于 Redis 有序集合和租约认领的工作流触发器仓储。
*
* <p>当前实现的待执行集合与触发器数据使用同一 Lua 操作,派生触发器保存时会同时
* 校验实例锁 fencing token 和父触发器认领代际。部署约束为 Redis Standalone 或
* Sentinel不支持 Redis Cluster。</p>
*/
@Component
public class RedisTriggerStore implements TriggerStore {
private static final Logger log = LoggerFactory.getLogger(RedisTriggerStore.class);
private static final Duration TRIGGER_TTL = Duration.ofDays(3);
private static final Duration CLAIM_GENERATION_TTL = Duration.ofDays(4);
/**
* 与底层调度器本地 Future 容量对齐,避免已预热任务反复占据扫描窗口。
*/
private static final int DUE_BATCH_SIZE = 1024;
private static final int RECOVERY_BATCH_SIZE = 1000;
private static final DefaultRedisScript<Long> SAVE_SCRIPT = longScript(
"redis.call('psetex', KEYS[1], ARGV[1], ARGV[2]); "
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[4]); return 1");
private static final DefaultRedisScript<Long> SAVE_IF_ABSENT_SCRIPT =
longScript(
"if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
+ "redis.call('psetex', KEYS[1], ARGV[1], ARGV[2]); "
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[4]); return 1");
private static final DefaultRedisScript<Long> LOCK_GUARDED_SAVE_SCRIPT = longScript(
"local fence = redis.call('hget', KEYS[3], 'version'); "
+ "if not fence or fence ~= ARGV[5] then return 0 end; "
+ "redis.call('psetex', KEYS[1], ARGV[1], ARGV[2]); "
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[4]); return 1");
private static final DefaultRedisScript<Long>
LOCK_GUARDED_SAVE_IF_ABSENT_SCRIPT =
longScript(
"local fence = redis.call('hget', KEYS[3], 'version'); "
+ "if not fence or fence ~= ARGV[5] then return -1 end; "
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
+ "redis.call('psetex', KEYS[1], ARGV[1], ARGV[2]); "
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[4]); return 1");
private static final DefaultRedisScript<Long> DOUBLE_GUARDED_SAVE_SCRIPT = longScript(
"local fence = redis.call('hget', KEYS[3], 'version'); "
+ "local claim = redis.call('hget', KEYS[4], 'version'); "
+ "if not fence or fence ~= ARGV[5] "
+ "or not claim or claim ~= ARGV[6] then return 0 end; "
+ "redis.call('psetex', KEYS[1], ARGV[1], ARGV[2]); "
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[4]); return 1");
private static final DefaultRedisScript<Long>
DOUBLE_GUARDED_SAVE_IF_ABSENT_SCRIPT =
longScript(
"local fence = redis.call('hget', KEYS[3], 'version'); "
+ "local claim = redis.call('hget', KEYS[4], 'version'); "
+ "if not fence or fence ~= ARGV[5] "
+ "or not claim or claim ~= ARGV[6] then return -1 end; "
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
+ "redis.call('psetex', KEYS[1], ARGV[1], ARGV[2]); "
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[4]); return 1");
private static final DefaultRedisScript<String> CLAIM_SCRIPT = stringScript(
"if redis.call('exists', KEYS[1]) == 0 then "
+ "redis.call('zrem', KEYS[3], ARGV[3]); return nil end; "
+ "local claimed = redis.call('set', KEYS[2], ARGV[1], 'PX', ARGV[2], 'NX'); "
+ "if not claimed then return nil end; "
+ "local payload = redis.call('get', KEYS[1]); "
+ "local generation = redis.call('hincrby', KEYS[5], 'version', 1); "
+ "redis.call('pexpire', KEYS[5], ARGV[5]); "
+ "redis.call('hset', KEYS[4], 'version', generation); "
+ "redis.call('pexpire', KEYS[4], ARGV[2]); "
+ "redis.call('zadd', KEYS[3], ARGV[4], ARGV[3]); "
+ "return tostring(generation) .. '\\n' .. payload");
private static final DefaultRedisScript<Long> ACK_SCRIPT = longScript(
"if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "redis.call('del', KEYS[1]); redis.call('del', KEYS[2]); "
+ "redis.call('del', KEYS[4]); "
+ "redis.call('zrem', KEYS[3], ARGV[2]); return 1 else return 0 end");
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = longScript(
"if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "redis.call('del', KEYS[1]); redis.call('del', KEYS[4]); "
+ "redis.call('psetex', KEYS[3], ARGV[4], ARGV[5]); "
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[2]); "
+ "return 1 else return 0 end");
private static final DefaultRedisScript<Long> MARK_DEAD_LETTER_PENDING_SCRIPT =
longScript(
"if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "redis.call('psetex', KEYS[2], ARGV[2], ARGV[3]); "
+ "return 1 else return 0 end");
private static final DefaultRedisScript<Long> DEAD_LETTER_SCRIPT = longScript(
"if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "redis.call('psetex', KEYS[4], ARGV[3], ARGV[4]); "
+ "redis.call('del', KEYS[5]); "
+ "redis.call('del', KEYS[1]); redis.call('del', KEYS[2]); "
+ "redis.call('zrem', KEYS[3], ARGV[2]); return 1 else return 0 end");
private static final DefaultRedisScript<Long> RENEW_SCRIPT = longScript(
"local guard = redis.call('hget', KEYS[3], 'version'); "
+ "if redis.call('get', KEYS[1]) == ARGV[1] "
+ "and guard and guard == ARGV[5] then "
+ "redis.call('pexpire', KEYS[1], ARGV[2]); "
+ "redis.call('pexpire', KEYS[3], ARGV[2]); "
+ "redis.call('zadd', KEYS[2], ARGV[4], ARGV[3]); "
+ "return 1 else return 0 end");
private static final DefaultRedisScript<Long> REMOVE_SCRIPT = longScript(
"redis.call('del', KEYS[1]); redis.call('del', KEYS[2]); "
+ "return redis.call('zrem', KEYS[3], ARGV[1])");
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final Map<Trigger, ClaimContext> claimContexts =
Collections.synchronizedMap(new IdentityHashMap<>());
/**
* 创建 Redis 触发器仓储。
*
* @param redisTemplate Redis 字符串模板
* @param objectMapper JSON 序列化器
*/
public RedisTriggerStore(StringRedisTemplate redisTemplate,
ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
}
/**
* {@inheritDoc}
*/
@Override
public Trigger save(Trigger trigger) {
if (trigger.getId() == null) {
trigger.setId(UUID.randomUUID().toString());
}
long ttlMillis = Math.max(
TRIGGER_TTL.toMillis(),
Math.max(0L, trigger.getTriggerAt() - System.currentTimeMillis())
+ TRIGGER_TTL.toMillis());
List<String> keys = new ArrayList<>();
keys.add(dataKey(trigger.getId()));
keys.add(CacheKey.TRIGGER_PENDING_KEY);
Long saved;
long requiredLockFencingToken = trigger.getRequiredLockFencingToken();
long requiredClaimGeneration = trigger.getRequiredFencingToken();
if (requiredLockFencingToken > 0L && requiredClaimGeneration > 0L) {
String requiredClaimId = requireText(
trigger.getRequiredFencingClaimId(),
"required fencing claim ID");
keys.add(lockFenceKey(trigger.getStateInstanceId()));
keys.add(executionGuardKey(trigger.getStateInstanceId(), requiredClaimId));
saved = redisTemplate.execute(
DOUBLE_GUARDED_SAVE_SCRIPT,
keys,
String.valueOf(ttlMillis),
serialize(trigger),
String.valueOf(trigger.getTriggerAt()),
trigger.getId(),
String.valueOf(requiredLockFencingToken),
String.valueOf(requiredClaimGeneration));
} else if (requiredLockFencingToken > 0L) {
keys.add(lockFenceKey(trigger.getStateInstanceId()));
saved = redisTemplate.execute(
LOCK_GUARDED_SAVE_SCRIPT,
keys,
String.valueOf(ttlMillis),
serialize(trigger),
String.valueOf(trigger.getTriggerAt()),
trigger.getId(),
String.valueOf(requiredLockFencingToken));
} else if (requiredClaimGeneration > 0L) {
throw new IllegalArgumentException(
"required lock fencing token is required with claim generation");
} else {
saved = redisTemplate.execute(
SAVE_SCRIPT,
keys,
String.valueOf(ttlMillis),
serialize(trigger),
String.valueOf(trigger.getTriggerAt()),
trigger.getId());
}
if (!Long.valueOf(1L).equals(saved)) {
throw new TriggerClaimLostException(trigger.getId());
}
return trigger;
}
/**
* {@inheritDoc}
*/
@Override
public boolean saveIfAbsent(Trigger trigger) {
String triggerId =
requireText(trigger.getId(),
"stable trigger ID");
long ttlMillis = Math.max(
TRIGGER_TTL.toMillis(),
Math.max(0L,
trigger.getTriggerAt()
- System.currentTimeMillis())
+ TRIGGER_TTL.toMillis());
List<String> keys = new ArrayList<>();
keys.add(dataKey(triggerId));
keys.add(CacheKey.TRIGGER_PENDING_KEY);
Long saved;
long requiredLockFencingToken =
trigger.getRequiredLockFencingToken();
long requiredClaimGeneration =
trigger.getRequiredFencingToken();
if (requiredLockFencingToken > 0L
&& requiredClaimGeneration > 0L) {
String requiredClaimId = requireText(
trigger.getRequiredFencingClaimId(),
"required fencing claim ID");
keys.add(lockFenceKey(
trigger.getStateInstanceId()));
keys.add(executionGuardKey(
trigger.getStateInstanceId(),
requiredClaimId));
saved = redisTemplate.execute(
DOUBLE_GUARDED_SAVE_IF_ABSENT_SCRIPT,
keys,
String.valueOf(ttlMillis),
serialize(trigger),
String.valueOf(trigger.getTriggerAt()),
triggerId,
String.valueOf(requiredLockFencingToken),
String.valueOf(requiredClaimGeneration));
} else if (requiredLockFencingToken > 0L) {
keys.add(lockFenceKey(
trigger.getStateInstanceId()));
saved = redisTemplate.execute(
LOCK_GUARDED_SAVE_IF_ABSENT_SCRIPT,
keys,
String.valueOf(ttlMillis),
serialize(trigger),
String.valueOf(trigger.getTriggerAt()),
triggerId,
String.valueOf(requiredLockFencingToken));
} else if (requiredClaimGeneration > 0L) {
throw new IllegalArgumentException(
"required lock fencing token is required with claim generation");
} else {
saved = redisTemplate.execute(
SAVE_IF_ABSENT_SCRIPT,
keys,
String.valueOf(ttlMillis),
serialize(trigger),
String.valueOf(trigger.getTriggerAt()),
triggerId);
}
if (Long.valueOf(1L).equals(saved)) {
return true;
}
if (Long.valueOf(0L).equals(saved)) {
return false;
}
if (Long.valueOf(-1L).equals(saved)) {
throw new TriggerClaimLostException(triggerId);
}
throw new IllegalStateException(
"Trigger create returned no result: "
+ triggerId);
}
/**
* {@inheritDoc}
*/
@Override
public boolean remove(String triggerId) {
List<String> guardKeys = removeLocalClaims(triggerId);
if (!guardKeys.isEmpty()) {
redisTemplate.delete(guardKeys);
}
Long removed = redisTemplate.execute(
REMOVE_SCRIPT,
java.util.Arrays.asList(
dataKey(triggerId), claimKey(triggerId), CacheKey.TRIGGER_PENDING_KEY),
triggerId);
return Long.valueOf(1L).equals(removed);
}
/**
* {@inheritDoc}
*/
@Override
public Trigger find(String triggerId) {
return deserialize(redisTemplate.opsForValue().get(dataKey(triggerId)));
}
/**
* {@inheritDoc}
*/
@Override
public List<Trigger> findDue(long uptoTimestamp) {
return findByScore(0L, uptoTimestamp, DUE_BATCH_SIZE);
}
/**
* {@inheritDoc}
*/
@Override
public List<Trigger> findAllPending() {
return findByScore(0L, Long.MAX_VALUE, RECOVERY_BATCH_SIZE);
}
/**
* {@inheritDoc}
*/
@Override
public Trigger claim(String triggerId, long leaseMillis) {
return claim(find(triggerId), leaseMillis);
}
/**
* {@inheritDoc}
*/
@Override
public Trigger claim(Trigger candidate, long leaseMillis) {
if (candidate == null) {
return null;
}
String triggerId = requireText(candidate.getId(), "trigger ID");
String instanceId = requireText(candidate.getStateInstanceId(), "state instance ID");
String claimToken = UUID.randomUUID().toString();
long lease = Math.max(1L, leaseMillis);
String guardKey = executionGuardKey(instanceId, triggerId);
String payload = redisTemplate.execute(
CLAIM_SCRIPT,
Arrays.asList(
dataKey(triggerId),
claimKey(triggerId),
CacheKey.TRIGGER_PENDING_KEY,
guardKey,
claimGenerationKey(instanceId)),
claimToken,
String.valueOf(lease),
triggerId,
String.valueOf(System.currentTimeMillis() + lease),
String.valueOf(CLAIM_GENERATION_TTL.toMillis()));
int separator = payload == null ? -1 : payload.indexOf('\n');
if (payload != null && separator <= 0) {
redisTemplate.delete(guardKey);
throw new IllegalStateException(
"Claim result is missing generation: " + triggerId);
}
long claimGeneration = payload == null
? 0L
: Long.parseLong(payload.substring(0, separator));
Trigger trigger = deserialize(
payload == null ? null : payload.substring(separator + 1));
if (trigger != null) {
if (!triggerId.equals(trigger.getId())
|| !instanceId.equals(trigger.getStateInstanceId())) {
redisTemplate.delete(guardKey);
throw new IllegalStateException("Claimed trigger identity changed: " + triggerId);
}
trigger.setFencingToken(claimGeneration);
claimContexts.put(
trigger,
new ClaimContext(claimToken, guardKey, claimGeneration));
}
return trigger;
}
/**
* {@inheritDoc}
*/
@Override
public boolean renewClaim(Trigger trigger, long leaseMillis) {
ClaimContext claim = claimContexts.get(trigger);
if (claim == null) {
return false;
}
long lease = Math.max(1L, leaseMillis);
Long renewed = redisTemplate.execute(
RENEW_SCRIPT,
Arrays.asList(
claimKey(trigger.getId()),
CacheKey.TRIGGER_PENDING_KEY,
claim.guardKey),
claim.ownerToken,
String.valueOf(lease),
trigger.getId(),
String.valueOf(System.currentTimeMillis() + lease),
String.valueOf(claim.claimGeneration));
return Long.valueOf(1L).equals(renewed);
}
/**
* {@inheritDoc}
*/
@Override
public void acknowledge(Trigger trigger) {
ClaimContext claim = claimContexts.get(trigger);
if (claim == null) {
throw new TriggerClaimLostException(
trigger.getId());
}
Long acknowledged = redisTemplate.execute(
ACK_SCRIPT,
Arrays.asList(
claimKey(trigger.getId()),
dataKey(trigger.getId()),
CacheKey.TRIGGER_PENDING_KEY,
claim.guardKey),
claim.ownerToken,
trigger.getId());
finishClaimMutation(
trigger, claim, acknowledged);
}
/**
* {@inheritDoc}
*/
@Override
public void release(Trigger trigger) {
ClaimContext claim = claimContexts.get(trigger);
if (claim == null) {
throw new TriggerClaimLostException(
trigger.getId());
}
Long released = redisTemplate.execute(
RELEASE_SCRIPT,
Arrays.asList(
claimKey(trigger.getId()),
CacheKey.TRIGGER_PENDING_KEY,
dataKey(trigger.getId()),
claim.guardKey),
claim.ownerToken,
trigger.getId(),
String.valueOf(trigger.getTriggerAt()),
String.valueOf(TRIGGER_TTL.toMillis()),
serialize(trigger));
finishClaimMutation(
trigger, claim, released);
}
/**
* {@inheritDoc}
*/
@Override
public void markDeadLetterPending(
Trigger trigger) {
ClaimContext claim =
claimContexts.get(trigger);
if (claim == null) {
throw new TriggerClaimLostException(
trigger.getId());
}
Long marked = redisTemplate.execute(
MARK_DEAD_LETTER_PENDING_SCRIPT,
Arrays.asList(
claimKey(trigger.getId()),
dataKey(trigger.getId())),
claim.ownerToken,
String.valueOf(
TRIGGER_TTL.toMillis()),
serialize(trigger));
if (Long.valueOf(1L).equals(marked)) {
return;
}
if (Long.valueOf(0L).equals(marked)) {
claimContexts.remove(trigger, claim);
throw new TriggerClaimLostException(
trigger.getId());
}
throw new IllegalStateException(
"Dead-letter marker returned no result: "
+ trigger.getId());
}
/**
* {@inheritDoc}
*/
@Override
public void deadLetter(Trigger trigger, String reason) {
ClaimContext claim = claimContexts.get(trigger);
if (claim == null) {
throw new TriggerClaimLostException(
trigger.getId());
}
Long moved = redisTemplate.execute(
DEAD_LETTER_SCRIPT,
Arrays.asList(
claimKey(trigger.getId()),
dataKey(trigger.getId()),
CacheKey.TRIGGER_PENDING_KEY,
CacheKey.TRIGGER_DEAD_LETTER_KEY + trigger.getId(),
claim.guardKey),
claim.ownerToken,
trigger.getId(),
String.valueOf(TRIGGER_TTL.toMillis()),
serialize(trigger));
if (Long.valueOf(1L).equals(moved)) {
claimContexts.remove(trigger, claim);
log.error(
"Workflow trigger moved to dead letter, triggerId={}, reason={}",
trigger.getId(),
reason);
return;
}
if (Long.valueOf(0L).equals(moved)) {
claimContexts.remove(trigger, claim);
throw new TriggerClaimLostException(
trigger.getId());
}
throw new IllegalStateException(
"Dead-letter operation returned no result: "
+ trigger.getId());
}
/**
* 校验 claim 变更结果,并在 Redis 已完成或确认失去 owner 后清理本地凭证。
*
* @param trigger 已认领触发器
* @param claim 本地认领上下文
* @param result Redis 原子脚本结果
*/
private void finishClaimMutation(
Trigger trigger,
ClaimContext claim,
Long result) {
if (Long.valueOf(1L).equals(result)) {
claimContexts.remove(trigger, claim);
return;
}
if (Long.valueOf(0L).equals(result)) {
claimContexts.remove(trigger, claim);
throw new TriggerClaimLostException(
trigger.getId());
}
throw new IllegalStateException(
"Trigger claim mutation returned no result: "
+ trigger.getId());
}
/**
* 清理指定触发器 ID 的所有进程内认领凭证。
*
* @param triggerId 触发器 ID
*/
private List<String> removeLocalClaims(String triggerId) {
List<String> guardKeys = new ArrayList<>();
synchronized (claimContexts) {
claimContexts.entrySet().removeIf(entry -> {
if (!triggerId.equals(entry.getKey().getId())) {
return false;
}
guardKeys.add(entry.getValue().guardKey);
return true;
});
}
return guardKeys;
}
/**
* 按触发时间范围批量加载触发器。
*
* @param minScore 最小触发时间
* @param maxScore 最大触发时间
* @param limit 最大返回数
* @return 保持触发时间顺序的触发器列表
*/
private List<Trigger> findByScore(long minScore, long maxScore, int limit) {
java.util.Set<String> triggerIds = redisTemplate.opsForZSet().rangeByScore(
CacheKey.TRIGGER_PENDING_KEY, minScore, maxScore, 0, limit);
if (triggerIds == null || triggerIds.isEmpty()) {
return Collections.emptyList();
}
List<String> ids = new ArrayList<>(triggerIds);
List<String> keys = new ArrayList<>(ids.size());
for (String triggerId : ids) {
keys.add(dataKey(triggerId));
}
List<String> payloads = redisTemplate.opsForValue().multiGet(keys);
List<Trigger> triggers = new ArrayList<>(ids.size());
if (payloads == null) {
return triggers;
}
for (int index = 0; index < payloads.size(); index++) {
String payload = payloads.get(index);
Trigger trigger;
try {
trigger = deserialize(payload);
} catch (IllegalStateException error) {
quarantine(ids.get(index), payload, error);
continue;
}
if (trigger != null) {
triggers.add(trigger);
} else {
redisTemplate.opsForZSet().remove(CacheKey.TRIGGER_PENDING_KEY, ids.get(index));
}
}
return triggers;
}
/**
* 隔离无法反序列化的触发器,避免毒数据持续阻断批量扫描。
*
* @param triggerId 触发器 ID
* @param payload 原始负载
* @param error 解析异常
*/
private void quarantine(String triggerId, String payload, RuntimeException error) {
if (payload != null) {
redisTemplate.opsForValue().set(
CacheKey.TRIGGER_DEAD_LETTER_KEY + triggerId,
payload,
TRIGGER_TTL);
}
remove(triggerId);
log.error("Quarantined invalid workflow trigger payload, triggerId={}", triggerId, error);
}
/**
* 序列化触发器。
*
* @param trigger 触发器
* @return JSON 文本
*/
private String serialize(Trigger trigger) {
try {
return objectMapper.writeValueAsString(trigger);
} catch (JsonProcessingException error) {
throw new IllegalStateException("Failed to serialize workflow trigger: " + trigger.getId(), error);
}
}
/**
* 反序列化触发器。
*
* @param payload JSON 文本
* @return 触发器;输入为空时返回 null
*/
private Trigger deserialize(String payload) {
if (payload == null) {
return null;
}
try {
return objectMapper.readValue(payload, Trigger.class);
} catch (JsonProcessingException error) {
throw new IllegalStateException("Failed to deserialize workflow trigger", error);
}
}
/**
* 构建触发器数据键。
*
* @param triggerId 触发器 ID
* @return Redis 键
*/
private String dataKey(String triggerId) {
return CacheKey.TRIGGER_DATA_KEY + triggerId;
}
/**
* 构建触发器认领键。
*
* @param triggerId 触发器 ID
* @return Redis 键
*/
private String claimKey(String triggerId) {
return CacheKey.TRIGGER_CLAIM_KEY + triggerId;
}
/**
* 构建触发器认领代际分配计数器键。
*
* @param instanceId 工作流实例 ID
* @return 认领代际计数器键
*/
private String claimGenerationKey(String instanceId) {
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:claim-seq";
}
/**
* 构建实例锁 fencing token 键。
*
* @param instanceId 工作流实例 ID
* @return fencing token 键
*/
private String lockFenceKey(String instanceId) {
return CacheKey.CHAIN_STATE_CAS_KEY
+ "{"
+ requireText(instanceId, "state instance ID")
+ "}:fence";
}
/**
* 构建与一次触发器认领绑定的执行守卫键。
*
* @param instanceId 工作流实例 ID
* @param claimId 触发器 ID
* @return 执行守卫键
*/
private String executionGuardKey(String instanceId, String claimId) {
return CacheKey.CHAIN_STATE_CAS_KEY
+ "{"
+ requireText(instanceId, "state instance ID")
+ "}:claim:"
+ requireText(claimId, "claim ID");
}
/**
* 校验必填文本。
*
* @param value 原始值
* @param name 字段名称
* @return 去除首尾空白后的值
*/
private String requireText(String value, String name) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.trim();
}
/**
* 当前进程持有的一次触发器认领上下文。
*/
private static final class ClaimContext {
private final String ownerToken;
private final String guardKey;
private final long claimGeneration;
/**
* 创建认领上下文。
*
* @param ownerToken Redis claim owner token
* @param guardKey 执行守卫键
* @param claimGeneration 本次认领的单调代际
*/
private ClaimContext(
String ownerToken, String guardKey, long claimGeneration) {
this.ownerToken = ownerToken;
this.guardKey = guardKey;
this.claimGeneration = claimGeneration;
}
}
/**
* 创建 Long 返回值的 Redis 脚本。
*
* @param scriptText Lua 脚本文本
* @return Redis 脚本
*/
private static DefaultRedisScript<Long> longScript(String scriptText) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(scriptText);
script.setResultType(Long.class);
return script;
}
/**
* 创建字符串返回值的 Redis 脚本。
*
* @param scriptText Lua 脚本文本
* @return Redis 脚本
*/
private static DefaultRedisScript<String> stringScript(String scriptText) {
DefaultRedisScript<String> script = new DefaultRedisScript<>();
script.setScriptText(scriptText);
script.setResultType(String.class);
return script;
}
}

View File

@@ -0,0 +1,74 @@
package tech.easyflow.ai.easyagentsflow.repository;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.common.constant.CacheKey;
import java.time.Duration;
import java.util.UUID;
/**
* 基于 Redis 的工作流定义缓存版本令牌仓储。
*/
@Component
public class RedisWorkflowDefinitionVersionStore implements WorkflowDefinitionVersionStore {
private static final Duration TOKEN_TTL = Duration.ofDays(7);
private final StringRedisTemplate redisTemplate;
/**
* 创建版本令牌仓储。
*
* @param redisTemplate Redis 字符串模板
*/
public RedisWorkflowDefinitionVersionStore(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* {@inheritDoc}
*/
@Override
public String currentToken(String definitionId) {
String key = versionKey(definitionId);
String current = redisTemplate.opsForValue().get(key);
if (current != null) {
return current;
}
String candidate = UUID.randomUUID().toString();
Boolean created = redisTemplate.opsForValue().setIfAbsent(key, candidate, TOKEN_TTL);
if (Boolean.TRUE.equals(created)) {
return candidate;
}
current = redisTemplate.opsForValue().get(key);
if (current == null) {
throw new IllegalStateException("Workflow definition version token is unavailable: " + definitionId);
}
return current;
}
/**
* {@inheritDoc}
*/
@Override
public void invalidateWorkflow(String workflowId) {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(versionKey(workflowId), token, TOKEN_TTL);
redisTemplate.opsForValue().set(
versionKey(PublishedWorkflowDefinitionIds.published(workflowId)),
token,
TOKEN_TTL);
}
/**
* 构建版本令牌 Redis 键。
*
* @param definitionId 定义 ID
* @return Redis 键
*/
private String versionKey(String definitionId) {
return CacheKey.WORKFLOW_DEFINITION_VERSION_KEY + definitionId;
}
}

View File

@@ -0,0 +1,224 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.ChainDefinition;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import tech.easyflow.ai.easyagentsflow.config.WorkflowRuntimeProperties;
import tech.easyflow.ai.easyagentsflow.event.WorkflowDefinitionChangedEvent;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Supplier;
/**
* 带跨实例版本校验和本地有界 LRU 的工作流编译定义缓存。
*/
@Component
public class WorkflowDefinitionCache {
private static final int MAX_COMPILE_RETRIES = 3;
private final WorkflowDefinitionVersionStore versionStore;
private final int maxEntries;
private final long maxBytes;
private final long expireAfterAccessNanos;
private final Map<String, CacheEntry> entries = new LinkedHashMap<>(16, 0.75F, true);
private final ConcurrentMap<String, Object> compileLocks = new ConcurrentHashMap<>();
private long currentBytes;
/**
* 创建工作流定义缓存。
*
* @param versionStore 跨实例版本令牌仓储
* @param properties 工作流运行时配置
*/
public WorkflowDefinitionCache(
WorkflowDefinitionVersionStore versionStore, WorkflowRuntimeProperties properties) {
this.versionStore = versionStore;
this.maxEntries = Math.max(1, properties.getDefinitionCacheMaxEntries());
this.maxBytes = Math.max(
1L, properties.getDefinitionCacheMaxBytes());
Duration expireAfterAccess = properties.getDefinitionCacheExpireAfterAccess();
this.expireAfterAccessNanos = expireAfterAccess == null
? Duration.ofMinutes(30).toNanos()
: Math.max(1L, expireAfterAccess.toNanos());
}
/**
* 获取已编译定义;缓存未命中时只允许一个线程执行加载与编译。
*
* @param definitionId 定义 ID
* @param loader 定义加载与编译函数
* @return 已编译工作流定义
*/
public ChainDefinition get(String definitionId, Supplier<ChainDefinition> loader) {
String token = versionStore.currentToken(definitionId);
ChainDefinition cached = getCached(definitionId, token);
if (cached != null) {
return cached;
}
Object compileLock = compileLocks.computeIfAbsent(definitionId, ignored -> new Object());
try {
synchronized (compileLock) {
for (int attempt = 0; attempt < MAX_COMPILE_RETRIES; attempt++) {
token = versionStore.currentToken(definitionId);
cached = getCached(definitionId, token);
if (cached != null) {
return cached;
}
ChainDefinition compiled = loader.get();
String tokenAfterCompile = versionStore.currentToken(definitionId);
if (token.equals(tokenAfterCompile)) {
put(definitionId, token, compiled);
return compiled;
}
}
}
} finally {
compileLocks.remove(definitionId, compileLock);
}
throw new IllegalStateException(
"Workflow definition changed repeatedly while compiling: " + definitionId);
}
/**
* 处理定义变更并更新跨实例版本令牌。
*
* @param event 工作流定义变更事件
*/
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true)
public void onDefinitionChanged(WorkflowDefinitionChangedEvent event) {
if (event == null || event.workflowId() == null) {
return;
}
versionStore.invalidateWorkflow(event.workflowId());
synchronized (entries) {
removeEntry(event.workflowId());
removeEntry(PublishedWorkflowDefinitionIds.published(
event.workflowId()));
}
}
/**
* 获取仍有效的本地缓存项。
*
* @param definitionId 定义 ID
* @param token 当前版本令牌
* @return 命中的定义;未命中时返回 null
*/
private ChainDefinition getCached(String definitionId, String token) {
long now = System.nanoTime();
synchronized (entries) {
CacheEntry entry = entries.get(definitionId);
if (entry == null) {
return null;
}
if (!entry.token.equals(token) || now - entry.lastAccessNanos > expireAfterAccessNanos) {
removeEntry(definitionId);
return null;
}
entry.lastAccessNanos = now;
return entry.definition;
}
}
/**
* 保存本地缓存并按 LRU 淘汰。
*
* @param definitionId 定义 ID
* @param token 版本令牌
* @param definition 已编译定义
*/
private void put(String definitionId, String token, ChainDefinition definition) {
synchronized (entries) {
removeEntry(definitionId);
long weight = serializedSize(definition);
entries.put(definitionId, new CacheEntry(
token,
definition,
System.nanoTime(),
weight));
currentBytes += weight;
while (entries.size() > maxEntries
|| (currentBytes > maxBytes
&& entries.size() > 1)) {
String eldestKey = entries.keySet().iterator().next();
removeEntry(eldestKey);
}
}
}
/**
* 删除缓存项并同步维护重量。
*
* @param definitionId 定义 ID
*/
private void removeEntry(String definitionId) {
CacheEntry removed = entries.remove(definitionId);
if (removed != null) {
currentBytes = Math.max(
0L, currentBytes - removed.weightBytes);
}
}
/**
* 使用实际 Java 序列化大小作为缓存重量。
*
* @param definition 编译定义
* @return 序列化字节数
*/
private long serializedSize(ChainDefinition definition) {
try (ByteArrayOutputStream output =
new ByteArrayOutputStream();
ObjectOutputStream objects =
new ObjectOutputStream(output)) {
objects.writeObject(definition);
objects.flush();
return Math.max(1L, output.size());
} catch (IOException error) {
throw new IllegalStateException(
"Failed to estimate workflow definition size",
error);
}
}
/**
* 本地定义缓存项。
*/
private static final class CacheEntry {
private final String token;
private final ChainDefinition definition;
private final long weightBytes;
private long lastAccessNanos;
/**
* 创建本地缓存项。
*
* @param token 版本令牌
* @param definition 已编译定义
* @param lastAccessNanos 最近访问时间
*/
private CacheEntry(
String token,
ChainDefinition definition,
long lastAccessNanos,
long weightBytes) {
this.token = token;
this.definition = definition;
this.lastAccessNanos = lastAccessNanos;
this.weightBytes = weightBytes;
}
}
}

View File

@@ -0,0 +1,22 @@
package tech.easyflow.ai.easyagentsflow.repository;
/**
* 工作流定义缓存版本令牌仓储。
*/
public interface WorkflowDefinitionVersionStore {
/**
* 获取定义当前版本令牌,不存在时原子创建。
*
* @param definitionId 定义 ID包含可选发布态前缀
* @return 当前版本令牌
*/
String currentToken(String definitionId);
/**
* 同时使指定工作流的草稿和发布态版本令牌失效。
*
* @param workflowId 工作流 ID
*/
void invalidateWorkflow(String workflowId);
}

View File

@@ -0,0 +1,288 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.NodeState;
import com.easyagents.flow.core.chain.repository.ChainStateField;
import com.easyagents.flow.core.chain.repository.NodeStateField;
import tech.easyflow.ai.easyagentsflow.support.WorkflowExecutionStepKey;
import tech.easyflow.common.cache.VersionedFields;
import java.io.Serializable;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 工作流状态对象与 Redis 字段之间的无反射映射。
*/
final class WorkflowStateFields {
static final String FORMAT_FIELD = "_format";
static final String FORMAT_VERSION = "2";
private static final String NODE_ID_FIELD = "_nodeId";
private static final String CHAIN_INSTANCE_ID_FIELD = "_chainInstanceId";
private WorkflowStateFields() {
}
/**
* 判断快照是否采用字段化格式。
*
* @param snapshot Redis 快照
* @return 字段化格式时为 {@code true}
*/
static boolean isFieldFormat(VersionedFields snapshot) {
return snapshot != null
&& FORMAT_VERSION.equals(snapshot.getFields().get(FORMAT_FIELD));
}
/**
* 将完整工作流状态编码为字段。
*
* @param state 工作流状态
* @return 完整字段映射
*/
static Map<String, Serializable> allChainFields(ChainState state) {
EnumSet<ChainStateField> fields = EnumSet.allOf(ChainStateField.class);
fields.remove(ChainStateField.VERSION);
return chainFields(state, fields);
}
/**
* 将变化的工作流状态字段编码为可独立提交的值。
*
* @param state 工作流状态
* @param fields 变化字段
* @return 字段映射
*/
static Map<String, Serializable> chainFields(
ChainState state, EnumSet<ChainStateField> fields) {
Map<String, Serializable> values = new LinkedHashMap<>();
values.put(FORMAT_FIELD, FORMAT_VERSION);
for (ChainStateField field : fields) {
Serializable value = switch (field) {
case INSTANCE_ID -> state.getInstanceId();
case STATUS -> state.getStatus();
case MESSAGE -> state.getMessage();
case ERROR -> state.getError();
case MEMORY -> state.getMemory();
case COMPUTE_COST -> state.getComputeCost();
case SUSPEND_NODE_IDS -> serializable(state.getSuspendNodeIds());
case SUSPEND_FOR_PARAMETERS -> serializable(state.getSuspendForParameters());
case EXECUTE_RESULT -> serializable(state.getExecuteResult());
case CHAIN_DEFINITION_ID -> state.getChainDefinitionId();
case ENVIRONMENT -> serializable(state.getEnvironment());
case PARENT_INSTANCE_ID -> state.getParentInstanceId();
case AUDIT_INSTANCE_ID -> state.getAuditInstanceId();
case TRIGGER_NODE_IDS -> serializable(state.getTriggerNodeIds());
case TRIGGER_EDGE_IDS -> serializable(state.getTriggerEdgeIds());
case UNCHECKED_EDGE_IDS -> serializable(state.getUncheckedEdgeIds());
case UNCHECKED_NODE_IDS -> serializable(state.getUncheckedNodeIds());
case STARTED_AT -> state.getStartedAt();
case CHILD_EXECUTION_COUNT -> state.getChildExecutionCount();
case VERSION, PAYLOAD, NODE_STATES, CHILD_STATE_IDS -> null;
};
if (field != ChainStateField.VERSION
&& field != ChainStateField.PAYLOAD
&& field != ChainStateField.NODE_STATES
&& field != ChainStateField.CHILD_STATE_IDS) {
values.put(field.name(), value);
}
}
return values;
}
/**
* 从字段快照还原工作流状态。
*
* @param snapshot Redis 字段快照
* @return 工作流状态
*/
@SuppressWarnings("unchecked")
static ChainState decodeChain(VersionedFields snapshot) {
Map<String, Object> fields = snapshot.getFields();
ChainState state = new ChainState();
state.setInstanceId((String) fields.get(ChainStateField.INSTANCE_ID.name()));
state.setStatus((com.easyagents.flow.core.chain.ChainStatus)
fields.get(ChainStateField.STATUS.name()));
state.setMessage((String) fields.get(ChainStateField.MESSAGE.name()));
state.setError((com.easyagents.flow.core.chain.ExceptionSummary)
fields.get(ChainStateField.ERROR.name()));
Object memory = fields.get(ChainStateField.MEMORY.name());
state.setMemory(memory == null
? new ConcurrentHashMap<>()
: new ConcurrentHashMap<>((Map<String, Object>) memory));
state.setComputeCost(number(fields.get(ChainStateField.COMPUTE_COST.name())));
state.setSuspendNodeIds((java.util.Set<String>)
fields.get(ChainStateField.SUSPEND_NODE_IDS.name()));
state.setSuspendForParameters((java.util.List<com.easyagents.flow.core.chain.Parameter>)
fields.get(ChainStateField.SUSPEND_FOR_PARAMETERS.name()));
state.setExecuteResult((Map<String, Object>)
fields.get(ChainStateField.EXECUTE_RESULT.name()));
state.setChainDefinitionId((String)
fields.get(ChainStateField.CHAIN_DEFINITION_ID.name()));
state.setEnvironment((Map<String, Object>)
fields.get(ChainStateField.ENVIRONMENT.name()));
state.setParentInstanceId((String)
fields.get(ChainStateField.PARENT_INSTANCE_ID.name()));
state.setAuditInstanceId((String)
fields.get(ChainStateField.AUDIT_INSTANCE_ID.name()));
state.setTriggerNodeIds((java.util.List<String>)
fields.get(ChainStateField.TRIGGER_NODE_IDS.name()));
state.setTriggerEdgeIds((java.util.List<String>)
fields.get(ChainStateField.TRIGGER_EDGE_IDS.name()));
state.setUncheckedEdgeIds((java.util.List<String>)
fields.get(ChainStateField.UNCHECKED_EDGE_IDS.name()));
state.setUncheckedNodeIds((java.util.List<String>)
fields.get(ChainStateField.UNCHECKED_NODE_IDS.name()));
state.setStartedAt(number(fields.get(ChainStateField.STARTED_AT.name())));
state.setChildExecutionCount(number(
fields.get(ChainStateField.CHILD_EXECUTION_COUNT.name())));
state.setVersion(snapshot.getVersion());
return state;
}
/**
* 将完整节点状态编码为字段。
*
* @param state 节点状态
* @return 完整字段映射
*/
static Map<String, Serializable> allNodeFields(NodeState state) {
EnumSet<NodeStateField> fields = EnumSet.allOf(NodeStateField.class);
fields.remove(NodeStateField.VERSION);
return nodeFields(state, fields);
}
/**
* 将变化的节点状态字段编码为可独立提交的值。
*
* @param state 节点状态
* @param fields 变化字段
* @return 字段映射
*/
static Map<String, Serializable> nodeFields(
NodeState state, EnumSet<NodeStateField> fields) {
Map<String, Serializable> values = new LinkedHashMap<>();
values.put(FORMAT_FIELD, FORMAT_VERSION);
values.put(NODE_ID_FIELD, state.getNodeId());
values.put(CHAIN_INSTANCE_ID_FIELD, state.getChainInstanceId());
for (NodeStateField field : fields) {
Serializable value = switch (field) {
case STATUS -> state.getStatus();
case ERROR -> state.getError();
case MEMORY -> state.getMemory();
case RETRY_COUNT -> state.getRetryCount();
case EXECUTE_COUNT -> state.getExecuteCount();
case EXECUTE_EDGE_IDS -> serializable(state.getExecuteEdgeIds());
case EXECUTION_ATTEMPT_KEY -> state.getExecutionAttemptKey();
case LOOP_COUNT -> state.getLoopCount();
case TRIGGER_COUNT -> state.getTriggerCount();
case TRIGGER_EDGE_IDS -> serializable(state.getTriggerEdgeIds());
case INSTANCE_ID, MESSAGE, PAYLOAD, NODE_STATES, COMPUTE_COST,
SUSPEND_NODE_IDS, SUSPEND_FOR_PARAMETERS, EXECUTE_RESULT,
ENVIRONMENT, VERSION -> null;
};
if (field == NodeStateField.STATUS
|| field == NodeStateField.ERROR
|| field == NodeStateField.MEMORY
|| field == NodeStateField.RETRY_COUNT
|| field == NodeStateField.EXECUTE_COUNT
|| field == NodeStateField.EXECUTE_EDGE_IDS
|| field == NodeStateField.EXECUTION_ATTEMPT_KEY
|| field == NodeStateField.LOOP_COUNT
|| field == NodeStateField.TRIGGER_COUNT
|| field == NodeStateField.TRIGGER_EDGE_IDS) {
values.put(field.name(), value);
}
}
return values;
}
/**
* 从字段快照还原节点状态。
*
* @param snapshot Redis 字段快照
* @return 节点状态
*/
@SuppressWarnings("unchecked")
static NodeState decodeNode(VersionedFields snapshot) {
Map<String, Object> fields = snapshot.getFields();
NodeState state = new NodeState();
state.setNodeId((String) fields.get(NODE_ID_FIELD));
state.setChainInstanceId((String) fields.get(CHAIN_INSTANCE_ID_FIELD));
state.setStatus((com.easyagents.flow.core.chain.NodeStatus)
fields.get(NodeStateField.STATUS.name()));
state.setError((com.easyagents.flow.core.chain.ExceptionSummary)
fields.get(NodeStateField.ERROR.name()));
Object memory = fields.get(NodeStateField.MEMORY.name());
state.setMemory(memory == null
? new ConcurrentHashMap<>()
: new ConcurrentHashMap<>((Map<String, Object>) memory));
state.setRetryCount(integer(fields.get(NodeStateField.RETRY_COUNT.name())));
state.setExecuteCount(atomic(fields.get(NodeStateField.EXECUTE_COUNT.name())));
state.setExecuteEdgeIds(defaultList(
(java.util.List<String>) fields.get(NodeStateField.EXECUTE_EDGE_IDS.name())));
state.setExecutionAttemptKey(
(String) fields.get(
NodeStateField
.EXECUTION_ATTEMPT_KEY
.name()));
state.setLoopCount(integer(fields.get(NodeStateField.LOOP_COUNT.name())));
state.setTriggerCount(atomic(fields.get(NodeStateField.TRIGGER_COUNT.name())));
state.setTriggerEdgeIds(defaultList(
(java.util.List<String>) fields.get(NodeStateField.TRIGGER_EDGE_IDS.name())));
state.setVersion(snapshot.getVersion());
return normalizeNode(state);
}
/**
* 补齐升级前节点快照缺失的执行尝试键。
*
* @param state 字段化或旧对象节点状态
* @return 原节点状态
*/
static NodeState normalizeNode(NodeState state) {
if (state == null
|| (state.getExecutionAttemptKey() != null
&& !state.getExecutionAttemptKey().isBlank())
|| state.getMemory() == null) {
return state;
}
Object legacyExecKey =
state.getMemory().get(
"executeId");
if (legacyExecKey instanceof String) {
state.setExecutionAttemptKey(
WorkflowExecutionStepKey
.encodeLegacy(
(String) legacyExecKey));
}
return state;
}
private static Serializable serializable(Object value) {
return value == null ? null : (Serializable) value;
}
private static long number(Object value) {
return value instanceof Number ? ((Number) value).longValue() : 0L;
}
private static int integer(Object value) {
return value instanceof Number ? ((Number) value).intValue() : 0;
}
private static AtomicInteger atomic(Object value) {
if (value instanceof AtomicInteger) {
return new AtomicInteger(((AtomicInteger) value).get());
}
return new AtomicInteger(integer(value));
}
private static java.util.List<String> defaultList(java.util.List<String> value) {
return value == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(value);
}
}

View File

@@ -3,6 +3,7 @@ package tech.easyflow.ai.easyagentsflow.service;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.ExceptionSummary;
import com.easyagents.flow.core.chain.NodeState;
import com.easyagents.flow.core.chain.NodeStatus;
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
@@ -14,14 +15,24 @@ import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
/**
* 为工作流设计器提供执行状态查询与结果解析能力。
*/
@Component
public class TinyFlowService {
/**
* 工作流执行器及其状态仓储入口。
*/
@Resource
private ChainExecutor chainExecutor;
/**
* 获取执行状态
* 获取工作流及其节点的执行状态
*
* @param executeId 工作流执行实例 ID
* @param nodes 设计器中的节点列表
* @return 工作流执行状态
*/
public ChainInfo getChainStatus(String executeId, List<NodeInfo> nodes) {
@@ -33,7 +44,7 @@ public class TinyFlowService {
if (nodes != null) {
for (NodeInfo node : nodes) {
processNodeState(executeId, node, chainStateRepository, nodeStateRepository);
processNodeState(executeId, node, chainState, nodeStateRepository);
res.getNodes().put(node.getNodeId(), node);
}
}
@@ -41,21 +52,31 @@ public class TinyFlowService {
}
/**
* 处理节点状态
* 使用同一工作流状态快照补充节点状态,避免轮询期间重复读取工作流状态。
*
* @param currentExecuteId 工作流执行实例 ID
* @param node 待补充状态的节点
* @param currentChainState 当前轮询取得的工作流状态快照
* @param nodeStateRepository 节点状态仓储
*/
private void processNodeState(String currentExecuteId,
NodeInfo node,
ChainStateRepository chainStateRepository,
ChainState currentChainState,
NodeStateRepository nodeStateRepository) {
// 加载当前层的状态
ChainState currentChainState = chainStateRepository.load(currentExecuteId);
NodeState currentNodeState = nodeStateRepository.load(currentExecuteId, node.getNodeId());
setNodeStatus(node, currentNodeState, currentChainState);
}
private static ChainInfo getChainInfo(String executeId, ChainState chainState) {
/**
* 将工作流状态转换为设计器响应。
*
* @param executeId 工作流执行实例 ID
* @param chainState 工作流状态快照
* @return 设计器工作流状态
*/
private ChainInfo getChainInfo(String executeId, ChainState chainState) {
ChainInfo res = new ChainInfo();
res.setExecuteId(executeId);
res.setStatus(chainState.getStatus().getValue());
@@ -65,24 +86,41 @@ public class TinyFlowService {
}
Map<String, Object> executeResult = chainState.getExecuteResult();
if (executeResult != null && !executeResult.isEmpty()) {
res.setResult(executeResult);
@SuppressWarnings("unchecked")
Map<String, Object> resolved = (Map<String, Object>)
chainExecutor.resolveResultReferences(executeResult);
res.setResult(resolved);
}
return res;
}
/**
* 将节点状态和节点执行结果写入设计器节点。
*
* @param node 设计器节点
* @param nodeState 节点状态;节点尚未开始执行时可为空
* @param chainState 工作流状态快照
*/
private void setNodeStatus(NodeInfo node, NodeState nodeState, ChainState chainState) {
String nodeId = node.getNodeId();
// 如果状态为空或不存在,可能不需要覆盖,这里视具体业务逻辑而定,目前保持原逻辑
node.setStatus(nodeState.getStatus().getValue());
// 旧仓储会为未启动节点返回 READY 状态;纯读取仓储返回空时保持相同行为但不产生写入。
node.setStatus(nodeState == null
? NodeStatus.READY.getValue()
: nodeState.getStatus().getValue());
ExceptionSummary error = nodeState.getError();
if (error != null) {
node.setMessage(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
if (nodeState != null) {
ExceptionSummary error = nodeState.getError();
if (error != null) {
node.setMessage(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
}
}
Map<String, Object> nodeExecuteResult = chainState.getNodeExecuteResult(nodeId);
if (nodeExecuteResult != null && !nodeExecuteResult.isEmpty()) {
node.setResult(nodeExecuteResult);
@SuppressWarnings("unchecked")
Map<String, Object> resolved = (Map<String, Object>)
chainExecutor.resolveResultReferences(nodeExecuteResult);
node.setResult(resolved);
}
// 只有当参数不为空时才覆盖

View File

@@ -22,6 +22,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.datacenter.execution.model.DatacenterSchemaResponse;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayDeque;
import java.util.ArrayList;
@@ -50,6 +51,8 @@ public class WorkflowCheckService {
private static final String TYPE_PLUGIN = "plugin-node";
private static final String TYPE_MAKE_FILE = "make-file";
private static final String SYSTEM_START_PARAM_NAME = "user_input";
private static final int MIN_LOOP_COUNT = 1;
private static final int MAX_LOOP_COUNT = 300;
@Resource
private WorkflowService workflowService;
@@ -171,6 +174,7 @@ public class WorkflowCheckService {
"父节点不存在: " + node.parentId, node.id, null, node.name);
}
}
checkLoopConfigurations(nodes, nodeMap, issues, issueKeys);
List<EdgeView> edges = new ArrayList<>();
Set<String> edgeIds = new HashSet<>();
@@ -221,6 +225,159 @@ public class WorkflowCheckService {
return parsedWorkflow;
}
/**
* 校验普通循环、显式循环和循环父子层级。
*
* @param nodes 节点列表
* @param nodeMap 节点索引
* @param issues 问题列表
* @param issueKeys 问题去重键
*/
private void checkLoopConfigurations(
List<NodeView> nodes,
Map<String, NodeView> nodeMap,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
for (NodeView node : nodes) {
checkConfiguredLoopCount(node, issues, issueKeys);
checkFixedExplicitLoopCount(node, issues, issueKeys);
if (StringUtils.hasText(node.parentId)) {
NodeView parent = nodeMap.get(node.parentId);
if (parent != null && !TYPE_LOOP.equals(parent.type)) {
addIssue(
issues,
issueKeys,
"NODE_PARENT_NOT_LOOP",
"嵌套节点的父节点必须是循环节点",
node.id,
null,
node.name);
}
}
checkLoopParentCycle(node, nodeMap, issues, issueKeys);
}
}
/**
* 校验普通节点启用循环后的总执行次数。
*
* @param node 节点
* @param issues 问题列表
* @param issueKeys 问题去重键
*/
private void checkConfiguredLoopCount(
NodeView node,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
if (node.data == null
|| !Boolean.TRUE.equals(node.data.getBoolean("loopEnable"))) {
return;
}
Object value = node.data.get("maxLoopCount");
if (value != null) {
addLoopCountIssueIfInvalid(
value, "LOOP_COUNT_INVALID", node, issues, issueKeys);
}
}
/**
* 校验显式循环节点使用固定数值时的次数范围。
*
* @param node 节点
* @param issues 问题列表
* @param issueKeys 问题去重键
*/
private void checkFixedExplicitLoopCount(
NodeView node,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
if (!TYPE_LOOP.equals(node.type) || node.data == null) {
return;
}
JSONArray loopVars = node.data.getJSONArray("loopVars");
if (loopVars == null || loopVars.isEmpty()) {
return;
}
JSONObject loopVar = loopVars.getJSONObject(0);
if (loopVar == null || !"fixed".equals(loopVar.getString("refType"))) {
return;
}
Object value = loopVar.get("value");
if (value != null && StringUtils.hasText(String.valueOf(value))) {
addLoopCountIssueIfInvalid(
value,
"EXPLICIT_LOOP_COUNT_INVALID",
node,
issues,
issueKeys);
}
}
/**
* 在次数值无效时添加校验问题。
*
* @param value 原始次数
* @param code 问题编码
* @param node 节点
* @param issues 问题列表
* @param issueKeys 问题去重键
*/
private void addLoopCountIssueIfInvalid(
Object value,
String code,
NodeView node,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
try {
int count = new BigDecimal(String.valueOf(value).trim())
.intValueExact();
if (count >= MIN_LOOP_COUNT && count <= MAX_LOOP_COUNT) {
return;
}
} catch (ArithmeticException | NumberFormatException ignored) {
// 统一在下方返回用户可执行的范围提示。
}
addIssue(
issues,
issueKeys,
code,
"循环次数必须是 1300 的整数",
node.id,
null,
node.name);
}
/**
* 校验 parentId 层级不存在循环引用。
*
* @param node 起始节点
* @param nodeMap 节点索引
* @param issues 问题列表
* @param issueKeys 问题去重键
*/
private void checkLoopParentCycle(
NodeView node,
Map<String, NodeView> nodeMap,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
Set<String> visited = new HashSet<>();
NodeView current = node;
while (current != null && StringUtils.hasText(current.parentId)) {
if (!visited.add(current.id)) {
addIssue(
issues,
issueKeys,
"LOOP_PARENT_CYCLE",
"循环嵌套层级存在循环引用",
node.id,
null,
node.name);
return;
}
current = nodeMap.get(current.parentId);
}
}
private void checkDatacenterNodes(ParsedWorkflow parsed, List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
for (NodeView node : parsed.nodes) {
if (node == null) {

View File

@@ -0,0 +1,47 @@
package tech.easyflow.ai.easyagentsflow.support;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
/**
* 工作流节点执行步骤键转换工具。
*/
public final class WorkflowExecutionStepKey {
private static final String LEGACY_PREFIX =
"easyflow-legacy-exec-key:";
private WorkflowExecutionStepKey() {
}
/**
* 将旧快照中的最终执行键编码为可随节点生命周期传递的兼容键。
*
* @param execKey 旧版最终执行键
* @return 兼容键;输入为空时为 {@code null}
*/
public static String encodeLegacy(String execKey) {
return StrUtil.isBlank(execKey)
? null
: LEGACY_PREFIX + execKey;
}
/**
* 将节点业务尝试键转换为最终执行步骤键。
*
* @param executionAttemptKey 业务尝试键或旧版兼容键
* @return 最终执行步骤键;输入为空时为 {@code null}
*/
public static String resolve(String executionAttemptKey) {
if (StrUtil.isBlank(executionAttemptKey)) {
return null;
}
if (executionAttemptKey.startsWith(
LEGACY_PREFIX)) {
return executionAttemptKey.substring(
LEGACY_PREFIX.length());
}
return DigestUtil.sha256Hex(
executionAttemptKey);
}
}

View File

@@ -4,6 +4,7 @@ import com.easyagents.core.model.chat.tool.Tool;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Table;
import tech.easyflow.ai.easyagents.tool.PluginTool;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.base.PluginItemBase;
@@ -31,4 +32,14 @@ public class PluginItem extends PluginItemBase {
return new PluginTool(this);
}
/**
* 使用调用方已经加载的插件快照创建工具,避免执行热路径重复查询。
*
* @param plugin 插件快照
* @return 插件工具
*/
public Tool toFunction(Plugin plugin) {
return new PluginTool(this, plugin);
}
}

View File

@@ -13,8 +13,14 @@ import tech.easyflow.ai.entity.base.WorkflowExecResultBase;
@Table(value = "tb_workflow_exec_result", comment = "工作流执行记录")
public class WorkflowExecResult extends WorkflowExecResultBase {
/**
* 获取工作流执行耗时。
*
* @return 起止时间完整时返回毫秒耗时,否则返回 null
*/
public Long getExecTime() {
if (getEndTime() == null) {
if (getStartTime() == null
|| getEndTime() == null) {
return null;
}
return getEndTime().getTime() - getStartTime().getTime();

View File

@@ -20,8 +20,14 @@ public class WorkflowExecStep extends WorkflowExecStepBase {
@Column(ignore = true)
private String nodeType;
/**
* 获取节点执行耗时。
*
* @return 起止时间完整时返回毫秒耗时,否则返回 null
*/
public Long getExecTime() {
if (getEndTime() == null) {
if (getStartTime() == null
|| getEndTime() == null) {
return null;
}
return getEndTime().getTime() - getStartTime().getTime();

View File

@@ -7,6 +7,7 @@ import com.easyagents.flow.core.node.BaseNode;
import com.easyagents.flow.core.util.JsConditionUtil;
import com.easyagents.flow.core.util.StringUtil;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.*;
import java.util.regex.Matcher;
@@ -16,6 +17,7 @@ import java.util.regex.Pattern;
* 条件判断节点首个命中if / else-if语义。
*/
public class ConditionNode extends BaseNode {
private static final long serialVersionUID = 1L;
private static final Pattern TEMPLATE_PARAM_PATTERN = Pattern.compile("\\{\\{\\s*([^{}]+?)\\s*}}");
private String branchMode = "first_match";
@@ -116,7 +118,10 @@ public class ConditionNode extends BaseNode {
while (matcher.find()) {
String path = matcher.group(1) == null ? "" : matcher.group(1).trim();
Object value = StringUtil.noText(path) ? null : chain.getState().resolveValue(path);
Object value = StringUtil.noText(path)
? null
: chain.getExecutionState()
.resolveValue(path);
matcher.appendReplacement(output, Matcher.quoteReplacement(toJsLiteral(value)));
}
matcher.appendTail(output);
@@ -218,7 +223,8 @@ public class ConditionNode extends BaseNode {
return null;
}
return chain.getState().resolveValue(path);
return chain.getExecutionState()
.resolveValue(path);
}
private boolean isEmpty(Object value) {
@@ -380,7 +386,9 @@ public class ConditionNode extends BaseNode {
this.branches = branches;
}
public static class ConditionBranch {
public static class ConditionBranch implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String label;
private String mode;
@@ -428,7 +436,9 @@ public class ConditionNode extends BaseNode {
}
}
public static class ConditionRule {
public static class ConditionRule implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String joiner;
private String leftRef;

View File

@@ -21,6 +21,8 @@ import java.util.Map;
* @since 2026-04-14
*/
public class DocNode extends BaseNode {
private static final long serialVersionUID = 1L;
/**
* 执行文件内容提取。
@@ -30,7 +32,8 @@ public class DocNode extends BaseNode {
*/
@Override
public Map<String, Object> execute(Chain chain) {
Map<String, Object> map = chain.getState().resolveParameters(this);
Map<String, Object> map =
chain.getExecutionState().resolveParameters(this);
DocNodeFileContentExtractor extractor = SpringContextUtil.getBean(DocNodeFileContentExtractor.class);
List<DocNodeFileContentExtractor.DocExtractedDocument> documents = extractor.extractDocuments(map.get("file"));

View File

@@ -3,27 +3,38 @@ package tech.easyflow.ai.node;
import cn.hutool.core.io.FileTypeUtil;
import cn.hutool.core.util.IdUtil;
import com.easyagents.core.util.StringUtil;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.tenant.TenantManager;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.runtime.RetryableTriggerException;
import com.easyagents.flow.core.node.BaseNode;
import com.easyagents.flow.core.util.IoBulkhead;
import tech.easyflow.ai.entity.Resource;
import tech.easyflow.ai.easyagentsflow.config.WorkflowRuntimeProperties;
import tech.easyflow.ai.service.ResourceService;
import tech.easyflow.ai.utils.DocUtil;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.constant.enums.EnumResourceOriginType;
import tech.easyflow.common.cache.RedisIdempotencyExecutor;
import tech.easyflow.common.cache.RedisIdempotencyExecutor.IdempotentOperationInProgressException;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.filestorage.FileStorageManager;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.FileStorageWriteResult;
import tech.easyflow.common.util.SpringContextUtil;
import java.io.ByteArrayInputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
public class DownloadNode extends BaseNode {
private static final long serialVersionUID = 1L;
private Integer resourceType;
@@ -36,57 +47,199 @@ public class DownloadNode extends BaseNode {
@Override
public Map<String, Object> execute(Chain chain) {
Map<String, Object> map = chain.getState().resolveParameters(this);
Map<String, Object> res = new HashMap<>();
Map<String, Object> map =
chain.getExecutionState().resolveParameters(this);
String originUrl = map.get("originUrl").toString();
LoginAccount account = WorkFlowUtil.getOperator(chain);
ResourceService resourceService =
SpringContextUtil.getBean(ResourceService.class);
String idempotencyKey =
chain.currentExecutionIdempotencyKey(this.id);
String resourceName = idempotencyKey == null
? IdUtil.simpleUUID()
: UUID.nameUUIDFromBytes(
idempotencyKey.getBytes(StandardCharsets.UTF_8))
.toString()
.replace("-", "");
byte[] bytes = DocUtil.downloadFile(originUrl);
String suffix = FileTypeUtil.getType(new ByteArrayInputStream(bytes));
if (suffix == null) {
suffix = "unknown";
Resource existing = findResource(
resourceService, resourceName, account);
if (existing != null) {
return output(existing.getResourceUrl());
}
String fileName = IdUtil.simpleUUID() + "." + suffix;
WorkflowRuntimeProperties runtimeProperties =
SpringContextUtil.getBean(WorkflowRuntimeProperties.class);
AtomicReference<String> resourceUrl =
new AtomicReference<>();
if (idempotencyKey == null) {
resourceUrl.set(downloadAndPersist(
originUrl,
resourceName,
account,
resourceService,
runtimeProperties));
} else {
RedisIdempotencyExecutor idempotencyExecutor =
SpringContextUtil.getBean(
RedisIdempotencyExecutor.class);
boolean executed;
try {
executed = idempotencyExecutor.executeOnce(
idempotencyKey + ":download-resource",
() -> resourceUrl.set(downloadAndPersist(
originUrl,
resourceName,
account,
resourceService,
runtimeProperties)));
} catch (IdempotentOperationInProgressException conflict) {
throw new RetryableTriggerException(
"下载幂等操作仍在处理中", conflict);
}
if (!executed) {
Resource completed = findResource(
resourceService, resourceName, account);
if (completed == null) {
throw new IllegalStateException(
"Download idempotency receipt exists "
+ "without resource record");
}
resourceUrl.set(completed.getResourceUrl());
}
}
return output(resourceUrl.get());
}
FileStorageManager manager = SpringContextUtil.getBean(FileStorageManager.class);
/**
* 流式下载、稳定上传并持久化素材记录。
*
* @param originUrl 原始 URL
* @param resourceName 稳定资源名称
* @param account 操作账号
* @param resourceService 素材服务
* @param runtimeProperties 工作流运行配置
* @return 素材 URL
*/
private String downloadAndPersist(
String originUrl,
String resourceName,
LoginAccount account,
ResourceService resourceService,
WorkflowRuntimeProperties runtimeProperties) {
try (DocUtil.DownloadedFile downloadedFile =
DocUtil.downloadFileToTemp(originUrl, runtimeProperties.getDownloadMaxBytes())) {
String suffix = FileTypeUtil.getType(downloadedFile.path().toFile());
if (suffix == null) {
suffix = "unknown";
}
String resourceUrl = manager.save(new CustomFile(fileName, bytes));
String fileName = resourceName + "." + suffix;
FileStorageManager manager = SpringContextUtil.getBean(FileStorageManager.class);
FileStorageWriteHandle handle =
manager.prepareRecoverableWrite(
"workflow/download", fileName);
boolean existedBefore = manager.existsRecoverable(handle);
FileStorageWriteResult writeResult;
try {
try (IoBulkhead.Permit ignored =
IoBulkhead.storage().acquire(
"storage:upload")) {
writeResult = manager.saveRecoverable(
new TemporaryFileMultipartFile(
fileName,
downloadedFile.path(),
downloadedFile.contentType()),
handle);
}
Resource resource = new Resource();
Resource alreadySaved = findResource(
resourceService, resourceName, account);
if (alreadySaved != null) {
return alreadySaved.getResourceUrl();
}
Resource resource = new Resource();
resource.setDeptId(account.getDeptId());
resource.setTenantId(account.getTenantId());
resource.setResourceType(this.resourceType);
resource.setResourceName(resourceName);
resource.setSuffix(suffix);
resource.setResourceUrl(writeResult.getUrl());
resource.setOrigin(
EnumResourceOriginType.GENERATE.getCode());
resource.setCreated(new Date());
resource.setCreatedBy(account.getId());
resource.setModified(new Date());
resource.setModifiedBy(account.getId());
resource.setFileSize(
BigInteger.valueOf(downloadedFile.size()));
try {
TenantManager.ignoreTenantCondition();
if (!resourceService.save(resource)) {
throw new IllegalStateException(
"素材记录保存失败");
}
} finally {
TenantManager.restoreTenantCondition();
}
return writeResult.getUrl();
} catch (RuntimeException | Error error) {
if (!existedBefore) {
try {
manager.deleteRecoverable(handle);
} catch (RuntimeException cleanupError) {
error.addSuppressed(cleanupError);
}
}
throw error;
}
}
}
LoginAccount account = WorkFlowUtil.getOperator(chain);
resource.setDeptId(account.getDeptId());
resource.setTenantId(account.getTenantId());
resource.setResourceType(this.resourceType);
resource.setResourceName(DocUtil.getFileNameByUrl(resourceUrl).split("\\.")[0]);
resource.setSuffix(suffix);
resource.setResourceUrl(resourceUrl);
resource.setOrigin(EnumResourceOriginType.GENERATE.getCode());
resource.setCreated(new Date());
resource.setCreatedBy(account.getId());
resource.setModified(new Date());
resource.setModifiedBy(account.getId());
resource.setFileSize(BigInteger.valueOf(bytes.length));
/**
* 查询同一稳定执行生成的素材记录。
*
* @param service 素材服务
* @param resourceName 稳定资源名
* @param account 操作账号
* @return 已存在记录
*/
private Resource findResource(
ResourceService service,
String resourceName,
LoginAccount account) {
try {
TenantManager.ignoreTenantCondition();
ResourceService service = SpringContextUtil.getBean(ResourceService.class);
service.save(resource);
return service.getOne(QueryWrapper.create()
.where(Resource::getResourceName)
.eq(resourceName)
.and(Resource::getTenantId)
.eq(account.getTenantId())
.and(Resource::getResourceType)
.eq(this.resourceType));
} finally {
TenantManager.restoreTenantCondition();
}
}
/**
* 按节点定义的输出名称返回资源 URL。
*
* @param resourceUrl 资源 URL
* @return 节点输出
*/
private Map<String, Object> output(String resourceUrl) {
Map<String, Object> result = new HashMap<>();
String key = "resourceUrl";
List<Parameter> outputDefs = getOutputDefs();
if (outputDefs != null && !outputDefs.isEmpty()) {
String defName = outputDefs.get(0).getName();
if (StringUtil.hasText(defName)) key = defName;
if (StringUtil.hasText(defName)) {
key = defName;
}
}
res.put(key, resourceUrl);
return res;
result.put(key, resourceUrl);
return result;
}
public Integer getResourceType() {

View File

@@ -1,6 +1,5 @@
package tech.easyflow.ai.node;
import cn.hutool.core.thread.ThreadUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
@@ -22,13 +21,35 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
@Component("giteeReader")
public class GiteeParseService implements ReadDocService {
@Value("${node.gitee.appKey}")
private String appKey;
@Value("${node.gitee.parse-timeout-ms:900000}")
private long parseTimeoutMillis;
private static final Logger log = LoggerFactory.getLogger(GiteeParseService.class);
private static final int PARSER_THREADS = 5;
private static final int PARSER_QUEUE_CAPACITY = 64;
private static final AtomicInteger THREAD_SEQUENCE = new AtomicInteger();
private static final ExecutorService PARSER_EXECUTOR =
new ThreadPoolExecutor(
PARSER_THREADS,
PARSER_THREADS,
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(PARSER_QUEUE_CAPACITY),
runnable -> {
Thread thread = new Thread(
runnable,
"gitee-document-parser-"
+ THREAD_SEQUENCE.incrementAndGet());
thread.setDaemon(true);
return thread;
},
new ThreadPoolExecutor.AbortPolicy());
@Resource(name = "defaultCache")
private Cache<String, Object> defaultCache;
@@ -45,7 +66,9 @@ public class GiteeParseService implements ReadDocService {
return cache.toString();
}
String content;
ExecutorService executor = Executors.newFixedThreadPool(5);
long timeoutMillis = Math.max(1_000L, parseTimeoutMillis);
long deadlineNanos = System.nanoTime()
+ TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
try {
byte[] b = DocUtil.readBytes(is);
Map<Integer, byte[]> split = splitDocFile(DocUtil.getSuffix(fileName), b, 30);
@@ -54,12 +77,23 @@ public class GiteeParseService implements ReadDocService {
for (Map.Entry<Integer, byte[]> entry : split.entrySet()) {
int index = entry.getKey();
byte[] splitBytes = entry.getValue();
tasks.add(() -> splitContent(index + "-" + fileName, splitBytes));
tasks.add(() -> splitContent(
index + "-" + fileName,
splitBytes,
deadlineNanos));
}
// 提交所有任务并等待完成
List<Future<String>> futures = executor.invokeAll(tasks);
long remainingNanos = Math.max(
1L, deadlineNanos - System.nanoTime());
List<Future<String>> futures = PARSER_EXECUTOR.invokeAll(
tasks, remainingNanos, TimeUnit.NANOSECONDS);
StringBuilder res = new StringBuilder();
for (Future<String> future : futures) {
if (future.isCancelled()) {
throw new TimeoutException(
"文档解析超过 "
+ timeoutMillis
+ "ms");
}
String call = future.get();
if (StrUtil.isEmpty(call)) {
throw new RuntimeException("读取文件任务失败:" + call);
@@ -69,20 +103,12 @@ public class GiteeParseService implements ReadDocService {
content = res.toString();
defaultCache.put(CacheKey.DOC_NODE_CONTENT_KEY + fileName, content);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("读取文档内容被中断", e);
} catch (Exception e) {
log.error("读取文档内容失败:", e);
throw new RuntimeException("读取文档内容失败:", e);
} finally {
// 关闭线程池
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
return content;
}
@@ -104,7 +130,8 @@ public class GiteeParseService implements ReadDocService {
.addHeader("Authorization", "Bearer " + appKey)
.post(requestBody).build();
OkHttpClient okHttpClient = OkHttpClientUtil.buildDefaultClient();
// 创建任务是非幂等 POST禁止 OkHttp 在连接失败后隐式重发。
OkHttpClient okHttpClient = OkHttpClientUtil.buildNoRetryClient();
Call call = okHttpClient.newCall(request);
try (Response response = call.execute()) {
if (response.body() == null) {
@@ -112,7 +139,11 @@ public class GiteeParseService implements ReadDocService {
}
String jsonStr = response.body().string();
JSONObject object = JSON.parseObject(jsonStr);
log.info("读取文件接口返回:{}", jsonStr);
log.info(
"文档解析任务已创建fileName={}, status={}, taskId={}",
fileName,
object.getString("status"),
object.getString("task_id"));
String error = object.getString("error");
if (StrUtil.isNotEmpty(error)) {
throw new RuntimeException(object.getString("message"));
@@ -154,7 +185,10 @@ public class GiteeParseService implements ReadDocService {
}
return md.toString();
} else {
System.out.println(taskId + " >>>>>>>>> " + object);
log.debug(
"文档解析任务等待中taskId={}, status={}",
taskId,
object.getString("status"));
}
} catch (Exception e) {
log.error("请求失败:", e);
@@ -175,10 +209,16 @@ public class GiteeParseService implements ReadDocService {
}
}
private String splitContent(String fileName, byte[] b) {
private String splitContent(
String fileName, byte[] b, long deadlineNanos)
throws InterruptedException, TimeoutException {
String taskId = giteeParse(fileName, b);
while (true) {
ThreadUtil.sleep(1000);
if (System.nanoTime() >= deadlineNanos) {
throw new TimeoutException(
"文档解析任务超时:" + taskId);
}
Thread.sleep(1_000L);
String result = giteeParseResult(taskId);
if (!"waiting".equals(result)) {
// 去掉 HTML 标签,![images/xx](xxx)的内容,提取纯文本

View File

@@ -21,6 +21,8 @@ import java.util.Map;
* @since 2026-04-18
*/
public class MakeFileNode extends BaseNode {
private static final long serialVersionUID = 1L;
private String targetFormat;
private String sourceFormat;
@@ -45,7 +47,8 @@ public class MakeFileNode extends BaseNode {
*/
@Override
public Map<String, Object> execute(Chain chain) {
Map<String, Object> map = chain.getState().resolveParameters(this);
Map<String, Object> map =
chain.getExecutionState().resolveParameters(this);
Object rawContent = map.get("content");
if (rawContent == null) {
throw new BusinessException("文件生成节点缺少 content 参数");

View File

@@ -25,6 +25,8 @@ import java.util.Collections;
import java.util.Map;
public class PluginToolNode extends BaseNode {
private static final long serialVersionUID = 1L;
private BigInteger pluginId;
@@ -38,7 +40,8 @@ public class PluginToolNode extends BaseNode {
@SuppressWarnings("unchecked")
@Override
public Map<String, Object> execute(Chain chain) {
Map<String, Object> map = chain.getState().resolveParameters(this);
Map<String, Object> map =
chain.getExecutionState().resolveParameters(this);
PluginItemService bean = SpringContextUtil.getBean(PluginItemService.class);
PluginItem tool = bean.getById(pluginId);
if (tool == null) {
@@ -49,7 +52,7 @@ public class PluginToolNode extends BaseNode {
if (plugin != null && PluginType.isWorkflow(plugin.getType())) {
return executeWorkflowPlugin(chain, map, plugin);
}
Tool function = tool.toFunction();
Tool function = tool.toFunction(plugin);
if (function == null) {
return Collections.emptyMap();
}

View File

@@ -3,21 +3,28 @@ package tech.easyflow.ai.node;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.runtime.RetryableTriggerException;
import com.easyagents.flow.core.node.BaseNode;
import com.easyagents.flow.core.util.IoBulkhead;
import com.mybatisflex.core.tenant.TenantManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.ai.easyagentsflow.config.WorkflowRuntimeProperties;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.cache.RedisIdempotencyExecutor.IdempotentOperationInProgressException;
import tech.easyflow.common.util.SpringContextUtil;
import tech.easyflow.datacenter.execution.model.DatasetRef;
import tech.easyflow.datacenter.execution.service.DatacenterDatasetQueryService;
import tech.easyflow.datacenter.execution.service.DatacenterDatasetWriteService;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class SaveDatasetNode extends BaseNode {
private static final long serialVersionUID = 1L;
private static final Logger log = LoggerFactory.getLogger(SaveDatasetNode.class);
@@ -32,7 +39,8 @@ public class SaveDatasetNode extends BaseNode {
@Override
public Map<String, Object> execute(Chain chain) {
Map<String, Object> state = chain.getState().resolveParameters(this);
Map<String, Object> state =
chain.getExecutionState().resolveParameters(this);
JSONObject payload = new JSONObject(state);
JSONArray saveList = payload.getJSONArray("saveList");
if (saveList == null || saveList.isEmpty()) {
@@ -41,22 +49,31 @@ public class SaveDatasetNode extends BaseNode {
LoginAccount account = WorkFlowUtil.getOperator(chain);
DatacenterDatasetWriteService writeService = SpringContextUtil.getBean(DatacenterDatasetWriteService.class);
DatacenterDatasetQueryService queryService = SpringContextUtil.getBean(DatacenterDatasetQueryService.class);
int successRows = 0;
WorkflowRuntimeProperties runtimeProperties = SpringContextUtil.getBean(WorkflowRuntimeProperties.class);
List<JSONObject> rows = new ArrayList<>(saveList.size());
for (Object item : saveList) {
rows.add(item instanceof JSONObject json ? json : JSONObject.from(item));
}
try {
TenantManager.ignoreTenantCondition();
for (Object item : saveList) {
JSONObject row = item instanceof JSONObject json ? json : JSONObject.from(item);
writeService.saveRow(datasetRef, row, account);
successRows++;
try (IoBulkhead.Permit ignored = IoBulkhead.dataset().acquire(resolveIoTarget())) {
writeService.saveRowsIdempotently(
datasetRef,
rows,
account,
runtimeProperties.getDataWriteBatchSize(),
chain.currentExecutionIdempotencyKey(getId()));
var schema = queryService.getLocation(datasetRef);
Map<String, Object> result = new HashMap<>();
result.put("successRows", rows.size());
result.put("source", schema.getSource());
result.put("catalog", schema.getCatalog());
result.put("table", schema.getTable());
result.put("version", datasetRef.getVersionId());
return result;
}
var schema = queryService.getSchema(datasetRef);
Map<String, Object> result = new HashMap<>();
result.put("successRows", successRows);
result.put("source", schema.getSource());
result.put("catalog", schema.getCatalog());
result.put("table", schema.getTable());
result.put("version", datasetRef.getVersionId());
return result;
} catch (IdempotentOperationInProgressException conflict) {
throw new RetryableTriggerException("数据集写入幂等操作仍在处理中", conflict);
} catch (Exception ex) {
log.error("工作流保存数据到统一数据集失败datasetRef={}", datasetRef, ex);
throw ex;
@@ -65,6 +82,18 @@ public class SaveDatasetNode extends BaseNode {
}
}
/**
* 获取数据源级 I/O 隔离目标。
*
* @return 数据源目标键
*/
private String resolveIoTarget() {
return "dataset:"
+ (datasetRef == null || datasetRef.getSourceId() == null
? "unknown"
: datasetRef.getSourceId());
}
public DatasetRef getDatasetRef() {
return datasetRef;
}

View File

@@ -3,7 +3,9 @@ package tech.easyflow.ai.node;
import com.easyagents.core.util.StringUtil;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.repository.LoopInputReference;
import com.easyagents.flow.core.node.BaseNode;
import com.easyagents.flow.core.util.IoBulkhead;
import com.mybatisflex.core.row.Row;
import com.mybatisflex.core.tenant.TenantManager;
import tech.easyflow.common.util.SpringContextUtil;
@@ -15,12 +17,20 @@ import tech.easyflow.datacenter.execution.service.DatacenterDatasetQueryService;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class SearchDatasetNode extends BaseNode {
private static final long serialVersionUID = 1L;
private static final Pattern PARAM_PATTERN = Pattern.compile("\\{\\{(.+?)\\}\\}");
private static final int QUERY_PAGE_SIZE = Math.max(
1,
Integer.getInteger(
"easyflow.workflow.dataset.page-size",
1_000));
private DatasetRef datasetRef;
private String querySql;
@@ -39,20 +49,50 @@ public class SearchDatasetNode extends BaseNode {
@Override
public Map<String, Object> execute(Chain chain) {
Map<String, Object> params = chain.getState().resolveParameters(this);
Map<String, Object> params =
chain.getExecutionState().resolveParameters(this);
DatacenterDatasetQueryService queryService = SpringContextUtil.getBean(DatacenterDatasetQueryService.class);
DatacenterSqlQueryRequest request = buildRuntimeRequest(params);
Map<String, Object> result = new HashMap<>();
try {
TenantManager.ignoreTenantCondition();
List<Row> rows = queryService.queryBySql(request);
result.put(resolveOutputKey("data"), rows);
return result;
try (IoBulkhead.Permit ignored = IoBulkhead.dataset().acquire(resolveIoTarget())) {
String resultId = chain.getStateInstanceId()
+ ":dataset:"
+ UUID.randomUUID();
int rowCount =
chain.storeProducedLoopInputOutsideLock(
resultId,
sink -> queryService.consumeBySql(
request,
QUERY_PAGE_SIZE,
sink::accept),
0L,
chain.currentFencingClaimId(),
chain.currentClaimGeneration());
result.put(
resolveOutputKey("data"),
new LoopInputReference(
resultId, rowCount));
return result;
}
} finally {
TenantManager.restoreTenantCondition();
}
}
/**
* 获取数据源级 I/O 隔离目标。
*
* @return 数据源目标键
*/
private String resolveIoTarget() {
return "dataset:"
+ (datasetRef == null || datasetRef.getSourceId() == null
? "unknown"
: datasetRef.getSourceId());
}
private DatacenterSqlQueryRequest buildRuntimeRequest(Map<String, Object> params) {
DatacenterSqlQueryRequest request = new DatacenterSqlQueryRequest();
request.setDatasetRef(copyDatasetRef());

View File

@@ -0,0 +1,137 @@
package tech.easyflow.ai.node;
import org.apache.tika.Tika;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
/**
* 基于临时文件的 MultipartFile供工作流大文件上传路径复用文件流。
*/
public final class TemporaryFileMultipartFile implements MultipartFile {
private static final Logger log = LoggerFactory.getLogger(TemporaryFileMultipartFile.class);
private static final Tika tika = new Tika();
private final String fileName;
private final Path path;
private final String contentType;
/**
* 创建临时文件上传对象。
*
* @param fileName 上传文件名
* @param path 临时文件路径
* @param contentType 已知媒体类型,可为空
*/
public TemporaryFileMultipartFile(String fileName, Path path, String contentType) {
this.fileName = Objects.requireNonNull(fileName, "fileName 不能为空");
this.path = Objects.requireNonNull(path, "path 不能为空");
this.contentType = contentType;
}
/**
* 获取表单字段名。
*
* @return 表单字段名
*/
@Override
public String getName() {
return fileName;
}
/**
* 获取原始文件名。
*
* @return 原始文件名
*/
@Override
public String getOriginalFilename() {
return fileName;
}
/**
* 获取媒体类型;响应未提供时从临时文件检测。
*
* @return 媒体类型,检测失败时返回空字符串
*/
@Override
public String getContentType() {
if (contentType != null && !contentType.isBlank()) {
return contentType;
}
try {
return tika.detect(path);
} catch (IOException exception) {
log.warn("检测工作流临时文件媒体类型失败path={}", path, exception);
return "";
}
}
/**
* 判断文件是否为空。
*
* @return 文件为空时返回 true
*/
@Override
public boolean isEmpty() {
return getSize() == 0L;
}
/**
* 获取文件大小。
*
* @return 文件字节数
* @throws IllegalStateException 无法读取文件元数据时抛出
*/
@Override
public long getSize() {
try {
return Files.size(path);
} catch (IOException exception) {
throw new IllegalStateException("读取工作流临时文件大小失败", exception);
}
}
/**
* 读取完整字节数组,兼容仅支持字节数组的存储后端。
*
* @return 文件字节
* @throws IOException 读取失败时抛出
*/
@Override
public byte[] getBytes() throws IOException {
return Files.readAllBytes(path);
}
/**
* 打开文件输入流。
*
* @return 文件输入流
* @throws IOException 打开失败时抛出
*/
@Override
public InputStream getInputStream() throws IOException {
return Files.newInputStream(path);
}
/**
* 将临时文件复制到目标位置。
*
* @param destination 目标文件
* @throws IOException 复制失败时抛出
* @throws IllegalStateException 目标不可写时抛出
*/
@Override
public void transferTo(File destination) throws IOException, IllegalStateException {
Files.copy(path, destination.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}

View File

@@ -9,7 +9,12 @@ import tech.easyflow.common.util.SpringContextUtil;
import java.util.Map;
/**
* 在独立子工作流执行通道中同步执行子工作流。
*/
public class WorkflowNode extends BaseNode {
private static final long serialVersionUID = 1L;
private String workflowId;
@@ -20,17 +25,27 @@ public class WorkflowNode extends BaseNode {
this.workflowId = workflowId;
}
/**
* 执行子流程并返回其业务结果。
*
* @param chain 父工作流
* @return 子流程完成结果,或保持当前节点运行的控制结果
*/
@Override
public Map<String, Object> execute(Chain chain) {
Map<String, Object> params = chain.getState().resolveParameters(this);
WorkflowService service = SpringContextUtil.getBean(WorkflowService.class);
Map<String, Object> params =
chain.getExecutionState()
.resolveParameters(this);
WorkflowService service =
SpringContextUtil.getBean(WorkflowService.class);
Workflow workflow = service.getById(workflowId);
if (workflow == null) {
throw new RuntimeException("工作流不存在:" + workflowId);
}
ChainExecutor executor = SpringContextUtil.getBean(ChainExecutor.class);
return executor.execute(workflowId, params);
ChainExecutor executor =
SpringContextUtil.getBean(ChainExecutor.class);
return executor.executeChild(
workflowId, params, chain, this.id);
}
public String getWorkflowId() {

View File

@@ -11,5 +11,19 @@ import tech.easyflow.ai.entity.WorkflowExecResult;
*/
public interface WorkflowExecResultService extends IService<WorkflowExecResult> {
/**
* 根据稳定执行键查询记录。
*
* @param execKey 执行键
* @return 执行记录;不存在时为 {@code null}
*/
WorkflowExecResult getByExecKey(String execKey);
/**
* 根据稳定执行键更新非空审计字段。
*
* @param record 包含执行键和待更新字段的记录
* @return 受影响行数
*/
int updateByExecKey(WorkflowExecResult record);
}

View File

@@ -11,6 +11,19 @@ import tech.easyflow.ai.entity.WorkflowExecStep;
*/
public interface WorkflowExecStepService extends IService<WorkflowExecStep> {
// 根据 execKey 获取记录
/**
* 根据稳定执行键查询步骤。
*
* @param execKey 执行键
* @return 执行步骤;不存在时为 {@code null}
*/
WorkflowExecStep getByExecKey(String execKey);
/**
* 根据稳定执行键更新非空审计字段。
*
* @param step 包含执行键和待更新字段的步骤
* @return 受影响行数
*/
int updateByExecKey(WorkflowExecStep step);
}

View File

@@ -22,4 +22,17 @@ public class WorkflowExecResultServiceImpl extends ServiceImpl<WorkflowExecResul
w.eq(WorkflowExecResult::getExecKey, execKey);
return getOne(w);
}
/**
* {@inheritDoc}
*/
@Override
public int updateByExecKey(WorkflowExecResult record) {
if (record == null || record.getExecKey() == null) {
throw new IllegalArgumentException("execKey is required");
}
QueryWrapper wrapper = QueryWrapper.create()
.eq(WorkflowExecResult::getExecKey, record.getExecKey());
return getMapper().updateByQuery(record, wrapper);
}
}

View File

@@ -22,4 +22,17 @@ public class WorkflowExecStepServiceImpl extends ServiceImpl<WorkflowExecStepMap
w.eq(WorkflowExecStep::getExecKey, execKey);
return getOne(w);
}
/**
* {@inheritDoc}
*/
@Override
public int updateByExecKey(WorkflowExecStep step) {
if (step == null || step.getExecKey() == null) {
throw new IllegalArgumentException("execKey is required");
}
QueryWrapper wrapper = QueryWrapper.create()
.eq(WorkflowExecStep::getExecKey, step.getExecKey());
return getMapper().updateByQuery(step, wrapper);
}
}

View File

@@ -7,12 +7,16 @@ import tech.easyflow.ai.mapper.WorkflowMapper;
import tech.easyflow.ai.service.WorkflowService;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.context.ApplicationEventPublisher;
import tech.easyflow.ai.easyagentsflow.event.WorkflowDefinitionChangedEvent;
import tech.easyflow.ai.utils.RegexUtils;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.ai.utils.CustomBeanUtils;
import java.math.BigInteger;
import java.io.Serializable;
import java.util.Collection;
import java.util.Date;
import java.util.Map;
@@ -25,6 +29,9 @@ import java.util.Map;
@Service
public class WorkflowServiceImpl extends ServiceImpl<WorkflowMapper, Workflow> implements WorkflowService {
@javax.annotation.Resource
private ApplicationEventPublisher eventPublisher;
/**
* 根据别名或 id 查询详情
*/
@@ -127,7 +134,9 @@ public class WorkflowServiceImpl extends ServiceImpl<WorkflowMapper, Workflow> i
}
return super.updateById(workFlow,false);
boolean updated = super.updateById(workFlow,false);
publishDefinitionChanged(updated, workFlow.getId());
return updated;
}
/**
@@ -141,13 +150,61 @@ public class WorkflowServiceImpl extends ServiceImpl<WorkflowMapper, Workflow> i
Date modified,
BigInteger modifiedBy
) {
return getMapper().updateContentByRevision(
boolean updated = getMapper().updateContentByRevision(
id,
content,
expectedRevision,
modified,
modifiedBy
) == 1;
publishDefinitionChanged(updated, id);
return updated;
}
/**
* 删除工作流后使编译定义缓存失效。
*
* @param id 工作流 ID
* @return 删除成功时为 true
*/
@Override
public boolean removeById(Serializable id) {
boolean removed = super.removeById(id);
if (removed && id != null && eventPublisher != null) {
eventPublisher.publishEvent(new WorkflowDefinitionChangedEvent(String.valueOf(id)));
}
return removed;
}
/**
* 批量删除工作流后使对应编译定义缓存失效。
*
* @param ids 工作流 ID 集合
* @return 删除成功时为 true
*/
@Override
public boolean removeByIds(Collection<? extends Serializable> ids) {
boolean removed = super.removeByIds(ids);
if (removed && ids != null && eventPublisher != null) {
for (Serializable id : ids) {
if (id != null) {
eventPublisher.publishEvent(new WorkflowDefinitionChangedEvent(String.valueOf(id)));
}
}
}
return removed;
}
/**
* 在工作流变更成功后发布定义失效事件。
*
* @param changed 是否已发生变更
* @param workflowId 工作流 ID
*/
private void publishDefinitionChanged(boolean changed, BigInteger workflowId) {
if (changed && workflowId != null && eventPublisher != null) {
eventPublisher.publishEvent(new WorkflowDefinitionChangedEvent(String.valueOf(workflowId)));
}
}
}

View File

@@ -5,6 +5,7 @@ import okhttp3.Call;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.apache.poi.extractor.ExtractorFactory;
import org.apache.poi.extractor.POITextExtractor;
import org.apache.pdfbox.multipdf.Splitter;
@@ -24,6 +25,10 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
@@ -52,6 +57,103 @@ public class DocUtil {
}
}
/**
* 将远程文件流式下载到临时文件,避免在工作流热路径持有整文件字节数组。
*
* @param url 远程文件地址
* @param maxBytes 最大允许字节数,小于等于 0 时不限制
* @return 可自动清理的临时下载结果
* @throws RuntimeException 下载失败或文件超过限制时抛出
*/
public static DownloadedFile downloadFileToTemp(String url, long maxBytes) {
Request request = new Request.Builder().url(url).build();
OkHttpClient client = OkHttpClientUtil.buildDefaultClient();
Path tempFile = null;
// 共享客户端拦截器覆盖完整响应生命周期,避免同一下载重复领取 I/O 许可。
try (Response response = client.newCall(request).execute()) {
ResponseBody body = response.body();
if (body == null) {
throw new IOException("下载内容为空");
}
long contentLength = body.contentLength();
if (maxBytes > 0 && contentLength > maxBytes) {
throw new IOException("下载文件超过限制: " + maxBytes + " bytes");
}
tempFile = Files.createTempFile("easyflow-workflow-download-", ".tmp");
long size = copyWithLimit(body.byteStream(), tempFile, maxBytes);
String contentType = body.contentType() == null ? null : body.contentType().toString();
return new DownloadedFile(tempFile, size, contentType);
} catch (Exception exception) {
deleteTempFile(tempFile);
log.error("下载文件失败:", exception);
throw new RuntimeException(exception);
}
}
/**
* 将输入流复制到临时文件,并在复制过程中执行大小保护。
*
* @param inputStream 输入流
* @param target 目标临时文件
* @param maxBytes 最大允许字节数,小于等于 0 时不限制
* @return 实际复制字节数
* @throws IOException 读写失败或超出限制时抛出
*/
private static long copyWithLimit(InputStream inputStream, Path target, long maxBytes) throws IOException {
long total = 0L;
byte[] buffer = new byte[64 * 1024];
try (InputStream input = inputStream;
OutputStream output = Files.newOutputStream(
target,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING)) {
int read;
while ((read = input.read(buffer)) != -1) {
if (maxBytes > 0 && total > maxBytes - read) {
throw new IOException("下载文件超过限制: " + maxBytes + " bytes");
}
output.write(buffer, 0, read);
total += read;
}
}
return total;
}
/**
* 尽力删除下载临时文件。
*
* @param path 临时文件路径
*/
private static void deleteTempFile(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
log.warn("清理工作流下载临时文件失败path={}", path, exception);
}
}
/**
* 工作流流式下载结果。
*
* @param path 临时文件路径
* @param size 文件字节数
* @param contentType 响应媒体类型
*/
public record DownloadedFile(Path path, long size, String contentType) implements AutoCloseable {
/**
* 删除临时文件。
*/
@Override
public void close() {
deleteTempFile(path);
}
}
public static String readWordFile(String suffix, InputStream is) {
String content = "";
try {

View File

@@ -33,7 +33,9 @@ public class WorkFlowUtil {
}
public static LoginAccount getOperator(Chain chain) {
Object cache = chain.getState().getMemory().get(Constants.LOGIN_USER_KEY);
Object cache = chain.getExecutionState()
.getMemory()
.get(Constants.LOGIN_USER_KEY);
return cache == null ? defaultAccount() : (LoginAccount) cache;
}
@@ -44,7 +46,9 @@ public class WorkFlowUtil {
* @return 执行人标识
*/
public static String getCreatedKey(Chain chain) {
Object value = chain.getState().getMemory().get(CREATED_KEY_MEMORY_KEY);
Object value = chain.getExecutionState()
.getMemory()
.get(CREATED_KEY_MEMORY_KEY);
return value == null ? USER_KEY : String.valueOf(value);
}

View File

@@ -0,0 +1,747 @@
package tech.easyflow.ai.easyagentsflow.event;
import com.alibaba.fastjson2.JSON;
import com.easyagents.flow.core.chain.repository.InMemoryLoopResultRepository;
import com.easyagents.flow.core.chain.repository.LoopInputReference;
import com.easyagents.flow.core.chain.repository.LoopResultReference;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.common.mq.config.MQProperties;
import tech.easyflow.common.mq.core.MQDeadLetterService;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQProducer;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
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.concurrent.atomic.AtomicInteger;
/**
* 工作流执行审计异步持久化测试。
*/
public class WorkflowExecutionAuditConsumerTest {
/**
* 验证存在测试构造器时 Spring 仍能选择生产构造器创建 Bean。
*/
@Test
public void shouldCreateProducerThroughSpringContext() {
MQProducer mqProducer =
Mockito.mock(MQProducer.class);
MQDeadLetterService deadLetterService =
Mockito.mock(
MQDeadLetterService.class);
try (AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext()) {
context.registerBean(
MQProducer.class,
() -> mqProducer);
context.registerBean(
MQDeadLetterService.class,
() -> deadLetterService);
context.registerBean(
WorkflowExecutionAuditProducer.class);
context.refresh();
Assert.assertNotNull(
context.getBean(
WorkflowExecutionAuditProducer.class));
}
}
/**
* 验证生产者固定投递到单一有序分片。
*/
@Test
public void shouldPublishAuditEventToOrderedShard() {
MQProducer mqProducer = Mockito.mock(MQProducer.class);
MQDeadLetterService deadLetterService =
Mockito.mock(MQDeadLetterService.class);
WorkflowExecutionAuditProducer producer =
new WorkflowExecutionAuditProducer(
mqProducer, deadLetterService);
try {
WorkflowExecutionAuditEvent event = event(
WorkflowExecutionAuditEvent.Type.CHAIN_STARTED,
"instance-1:chain-started",
"instance-1",
new WorkflowExecResult(),
null);
producer.send(event);
Mockito.verify(mqProducer).send(Mockito.argThat(message ->
WorkflowExecutionAuditMqConstants.TOPIC.equals(
message.getTopic())
&& "instance-1:chain-started".equals(
message.getMessageId())
&& "instance-1".equals(message.getKey())
&& message.getBody().contains(
"CHAIN_STARTED")));
} finally {
producer.close();
}
}
/**
* 验证结束事件仅携带结束时间时仍可安全序列化投递。
*/
@Test
public void shouldPublishEndEventsWithoutStartTime() {
MQProducer mqProducer =
Mockito.mock(MQProducer.class);
MQDeadLetterService deadLetterService =
Mockito.mock(
MQDeadLetterService.class);
WorkflowExecutionAuditProducer producer =
new WorkflowExecutionAuditProducer(
mqProducer,
deadLetterService);
Mockito.when(mqProducer.send(
Mockito.any()))
.thenReturn("message-id");
try {
WorkflowExecStep step =
new WorkflowExecStep();
step.setExecKey("step-ended");
step.setEndTime(new Date());
WorkflowExecResult result =
new WorkflowExecResult();
result.setExecKey("instance-ended");
result.setEndTime(new Date());
producer.send(event(
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
"step-ended:event",
"instance-ended",
null,
step));
producer.send(event(
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
"instance-ended:event",
"instance-ended",
result,
null));
Mockito.verify(
mqProducer,
Mockito.times(2))
.send(Mockito.argThat(message ->
message.getBody() != null
&& (message.getBody()
.contains("NODE_ENDED")
|| message.getBody()
.contains("CHAIN_ENDED"))));
} finally {
producer.close();
}
}
/**
* 验证一个实例的毒消息退避不会阻塞其他发送 lane。
*
* @throws Exception 等待健康实例发送失败时抛出
*/
@Test
public void shouldIsolateRetryHeadBlockingAcrossLanes()
throws Exception {
MQProducer mqProducer =
Mockito.mock(MQProducer.class);
MQDeadLetterService deadLetterService =
Mockito.mock(
MQDeadLetterService.class);
CountDownLatch healthySent =
new CountDownLatch(1);
Mockito.when(mqProducer.send(
Mockito.any()))
.thenAnswer(invocation -> {
MQMessage message =
invocation.getArgument(0);
if ("instance-0".equals(
message.getKey())) {
throw new IllegalStateException(
"poison");
}
healthySent.countDown();
return "sent";
});
WorkflowExecutionAuditProducer producer =
new WorkflowExecutionAuditProducer(
mqProducer,
deadLetterService,
8,
100,
1024L * 1024L,
8L * 1024L * 1024L,
0L);
try {
producer.send(event(
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
"poison",
"instance-0",
null,
new WorkflowExecStep()));
producer.send(event(
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
"healthy",
"instance-1",
null,
new WorkflowExecStep()));
Assert.assertTrue(
healthySent.await(
1L,
TimeUnit.SECONDS));
} finally {
producer.close();
}
Mockito.verify(deadLetterService)
.deadLetter(
Mockito.argThat(message ->
"poison".equals(
message.getMessageId())),
Mockito.contains("shutdown"));
}
/**
* 验证同一实例失败恢复后仍按原事件顺序发送。
*
* @throws Exception 等待重试发送失败时抛出
*/
@Test
public void shouldPreserveOrderWithinAuditLane()
throws Exception {
MQProducer mqProducer =
Mockito.mock(MQProducer.class);
MQDeadLetterService deadLetterService =
Mockito.mock(
MQDeadLetterService.class);
AtomicInteger firstAttempts =
new AtomicInteger();
CountDownLatch sent =
new CountDownLatch(2);
List<String> order =
Collections.synchronizedList(
new ArrayList<>());
Mockito.when(mqProducer.send(
Mockito.any()))
.thenAnswer(invocation -> {
MQMessage message =
invocation.getArgument(0);
if ("first".equals(
message.getMessageId())
&& firstAttempts
.getAndIncrement() == 0) {
throw new IllegalStateException(
"temporary");
}
order.add(
message.getMessageId());
sent.countDown();
return "sent";
});
WorkflowExecutionAuditProducer producer =
new WorkflowExecutionAuditProducer(
mqProducer,
deadLetterService,
2,
100,
1024L * 1024L,
8L * 1024L * 1024L,
1000L);
try {
producer.send(event(
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
"first",
"same-instance",
null,
new WorkflowExecStep()));
producer.send(event(
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
"second",
"same-instance",
null,
new WorkflowExecStep()));
Assert.assertTrue(
sent.await(
2L,
TimeUnit.SECONDS));
Assert.assertEquals(
List.of("first", "second"),
order);
} finally {
producer.close();
}
}
/**
* 验证超出单条字节预算的审计消息直接进入死信并显式失败。
*/
@Test
public void shouldRejectOversizedAuditMessage() {
MQProducer mqProducer =
Mockito.mock(MQProducer.class);
MQDeadLetterService deadLetterService =
Mockito.mock(
MQDeadLetterService.class);
WorkflowExecutionAuditProducer producer =
new WorkflowExecutionAuditProducer(
mqProducer,
deadLetterService,
1,
10,
512L,
2048L,
0L);
WorkflowExecResult result =
new WorkflowExecResult();
result.setOutput(
"x".repeat(1024));
try {
producer.send(event(
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
"oversized",
"instance",
result,
null));
Assert.fail(
"oversized audit message should fail");
} catch (IllegalArgumentException expected) {
Assert.assertTrue(
expected.getMessage()
.contains("byte limit"));
} finally {
producer.close();
}
Mockito.verify(
deadLetterService)
.deadLetter(
Mockito.argThat(message ->
"oversized".equals(
message.getMessageId())),
Mockito.contains("byte limit"));
Mockito.verifyNoInteractions(
mqProducer);
}
/**
* 验证关闭期间仍在直发的失败消息会转入死信且不会重新形成孤儿积压。
*
* @throws Exception 并发关闭、等待或反射读取失败时抛出
*/
@Test
public void shouldNotEnqueueAfterConcurrentClose()
throws Exception {
MQProducer mqProducer =
Mockito.mock(MQProducer.class);
MQDeadLetterService deadLetterService =
Mockito.mock(
MQDeadLetterService.class);
CountDownLatch sending =
new CountDownLatch(1);
CountDownLatch releaseSend =
new CountDownLatch(1);
Mockito.when(mqProducer.send(
Mockito.any()))
.thenAnswer(invocation -> {
sending.countDown();
releaseSend.await(
2L,
TimeUnit.SECONDS);
throw new IllegalStateException(
"send failed during close");
});
WorkflowExecutionAuditProducer producer =
new WorkflowExecutionAuditProducer(
mqProducer,
deadLetterService,
1,
100,
1024L * 1024L,
8L * 1024L * 1024L,
0L);
ExecutorService callers =
Executors.newFixedThreadPool(2);
try {
Future<?> sender =
callers.submit(() -> {
try {
producer.send(event(
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
"closing",
"instance",
null,
new WorkflowExecStep()));
Assert.fail(
"send should report concurrent close");
} catch (IllegalStateException expected) {
Assert.assertTrue(
expected.getMessage()
.contains("closed"));
}
});
Assert.assertTrue(
sending.await(
1L,
TimeUnit.SECONDS));
Future<?> closer =
callers.submit(
producer::close);
closer.get(
1L,
TimeUnit.SECONDS);
releaseSend.countDown();
sender.get(
2L,
TimeUnit.SECONDS);
Field backlogCountField =
WorkflowExecutionAuditProducer.class
.getDeclaredField(
"backlogCount");
backlogCountField.setAccessible(true);
Assert.assertEquals(
0,
backlogCountField.getInt(
producer));
Mockito.verify(deadLetterService)
.deadLetter(
Mockito.argThat(message ->
"closing".equals(
message.getMessageId())),
Mockito.contains(
"closed during send"));
} finally {
releaseSend.countDown();
producer.close();
callers.shutdownNow();
}
}
/**
* 验证启动、节点开始、节点结束和流程结束事件按顺序幂等落库。
*/
@Test
public void shouldApplyOrderedExecutionAuditEvents() {
WorkflowExecResultService resultService =
Mockito.mock(WorkflowExecResultService.class);
WorkflowExecStepService stepService =
Mockito.mock(WorkflowExecStepService.class);
InMemoryLoopResultRepository loopRepository =
new InMemoryLoopResultRepository();
WorkflowExecutionAuditConsumer consumer =
new WorkflowExecutionAuditConsumer(
resultService,
stepService,
new MQProperties(),
loopRepository);
WorkflowExecResult persistedResult = new WorkflowExecResult();
persistedResult.setId(BigInteger.ONE);
persistedResult.setExecKey("instance-1");
Mockito.when(resultService.getByExecKey("instance-1"))
.thenReturn(persistedResult);
Mockito.when(resultService.updateByExecKey(Mockito.any()))
.thenReturn(1);
Mockito.when(stepService.updateByExecKey(Mockito.any()))
.thenReturn(1);
WorkflowExecResult startRecord = new WorkflowExecResult();
startRecord.setExecKey("instance-1");
startRecord.setStatus(1);
WorkflowExecStep startStep = new WorkflowExecStep();
startStep.setExecKey("step-1");
startStep.setNodeId("node-1");
startStep.setNodeName("node");
startStep.setStatus(1);
WorkflowExecStep endStep = new WorkflowExecStep();
endStep.setExecKey("step-1");
endStep.setStatus(2);
endStep.setOutput("{\"value\":1}");
WorkflowExecResult endRecord = new WorkflowExecResult();
endRecord.setExecKey("instance-1");
endRecord.setStatus(2);
endRecord.setOutput("{\"value\":1}");
consumer.handle(List.of(
message(event(WorkflowExecutionAuditEvent.Type.CHAIN_STARTED,
"start", "instance-1", startRecord, null)),
message(event(WorkflowExecutionAuditEvent.Type.NODE_STARTED,
"node-start", "instance-1", null, startStep)),
message(event(WorkflowExecutionAuditEvent.Type.NODE_ENDED,
"node-end", "instance-1", null, endStep)),
message(event(WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
"end", "instance-1", endRecord, null))
));
Mockito.verify(resultService).save(Mockito.argThat(record ->
"instance-1".equals(record.getExecKey())
&& Integer.valueOf(1).equals(record.getStatus())));
Mockito.verify(stepService).save(Mockito.argThat(step ->
"step-1".equals(step.getExecKey())
&& BigInteger.ONE.equals(step.getRecordId())));
Mockito.verify(stepService).updateByExecKey(
Mockito.argThat(step ->
"step-1".equals(step.getExecKey())
&& "{\"value\":1}".equals(
step.getOutput())));
Mockito.verify(resultService).updateByExecKey(
Mockito.argThat(record ->
"instance-1".equals(record.getExecKey())
&& "{\"value\":1}".equals(
record.getOutput())));
Mockito.verify(stepService, Mockito.never())
.getByExecKey(Mockito.anyString());
}
/**
* 验证大型结果引用在审计消费线程还原,持久记录仍保持完整 JSON。
*/
@Test
public void shouldResolveLargeReferenceInAuditConsumer() {
WorkflowExecResultService resultService =
Mockito.mock(
WorkflowExecResultService.class);
WorkflowExecStepService stepService =
Mockito.mock(
WorkflowExecStepService.class);
InMemoryLoopResultRepository loopRepository =
new InMemoryLoopResultRepository();
String resultId = "instance:dataset:rows";
loopRepository.storeInput(
resultId, List.of(1, 2, 3));
WorkflowExecutionAuditConsumer consumer =
new WorkflowExecutionAuditConsumer(
resultService,
stepService,
new MQProperties(),
loopRepository);
Mockito.when(stepService.updateByExecKey(
Mockito.any()))
.thenReturn(1);
WorkflowExecStep incoming =
new WorkflowExecStep();
incoming.setExecKey("step-reference");
incoming.setOutput(JSON.toJSONString(
Map.of(
"data",
new LoopInputReference(
resultId, 3))));
consumer.handle(List.of(message(event(
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
"node-reference",
"instance",
null,
incoming))));
Mockito.verify(stepService).updateByExecKey(
Mockito.argThat(step ->
"{\"data\":[1,2,3]}"
.equals(step.getOutput())));
}
/**
* 验证节点启动输入引用在审计消费者中还原后再保存。
*/
@Test
public void shouldResolveLargeInputReferenceWhenCreatingStep() {
WorkflowExecResultService resultService =
Mockito.mock(
WorkflowExecResultService.class);
WorkflowExecStepService stepService =
Mockito.mock(
WorkflowExecStepService.class);
InMemoryLoopResultRepository loopRepository =
new InMemoryLoopResultRepository();
String resultId = "instance:dataset:input";
loopRepository.storeInput(
resultId, List.of(1, 2, 3));
WorkflowExecutionAuditConsumer consumer =
new WorkflowExecutionAuditConsumer(
resultService,
stepService,
new MQProperties(),
loopRepository);
WorkflowExecResult record =
new WorkflowExecResult();
record.setId(BigInteger.ONE);
Mockito.when(resultService.getByExecKey(
"instance"))
.thenReturn(record);
WorkflowExecStep incoming =
new WorkflowExecStep();
incoming.setExecKey("step-input");
incoming.setInput(JSON.toJSONString(
Map.of(
"items",
new LoopInputReference(
resultId, 3))));
consumer.handle(List.of(message(event(
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
"node-input",
"instance",
null,
incoming))));
Mockito.verify(stepService).save(
Mockito.argThat(step ->
BigInteger.ONE.equals(
step.getRecordId())
&& "{\"items\":[1,2,3]}"
.equals(step.getInput())));
}
/**
* 验证节点结束与流程结束审计均还原循环累计输出。
*/
@Test
@SuppressWarnings("unchecked")
public void shouldResolveLoopResultReferenceForEndedAudits() {
WorkflowExecResultService resultService =
Mockito.mock(
WorkflowExecResultService.class);
WorkflowExecStepService stepService =
Mockito.mock(
WorkflowExecStepService.class);
InMemoryLoopResultRepository loopRepository =
new InMemoryLoopResultRepository();
String resultId = "instance:loop:result";
loopRepository.append(
resultId,
0,
Map.of("answer", "first"));
loopRepository.append(
resultId,
1,
Map.of("answer", "second"));
WorkflowExecutionAuditConsumer consumer =
new WorkflowExecutionAuditConsumer(
resultService,
stepService,
new MQProperties(),
loopRepository);
Mockito.when(stepService.updateByExecKey(
Mockito.any()))
.thenReturn(1);
Mockito.when(resultService.updateByExecKey(
Mockito.any()))
.thenReturn(1);
Map<String, Object> referenceOutput =
Map.of(
"answers",
new LoopResultReference(
resultId,
2,
"answer"));
WorkflowExecStep incomingStep =
new WorkflowExecStep();
incomingStep.setExecKey("step-loop");
incomingStep.setOutput(
JSON.toJSONString(
referenceOutput));
WorkflowExecResult incomingResult =
new WorkflowExecResult();
incomingResult.setExecKey(
"instance-loop");
incomingResult.setOutput(
JSON.toJSONString(
referenceOutput));
consumer.handle(List.of(
message(event(
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
"node-loop-ended",
"instance-loop",
null,
incomingStep)),
message(event(
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
"chain-loop-ended",
"instance-loop",
incomingResult,
null))));
org.mockito.ArgumentCaptor<WorkflowExecStep>
stepCaptor =
org.mockito.ArgumentCaptor.forClass(
WorkflowExecStep.class);
org.mockito.ArgumentCaptor<WorkflowExecResult>
resultCaptor =
org.mockito.ArgumentCaptor.forClass(
WorkflowExecResult.class);
Mockito.verify(stepService)
.updateByExecKey(stepCaptor.capture());
Mockito.verify(resultService)
.updateByExecKey(resultCaptor.capture());
Map<String, Object> stepOutput =
JSON.parseObject(
stepCaptor.getValue().getOutput(),
Map.class);
Map<String, Object> resultOutput =
JSON.parseObject(
resultCaptor.getValue().getOutput(),
Map.class);
Assert.assertEquals(
List.of("first", "second"),
stepOutput.get("answers"));
Assert.assertEquals(
List.of("first", "second"),
resultOutput.get("answers"));
}
/**
* 构造审计事件。
*
* @param type 事件类型
* @param eventId 事件 ID
* @param instanceId 实例 ID
* @param result 工作流记录
* @param step 节点步骤
* @return 审计事件
*/
private WorkflowExecutionAuditEvent event(WorkflowExecutionAuditEvent.Type type,
String eventId,
String instanceId,
WorkflowExecResult result,
WorkflowExecStep step) {
WorkflowExecutionAuditEvent event = new WorkflowExecutionAuditEvent();
event.setType(type);
event.setEventId(eventId);
event.setInstanceId(instanceId);
event.setOccurredAt(new Date());
event.setResult(result);
event.setStep(step);
return event;
}
/**
* 将审计事件包装为通用 MQ 消息。
*
* @param event 审计事件
* @return MQ 消息
*/
private MQMessage message(WorkflowExecutionAuditEvent event) {
MQMessage message = new MQMessage();
message.setMessageId(event.getEventId());
message.setBody(JSON.toJSONString(event));
return message;
}
}

View File

@@ -0,0 +1,112 @@
package tech.easyflow.ai.easyagentsflow.listener;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.NodeStatus;
import com.easyagents.flow.core.chain.event.NodeEndEvent;
import com.easyagents.flow.core.chain.event.NodeStartEvent;
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
import com.easyagents.flow.core.node.StartNode;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditEvent;
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditProducer;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* {@link ChainEventListenerForSave} 节点审计归属回归测试。
*/
public class ChainEventListenerForSaveTest {
/**
* 验证 parent-linked 节点开始与结束事件使用同一顶级实例顺序键。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldUseSameRootInstanceForNodeStartAndEnd()
throws Exception {
String suffix =
UUID.randomUUID().toString();
String rootId =
"root-" + suffix;
String childId =
"child-" + suffix;
InMemoryChainStateRepository repository =
new InMemoryChainStateRepository();
ChainState root =
repository.create(rootId);
ChainState child =
repository.create(childId);
child.setParentInstanceId(rootId);
child.setAuditInstanceId(null);
ChainDefinition definition =
new ChainDefinition();
definition.setId("1");
StartNode node =
new StartNode();
node.setId("node");
definition.addNode(node);
Chain chain =
new Chain(definition, childId);
chain.setChainStateRepository(repository);
WorkflowExecutionAuditProducer producer =
Mockito.mock(
WorkflowExecutionAuditProducer.class);
ChainEventListenerForSave listener =
new ChainEventListenerForSave();
Field producerField =
ChainEventListenerForSave.class
.getDeclaredField("auditProducer");
producerField.setAccessible(true);
producerField.set(listener, producer);
listener.onEvent(
new NodeStartEvent(
chain,
node,
"attempt",
NodeStatus.RUNNING,
chain.getAuditInstanceId()),
chain);
listener.onEvent(
new NodeEndEvent(
chain,
node,
Map.of("value", "ok"),
null,
NodeStatus.SUCCEEDED,
"attempt"),
chain);
ArgumentCaptor<WorkflowExecutionAuditEvent> captor =
ArgumentCaptor.forClass(
WorkflowExecutionAuditEvent.class);
Mockito.verify(
producer,
Mockito.times(2))
.send(captor.capture());
List<WorkflowExecutionAuditEvent> events =
captor.getAllValues();
Assert.assertEquals(
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
events.get(0).getType());
Assert.assertEquals(
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
events.get(1).getType());
Assert.assertEquals(
rootId,
events.get(0).getInstanceId());
Assert.assertEquals(
rootId,
events.get(1).getInstanceId());
}
}

View File

@@ -0,0 +1,100 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.alicp.jetcache.Cache;
import com.alicp.jetcache.CacheException;
import com.alicp.jetcache.CacheResult;
import com.alicp.jetcache.CacheResultCode;
import org.junit.Assert;
import org.junit.Test;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
/**
* {@link BaseRepository} 缓存操作语义回归测试。
*/
public class BaseRepositoryTest {
/**
* 验证删除不存在的缓存键按幂等成功处理。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void removeCacheShouldAcceptMissingKey() throws Exception {
TestRepository repository = repository(
new CacheResult(CacheResultCode.NOT_EXISTS, null));
repository.remove("missing-key");
}
/**
* 验证真实删除错误仍会向上抛出。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void removeCacheShouldRejectOperationFailure() throws Exception {
TestRepository repository = repository(
new CacheResult(CacheResultCode.FAIL, "redis unavailable"));
try {
repository.remove("failed-key");
Assert.fail("cache failure should be propagated");
} catch (CacheException expected) {
Assert.assertTrue(expected.getMessage().contains("failed-key"));
Assert.assertTrue(expected.getMessage().contains("redis unavailable"));
}
}
/**
* 创建注入指定删除结果的测试仓储。
*
* @param removeResult 删除操作结果
* @return 测试仓储
* @throws Exception 反射注入失败时抛出
*/
private TestRepository repository(CacheResult removeResult) throws Exception {
Cache<String, Object> cache = cache(removeResult);
TestRepository repository = new TestRepository();
Field field = BaseRepository.class.getDeclaredField("cache");
field.setAccessible(true);
field.set(repository, cache);
return repository;
}
/**
* 创建只支持删除操作的 JetCache 代理。
*
* @param removeResult 删除操作结果
* @return JetCache 测试代理
*/
@SuppressWarnings("unchecked")
private Cache<String, Object> cache(CacheResult removeResult) {
return (Cache<String, Object>) Proxy.newProxyInstance(
Cache.class.getClassLoader(),
new Class<?>[]{Cache.class},
(proxy, method, args) -> {
if ("REMOVE".equals(method.getName())) {
return removeResult;
}
throw new UnsupportedOperationException(
"unsupported cache method: " + method.getName());
});
}
/**
* 暴露受保护缓存删除能力的测试仓储。
*/
private static final class TestRepository extends BaseRepository {
/**
* 删除指定缓存键。
*
* @param key 缓存键
*/
private void remove(String key) {
removeCache(key);
}
}
}

View File

@@ -0,0 +1,168 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.Node;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.node.BaseNode;
import com.easyagents.flow.core.node.EndNode;
import com.easyagents.flow.core.node.HttpNode;
import com.easyagents.flow.core.node.LlmNode;
import com.easyagents.flow.core.node.LoopNode;
import com.easyagents.flow.core.node.StartNode;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.node.ConditionNode;
import tech.easyflow.ai.node.DocNode;
import tech.easyflow.ai.node.DownloadNode;
import tech.easyflow.ai.node.MakeFileNode;
import tech.easyflow.ai.node.PluginToolNode;
import tech.easyflow.ai.node.SaveDatasetNode;
import tech.easyflow.ai.node.SearchDatasetNode;
import tech.easyflow.ai.node.WorkflowNode;
import tech.easyflow.datacenter.execution.model.DatasetRef;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.ObjectStreamClass;
import java.util.List;
/**
* 工作流定义快照 Java 序列化兼容约束测试。
*/
public class ChainDefinitionSnapshotSerializationTest {
/**
* 验证全部运行时业务节点对象图可完成定义快照往返。
*
* @throws Exception 序列化失败时抛出
*/
@Test
public void shouldRoundTripAllRuntimeNodeTypes()
throws Exception {
ChainDefinition definition = new ChainDefinition();
definition.setId("snapshot-all-node-types");
List<Node> nodes = List.of(
node(new StartNode(), "start"),
node(new EndNode(), "end"),
node(new HttpNode(), "http"),
node(new LlmNode(), "llm"),
node(new LoopNode(), "loop"),
node(new PluginToolNode(), "plugin"),
node(new DownloadNode(), "download"),
node(new WorkflowNode(), "workflow"),
node(new DocNode(), "doc"),
datasetNode(new SearchDatasetNode(), "search"),
datasetNode(new SaveDatasetNode(), "save"),
conditionNode(),
node(new MakeFileNode(), "make-file"));
nodes.forEach(definition::addNode);
byte[] bytes;
try (ByteArrayOutputStream output =
new ByteArrayOutputStream();
ObjectOutputStream objectOutput =
new ObjectOutputStream(output)) {
objectOutput.writeObject(definition);
objectOutput.flush();
bytes = output.toByteArray();
}
ChainDefinition restored;
try (ObjectInputStream input =
new ObjectInputStream(
new ByteArrayInputStream(bytes))) {
restored = (ChainDefinition) input.readObject();
}
Assert.assertEquals(
definition.getId(), restored.getId());
Assert.assertEquals(
nodes.size(), restored.getNodes().size());
}
/**
* 验证定义对象图关键类使用显式稳定 UID防止新增方法导致默认 UID 漂移。
*/
@Test
public void shouldKeepStableSerialVersionUids() {
List<Class<?>> stableTypes = List.of(
Node.class,
BaseNode.class,
Parameter.class,
StartNode.class,
EndNode.class,
HttpNode.class,
LlmNode.class,
LoopNode.class,
PluginToolNode.class,
DownloadNode.class,
WorkflowNode.class,
DocNode.class,
SearchDatasetNode.class,
SaveDatasetNode.class,
ConditionNode.class,
ConditionNode.ConditionBranch.class,
ConditionNode.ConditionRule.class,
MakeFileNode.class,
DatasetRef.class);
for (Class<?> type : stableTypes) {
Assert.assertEquals(
"unstable serialVersionUID: "
+ type.getName(),
1L,
ObjectStreamClass.lookup(type)
.getSerialVersionUID());
}
}
/**
* 设置测试节点 ID。
*
* @param node 节点
* @param id 节点 ID
* @return 原节点
*/
private <T extends Node> T node(T node, String id) {
node.setId(id);
return node;
}
/**
* 设置带数据集引用的节点。
*
* @param node 数据集节点
* @param id 节点 ID
* @return 原节点
*/
private <T extends Node> T datasetNode(
T node, String id) {
DatasetRef ref = new DatasetRef();
ref.setTableName("dataset_table");
if (node instanceof SearchDatasetNode) {
((SearchDatasetNode) node).setDatasetRef(ref);
} else {
((SaveDatasetNode) node).setDatasetRef(ref);
}
return node(node, id);
}
/**
* 创建带完整嵌套规则对象图的条件节点。
*
* @return 条件节点
*/
private ConditionNode conditionNode() {
ConditionNode.ConditionRule rule =
new ConditionNode.ConditionRule();
rule.setId("rule");
ConditionNode.ConditionBranch branch =
new ConditionNode.ConditionBranch();
branch.setId("branch");
branch.setRules(List.of(rule));
ConditionNode node = node(
new ConditionNode(), "condition");
node.setBranches(List.of(branch));
return node;
}
}

View File

@@ -5,36 +5,52 @@ import com.alicp.jetcache.CacheException;
import com.alicp.jetcache.CacheGetResult;
import com.alicp.jetcache.CacheResult;
import com.alicp.jetcache.CacheResultCode;
import com.alicp.jetcache.CacheValueHolder;
import com.alicp.jetcache.support.CacheEncodeException;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.repository.ChainLock;
import com.easyagents.flow.core.chain.repository.ChainStateField;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.cache.VersionedObjectStore;
import tech.easyflow.common.cache.VersionedFields;
import java.io.Serializable;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.time.Duration;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ConcurrentHashMap;
/**
* {@link ChainStateRepositoryImpl} 缓存异常处理回归测试。
* {@link ChainStateRepositoryImpl} 缓存迁移和版本提交回归测试。
*/
public class ChainStateRepositoryImplTest {
/**
* 验证缓存解码失败时抛出异常且不创建空工作流状态。
* 验证缓存解码失败时抛出异常且不创建空工作流状态。
*
* @throws Exception 缓存依赖注入失败时抛出
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void loadShouldFailWithoutOverwritingStateWhenCacheDecodeFails() throws Exception {
String instanceId = "decode-failed-instance";
CacheGetResult<Object> failure = new CacheGetResult<>(new CacheEncodeException(
"decode error",
new ClassNotFoundException("com.alicp.jetcache.CacheValueHolder")
"decode error",
new ClassNotFoundException("com.alicp.jetcache.CacheValueHolder")
));
RecordingCache cache = new RecordingCache(failure, CacheResult.SUCCESS_WITHOUT_MSG);
ChainStateRepositoryImpl repository = repository(cache.asCache());
RecordingCache cache = new RecordingCache(failure);
RecordingVersionedObjectStore stateStore = new RecordingVersionedObjectStore();
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
try {
repository.load(instanceId);
@@ -44,142 +60,448 @@ public class ChainStateRepositoryImplTest {
Assert.assertTrue(expected.getMessage().contains(instanceId));
}
Assert.assertEquals(0, cache.getPutCount());
Assert.assertEquals(0, stateStore.getCreateCount());
}
/**
* 验证缓存未命中时创建并持久化新的工作流状态
* 验证新实例通过版本对象存储显式创建
*
* @throws Exception 缓存依赖注入失败时抛出
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void loadShouldCreateStateWhenCacheDoesNotExist() throws Exception {
public void createShouldPersistStateWhenStateDoesNotExist() throws Exception {
String instanceId = "new-instance";
RecordingCache cache = new RecordingCache(
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null),
CacheResult.SUCCESS_WITHOUT_MSG
);
ChainStateRepositoryImpl repository = repository(cache.asCache());
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null));
RecordingVersionedObjectStore stateStore = new RecordingVersionedObjectStore();
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
ChainState state = repository.load(instanceId);
ChainState state = repository.create(instanceId);
Assert.assertEquals(instanceId, state.getInstanceId());
Assert.assertEquals(1, cache.getPutCount());
Assert.assertSame(state, cache.getLastPutValue());
Assert.assertEquals(1, stateStore.getCreateCount());
Assert.assertEquals(
instanceId,
stateStore.getLastCreatedFields().get(ChainStateField.INSTANCE_ID.name()));
}
/**
* 验证缓存写入失败时不会返回未持久化的工作流状态
* 验证旧 JetCache 状态首次读取后迁移到版本对象存储
*
* @throws Exception 缓存依赖注入失败时抛出
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void loadShouldFailWhenNewStateCannotBePersisted() throws Exception {
public void loadShouldMigrateLegacyStateOnce() throws Exception {
ChainState legacy = new ChainState();
legacy.setInstanceId("legacy-instance");
legacy.setVersion(7L);
legacy.setStatus(ChainStatus.SUCCEEDED);
RecordingCache cache = new RecordingCache(
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null),
new CacheResult(new IllegalStateException("redis unavailable"))
);
ChainStateRepositoryImpl repository = repository(cache.asCache());
new CacheGetResult<>(
CacheResultCode.SUCCESS,
null,
new CacheValueHolder<>(legacy, Long.MAX_VALUE)));
RecordingVersionedObjectStore stateStore = new RecordingVersionedObjectStore();
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
try {
repository.load("write-failed-instance");
Assert.fail("cache write failure should be propagated");
} catch (CacheException expected) {
Assert.assertTrue(expected.getMessage().contains("工作流状态缓存写入失败"));
}
ChainState loaded = repository.load(legacy.getInstanceId());
Assert.assertEquals(1, cache.getPutCount());
Assert.assertNotSame(legacy, loaded);
Assert.assertEquals(legacy.getInstanceId(), loaded.getInstanceId());
Assert.assertEquals(legacy.getStatus(), loaded.getStatus());
Assert.assertEquals(legacy.getVersion(), loaded.getVersion());
Assert.assertEquals(1, stateStore.getCreateCount());
Assert.assertEquals(7L, stateStore.getVersionForLastKey());
}
/**
* 创建工作流状态仓储并注入缓存
* 验证过期版本不能覆盖已经成功提交的新状态
*
* @param cache 测试缓存
* @return 已完成依赖注入的工作流状态仓储
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void tryUpdateShouldRejectStaleVersion() throws Exception {
RecordingCache cache = new RecordingCache(
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null));
RecordingVersionedObjectStore stateStore = new RecordingVersionedObjectStore();
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
ChainState created = repository.create("cas-instance");
ChainState firstUpdate = new ChainState();
firstUpdate.setInstanceId(created.getInstanceId());
firstUpdate.setVersion(1L);
Assert.assertTrue(repository.tryUpdate(
firstUpdate, EnumSet.of(ChainStateField.VERSION)));
ChainState staleUpdate = new ChainState();
staleUpdate.setInstanceId(created.getInstanceId());
staleUpdate.setVersion(1L);
Assert.assertFalse(repository.tryUpdate(
staleUpdate, EnumSet.of(ChainStateField.VERSION)));
}
/**
* 验证实例锁成功获取后分配独立的实例 fencing token。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void getLockShouldAllocateInstanceFencingToken() throws Exception {
RecordingCache cache = new RecordingCache(
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null));
ChainStateRepositoryImpl repository = repository(
cache.asCache(), new RecordingVersionedObjectStore());
RedisLockExecutor lockExecutor = Mockito.mock(RedisLockExecutor.class);
RedisLockExecutor.LockHandle handle =
Mockito.mock(RedisLockExecutor.LockHandle.class);
Mockito.when(lockExecutor.tryAcquireFenced(
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.any(Duration.class),
ArgumentMatchers.any(Duration.class),
ArgumentMatchers.any(Duration.class)
)).thenReturn(handle);
Mockito.when(handle.getFencingToken()).thenReturn(17L);
setField(repository, "redisLockExecutor", lockExecutor);
ChainLock lock = repository.getLock("fenced-instance", 10L, TimeUnit.SECONDS);
try {
Assert.assertTrue(lock.isAcquired());
Assert.assertEquals(17L, lock.getFencingToken());
} finally {
lock.close();
}
Mockito.verify(lockExecutor).tryAcquireFenced(
ArgumentMatchers.eq("chainLock:{fenced-instance}"),
ArgumentMatchers.eq("workflowState:{fenced-instance}:fence"),
ArgumentMatchers.any(Duration.class),
ArgumentMatchers.any(Duration.class),
ArgumentMatchers.eq(Duration.ofDays(4)));
Mockito.verify(handle).release();
}
/**
* 验证状态 CAS 同时校验实例锁和 trigger claim 守卫。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void tryUpdateShouldGuardLockAndSpecificTriggerClaim() throws Exception {
RecordingCache cache = new RecordingCache(
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null));
VersionedObjectStore stateStore = Mockito.mock(VersionedObjectStore.class);
Mockito.when(stateStore.compareAndSetFieldsAndRefresh(
ArgumentMatchers.anyString(),
ArgumentMatchers.anyLong(),
ArgumentMatchers.anyMap(),
ArgumentMatchers.anyLong(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyLong(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyLong(),
ArgumentMatchers.any(Duration.class),
ArgumentMatchers.anyString(),
ArgumentMatchers.any(Duration.class)
)).thenReturn(true);
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
ChainState update = new ChainState();
update.setInstanceId("claim-guard-instance");
update.setVersion(1L);
Assert.assertTrue(repository.tryUpdate(
update,
EnumSet.of(ChainStateField.VERSION),
17L,
"trigger-1",
42L));
Mockito.verify(stateStore).compareAndSetFieldsAndRefresh(
ArgumentMatchers.eq("workflowState:{claim-guard-instance}:chain"),
ArgumentMatchers.eq(0L),
ArgumentMatchers.anyMap(),
ArgumentMatchers.eq(1L),
ArgumentMatchers.eq("workflowState:{claim-guard-instance}:fence"),
ArgumentMatchers.eq(17L),
ArgumentMatchers.eq(
"workflowState:{claim-guard-instance}:claim:trigger-1"),
ArgumentMatchers.eq(42L),
ArgumentMatchers.eq(Duration.ofDays(3)),
ArgumentMatchers.eq("workflowState:{claim-guard-instance}:format"),
ArgumentMatchers.eq(Duration.ofDays(4)));
}
/**
* 创建工作流状态仓储并注入测试依赖。
*
* @param cache 旧 JetCache 测试代理
* @param stateStore 版本对象存储
* @return 已完成依赖注入的仓储
* @throws Exception 反射注入失败时抛出
*/
private ChainStateRepositoryImpl repository(Cache<String, Object> cache) throws Exception {
private ChainStateRepositoryImpl repository(Cache<String, Object> cache,
VersionedObjectStore stateStore) throws Exception {
ChainStateRepositoryImpl repository = new ChainStateRepositoryImpl();
Field field = BaseRepository.class.getDeclaredField("cache");
field.setAccessible(true);
field.set(repository, cache);
Field cacheField = BaseRepository.class.getDeclaredField("cache");
cacheField.setAccessible(true);
cacheField.set(repository, cache);
Field storeField = ChainStateRepositoryImpl.class.getDeclaredField("versionedObjectStore");
storeField.setAccessible(true);
storeField.set(repository, stateStore);
return repository;
}
/**
* 仅实现当前仓储测试所需操作的 JetCache 调用记录器
* 反射注入测试依赖
*
* @param target 目标对象
* @param name 字段名
* @param value 字段值
* @throws Exception 字段访问失败时抛出
*/
private void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
/**
* 仅实现当前仓储测试所需读取操作的 JetCache 代理。
*/
private static final class RecordingCache implements InvocationHandler {
private final CacheGetResult<Object> getResult;
private final CacheResult putResult;
private int putCount;
private Object lastPutValue;
/**
* 创建缓存调用记录器。
* 创建 JetCache 调用记录器。
*
* @param getResult 读取操作结果
* @param putResult 写入操作结果
*/
private RecordingCache(CacheGetResult<Object> getResult, CacheResult putResult) {
private RecordingCache(CacheGetResult<Object> getResult) {
this.getResult = getResult;
this.putResult = putResult;
}
/**
* 创建实现 JetCache 接口的 JDK 动态代理。
* 创建实现 JetCache 接口的动态代理。
*
* @return JetCache 测试代理
*/
@SuppressWarnings("unchecked")
private Cache<String, Object> asCache() {
return (Cache<String, Object>) Proxy.newProxyInstance(
Cache.class.getClassLoader(),
new Class<?>[]{Cache.class},
this
Cache.class.getClassLoader(),
new Class<?>[]{Cache.class},
this
);
}
/**
* 处理仓储发起的缓存读写操作
* 处理仓储发起的缓存调用
*
* @param proxy 代理对象
* @param method 被调用方法
* @param args 调用参数
* @return 预设的缓存操作结果
* @return 预设结果
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
if ("GET".equals(method.getName())) {
return getResult;
}
if ("PUT".equals(method.getName()) && args != null && args.length == 4) {
putCount++;
lastPutValue = args[1];
Assert.assertEquals(3L, args[2]);
Assert.assertEquals(TimeUnit.DAYS, args[3]);
return putResult;
if ("REMOVE".equals(method.getName())) {
return CacheResult.SUCCESS_WITHOUT_MSG;
}
throw new UnsupportedOperationException("unsupported cache method: " + method.getName());
throw new UnsupportedOperationException(
"unsupported cache method: " + method.getName());
}
}
/**
* 以进程内 Map 模拟原子版本对象存储。
*/
private static final class RecordingVersionedObjectStore implements VersionedObjectStore {
private final Map<String, Serializable> values = new ConcurrentHashMap<>();
private final Map<String, Map<String, Object>> fieldValues = new ConcurrentHashMap<>();
private final Map<String, Long> versions = new ConcurrentHashMap<>();
private int createCount;
private Map<String, Object> lastCreatedFields;
private String lastKey;
/**
* {@inheritDoc}
*/
@Override
public <T> T load(String key, Class<T> type) {
Serializable value = values.get(key);
return value == null ? null : type.cast(value);
}
/**
* 获取写入调用次数。
*
* @return 写入调用次数
* {@inheritDoc}
*/
private int getPutCount() {
return putCount;
@Override
public VersionedFields loadFields(String key) {
Map<String, Object> fields = fieldValues.get(key);
Long version = versions.get(key);
return fields == null || version == null
? null
: new VersionedFields(version, fields);
}
/**
* 获取最后一次写入的缓存值。
*
* @return 最后一次写入的缓存值
* {@inheritDoc}
*/
private Object getLastPutValue() {
return lastPutValue;
@Override
public synchronized boolean createFieldsIfAbsent(
String key,
Map<String, ? extends Serializable> fields,
long version,
Duration ttl) {
if (fieldValues.containsKey(key) || values.containsKey(key)) {
return false;
}
fieldValues.put(key, new LinkedHashMap<>(fields));
versions.put(key, version);
if (!key.endsWith(":format")) {
createCount++;
lastCreatedFields = new LinkedHashMap<>(fields);
lastKey = key;
}
return true;
}
/**
* {@inheritDoc}
*/
@Override
public synchronized boolean compareAndSetFields(
String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
long newVersion,
Duration ttl) {
Long currentVersion = versions.get(key);
if (currentVersion == null || currentVersion != expectedVersion) {
return false;
}
fieldValues.computeIfAbsent(key, ignored -> new LinkedHashMap<>()).putAll(fields);
versions.put(key, newVersion);
return true;
}
/**
* {@inheritDoc}
*/
@Override
public synchronized boolean rewriteAsFields(
String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
Duration ttl) {
Long currentVersion = versions.get(key);
if (currentVersion == null || currentVersion != expectedVersion) {
return false;
}
values.remove(key);
fieldValues.put(key, new LinkedHashMap<>(fields));
return true;
}
/**
* {@inheritDoc}
*/
@Override
public synchronized boolean createIfAbsent(String key,
Serializable value,
long version,
Duration ttl) {
if (values.containsKey(key)) {
return false;
}
values.put(key, value);
versions.put(key, version);
createCount++;
lastKey = key;
return true;
}
/**
* {@inheritDoc}
*/
@Override
public boolean createIfAbsent(String key,
Serializable value,
long version,
String guardKey,
long guardVersion,
Duration ttl) {
Long currentGuard = versions.get(guardKey);
return currentGuard != null
&& currentGuard == guardVersion
&& createIfAbsent(key, value, version, ttl);
}
/**
* {@inheritDoc}
*/
@Override
public synchronized boolean compareAndSet(String key,
long expectedVersion,
Serializable value,
long newVersion,
Duration ttl) {
Long currentVersion = versions.get(key);
if (currentVersion == null || currentVersion != expectedVersion) {
return false;
}
values.put(key, value);
versions.put(key, newVersion);
return true;
}
/**
* {@inheritDoc}
*/
@Override
public boolean compareAndSet(String key,
long expectedVersion,
Serializable value,
long newVersion,
String guardKey,
long guardVersion,
Duration ttl) {
Long currentGuard = versions.get(guardKey);
return currentGuard != null
&& currentGuard == guardVersion
&& compareAndSet(key, expectedVersion, value, newVersion, ttl);
}
/**
* 获取创建次数。
*
* @return 创建次数
*/
private int getCreateCount() {
return createCount;
}
/**
* 获取最后创建的对象。
*
* @return 最后创建的对象
*/
private Map<String, Object> getLastCreatedFields() {
return lastCreatedFields;
}
/**
* 获取最后写入键的版本。
*
* @return 最后写入版本
*/
private long getVersionForLastKey() {
return versions.get(lastKey);
}
}
}

View File

@@ -0,0 +1,621 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.repository.LoopResultReference;
import com.easyagents.flow.core.chain.repository.LoopInputReference;
import com.easyagents.flow.core.chain.runtime.TriggerClaimLostException;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.common.cache.VersionedObjectStore;
import java.lang.reflect.Field;
import java.io.Serializable;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 循环结果分块仓储测试。
*/
public class LoopResultRepositoryImplTest {
/**
* 验证跨多个分块的结果顺序、完整性及幂等重放。
*/
@Test
public void shouldPreserveOrderingAcrossChunkBoundaries() {
InMemoryLoopResultRepository repository = new InMemoryLoopResultRepository();
String resultId = "loop-result";
int iterations = LoopResultRepositoryImpl.CHUNK_SIZE * 2 + 1;
for (int index = 0; index < iterations; index++) {
repository.append(resultId, index, Map.of(
"index", index,
"value", "value-" + index));
}
repository.append(resultId, iterations - 1, Map.of(
"index", iterations - 1,
"value", "value-" + (iterations - 1)));
Map<String, Object> result =
repository.load(resultId, iterations, List.of("index", "value"));
Assert.assertEquals(iterations, ((List<?>) result.get("index")).size());
Assert.assertEquals(0, ((List<?>) result.get("index")).get(0));
Assert.assertEquals(iterations - 1, ((List<?>) result.get("index")).get(iterations - 1));
Assert.assertEquals("value-128", ((List<?>) result.get("value")).get(128));
}
/**
* 验证同一轮次写入不同结果时拒绝覆盖。
*/
@Test(expected = IllegalStateException.class)
public void shouldRejectConflictingReplay() {
InMemoryLoopResultRepository repository = new InMemoryLoopResultRepository();
repository.append("loop-result", 0, Map.of("value", "first"));
repository.append("loop-result", 0, Map.of("value", "changed"));
}
/**
* 验证热状态只保存轻量引用,业务读取边界仍还原为原有列表结构。
*/
@Test
public void shouldResolveLightweightReferenceAtReadBoundary() {
InMemoryLoopResultRepository repository = new InMemoryLoopResultRepository();
String resultId = "instance:loop-result";
repository.append(resultId, 0, Map.of("value", "first"));
repository.append(resultId, 1, Map.of("value", "second"));
Map<String, Object> references = repository.references(
resultId, 2, List.of("value"));
Assert.assertTrue(references.get("value") instanceof LoopResultReference);
@SuppressWarnings("unchecked")
Map<String, Object> resolved =
(Map<String, Object>) repository.resolveReferences(references);
Assert.assertEquals(List.of("first", "second"), resolved.get("value"));
}
/**
* 验证没有声明输出的长循环跨分块时仍会续期输入生命周期。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldRefreshInputChunksWhenLoopHasNoOutputs() throws Exception {
LoopResultRepositoryImpl repository = new LoopResultRepositoryImpl();
VersionedObjectStore store = mock(VersionedObjectStore.class);
Field field = LoopResultRepositoryImpl.class.getDeclaredField(
"versionedObjectStore");
field.setAccessible(true);
field.set(repository, store);
when(store.load(any(String.class), eq(Integer.class))).thenReturn(256);
when(store.createIfAbsent(
anyString(),
any(Serializable.class),
anyLong(),
anyString(),
anyLong(),
anyString(),
anyLong(),
any(Duration.class))).thenReturn(true);
repository.append(
"instance",
1L,
"claim",
1L,
"instance:loop",
LoopResultRepositoryImpl.CHUNK_SIZE,
Map.of());
verify(store).refreshExpirations(anyList(), any(Duration.class));
}
/**
* 验证同一输入分块内的多轮读取只访问一次底层对象存储。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldLoadEachInputChunkOnlyOnce() throws Exception {
LoopResultRepositoryImpl repository = new LoopResultRepositoryImpl();
VersionedObjectStore store = mock(VersionedObjectStore.class);
Field field = LoopResultRepositoryImpl.class.getDeclaredField(
"versionedObjectStore");
field.setAccessible(true);
field.set(repository, store);
List<Object> values = java.util.stream.IntStream.range(0, 128)
.boxed()
.map(value -> (Object) value)
.toList();
when(store.load(anyString(), eq(List.class))).thenReturn(values);
Assert.assertEquals(0, repository.loadInputItem("instance:loop", 0));
Assert.assertEquals(127, repository.loadInputItem("instance:loop", 127));
verify(store, times(1)).load(anyString(), eq(List.class));
}
/**
* 验证调用方修改已读取的可变输入时不会污染活动分块缓存。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldIsolateMutableInputValuesFromActiveCache()
throws Exception {
LoopResultRepositoryImpl repository =
new LoopResultRepositoryImpl();
VersionedObjectStore store =
mock(VersionedObjectStore.class);
Field field = LoopResultRepositoryImpl.class
.getDeclaredField("versionedObjectStore");
field.setAccessible(true);
field.set(repository, store);
Map<String, Object> persisted =
new LinkedHashMap<>();
persisted.put("name", "original");
when(store.load(anyString(), eq(List.class)))
.thenReturn(List.of(persisted));
@SuppressWarnings("unchecked")
Map<String, Object> first =
(Map<String, Object>)
repository.loadInputItem(
"instance:mutable-input",
0);
first.put("name", "changed");
@SuppressWarnings("unchecked")
Map<String, Object> second =
(Map<String, Object>)
repository.loadInputItem(
"instance:mutable-input",
0);
Assert.assertEquals(
"original", second.get("name"));
verify(store, times(1)).load(
anyString(), eq(List.class));
}
/**
* 验证完整输入还原按分块批量读取,并保持不同调用方的可变值隔离。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
@SuppressWarnings("unchecked")
public void shouldBulkLoadMutableInputChunks()
throws Exception {
LoopResultRepositoryImpl repository =
new LoopResultRepositoryImpl();
VersionedObjectStore store =
mock(VersionedObjectStore.class);
Field field = LoopResultRepositoryImpl.class
.getDeclaredField("versionedObjectStore");
field.setAccessible(true);
field.set(repository, store);
int itemCount =
LoopResultRepositoryImpl.CHUNK_SIZE
* 2 + 1;
when(store.loadAll(
anyList(), eq(List.class)))
.thenAnswer(invocation -> {
List<List<Object>> chunks =
new java.util.ArrayList<>();
for (int chunkIndex = 0;
chunkIndex < 3;
chunkIndex++) {
int chunkSize = chunkIndex < 2
? LoopResultRepositoryImpl.CHUNK_SIZE
: 1;
List<Object> chunk =
new java.util.ArrayList<>();
for (int offset = 0;
offset < chunkSize;
offset++) {
Map<String, Object> value =
new LinkedHashMap<>();
value.put(
"index",
chunkIndex
* LoopResultRepositoryImpl.CHUNK_SIZE
+ offset);
chunk.add(value);
}
chunks.add(chunk);
}
return chunks;
});
LoopInputReference reference =
new LoopInputReference(
"instance:bulk-input",
itemCount);
List<Object> first =
repository.loadInput(reference);
((Map<String, Object>) first.get(0))
.put("index", -1);
List<Object> second =
repository.loadInput(reference);
Assert.assertEquals(
itemCount, second.size());
Assert.assertEquals(
0,
((Map<String, Object>) second.get(0))
.get("index"));
verify(store, times(2)).loadAll(
anyList(), eq(List.class));
verify(store, times(0)).load(
anyString(), eq(List.class));
}
/**
* 验证连续循环输出命中活动分块缓存时不重复读取 Redis 对象。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldReuseActiveOutputChunkAfterSuccessfulCommit()
throws Exception {
LoopResultRepositoryImpl repository =
new LoopResultRepositoryImpl();
VersionedObjectStore store =
mock(VersionedObjectStore.class);
Field field = LoopResultRepositoryImpl.class
.getDeclaredField("versionedObjectStore");
field.setAccessible(true);
field.set(repository, store);
when(store.load(
anyString(),
eq(LoopResultRepositoryImpl
.LoopResultChunk.class)))
.thenReturn(null);
when(store.createIfAbsent(
anyString(),
any(Serializable.class),
anyLong(),
anyString(),
anyLong(),
anyString(),
anyLong(),
any(Duration.class)))
.thenReturn(true);
when(store.compareAndSet(
anyString(),
anyLong(),
any(Serializable.class),
anyLong(),
anyString(),
anyLong(),
anyString(),
anyLong(),
any(Duration.class)))
.thenReturn(true);
repository.append(
"instance",
1L,
"claim",
1L,
"instance:cached-output",
0,
Map.of("value", "first"));
repository.append(
"instance",
1L,
"claim",
1L,
"instance:cached-output",
1,
Map.of("value", "second"));
verify(store, times(1)).load(
anyString(),
eq(LoopResultRepositoryImpl
.LoopResultChunk.class));
verify(store, times(1)).compareAndSet(
anyString(),
eq(0L),
any(Serializable.class),
eq(1L),
anyString(),
eq(1L),
anyString(),
eq(1L),
any(Duration.class));
}
/**
* 验证调用方修改已提交的可变输出时不会污染下一轮活动分块写入。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldIsolateMutableOutputValuesFromActiveCache()
throws Exception {
LoopResultRepositoryImpl repository =
new LoopResultRepositoryImpl();
VersionedObjectStore store =
mock(VersionedObjectStore.class);
Field field = LoopResultRepositoryImpl.class
.getDeclaredField("versionedObjectStore");
field.setAccessible(true);
field.set(repository, store);
when(store.load(
anyString(),
eq(LoopResultRepositoryImpl
.LoopResultChunk.class)))
.thenReturn(null);
when(store.createIfAbsent(
anyString(),
any(Serializable.class),
anyLong(),
anyString(),
anyLong(),
anyString(),
anyLong(),
any(Duration.class)))
.thenReturn(true);
when(store.compareAndSet(
anyString(),
anyLong(),
any(Serializable.class),
anyLong(),
anyString(),
anyLong(),
anyString(),
anyLong(),
any(Duration.class)))
.thenReturn(true);
Map<String, Object> firstValue =
new LinkedHashMap<>();
firstValue.put("name", "original");
repository.append(
"instance",
1L,
"claim",
1L,
"instance:mutable-output",
0,
Map.of("value", firstValue));
firstValue.put("name", "changed");
repository.append(
"instance",
1L,
"claim",
1L,
"instance:mutable-output",
1,
Map.of("value", Map.of(
"name", "second")));
org.mockito.ArgumentCaptor<Serializable>
chunkCaptor =
org.mockito.ArgumentCaptor.forClass(
Serializable.class);
verify(store).compareAndSet(
anyString(),
eq(0L),
chunkCaptor.capture(),
eq(1L),
anyString(),
eq(1L),
anyString(),
eq(1L),
any(Duration.class));
LoopResultRepositoryImpl.LoopResultChunk
committed =
(LoopResultRepositoryImpl.LoopResultChunk)
chunkCaptor.getValue();
@SuppressWarnings("unchecked")
Map<String, Object> committedFirst =
(Map<String, Object>)
committed.getValues()
.get("value")
.get(0);
Assert.assertEquals(
"original",
committedFirst.get("name"));
}
/**
* 验证跨分块后活动输出缓存只保留当前分块。
*
* @throws Exception 测试依赖注入或反射读取失败时抛出
*/
@Test
public void shouldKeepOnlyCurrentOutputChunkInActiveCache()
throws Exception {
LoopResultRepositoryImpl repository =
new LoopResultRepositoryImpl();
VersionedObjectStore store =
mock(VersionedObjectStore.class);
Field storeField = LoopResultRepositoryImpl.class
.getDeclaredField("versionedObjectStore");
storeField.setAccessible(true);
storeField.set(repository, store);
when(store.createIfAbsent(
anyString(),
any(Serializable.class),
anyLong(),
anyString(),
anyLong(),
anyString(),
anyLong(),
any(Duration.class)))
.thenReturn(true);
repository.append(
"instance",
1L,
"claim",
1L,
"instance:chunk-release",
0,
Map.of("value", "first"));
repository.append(
"instance",
1L,
"claim",
1L,
"instance:chunk-release",
LoopResultRepositoryImpl.CHUNK_SIZE,
Map.of("value", "next"));
Field cacheField = LoopResultRepositoryImpl.class
.getDeclaredField("outputChunkCache");
cacheField.setAccessible(true);
Object cache = cacheField.get(repository);
Field valuesField = cache.getClass()
.getDeclaredField("values");
valuesField.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, Object> cachedValues =
(Map<String, Object>)
valuesField.get(cache);
Assert.assertEquals(
1, cachedValues.size());
}
/**
* 验证物化中失去 fencing 守卫后立即停止并清理本 owner 已写分块。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldCleanupPartialInputWhenClaimIsLost()
throws Exception {
LoopResultRepositoryImpl repository =
new LoopResultRepositoryImpl();
VersionedObjectStore store =
mock(VersionedObjectStore.class);
Field field = LoopResultRepositoryImpl.class
.getDeclaredField("versionedObjectStore");
field.setAccessible(true);
field.set(repository, store);
when(store.createIfAbsent(
anyString(),
any(Serializable.class),
anyLong(),
anyString(),
anyLong(),
anyString(),
anyLong(),
any(Duration.class)))
.thenReturn(true, false);
List<Integer> input = java.util.stream.IntStream
.range(0, LoopResultRepositoryImpl.CHUNK_SIZE + 1)
.boxed()
.toList();
try {
repository.storeInput(
"instance",
1L,
"claim",
1L,
"instance:guarded-input",
input,
10_000L);
Assert.fail("lost claim must stop materialization");
} catch (TriggerClaimLostException expected) {
// 第二个分块守卫失败后立即退出。
}
verify(store).deleteAll(
org.mockito.ArgumentMatchers.argThat(
keys -> keys.size() == 1));
}
/**
* 验证锁外物化只依赖稳定 claim合法实例锁代际推进不会中断后续分块。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldMaterializeAllChunksWithStableClaimGuard()
throws Exception {
LoopResultRepositoryImpl repository =
new LoopResultRepositoryImpl();
VersionedObjectStore store =
mock(VersionedObjectStore.class);
Field field = LoopResultRepositoryImpl.class
.getDeclaredField("versionedObjectStore");
field.setAccessible(true);
field.set(repository, store);
when(store.createIfAbsent(
anyString(),
any(Serializable.class),
anyLong(),
anyString(),
anyLong(),
any(Duration.class))).thenReturn(true);
List<Integer> input = java.util.stream.IntStream
.range(0, LoopResultRepositoryImpl.CHUNK_SIZE + 1)
.boxed()
.toList();
int stored = repository.storeProducedInput(
"instance",
0L,
"claim",
7L,
"instance:stable-input",
sink -> input.forEach(sink),
10_000L);
Assert.assertEquals(input.size(), stored);
verify(store, times(3)).createIfAbsent(
anyString(),
any(Serializable.class),
anyLong(),
anyString(),
eq(7L),
any(Duration.class));
}
/**
* 使用内存 Map 隔离 JetCache 的测试仓储。
*/
private static final class InMemoryLoopResultRepository extends LoopResultRepositoryImpl {
private final Map<String, Object> values = new LinkedHashMap<>();
/**
* 将分块写入测试内存。
*
* @param key 缓存键
* @param value 缓存值
*/
@Override
protected void putCache(String key, Object value) {
values.put(key, value);
}
/**
* 从测试内存读取分块。
*
* @param key 缓存键
* @param clazz 期望类型
* @param <T> 缓存值类型
* @return 命中的分块
*/
@Override
protected <T> T getCache(String key, Class<T> clazz) {
return clazz.cast(values.get(key));
}
}
}

View File

@@ -0,0 +1,193 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.runtime.Trigger;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.data.redis.core.script.RedisScript;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* {@link RedisTriggerStore} 分布式认领语义回归测试。
*/
public class RedisTriggerStoreTest {
/**
* 验证同一到期窗口超过 200 条任务时仍可一次填充本地调度容量。
*
* @throws Exception 测试触发器序列化失败时抛出
*/
@Test
@SuppressWarnings("unchecked")
public void findDueShouldLoadMoreThanLegacyBatchLimit()
throws Exception {
StringRedisTemplate redisTemplate =
Mockito.mock(StringRedisTemplate.class);
ZSetOperations<String, String> zSetOperations =
Mockito.mock(ZSetOperations.class);
ValueOperations<String, String> valueOperations =
Mockito.mock(ValueOperations.class);
Mockito.when(redisTemplate.opsForZSet())
.thenReturn(zSetOperations);
Mockito.when(redisTemplate.opsForValue())
.thenReturn(valueOperations);
ObjectMapper objectMapper = new ObjectMapper();
Set<String> ids = new LinkedHashSet<>();
List<String> payloads = new ArrayList<>();
for (int index = 0; index < 512; index++) {
String id = "due-" + index;
Trigger trigger = new Trigger();
trigger.setId(id);
trigger.setStateInstanceId(
"instance-" + index);
trigger.setTriggerAt(1000L);
ids.add(id);
payloads.add(
objectMapper.writeValueAsString(
trigger));
}
Mockito.when(zSetOperations.rangeByScore(
ArgumentMatchers.anyString(),
ArgumentMatchers.anyDouble(),
ArgumentMatchers.anyDouble(),
ArgumentMatchers.eq(0L),
ArgumentMatchers.eq(1024L)))
.thenReturn(ids);
Mockito.when(valueOperations.multiGet(
ArgumentMatchers.anyList()))
.thenReturn(payloads);
RedisTriggerStore store =
new RedisTriggerStore(
redisTemplate,
objectMapper);
List<Trigger> due =
store.findDue(1000L);
Assert.assertEquals(512, due.size());
Mockito.verify(zSetOperations)
.rangeByScore(
ArgumentMatchers.anyString(),
ArgumentMatchers.eq(0.0),
ArgumentMatchers.eq(1000.0),
ArgumentMatchers.eq(0L),
ArgumentMatchers.eq(1024L));
}
/**
* 验证稳定触发器通过单条 Redis 脚本完成存在性判断和创建。
*/
@Test
@SuppressWarnings({"unchecked", "rawtypes"})
public void saveIfAbsentShouldUseAtomicRedisScript() {
StringRedisTemplate redisTemplate =
Mockito.mock(StringRedisTemplate.class);
Mockito.doReturn(1L).when(redisTemplate).execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString());
RedisTriggerStore store =
new RedisTriggerStore(
redisTemplate,
new ObjectMapper());
Trigger trigger = new Trigger();
trigger.setId("stable-trigger");
trigger.setTriggerAt(
System.currentTimeMillis());
Assert.assertTrue(
store.saveIfAbsent(trigger));
ArgumentCaptor<RedisScript<Long>>
scriptCaptor =
ArgumentCaptor.forClass(
(Class) RedisScript.class);
Mockito.verify(redisTemplate).execute(
scriptCaptor.capture(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString());
String script =
scriptCaptor.getValue()
.getScriptAsString();
Assert.assertTrue(script.contains(
"exists', KEYS[1]"));
Assert.assertTrue(script.contains(
"psetex', KEYS[1]"));
}
/**
* 验证认领触发器分配一次独立代际,并创建与该 trigger claim 绑定的执行守卫。
*
* <p>认领代际与实例锁 fencing token 使用不同计数器claim 不推进实例锁 fence。</p>
*
* @throws Exception JSON 构造失败时抛出
*/
@Test
@SuppressWarnings({"unchecked", "rawtypes"})
public void claimShouldCreateTriggerScopedExecutionGuard() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
ObjectMapper objectMapper = new ObjectMapper();
Trigger stored = new Trigger();
stored.setId("trigger-1");
stored.setStateInstanceId("instance-1");
stored.setTriggerAt(System.currentTimeMillis());
stored.setFencingToken(7L);
String payload = objectMapper.writeValueAsString(stored);
Mockito.doReturn("8\n" + payload).when(redisTemplate).execute(
ArgumentMatchers.<RedisScript<String>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString());
RedisTriggerStore store =
new RedisTriggerStore(redisTemplate, objectMapper);
Trigger claimed = store.claim(stored, 60_000L);
Assert.assertNotNull(claimed);
Assert.assertEquals(8L, claimed.getFencingToken());
ArgumentCaptor<RedisScript<String>> scriptCaptor =
ArgumentCaptor.forClass((Class) RedisScript.class);
ArgumentCaptor<List<String>> keysCaptor =
ArgumentCaptor.forClass((Class) List.class);
Mockito.verify(redisTemplate).execute(
scriptCaptor.capture(),
keysCaptor.capture(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString());
Assert.assertTrue(
scriptCaptor.getValue().getScriptAsString().contains(
"hset', KEYS[4], 'version'"));
Assert.assertEquals(
"workflowState:{instance-1}:claim:trigger-1",
keysCaptor.getValue().get(3));
Assert.assertEquals(
"workflowState:{instance-1}:claim-seq",
keysCaptor.getValue().get(4));
Assert.assertTrue(
scriptCaptor.getValue().getScriptAsString().contains(
"hincrby', KEYS[5], 'version'"));
}
}

View File

@@ -0,0 +1,107 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.ChainDefinition;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.easyagentsflow.config.WorkflowRuntimeProperties;
import tech.easyflow.ai.easyagentsflow.event.WorkflowDefinitionChangedEvent;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* {@link WorkflowDefinitionCache} 命中、失效和编译去重回归测试。
*/
public class WorkflowDefinitionCacheTest {
/**
* 验证同一版本重复执行只编译一次。
*/
@Test
public void shouldCompileOnlyOnceForRepeatedReads() {
InMemoryVersionStore versionStore = new InMemoryVersionStore();
WorkflowDefinitionCache cache = cache(versionStore);
AtomicInteger loads = new AtomicInteger();
ChainDefinition first = cache.get("1", () -> definition("1", loads));
ChainDefinition second = cache.get("1", () -> definition("1", loads));
Assert.assertSame(first, second);
Assert.assertEquals(1, loads.get());
}
/**
* 验证工作流变更后草稿态和发布态缓存同时失效。
*/
@Test
public void shouldInvalidateDraftAndPublishedDefinitionsTogether() {
InMemoryVersionStore versionStore = new InMemoryVersionStore();
WorkflowDefinitionCache cache = cache(versionStore);
AtomicInteger loads = new AtomicInteger();
String publishedId = PublishedWorkflowDefinitionIds.published("2");
ChainDefinition draftBefore = cache.get("2", () -> definition("2", loads));
ChainDefinition publishedBefore = cache.get(publishedId, () -> definition(publishedId, loads));
cache.onDefinitionChanged(new WorkflowDefinitionChangedEvent("2"));
ChainDefinition draftAfter = cache.get("2", () -> definition("2", loads));
ChainDefinition publishedAfter = cache.get(publishedId, () -> definition(publishedId, loads));
Assert.assertNotSame(draftBefore, draftAfter);
Assert.assertNotSame(publishedBefore, publishedAfter);
Assert.assertEquals(4, loads.get());
}
/**
* 创建测试缓存。
*
* @param versionStore 版本令牌仓储
* @return 定义缓存
*/
private WorkflowDefinitionCache cache(WorkflowDefinitionVersionStore versionStore) {
WorkflowRuntimeProperties properties = new WorkflowRuntimeProperties();
properties.setDefinitionCacheMaxEntries(4);
return new WorkflowDefinitionCache(versionStore, properties);
}
/**
* 创建测试定义并记录编译次数。
*
* @param id 定义 ID
* @param loads 编译计数
* @return 工作流定义
*/
private ChainDefinition definition(String id, AtomicInteger loads) {
loads.incrementAndGet();
ChainDefinition definition = new ChainDefinition();
definition.setId(id);
return definition;
}
/**
* 进程内版本令牌测试仓储。
*/
private static final class InMemoryVersionStore implements WorkflowDefinitionVersionStore {
private final Map<String, String> tokens = new ConcurrentHashMap<>();
/**
* {@inheritDoc}
*/
@Override
public String currentToken(String definitionId) {
return tokens.computeIfAbsent(definitionId, ignored -> UUID.randomUUID().toString());
}
/**
* {@inheritDoc}
*/
@Override
public void invalidateWorkflow(String workflowId) {
tokens.put(workflowId, UUID.randomUUID().toString());
tokens.put(PublishedWorkflowDefinitionIds.published(workflowId), UUID.randomUUID().toString());
}
}
}

View File

@@ -0,0 +1,103 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.NodeState;
import com.easyagents.flow.core.chain.repository.NodeStateField;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.easyagentsflow.support.WorkflowExecutionStepKey;
import tech.easyflow.common.cache.VersionedFields;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 节点字段化状态编码回归测试。
*/
public class WorkflowStateFieldsNodeTest {
/**
* 验证节点生命周期业务尝试键可跨 Redis 字段快照恢复。
*/
@Test
public void shouldPreserveExecutionAttemptKey() {
NodeState state = new NodeState();
state.setNodeId("loop");
state.setChainInstanceId("instance");
state.setExecutionAttemptKey(
"instance:loop:trigger");
state.setVersion(7L);
Map<String, Object> encoded =
new LinkedHashMap<>(
WorkflowStateFields
.allNodeFields(state));
NodeState decoded =
WorkflowStateFields.decodeNode(
new VersionedFields(
7L,
encoded));
Assert.assertEquals(
"instance:loop:trigger",
decoded
.getExecutionAttemptKey());
Assert.assertEquals(
7L, decoded.getVersion());
}
/**
* 验证升级前在途节点沿用 memory.executeId避免结束审计关联到新键。
*/
@Test
public void shouldRestoreLegacyExecutionKey() {
NodeState legacyState = new NodeState();
legacyState.setNodeId("loop");
legacyState.setChainInstanceId(
"instance");
legacyState.getMemory().put(
"executeId",
"legacy-step-key");
Map<String, Object> encoded =
new LinkedHashMap<>(
WorkflowStateFields
.allNodeFields(
legacyState));
encoded.remove(
NodeStateField
.EXECUTION_ATTEMPT_KEY
.name());
NodeState decoded =
WorkflowStateFields.decodeNode(
new VersionedFields(
3L,
encoded));
Assert.assertEquals(
"legacy-step-key",
WorkflowExecutionStepKey.resolve(
decoded
.getExecutionAttemptKey()));
}
/**
* 验证旧对象快照同样补齐最终执行键。
*/
@Test
public void shouldNormalizeLegacyObjectState() {
NodeState legacyState = new NodeState();
legacyState.getMemory().put(
"executeId",
"legacy-object-step");
WorkflowStateFields.normalizeNode(
legacyState);
Assert.assertEquals(
"legacy-object-step",
WorkflowExecutionStepKey.resolve(
legacyState
.getExecutionAttemptKey()));
}
}

View File

@@ -0,0 +1,138 @@
package tech.easyflow.ai.easyagentsflow.service;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.NodeState;
import com.easyagents.flow.core.chain.NodeStatus;
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import java.lang.reflect.Field;
import java.util.List;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 工作流设计器状态轮询服务测试。
*/
public class TinyFlowServiceTest {
private static final String EXECUTE_ID = "execution-1";
private static final String NODE_ID = "node-1";
/**
* 验证尚未启动的节点返回 READY且一次轮询只读取一次工作流状态。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldReturnReadyForMissingNodeStateWithoutRepeatedChainReads()
throws Exception {
ChainExecutor chainExecutor = mock(ChainExecutor.class);
ChainStateRepository chainStateRepository =
mock(ChainStateRepository.class);
NodeStateRepository nodeStateRepository =
mock(NodeStateRepository.class);
ChainState chainState = new ChainState();
chainState.setStatus(ChainStatus.RUNNING);
when(chainExecutor.getChainStateRepository())
.thenReturn(chainStateRepository);
when(chainExecutor.getNodeStateRepository())
.thenReturn(nodeStateRepository);
when(chainStateRepository.load(EXECUTE_ID))
.thenReturn(chainState);
when(nodeStateRepository.load(EXECUTE_ID, NODE_ID))
.thenReturn(null);
TinyFlowService service = service(chainExecutor);
NodeInfo node = node(NodeStatus.SUCCEEDED);
ChainInfo result = service.getChainStatus(
EXECUTE_ID, List.of(node));
Assert.assertEquals(
Integer.valueOf(ChainStatus.RUNNING.getValue()),
result.getStatus());
Assert.assertEquals(
Integer.valueOf(NodeStatus.READY.getValue()),
result.getNodes().get(NODE_ID).getStatus());
verify(chainStateRepository, times(1)).load(EXECUTE_ID);
verify(nodeStateRepository, times(1))
.load(EXECUTE_ID, NODE_ID);
}
/**
* 验证已存在节点仍返回仓储中的真实执行状态。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldReturnPersistedNodeStatus()
throws Exception {
ChainExecutor chainExecutor = mock(ChainExecutor.class);
ChainStateRepository chainStateRepository =
mock(ChainStateRepository.class);
NodeStateRepository nodeStateRepository =
mock(NodeStateRepository.class);
ChainState chainState = new ChainState();
chainState.setStatus(ChainStatus.RUNNING);
NodeState nodeState = new NodeState();
nodeState.setStatus(NodeStatus.RUNNING);
when(chainExecutor.getChainStateRepository())
.thenReturn(chainStateRepository);
when(chainExecutor.getNodeStateRepository())
.thenReturn(nodeStateRepository);
when(chainStateRepository.load(EXECUTE_ID))
.thenReturn(chainState);
when(nodeStateRepository.load(EXECUTE_ID, NODE_ID))
.thenReturn(nodeState);
TinyFlowService service = service(chainExecutor);
ChainInfo result = service.getChainStatus(
EXECUTE_ID, List.of(node(NodeStatus.READY)));
Assert.assertEquals(
Integer.valueOf(NodeStatus.RUNNING.getValue()),
result.getNodes().get(NODE_ID).getStatus());
verify(chainStateRepository, times(1)).load(EXECUTE_ID);
verify(nodeStateRepository, times(1))
.load(EXECUTE_ID, NODE_ID);
}
/**
* 创建带指定初始状态的设计器节点。
*
* @param status 初始节点状态
* @return 设计器节点
*/
private NodeInfo node(NodeStatus status) {
NodeInfo node = new NodeInfo();
node.setNodeId(NODE_ID);
node.setStatus(status.getValue());
return node;
}
/**
* 创建并注入执行器的轮询服务。
*
* @param chainExecutor 工作流执行器
* @return 已完成依赖注入的服务
* @throws Exception 反射访问失败时抛出
*/
private TinyFlowService service(ChainExecutor chainExecutor)
throws Exception {
TinyFlowService service = new TinyFlowService();
Field field = TinyFlowService.class.getDeclaredField(
"chainExecutor");
field.setAccessible(true);
field.set(service, chainExecutor);
return service;
}
}

View File

@@ -21,6 +21,89 @@ import java.util.Map;
public class WorkflowCheckServiceTest {
/**
* 验证普通节点循环次数必须处于 1300。
*/
@Test
public void testSaveShouldBlockConfiguredLoopCountAboveLimit() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject codeData = data("循环处理");
codeData.put("loopEnable", true);
codeData.put("maxLoopCount", 301);
String content = workflowJson(
array(node("code-1", "codeNode", null, codeData)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "LOOP_COUNT_INVALID");
}
/**
* 验证显式循环节点的固定次数不能为零。
*/
@Test
public void testSaveShouldBlockFixedExplicitLoopCountZero() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject loopData = data("循环");
JSONObject loopVar = new JSONObject();
loopVar.put("name", "loopVar");
loopVar.put("refType", "fixed");
loopVar.put("value", "0");
loopData.put("loopVars", array(loopVar));
String content = workflowJson(
array(node("loop-1", "loopNode", null, loopData)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "EXPLICIT_LOOP_COUNT_INVALID");
}
/**
* 验证嵌套节点只能挂在显式循环节点下。
*/
@Test
public void testSaveShouldBlockNonLoopParent() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
String content = workflowJson(
array(
node("code-parent", "codeNode", null, data("父节点")),
node("code-child", "codeNode", "code-parent", data("子节点"))
),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "NODE_PARENT_NOT_LOOP");
}
/**
* 验证显式循环嵌套层级不能形成 parentId 环。
*/
@Test
public void testSaveShouldBlockLoopParentCycle() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
String content = workflowJson(
array(
node("loop-a", "loopNode", "loop-b", data("循环 A")),
node("loop-b", "loopNode", "loop-a", data("循环 B"))
),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "LOOP_PARENT_CYCLE");
}
@Test
public void testSaveShouldPassForValidDraft() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());

View File

@@ -0,0 +1,40 @@
package tech.easyflow.ai.node;
import org.junit.Assert;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 临时文件 MultipartFile 适配测试。
*/
public class TemporaryFileMultipartFileTest {
/**
* 验证文件流、大小及 transferTo 均复用磁盘内容。
*
* @throws Exception 临时文件读写失败时抛出
*/
@Test
public void shouldExposeTemporaryFileWithoutChangingContent() throws Exception {
byte[] content = "workflow-streaming-file".getBytes(java.nio.charset.StandardCharsets.UTF_8);
Path source = Files.createTempFile("temporary-file-multipart-source-", ".txt");
Path target = Files.createTempFile("temporary-file-multipart-target-", ".txt");
try {
Files.write(source, content);
TemporaryFileMultipartFile file =
new TemporaryFileMultipartFile("result.txt", source, "text/plain");
Assert.assertEquals(content.length, file.getSize());
Assert.assertEquals("text/plain", file.getContentType());
Assert.assertArrayEquals(content, file.getInputStream().readAllBytes());
file.transferTo(target.toFile());
Assert.assertArrayEquals(content, Files.readAllBytes(target));
} finally {
Files.deleteIfExists(source);
Files.deleteIfExists(target);
}
}
}

View File

@@ -0,0 +1,87 @@
package tech.easyflow.ai.utils;
import com.sun.net.httpserver.HttpServer;
import org.junit.Assert;
import org.junit.Test;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.util.Arrays;
/**
* DocUtil 流式下载测试。
*/
public class DocUtilStreamingDownloadTest {
/**
* 验证大响应按流落盘、内容完整且关闭后清理临时文件。
*
* @throws Exception 测试服务器或文件读取失败时抛出
*/
@Test
public void shouldStreamResponseToTemporaryFileAndCleanup() throws Exception {
byte[] content = new byte[2 * 1024 * 1024 + 17];
Arrays.fill(content, (byte) 7);
HttpServer server = startServer(content);
try {
String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/download";
java.nio.file.Path path;
try (DocUtil.DownloadedFile downloadedFile =
DocUtil.downloadFileToTemp(url, content.length + 1L)) {
path = downloadedFile.path();
Assert.assertEquals(content.length, downloadedFile.size());
Assert.assertEquals("application/octet-stream", downloadedFile.contentType());
Assert.assertArrayEquals(content, Files.readAllBytes(path));
}
Assert.assertFalse(Files.exists(path));
} finally {
server.stop(0);
}
}
/**
* 验证超过配置上限时显式失败。
*
* @throws Exception 测试服务器初始化失败时抛出
*/
@Test
public void shouldRejectResponseAboveConfiguredLimit() throws Exception {
byte[] content = new byte[1024];
HttpServer server = startServer(content);
try {
String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/download";
try {
DocUtil.downloadFileToTemp(url, content.length - 1L);
Assert.fail("expected download limit failure");
} catch (RuntimeException exception) {
Assert.assertTrue(exception.getCause().getMessage().contains("超过限制"));
}
} finally {
server.stop(0);
}
}
/**
* 启动仅用于本测试的本地 HTTP 文件服务。
*
* @param content 响应内容
* @return 已启动的 HTTP 服务
* @throws Exception 服务创建失败时抛出
*/
private HttpServer startServer(byte[] content) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/download", exchange -> {
exchange.getResponseHeaders().set("Content-Type", "application/octet-stream");
exchange.sendResponseHeaders(200, content.length);
try (OutputStream output = exchange.getResponseBody()) {
for (int offset = 0; offset < content.length; offset += 8192) {
int length = Math.min(8192, content.length - offset);
output.write(content, offset, length);
}
}
});
server.start();
return server;
}
}

View File

@@ -0,0 +1,341 @@
package tech.easyflow.datacenter.connector.impl;
import com.alibaba.fastjson2.JSONObject;
import org.junit.Test;
import tech.easyflow.datacenter.entity.DatacenterTable;
import tech.easyflow.datacenter.entity.DatacenterTableField;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.common.web.exceptions.BusinessException;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 项目 MySQL 连接器批量写入测试。
*/
public class ProjectMysqlConnectorBatchTest {
/**
* 验证原始 SQL 在单连接、单 ResultSet 中原样流式消费。
*
* @throws Exception JDBC 模拟初始化失败时抛出
*/
@Test
public void shouldStreamOriginalSqlWithoutPaginationRewrite()
throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
PreparedStatement statement =
mock(PreparedStatement.class);
ResultSet resultSet = mock(ResultSet.class);
ResultSetMetaData metadata =
mock(ResultSetMetaData.class);
String sql =
"SELECT id FROM sample LIMIT 10 FOR UPDATE";
when(dataSource.getConnection())
.thenReturn(connection);
when(connection.prepareStatement(
eq(sql),
eq(ResultSet.TYPE_FORWARD_ONLY),
eq(ResultSet.CONCUR_READ_ONLY)))
.thenReturn(statement);
when(statement.executeQuery())
.thenReturn(resultSet);
when(resultSet.getMetaData())
.thenReturn(metadata);
when(metadata.getColumnCount()).thenReturn(1);
when(metadata.getColumnLabel(1)).thenReturn("id");
when(resultSet.next())
.thenReturn(true, false);
when(resultSet.getObject(1))
.thenReturn(1L);
List<String> ids = new ArrayList<>();
new ProjectMysqlConnector(dataSource)
.consumeBySql(
source(),
sql,
1_000,
row -> ids.add(
row.getString("id")));
org.junit.Assert.assertEquals(
List.of("1"), ids);
verify(dataSource).getConnection();
verify(connection).prepareStatement(
sql,
ResultSet.TYPE_FORWARD_ONLY,
ResultSet.CONCUR_READ_ONLY);
verify(statement).setFetchSize(
Integer.MIN_VALUE);
verify(statement).executeQuery();
}
/**
* 验证多行写入仅获取一次连接,并按批次执行 JDBC batch。
*
* @throws Exception JDBC 模拟初始化失败时抛出
*/
@Test
public void shouldReuseSingleConnectionAndExecuteConfiguredBatches() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
List<PreparedStatement> statements = new ArrayList<>();
when(dataSource.getConnection()).thenReturn(connection);
when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
PreparedStatement statement = mock(PreparedStatement.class);
statements.add(statement);
return statement;
});
ProjectMysqlConnector connector = new ProjectMysqlConnector(dataSource);
DatacenterSource source = new DatacenterSource();
source.setDatabaseName("easyflow");
DatacenterTable table = new DatacenterTable();
table.setTableName("sample");
DatacenterTableField nameField = new DatacenterTableField();
nameField.setFieldName("name");
nameField.setWritable(1);
table.setFields(List.of(nameField));
List<JSONObject> rows = new ArrayList<>();
for (int index = 0; index < 5; index++) {
JSONObject row = new JSONObject();
row.put("name", "row-" + index);
rows.add(row);
}
connector.saveRows(source, table, rows, null, 2);
verify(dataSource, times(1)).getConnection();
if (statements.size() != 3) {
throw new AssertionError("expected 3 JDBC batches but got " + statements.size());
}
int addBatchCalls = 0;
for (PreparedStatement statement : statements) {
verify(statement, times(1)).executeBatch();
addBatchCalls += org.mockito.Mockito.mockingDetails(statement)
.getInvocations()
.stream()
.filter(invocation -> "addBatch".equals(invocation.getMethod().getName()))
.count();
}
if (addBatchCalls != rows.size()) {
throw new AssertionError("expected " + rows.size() + " addBatch calls but got " + addBatchCalls);
}
}
/**
* 验证回执和业务批量写入在同一 JDBC 事务中提交。
*
* @throws Exception JDBC 模拟初始化失败时抛出
*/
@Test
public void shouldCommitReceiptAndRowsInSingleTransaction() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
PreparedStatement receiptStatement = mock(PreparedStatement.class);
PreparedStatement queryStatement = mock(PreparedStatement.class);
PreparedStatement rowStatement = mock(PreparedStatement.class);
ResultSet resultSet = mock(ResultSet.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.getAutoCommit()).thenReturn(true);
when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
String sql = invocation.getArgument(0);
if (sql.startsWith("SELECT")) {
return queryStatement;
}
return sql.contains("tb_datacenter_write_receipt")
? receiptStatement
: rowStatement;
});
when(queryStatement.executeQuery()).thenReturn(resultSet);
when(resultSet.next()).thenReturn(false);
ProjectMysqlConnector connector = new ProjectMysqlConnector(dataSource);
DatacenterSource source = source();
DatacenterTable table = table();
JSONObject row = new JSONObject();
row.put("name", "row-1");
boolean written = connector.saveRowsIdempotently(
source,
table,
List.of(row),
null,
100,
"receipt-key",
"payload-hash");
assertTrue(written);
verify(connection).setAutoCommit(false);
verify(receiptStatement).executeBatch();
verify(receiptStatement).executeUpdate();
verify(rowStatement).executeBatch();
verify(connection, times(2)).commit();
verify(connection, never()).rollback();
verify(connection).setAutoCommit(true);
}
/**
* 验证业务批量失败时回执与业务数据一并回滚。
*
* @throws Exception JDBC 模拟初始化失败时抛出
*/
@Test
public void shouldRollbackReceiptWhenBatchWriteFails() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
PreparedStatement receiptStatement = mock(PreparedStatement.class);
PreparedStatement queryStatement = mock(PreparedStatement.class);
PreparedStatement rowStatement = mock(PreparedStatement.class);
ResultSet resultSet = mock(ResultSet.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.getAutoCommit()).thenReturn(true);
when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
String sql = invocation.getArgument(0);
if (sql.startsWith("SELECT")) {
return queryStatement;
}
return sql.contains("tb_datacenter_write_receipt")
? receiptStatement
: rowStatement;
});
when(queryStatement.executeQuery()).thenReturn(resultSet);
when(resultSet.next()).thenReturn(false);
when(rowStatement.executeBatch()).thenThrow(new SQLException("write failed"));
ProjectMysqlConnector connector = new ProjectMysqlConnector(dataSource);
JSONObject row = new JSONObject();
row.put("name", "row-1");
try {
connector.saveRowsIdempotently(
source(),
table(),
List.of(row),
null,
100,
"receipt-key",
"payload-hash");
throw new AssertionError("failed business batch must rollback");
} catch (BusinessException expected) {
assertTrue(expected.getMessage().contains("write failed"));
}
verify(connection, atLeastOnce()).rollback();
verify(connection, never()).commit();
verify(connection).setAutoCommit(true);
}
/**
* 验证中间行失败时前序行已经提交,后续行不会执行。
*
* @throws Exception JDBC 模拟初始化失败时抛出
*/
@Test
public void shouldKeepEarlierRowsCommittedWhenMiddleRowFails() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
PreparedStatement receiptStatement = mock(PreparedStatement.class);
PreparedStatement queryStatement = mock(PreparedStatement.class);
PreparedStatement rowStatement = mock(PreparedStatement.class);
ResultSet resultSet = mock(ResultSet.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.getAutoCommit()).thenReturn(true);
when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
String sql = invocation.getArgument(0);
if (sql.startsWith("SELECT")) {
return queryStatement;
}
return sql.contains("tb_datacenter_write_receipt")
? receiptStatement
: rowStatement;
});
when(queryStatement.executeQuery()).thenReturn(resultSet);
when(resultSet.next()).thenReturn(false);
when(rowStatement.executeBatch())
.thenThrow(new SQLException("batch failed"))
.thenReturn(new int[]{1})
.thenThrow(new SQLException("middle row failed"));
ProjectMysqlConnector connector = new ProjectMysqlConnector(dataSource);
List<JSONObject> rows = List.of(
row("row-0"), row("row-1"), row("row-2"));
try {
connector.saveRowsIdempotently(
source(),
table(),
rows,
null,
100,
"receipt-key",
"payload-hash");
throw new AssertionError("middle row failure must be propagated");
} catch (BusinessException expected) {
assertTrue(expected.getMessage().contains("middle row failed"));
}
verify(dataSource, times(1)).getConnection();
verify(connection, times(1)).commit();
verify(connection, atLeastOnce()).rollback();
verify(rowStatement, times(3)).executeBatch();
verify(receiptStatement).executeBatch();
verify(receiptStatement, times(2)).executeUpdate();
}
/**
* 创建测试数据源元数据。
*
* @return 项目 MySQL 数据源
*/
private DatacenterSource source() {
DatacenterSource source = new DatacenterSource();
source.setDatabaseName("easyflow");
return source;
}
/**
* 创建包含一个可写字段的测试表。
*
* @return 测试数据表
*/
private DatacenterTable table() {
DatacenterTable table = new DatacenterTable();
table.setTableName("sample");
DatacenterTableField nameField = new DatacenterTableField();
nameField.setFieldName("name");
nameField.setWritable(1);
table.setFields(List.of(nameField));
return table;
}
/**
* 创建测试数据行。
*
* @param name 行名称
* @return JSON 行
*/
private JSONObject row(String name) {
JSONObject row = new JSONObject();
row.put("name", name);
return row;
}
}

View File

@@ -0,0 +1,113 @@
package tech.easyflow.datacenter.connector.support;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.row.Db;
import com.mybatisflex.core.row.Row;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.datacenter.entity.DatacenterTable;
import tech.easyflow.datacenter.entity.DatacenterTableField;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.enums.DatacenterCapability;
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
import javax.sql.DataSource;
import java.math.BigInteger;
import java.util.Collections;
import java.util.List;
import java.util.function.Supplier;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
/**
* 内部动态表幂等批量写入回归测试。
*/
public class AbstractInternalTableConnectorBatchTest {
/**
* 验证正常路径按批次写入回执和数据,不退化为逐行 SQL。
*/
@Test
public void shouldBatchReceiptsAndRowsOnNormalPath() {
DatacenterTable table = mock(DatacenterTable.class);
DatacenterTableField field =
mock(DatacenterTableField.class);
org.mockito.Mockito.when(table.getFields())
.thenReturn(List.of(field));
org.mockito.Mockito.when(table.getMaterializedTable())
.thenReturn("tb_internal_test");
org.mockito.Mockito.when(field.getFieldName())
.thenReturn("name");
LoginAccount account = mock(LoginAccount.class);
org.mockito.Mockito.when(account.getId())
.thenReturn(BigInteger.ONE);
org.mockito.Mockito.when(account.getDeptId())
.thenReturn(BigInteger.ONE);
org.mockito.Mockito.when(account.getTenantId())
.thenReturn(BigInteger.ONE);
JSONObject first = JSONObject.of("name", "first");
JSONObject second = JSONObject.of("name", "second");
try (MockedStatic<Db> db = mockStatic(Db.class)) {
db.when(() -> Db.selectOneByMap(
eq("tb_datacenter_write_receipt"),
anyMap()))
.thenReturn(null);
db.when(() -> Db.txWithResult(
org.mockito.ArgumentMatchers
.<Supplier<Object>>any()))
.thenAnswer(invocation -> invocation
.<Supplier<?>>getArgument(0)
.get());
boolean written = new TestInternalConnector()
.saveRowsIdempotently(
new DatacenterSource(),
table,
List.of(first, second),
account,
2,
"receipt",
"hash");
Assert.assertTrue(written);
db.verify(() -> Db.insertBatch(
eq("tb_datacenter_write_receipt"),
anyCollection(),
eq(2)));
db.verify(() -> Db.insertBatch(
eq("tb_internal_test"),
anyCollection(),
eq(2)));
db.verify(() -> Db.updateBatchById(
eq("tb_internal_test"),
anyList()), never());
}
}
/**
* 仅用于测试内部动态表批量协议的最小连接器。
*/
private static final class TestInternalConnector
extends AbstractInternalTableConnector {
/**
* 创建测试连接器。
*/
private TestInternalConnector() {
super(
DatacenterSourceType.EXCEL,
Collections.<DatacenterCapability>emptySet(),
mock(DataSource.class));
}
}
}

View File

@@ -0,0 +1,125 @@
package tech.easyflow.datacenter.connector.support;
import org.junit.Test;
import org.mockito.InOrder;
import org.mockito.Mockito;
import tech.easyflow.datacenter.connector.dialect.PostgresqlSqlDialect;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.enums.DatacenterCapability;
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.util.EnumSet;
/**
* PostgreSQL 服务端游标连接状态回归测试。
*/
public class PostgresqlStreamingConnectorTest {
/**
* 验证自动提交连接进入游标事务并在消费完成后恢复。
*
* @throws Exception JDBC 模拟调用失败时抛出
*/
@Test
public void shouldRestoreAutoCommitAfterStreaming()
throws Exception {
Connection connection =
Mockito.mock(Connection.class);
PreparedStatement statement =
Mockito.mock(
PreparedStatement.class);
ResultSet resultSet =
Mockito.mock(ResultSet.class);
ResultSetMetaData metaData =
Mockito.mock(
ResultSetMetaData.class);
Mockito.when(connection.getAutoCommit())
.thenReturn(true);
Mockito.when(connection.prepareStatement(
"SELECT id FROM sample",
ResultSet.TYPE_FORWARD_ONLY,
ResultSet.CONCUR_READ_ONLY))
.thenReturn(statement);
Mockito.when(statement.executeQuery())
.thenReturn(resultSet);
Mockito.when(resultSet.getMetaData())
.thenReturn(metaData);
Mockito.when(resultSet.next())
.thenReturn(false);
TestConnector connector =
new TestConnector(connection);
connector.consumeBySql(
new DatacenterSource(),
"SELECT id FROM sample",
512,
row -> {
});
InOrder order = Mockito.inOrder(
connection,
statement,
resultSet);
order.verify(connection)
.getAutoCommit();
order.verify(connection)
.setAutoCommit(false);
order.verify(connection)
.prepareStatement(
"SELECT id FROM sample",
ResultSet.TYPE_FORWARD_ONLY,
ResultSet.CONCUR_READ_ONLY);
order.verify(statement)
.setFetchSize(512);
order.verify(statement)
.executeQuery();
order.verify(resultSet)
.close();
order.verify(statement)
.close();
order.verify(connection)
.rollback();
order.verify(connection)
.setAutoCommit(true);
}
/**
* 使用测试连接执行 PostgreSQL 查询。
*/
private static final class TestConnector
extends AbstractJdbcConnector {
private final Connection connection;
/**
* 创建测试连接器。
*
* @param connection 测试 JDBC 连接
*/
private TestConnector(
Connection connection) {
super(
DatacenterSourceType.POSTGRESQL,
new PostgresqlSqlDialect(),
EnumSet.of(
DatacenterCapability.READ_QUERY));
this.connection = connection;
}
/**
* {@inheritDoc}
*/
@Override
protected <T> T withConnection(
DatacenterSource source,
boolean cacheable,
JdbcCallback<T> callback)
throws Exception {
return callback.apply(connection);
}
}
}

View File

@@ -0,0 +1,122 @@
package tech.easyflow.datacenter.execution.service.impl;
import com.alibaba.fastjson2.JSONObject;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import tech.easyflow.common.cache.RedisIdempotencyExecutor;
import tech.easyflow.datacenter.connector.DatacenterConnector;
import tech.easyflow.datacenter.connector.DatacenterConnectorRegistry;
import tech.easyflow.datacenter.entity.DatacenterTable;
import tech.easyflow.datacenter.execution.model.DatasetRef;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 数据集写入服务的幂等批写测试。
*/
public class DatacenterDatasetWriteServiceImplTest {
/**
* 验证服务只调用一次连接器,并保留配置的批大小供连接器复用连接处理。
*
* @throws Exception 测试依赖注入失败时抛出
*/
@Test
public void shouldDelegateIdempotentRowsInSingleConnectorCall() throws Exception {
DatacenterDatasetRegistryService registryService =
mock(DatacenterDatasetRegistryService.class);
DatacenterConnectorRegistry connectorRegistry =
mock(DatacenterConnectorRegistry.class);
DatacenterConnector connector = mock(DatacenterConnector.class);
RedisIdempotencyExecutor idempotencyExecutor =
mock(RedisIdempotencyExecutor.class);
BigInteger tableId = BigInteger.ONE;
BigInteger sourceId = BigInteger.TWO;
DatasetRef datasetRef = new DatasetRef();
datasetRef.setTableId(tableId);
DatacenterTable table = new DatacenterTable();
table.setSourceId(sourceId);
DatacenterSource source = new DatacenterSource();
source.setSourceType(DatacenterSourceType.PROJECT_MYSQL.name());
when(registryService.getTableWithFields(tableId)).thenReturn(table);
when(registryService.getSourceRequired(sourceId)).thenReturn(source);
when(connectorRegistry.getConnector(
DatacenterSourceType.PROJECT_MYSQL.name())).thenReturn(connector);
when(idempotencyExecutor.executeOnce(
anyString(), anyString(), any(Runnable.class)))
.thenAnswer(invocation -> {
invocation.<Runnable>getArgument(2).run();
return true;
});
when(connector.saveRowsIdempotently(
any(), any(), anyList(), any(), anyInt(), anyString(), anyString()))
.thenReturn(true);
DatacenterDatasetWriteServiceImpl service =
new DatacenterDatasetWriteServiceImpl();
inject(service, "registryService", registryService);
inject(service, "connectorRegistry", connectorRegistry);
inject(service, "idempotencyExecutor", idempotencyExecutor);
List<JSONObject> rows = List.of(
row("row-0"), row("row-1"), row("row-2"));
Assert.assertTrue(service.saveRowsIdempotently(
datasetRef, rows, null, 64, "stable-execution-key"));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<JSONObject>> rowsCaptor =
ArgumentCaptor.forClass(List.class);
verify(connector, times(1)).saveRowsIdempotently(
eq(source),
eq(table),
rowsCaptor.capture(),
any(),
eq(64),
anyString(),
anyString());
Assert.assertEquals(rows, rowsCaptor.getValue());
}
/**
* 创建测试行。
*
* @param name 行名称
* @return JSON 行
*/
private JSONObject row(String name) {
JSONObject row = new JSONObject();
row.put("name", name);
return row;
}
/**
* 注入服务测试依赖。
*
* @param target 目标服务
* @param fieldName 字段名
* @param value 字段值
* @throws Exception 反射访问失败时抛出
*/
private void inject(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -0,0 +1,36 @@
package tech.easyflow.datacenter.schedule;
import org.junit.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 数据集写入回执清理任务测试。
*/
public class DatacenterWriteReceiptCleanupJobTest {
/**
* 验证清理任务按固定大小分批,并在最后一个非满批次后停止。
*/
@Test
public void shouldDeleteExpiredReceiptsInBoundedBatches() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.update(anyString(), any(), anyInt()))
.thenReturn(1000, 7);
DatacenterWriteReceiptCleanupJob job =
new DatacenterWriteReceiptCleanupJob(
jdbcTemplate, 14L, 1000, 20);
job.cleanup();
verify(jdbcTemplate, times(2)).update(
anyString(), any(), anyInt());
}
}