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

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