perf: 收敛工作流状态与高 IO 节点开销
- 落地 Redis 版本状态、触发租约和定义缓存 - 优化数据批写、插件请求、文件下载与审计日志 - 补齐循环范围校验、轮询兼容和专项测试
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助方法:根据参数名查找原始参数定义
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package tech.easyflow.ai.easyagentsflow.event;
|
||||
|
||||
/**
|
||||
* 工作流定义内容或发布快照发生变化的本地事件。
|
||||
*
|
||||
* @param workflowId 工作流 ID
|
||||
*/
|
||||
public record WorkflowDefinitionChangedEvent(String workflowId) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建包含缓存操作上下文的异常。
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 只有当参数不为空时才覆盖
|
||||
|
||||
@@ -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,
|
||||
"循环次数必须是 1~300 的整数",
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 标签,的内容,提取纯文本
|
||||
|
||||
@@ -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 参数");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user