diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Chain.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Chain.java index be2fe97..df5c58e 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Chain.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Chain.java @@ -25,6 +25,7 @@ import org.slf4j.LoggerFactory; import java.util.*; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; @@ -33,6 +34,14 @@ public class Chain { private static final Logger log = LoggerFactory.getLogger(Chain.class); private static final ThreadLocal EXECUTION_THREAD_LOCAL = new ThreadLocal<>(); + /** + * 当前节点执行期间的只读状态视图;线程隔离避免并行节点互相覆盖。 + */ + private static final ThreadLocal + NODE_EXECUTION_STATE_VIEW = new ThreadLocal<>(); + private static final ThreadLocal> HELD_INSTANCE_LOCKS = + new ThreadLocal<>(); + private static final ThreadLocal> DEFERRED_EVENT_ACTIONS = new ThreadLocal<>(); protected final ChainDefinition definition; @@ -41,8 +50,23 @@ public class Chain { // protected final ChainState state; protected ChainStateRepository chainStateRepository; protected NodeStateRepository nodeStateRepository; + protected LoopResultRepository loopResultRepository = new InMemoryLoopResultRepository(); protected EventManager eventManager; protected TriggerScheduler triggerScheduler; + /** + * 当前实例锁内已经读取或成功提交的状态快照。 + */ + private ChainState executionStateSnapshot; + /** + * 产生当前状态快照的实例锁;锁作用域变化后快照不得继续用于判断或修改。 + */ + private ChainLock executionStateSnapshotLock; + /** + * 当前执行实例使用的平台资源保护预算。 + */ + protected ExecutionBudget executionBudget = ExecutionBudget.defaults(); + protected String executionLane; + protected int nestedDepthBase; public static Chain currentChain() { return EXECUTION_THREAD_LOCAL.get(); @@ -54,7 +78,7 @@ public class Chain { } public void notifyEvent(Event event) { - eventManager.notifyEvent(event, this); + deferOrRun(() -> eventManager.notifyEvent(event, this)); } public void setStatusAndNotifyEvent(ChainStatus status) { @@ -89,6 +113,18 @@ public class Chain { public ChainState updateStateSafely(String stateInstanceId, ChainStateModifier modifier) { + return executeWithLock(stateInstanceId, 10, TimeUnit.SECONDS, + () -> updateStateSafelyLocked(stateInstanceId, modifier)); + } + + /** + * 在实例锁保护下更新工作流状态。 + * + * @param stateInstanceId 工作流实例 ID + * @param modifier 状态修改器 + * @return 更新后的状态 + */ + private ChainState updateStateSafelyLocked(String stateInstanceId, ChainStateModifier modifier) { final long timeoutMs = 30_000; // 30 seconds total timeout final long maxRetryDelayMs = 100; // Maximum delay between retries @@ -96,19 +132,41 @@ public class Chain { int attempt = 0; ChainState current = null; while (System.currentTimeMillis() - startTime < timeoutMs) { - current = chainStateRepository.load(stateInstanceId); + current = attempt == 0 + ? reusableExecutionState(stateInstanceId) + : null; + if (current == null) { + current = chainStateRepository.load(stateInstanceId); + } if (current == null) { throw new IllegalStateException("Chain state not found: " + stateInstanceId); } EnumSet updatedFields = modifier.modify(current); if (updatedFields == null || updatedFields.isEmpty()) { + cacheExecutionState(stateInstanceId, current); return current; // No actual changes, exit early } - if (chainStateRepository.tryUpdate(current, updatedFields)) { + current.setVersion(current.getVersion() + 1); + updatedFields.add(ChainStateField.VERSION); + executionBudget.checkHotStateBytes(estimateHotStateBytes(current)); + assertInstanceLockOwned(stateInstanceId); + if (chainStateRepository.tryUpdate( + current, + updatedFields, + currentLockFencingToken(stateInstanceId), + currentFencingClaimId(stateInstanceId), + currentClaimGeneration(stateInstanceId))) { + cacheExecutionState(stateInstanceId, current); return current; } + assertTriggerClaimOwned(TriggerContext.getCurrentTrigger()); + if (currentLockFencingToken(stateInstanceId) > 0L) { + throw new RetryableTriggerException( + "Workflow state commit guard rejected: " + stateInstanceId, + null); + } // Prepare next retry attempt++; @@ -132,24 +190,47 @@ public class Chain { } public NodeState updateNodeStateSafely(String stateInstanceId, String nodeId, NodeStateModifier modifier) { + return executeWithLock(stateInstanceId, 10, TimeUnit.SECONDS, + () -> updateNodeStateSafelyLocked(stateInstanceId, nodeId, modifier)); + } + + /** + * 在实例锁保护下更新节点状态。 + * + * @param stateInstanceId 工作流实例 ID + * @param nodeId 节点 ID + * @param modifier 状态修改器 + * @return 更新后的节点状态 + */ + private NodeState updateNodeStateSafelyLocked(String stateInstanceId, + String nodeId, + NodeStateModifier modifier) { final long timeoutMs = 30_000; final long maxRetryDelayMs = 100; long startTime = System.currentTimeMillis(); int attempt = 0; while (System.currentTimeMillis() - startTime < timeoutMs) { - // 1. 加载最新 ChainState(获取 chainVersion) - ChainState chainState = chainStateRepository.load(stateInstanceId); - if (chainState == null) { + // 1. 仅加载 ChainState 版本,避免为节点状态提交反序列化完整工作流热状态。 + Long chainStateVersion = chainStateRepository.loadVersion(stateInstanceId); + if (chainStateVersion == null) { throw new IllegalStateException("Chain state not found"); } // 2. 加载 NodeState NodeState nodeState = nodeStateRepository.load(stateInstanceId, nodeId); if (nodeState == null) { - nodeState = new NodeState(); - nodeState.setChainInstanceId(chainState.getInstanceId()); - nodeState.setNodeId(nodeId); + nodeState = nodeStateRepository.create( + stateInstanceId, + nodeId, + chainStateVersion, + currentLockFencingToken(stateInstanceId), + currentFencingClaimId(stateInstanceId), + currentClaimGeneration(stateInstanceId)); + if (nodeState == null) { + throw new IllegalStateException( + "Unable to initialize node state: " + stateInstanceId + "/" + nodeId); + } } // 3. 应用修改 @@ -160,9 +241,34 @@ public class Chain { } // 4. 尝试更新(传入 chainVersion 保证一致性) - if (nodeStateRepository.tryUpdate(nodeState, updatedFields, chainState.getVersion())) { + nodeState.setVersion(nodeState.getVersion() + 1); + updatedFields.add(NodeStateField.VERSION); + executionBudget.checkHotStateBytes(estimateValueBytes( + Arrays.asList( + nodeState.getMemory(), + nodeState.getTriggerEdgeIds(), + nodeState.getExecuteEdgeIds()), + new IdentityHashMap<>(), + executionBudget.getMaxHotStateBytes())); + assertInstanceLockOwned(stateInstanceId); + if (nodeStateRepository.tryUpdate( + nodeState, + updatedFields, + chainStateVersion, + currentLockFencingToken(stateInstanceId), + currentFencingClaimId(stateInstanceId), + currentClaimGeneration(stateInstanceId))) { return nodeState; } + assertTriggerClaimOwned(TriggerContext.getCurrentTrigger()); + if (currentLockFencingToken(stateInstanceId) > 0L) { + throw new RetryableTriggerException( + "Workflow node state commit guard rejected: " + + stateInstanceId + + "/" + + nodeId, + null); + } // 5. 退避重试 attempt++; @@ -207,6 +313,87 @@ public class Chain { } } + /** + * 以短路方式估算热状态体积,避免为保护预算额外进行完整序列化。 + * + * @param state 工作流状态 + * @return 估算字节数 + */ + private long estimateHotStateBytes(ChainState state) { + long limit = executionBudget.getMaxHotStateBytes(); + if (limit <= 0) { + return 0L; + } + return estimateValueBytes(Arrays.asList( + state.getMemory(), + state.getExecuteResult(), + state.getTriggerEdgeIds(), + state.getTriggerNodeIds()), new IdentityHashMap<>(), limit); + } + + /** + * 递归估算对象图大小,到达预算后立即短路。 + * + * @param value 当前对象 + * @param visited 已访问对象 + * @param limit 短路阈值 + * @return 估算字节数 + */ + private long estimateValueBytes(Object value, + IdentityHashMap visited, + long limit) { + if (value == null) { + return 0L; + } + if (value instanceof CharSequence) { + return (long) ((CharSequence) value).length() * Character.BYTES; + } + if (value instanceof byte[]) { + return ((byte[]) value).length; + } + if (value instanceof Number || value instanceof Date) { + return 16L; + } + if (value instanceof Boolean || value instanceof Character) { + return 2L; + } + if (visited.put(value, Boolean.TRUE) != null) { + return 0L; + } + long bytes = 32L; + if (value instanceof Map) { + for (Object entryObject : ((Map) value).entrySet()) { + Map.Entry entry = (Map.Entry) entryObject; + bytes += estimateValueBytes(entry.getKey(), visited, limit - bytes); + bytes += estimateValueBytes(entry.getValue(), visited, limit - bytes); + if (bytes > limit) { + return bytes; + } + } + } else if (value instanceof Collection) { + for (Object item : (Collection) value) { + bytes += estimateValueBytes(item, visited, limit - bytes); + if (bytes > limit) { + return bytes; + } + } + } else if (value instanceof Iterable) { + // 任意 Iterable 可能是单次消费流,预算估算不得改变业务输入。 + bytes += 64L; + } else if (value.getClass().isArray()) { + int length = java.lang.reflect.Array.getLength(value); + for (int index = 0; index < length; index++) { + bytes += estimateValueBytes(java.lang.reflect.Array.get(value, index), visited, limit - bytes); + if (bytes > limit) { + return bytes; + } + } + } else { + bytes += 64L; + } + return bytes; + } + public void start(Map variables) { Trigger prev = TriggerContext.getCurrentTrigger(); @@ -214,38 +401,127 @@ public class Chain { // start 可能在 node 里执行一个新的 chain 的情况, // 需要清空父级 chain 的 Trigger TriggerContext.setCurrentTrigger(null); - updateStateSafely(state -> { - EnumSet fields = EnumSet.of(ChainStateField.STATUS); - state.setStatus(ChainStatus.RUNNING); - - if (variables != null && !variables.isEmpty()) { - state.getMemory().putAll(variables); - applyStartParameterAliases(state.getMemory(), variables); - fields.add(ChainStateField.MEMORY); - } - - if (StringUtil.noText(state.getChainDefinitionId())) { - state.setChainDefinitionId(definition.getId()); - fields.add(ChainStateField.CHAIN_DEFINITION_ID); - } - - return fields; - }); - - notifyEvent(new ChainStartEvent(this, variables)); - setStatusAndNotifyEvent(ChainStatus.RUNNING); - - // 调度入口节点 - List startNodes = definition.getStartNodes(); - for (Node startNode : startNodes) { - scheduleNode(startNode, null, TriggerType.START, 0); - } + initializeState(); + ensureStarted(variables); } finally { // 恢复父级 chain 的 Trigger TriggerContext.setCurrentTrigger(prev); } } + /** + * 恢复被入口触发器捕获的 READY 启动。 + * + *

入口意图先于 RUNNING 状态保存。若进程在两者之间退出,扫描器重新投递 + * START 触发器时由该方法补齐所有入口意图、恢复初始变量并推进到 RUNNING, + * 当前触发器随后继续执行,避免被误确认后永久停留在 READY。

+ * + * @param trigger 当前 START 触发器 + * @return 实例已经可以继续执行业务节点时为 {@code true} + */ + private boolean recoverStart(Trigger trigger) { + if (trigger == null || trigger.getType() != TriggerType.START) { + return false; + } + return ensureStarted(trigger.getStartVariables()); + } + + /** + * 幂等持久化入口意图并推进实例启动状态。 + * + * @param variables 首次启动变量;恢复时可来自首个稳定入口意图 + * @return 实例处于 RUNNING 时为 {@code true} + */ + private boolean ensureStarted(Map variables) { + AtomicBoolean active = new AtomicBoolean(); + executeWithLock(stateInstanceId, 10L, TimeUnit.SECONDS, () -> { + AtomicReference before = + new AtomicReference<>(); + AtomicBoolean started = new AtomicBoolean(); + ChainState current = chainStateRepository.load( + stateInstanceId); + if (current == null + || (current.getStatus() != ChainStatus.READY + && current.getStatus() != ChainStatus.RUNNING)) { + return null; + } + + List pendingStartNodes = new ArrayList<>(); + for (Node startNode : definition.getStartNodes()) { + NodeState nodeState = nodeStateRepository.load( + stateInstanceId, startNode.getId()); + if (!isCompletedStartNode(nodeState)) { + pendingStartNodes.add(startNode); + } + } + + List startTriggerIds = new ArrayList<>(); + if (current.getStatus() == ChainStatus.READY + && !pendingStartNodes.isEmpty()) { + // 首个稳定入口意图携带变量,确保该意图保存后可独立恢复输入。 + startTriggerIds.add(prepareStartNodeLocked( + pendingStartNodes.get(0), variables)); + } + + if (current.getStatus() == ChainStatus.READY) { + updateStateSafely(state -> { + before.set(state.getStatus()); + EnumSet fields = + EnumSet.noneOf(ChainStateField.class); + if (variables != null && !variables.isEmpty()) { + state.getMemory().putAll(variables); + applyStartParameterAliases( + state.getMemory(), variables); + fields.add(ChainStateField.MEMORY); + } + if (StringUtil.noText( + state.getChainDefinitionId())) { + state.setChainDefinitionId(definition.getId()); + fields.add( + ChainStateField.CHAIN_DEFINITION_ID); + } + return fields; + }); + } + + int preparedCount = startTriggerIds.isEmpty() ? 0 : 1; + for (int index = preparedCount; + index < pendingStartNodes.size(); + index++) { + startTriggerIds.add(prepareStartNodeLocked( + pendingStartNodes.get(index), null)); + } + + if (current.getStatus() == ChainStatus.READY) { + updateStateSafely(state -> { + EnumSet fields = + EnumSet.of(ChainStateField.STATUS); + state.setStatus(ChainStatus.RUNNING); + if (state.getStartedAt() <= 0) { + state.setStartedAt( + System.currentTimeMillis()); + fields.add(ChainStateField.STARTED_AT); + } + started.set(true); + return fields; + }); + } + active.set(true); + if (started.get()) { + notifyEvent(new ChainStartEvent(this, variables)); + notifyEvent(new ChainStatusChangeEvent( + this, ChainStatus.RUNNING, before.get())); + } + + // 状态提交后主动领取;崩溃时持久扫描仍会重新投递。 + for (String triggerId : startTriggerIds) { + getTriggerScheduler().fire(triggerId); + } + return null; + }); + return active.get(); + } + /** * 为开始节点输入参数补齐 `nodeId.paramName` 与 `paramName` 双向别名。 * 这样既兼容运行表单仅提交裸参数名,也兼容设计器内部统一保存完整引用路径。 @@ -291,12 +567,25 @@ public class Chain { public void executeNode(Node node, Trigger trigger) { try { EXECUTION_THREAD_LOCAL.set(this); - ChainState chainState = getState(); + assertTriggerClaimOwned(trigger); + ChainState chainState = loadFreshState(); + bindNodeExecutionState(chainState); + + if (chainState.getStatus() == ChainStatus.READY + && trigger.getType() == TriggerType.START) { + if (!recoverStart(trigger)) { + throw new IllegalStateException( + "Failed to recover READY workflow start: " + + stateInstanceId); + } + chainState = loadFreshState(); + bindNodeExecutionState(chainState); + } // 当前处于挂起状态 if (chainState.getStatus() == ChainStatus.SUSPEND) { updateStateSafely(state -> { - chainState.addSuspendNodeId(node.getId()); + state.addSuspendNodeId(node.getId()); return EnumSet.of(ChainStateField.SUSPEND_NODE_IDS); }); return; @@ -306,6 +595,15 @@ public class Chain { return; } + // 可恢复启动可能重放同一稳定入口触发器;已开始的入口节点直接确认。 + if (trigger.getType() == TriggerType.START) { + NodeState existing = nodeStateRepository.load( + stateInstanceId, node.getId()); + if (isCompletedStartNode(existing)) { + return; + } + } + String triggerEdgeId = trigger.getEdgeId(); if (shouldSkipNode(node, triggerEdgeId)) { return; @@ -313,44 +611,95 @@ public class Chain { Map nodeResult = null; Throwable error = null; + String executionAttemptKey = null; try { - NodeState nodeState = getNodeState(node.id); - - // 如果节点状态不是运行中,则更新为运行中 - // 目前只有 Loop 节点会处于 Running 状态,因为它会多次触发 - if (nodeState.getStatus() != NodeStatus.RUNNING) { - updateNodeStateSafely(node.id, s -> { - s.setStatus(NodeStatus.RUNNING); - s.recordExecute(triggerEdgeId); - return EnumSet.of(NodeStateField.EXECUTE_COUNT, NodeStateField.EXECUTE_EDGE_IDS, NodeStateField.STATUS); - }); - TriggerType type = trigger.getType(); - notifyEvent(new NodeStartEvent(this, node)); - } - // 只需记录执行次数 - else { - updateNodeStateSafely(node.id, s -> { - s.recordExecute(triggerEdgeId); - return EnumSet.of(NodeStateField.EXECUTE_COUNT, NodeStateField.EXECUTE_EDGE_IDS); - }); + AtomicBoolean nodeStarted = new AtomicBoolean(); + String candidateAttemptKey = + currentExecutionAttemptKey( + node.getId()); + // 首次创建、状态转换和执行计数在同一实例锁内完成,避免额外读取和首次执行空状态。 + NodeState activeNodeState = + updateNodeStateSafely(node.id, s -> { + nodeStarted.set(false); + EnumSet fields = EnumSet.of( + NodeStateField.EXECUTE_COUNT, + NodeStateField.EXECUTE_EDGE_IDS); + if (s.getStatus() != NodeStatus.RUNNING) { + s.setStatus(NodeStatus.RUNNING); + nodeStarted.set(true); + fields.add(NodeStateField.STATUS); + s.setExecutionAttemptKey( + candidateAttemptKey); + fields.add( + NodeStateField + .EXECUTION_ATTEMPT_KEY); + } + if (node.getCondition() == null) { + s.recordTrigger(triggerEdgeId); + fields.add(NodeStateField.TRIGGER_COUNT); + fields.add(NodeStateField.TRIGGER_EDGE_IDS); + } + s.recordExecute(triggerEdgeId); + return fields; + }); + executionAttemptKey = + activeNodeState + .getExecutionAttemptKey(); + if (nodeStarted.get()) { + notifyEvent(new NodeStartEvent( + this, + node, + executionAttemptKey, + activeNodeState.getStatus(), + getAuditInstanceId())); } - updateStateSafely(state -> { + ChainState nodeExecutionState = updateStateSafely(state -> { + long childExecutionCount = state.getChildExecutionCount() + 1; + executionBudget.checkChildExecutions(childExecutionCount); + state.setChildExecutionCount(childExecutionCount); state.addTriggerNodeId(node.id); - return EnumSet.of(ChainStateField.TRIGGER_NODE_IDS); + return EnumSet.of( + ChainStateField.TRIGGER_NODE_IDS, + ChainStateField.CHILD_EXECUTION_COUNT); }); + bindNodeExecutionState(nodeExecutionState); + executionBudget.checkDuration(chainState.getStartedAt(), System.currentTimeMillis()); nodeResult = node.execute(this); + assertTriggerClaimOwned(trigger); + } catch (TriggerClaimLostException | RetryableTriggerException claimLost) { + throw claimLost; } catch (Throwable throwable) { log.error("Node execute error", throwable); error = throwable; } - handleNodeResult(node, nodeResult, triggerEdgeId, error); + // 结果提交入口会在实例锁内重读状态,统一拦截取消、超时和其他终态。 + handleNodeResult( + node, + nodeResult, + triggerEdgeId, + error, + executionAttemptKey); } finally { + executionStateSnapshot = null; + executionStateSnapshotLock = null; + NODE_EXECUTION_STATE_VIEW.remove(); EXECUTION_THREAD_LOCAL.remove(); } } + /** + * 验证当前触发器仍由本工作线程持有,阻止失去租约的工作线程提交状态。 + * + * @param trigger 当前触发器 + */ + private void assertTriggerClaimOwned(Trigger trigger) { + if (triggerScheduler != null) { + triggerScheduler.assertClaimOwned(trigger); + } + } + public NodeState getNodeState(String nodeId) { return getNodeState(this.stateInstanceId, nodeId); } @@ -360,31 +709,96 @@ public class Chain { } public T executeWithLock(String instanceId, long timeout, TimeUnit unit, Supplier action) { - try (ChainLock lock = chainStateRepository.getLock(instanceId, timeout, unit)) { - if (!lock.isAcquired()) { - throw new ChainLockTimeoutException("Failed to acquire lock for instance: " + instanceId); - } + Map heldLocks = HELD_INSTANCE_LOCKS.get(); + if (heldLocks != null && heldLocks.containsKey(instanceId)) { + assertInstanceLockOwned(instanceId); return action.get(); } + if (heldLocks == null) { + heldLocks = new HashMap<>(); + HELD_INSTANCE_LOCKS.set(heldLocks); + } + Map lockScope = heldLocks; + try { + try (ChainLock lock = chainStateRepository.getLock(instanceId, timeout, unit)) { + if (!lock.isAcquired()) { + throw new ChainLockTimeoutException("Failed to acquire lock for instance: " + instanceId); + } + lockScope.put(instanceId, lock); + try { + T result = action.get(); + assertInstanceLockOwned(instanceId); + return result; + } finally { + lockScope.remove(instanceId); + } + } + } finally { + if (lockScope.isEmpty()) { + HELD_INSTANCE_LOCKS.remove(); + } + } + } + + /** + * 验证当前线程持有的实例锁仍然有效。 + * + * @param instanceId 工作流实例 ID + */ + private void assertInstanceLockOwned(String instanceId) { + ChainLock lock = currentInstanceLock(instanceId); + if (lock == null || !lock.isValid()) { + throw new ChainLockTimeoutException( + "Workflow instance lock ownership lost: " + instanceId); + } + } + + /** + * 获取当前线程持有的实例锁。 + * + * @param instanceId 工作流实例 ID + * @return 当前实例锁;未持有时为 {@code null} + */ + private ChainLock currentInstanceLock( + String instanceId) { + Map heldLocks = + HELD_INSTANCE_LOCKS.get(); + return heldLocks == null + ? null + : heldLocks.get(instanceId); + } + + /** + * 显式创建当前工作流实例状态。 + * + * @return 已存在或新创建的状态 + */ + public ChainState initializeState() { + ChainState state = chainStateRepository.create(stateInstanceId); + if (state == null) { + throw new IllegalStateException("Unable to initialize chain state: " + stateInstanceId); + } + return state; } private boolean shouldSkipNode(Node node, String edgeId) { + NodeCondition condition = node.getCondition(); + if (condition == null) { + return false; + } return executeWithLock(stateInstanceId, 10, TimeUnit.SECONDS, () -> { NodeState newState = updateNodeStateSafely(node.id, s -> { s.recordTrigger(edgeId); return EnumSet.of(NodeStateField.TRIGGER_COUNT, NodeStateField.TRIGGER_EDGE_IDS); }); - NodeCondition condition = node.getCondition(); - if (condition == null) { - return false; - } Map prevResult = Collections.emptyMap(); boolean shouldSkipNode = !condition.check(this, newState, prevResult); if (shouldSkipNode) { updateStateSafely(state -> { - state.addUncheckedNodeId(node.id); - return EnumSet.of(ChainStateField.UNCHECKED_NODE_IDS); + return state.addUncheckedNodeId(node.id) + ? EnumSet.of(ChainStateField.UNCHECKED_NODE_IDS) + : null; }); } else { updateStateSafely(state -> { @@ -400,7 +814,53 @@ public class Chain { } - private void handleNodeResult(Node node, Map prevNodeResult, String triggerEdgeId, Throwable error) { + private void handleNodeResult(Node node, + Map prevNodeResult, + String triggerEdgeId, + Throwable error, + String executionAttemptKey) { + List deferredActions = new ArrayList<>(); + executeWithLock(stateInstanceId, 10, TimeUnit.SECONDS, () -> { + assertTriggerClaimOwned(TriggerContext.getCurrentTrigger()); + ChainState latestState = loadFreshState(); + cacheExecutionState( + stateInstanceId, latestState); + if (latestState == null) { + throw new IllegalStateException("Chain state not found: " + stateInstanceId); + } + if (latestState.getStatus() != ChainStatus.RUNNING) { + return null; + } + DEFERRED_EVENT_ACTIONS.set(deferredActions); + try { + handleNodeResultLocked( + node, + prevNodeResult, + triggerEdgeId, + error, + executionAttemptKey); + } finally { + DEFERRED_EVENT_ACTIONS.remove(); + } + return null; + }); + deferredActions.forEach(Runnable::run); + } + + /** + * 在实例锁保护下合并节点结果并调度后继节点。 + * + * @param node 已执行节点 + * @param prevNodeResult 节点输出 + * @param triggerEdgeId 触发边 ID + * @param error 节点执行异常 + * @param executionAttemptKey 进入节点时捕获的业务尝试键 + */ + private void handleNodeResultLocked(Node node, + Map prevNodeResult, + String triggerEdgeId, + Throwable error, + String executionAttemptKey) { ChainStatus finalChainStatus = null; NodeStatus finalNodeStatus = null; try { @@ -470,7 +930,7 @@ public class Chain { return EnumSet.of(NodeStateField.ERROR, NodeStateField.STATUS); }); - eventManager.notifyNodeError(error, node, prevNodeResult, this); + deferOrRun(() -> eventManager.notifyNodeError(error, node, prevNodeResult, this)); if (node.isRetryEnable() && node.getMaxRetryCount() > 0 @@ -495,7 +955,13 @@ public class Chain { state.setStatus(nodeStatus); return EnumSet.of(NodeStateField.STATUS); }); - notifyEvent(new NodeEndEvent(this, node, prevNodeResult, error)); + notifyEvent(new NodeEndEvent( + this, + node, + prevNodeResult, + error, + nodeStatus, + executionAttemptKey)); } if (finalChainStatus != null) { @@ -503,7 +969,7 @@ public class Chain { // chain 执行结束 if (finalChainStatus.isTerminal()) { - eventManager.notifyEvent(new ChainEndEvent(this), this); + notifyEvent(new ChainEndEvent(this)); // 执行结束,但是未执行成功,失败和取消等 // 更新父级链的状态 @@ -539,28 +1005,44 @@ public class Chain { } NodeState nodeState = getNodeState(node.getId()); - // 如果达到最大循环次数限制,则调度向外的节点 - if (node.getMaxLoopCount() > 0 && nodeState.getLoopCount() >= node.getMaxLoopCount()) { - scheduleOutwardNodes(node, result); - return; - } + int completedLoopCount = Math.addExact(nodeState.getLoopCount(), 1); + executionBudget.checkIterations(node.getId(), completedLoopCount); // 检查循环中断条件,如果满足则调度向外的节点 NodeCondition breakCondition = node.getLoopBreakCondition(); - if (breakCondition != null && breakCondition.check(this, nodeState, result)) { + boolean shouldBreak = breakCondition != null + && breakCondition.check(this, nodeState, result); + if (shouldBreak || completedLoopCount >= node.getMaxLoopCount()) { + resetNodeLoopCount(node.getId()); scheduleOutwardNodes(node, result); return; } - // 增加循环计数并重新调度当前节点 + // 记录已经完成的执行次数;下一次执行的零基索引与该值一致。 updateNodeStateSafely(node.getId(), s -> { - s.setLoopCount(s.getLoopCount() + 1); + s.setLoopCount(completedLoopCount); return EnumSet.of(NodeStateField.LOOP_COUNT); }); scheduleNode(node, byEdigeId, TriggerType.LOOP, node.getLoopIntervalMs()); } + /** + * 清理一次节点循环生命周期的计数,保证节点被外层循环再次触发时从零开始。 + * + * @param nodeId 节点 ID + */ + private void resetNodeLoopCount(String nodeId) { + NodeState currentState = getNodeState(nodeId); + if (currentState.getLoopCount() == 0) { + return; + } + updateNodeStateSafely(nodeId, state -> { + state.setLoopCount(0); + return EnumSet.of(NodeStateField.LOOP_COUNT); + }); + } + private void scheduleOutwardNodes(Node node, Map result) { List edges = definition.getOutwardEdge(node.getId()); @@ -608,10 +1090,11 @@ public class Chain { scheduleSuccess = true; } else { updateStateSafely(state -> { - state.addUncheckedEdgeId(edge.getId()); - return EnumSet.of(ChainStateField.UNCHECKED_EDGE_IDS); + return state.addUncheckedEdgeId(edge.getId()) + ? EnumSet.of(ChainStateField.UNCHECKED_EDGE_IDS) + : null; }); - eventManager.notifyEvent(new EdgeConditionCheckFailedEvent(this, edge, node, result), this); + notifyEvent(new EdgeConditionCheckFailedEvent(this, edge, node, result)); } } @@ -643,12 +1126,164 @@ public class Chain { public void scheduleNode(Node node, String edgeId, TriggerType type, long delayMs) { + scheduleNode(node, edgeId, type, delayMs, null, null); + } + + /** + * 调度循环体直属分支并写入稳定的代际游标。 + * + * @param node 目标节点 + * @param edgeId 触发边 ID + * @param delayMs 延迟毫秒数 + * @param loopNodeId 循环节点 ID + * @param cursor 本轮分支游标 + */ + public void scheduleLoopChild(Node node, + String edgeId, + long delayMs, + String loopNodeId, + Trigger.LoopCursor cursor) { + scheduleNode(node, edgeId, TriggerType.CHILD, delayMs, loopNodeId, cursor); + } + + /** + * 创建并持久化节点触发器。 + * + * @param node 目标节点 + * @param edgeId 触发边 ID + * @param type 触发类型 + * @param delayMs 延迟毫秒数 + * @param loopNodeId 需要覆盖的循环节点 ID + * @param cursor 循环代际游标 + */ + private void scheduleNode(Node node, + String edgeId, + TriggerType type, + long delayMs, + String loopNodeId, + Trigger.LoopCursor cursor) { + executeWithLock(stateInstanceId, 10L, TimeUnit.SECONDS, () -> { + scheduleNodeLocked(node, edgeId, type, delayMs, loopNodeId, cursor); + return null; + }); + } + + /** + * 在实例锁保护下创建并持久化节点触发器。 + * + * @param node 目标节点 + * @param edgeId 触发边 ID + * @param type 触发类型 + * @param delayMs 延迟毫秒数 + * @param loopNodeId 需要覆盖的循环节点 ID + * @param cursor 循环代际游标 + */ + private void scheduleNodeLocked(Node node, + String edgeId, + TriggerType type, + long delayMs, + String loopNodeId, + Trigger.LoopCursor cursor) { + scheduleNodeLocked( + node, edgeId, type, delayMs, loopNodeId, cursor, + null, false, true, null); + } + + /** + * 在状态切换前持久化可重放的入口触发器意图。 + * + * @param node 入口节点 + * @param startVariables 首个入口意图携带的启动变量 + * @return 稳定触发器 ID + */ + private String prepareStartNodeLocked( + Node node, Map startVariables) { + String stableId = "start-" + + UUID.nameUUIDFromBytes( + (stateInstanceId + '\n' + node.getId()) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + scheduleNodeLocked( + node, null, TriggerType.START, 1_000L, null, null, + stableId, true, false, startVariables); + return stableId; + } + + /** + * 判断稳定入口触发器是否已经完成状态提交。 + * + *

RUNNING 不能视为完成:进程可能在执行计数递增后、业务结果提交前崩溃, + * 此时必须允许同一稳定触发器重放。

+ * + * @param nodeState 入口节点状态 + * @return 已提交终态时为 {@code true} + */ + private boolean isCompletedStartNode(NodeState nodeState) { + if (nodeState == null || nodeState.getStatus() == null) { + return false; + } + return nodeState.getStatus() == NodeStatus.SUCCEEDED + || nodeState.getStatus() == NodeStatus.SUSPEND + || nodeState.getStatus() == NodeStatus.ERROR + || nodeState.getStatus() == NodeStatus.FAILED; + } + + /** + * 在实例锁保护下创建并持久化节点触发器。 + * + * @param node 目标节点 + * @param edgeId 触发边 ID + * @param type 触发类型 + * @param delayMs 延迟毫秒数 + * @param loopNodeId 循环节点 ID + * @param cursor 循环游标 + * @param stableTriggerId 可选稳定触发器 ID + * @param onlyIfAbsent 是否仅在仓储中不存在时保存 + * @param requireActive 是否要求实例已处于运行态 + * @param startVariables 首个入口意图携带的启动变量 + */ + private void scheduleNodeLocked(Node node, + String edgeId, + TriggerType type, + long delayMs, + String loopNodeId, + Trigger.LoopCursor cursor, + String stableTriggerId, + boolean onlyIfAbsent, + boolean requireActive, + Map startVariables) { + if (requireActive && !isExecutionActiveLocked()) { + return; + } Trigger trigger = new Trigger(); + trigger.setId(stableTriggerId); trigger.setStateInstanceId(stateInstanceId); trigger.setEdgeId(edgeId); trigger.setNodeId(node.getId()); trigger.setType(type); + trigger.setExecutionLane(executionLane); + trigger.setStartVariables(startVariables); trigger.setTriggerAt(System.currentTimeMillis() + delayMs); + Trigger currentTrigger = TriggerContext.getCurrentTrigger(); + String fencingClaimId = currentFencingClaimId(); + long claimGeneration = currentClaimGeneration(); + long lockFencingToken = currentLockFencingToken(stateInstanceId); + trigger.setRequiredLockFencingToken(lockFencingToken); + if (claimGeneration > 0L && StringUtil.hasText(fencingClaimId)) { + trigger.setRequiredFencingClaimId(fencingClaimId); + trigger.setRequiredFencingToken(claimGeneration); + } + if (type == TriggerType.RETRY && currentTrigger != null) { + String logicalExecutionId = StringUtil.hasText(currentTrigger.getLogicalExecutionId()) + ? currentTrigger.getLogicalExecutionId() + : currentTrigger.getId(); + trigger.setLogicalExecutionId(logicalExecutionId); + } + if (currentTrigger != null && !currentTrigger.getLoopCursors().isEmpty()) { + trigger.setLoopCursors(new LinkedHashMap<>(currentTrigger.getLoopCursors())); + } + if (loopNodeId != null && cursor != null) { + trigger.getLoopCursors().put(loopNodeId, cursor); + } if (edgeId != null) { updateStateSafely(state -> { @@ -656,10 +1291,386 @@ public class Chain { return EnumSet.of(ChainStateField.TRIGGER_EDGE_IDS); }); - eventManager.notifyEvent(new EdgeTriggerEvent(this, trigger), this); + notifyEvent(new EdgeTriggerEvent(this, trigger)); } - getTriggerScheduler().schedule(trigger); + if (onlyIfAbsent) { + getTriggerScheduler().scheduleIfAbsent(trigger); + } else { + getTriggerScheduler().schedule(trigger); + } + } + + /** + * 在已持有实例锁时判断工作流是否仍可推进。 + * + *

优先复用当前触发器内已加载或成功提交的状态快照。实例锁保证此期间其他执行者 + * 无法提交取消、超时或终态,因此无需再次访问状态仓储。

+ * + * @return 当前实例处于运行态时返回 {@code true} + */ + private boolean isExecutionActiveLocked() { + assertInstanceLockOwned(stateInstanceId); + ChainState state = reusableExecutionState(stateInstanceId); + if (state == null) { + state = loadFreshState(); + cacheExecutionState(stateInstanceId, state); + } + return state != null && state.getStatus() == ChainStatus.RUNNING; + } + + /** + * 获取当前触发器认领代际。 + * + * @param instanceId 工作流实例 ID + * @return 当前认领代际;非触发器调用为 {@code 0} + */ + private long currentClaimGeneration(String instanceId) { + Trigger trigger = TriggerContext.getCurrentTrigger(); + return trigger != null && Objects.equals(instanceId, trigger.getStateInstanceId()) + ? trigger.getFencingToken() + : 0L; + } + + /** + * 获取当前触发器认领代际。 + * + * @return 当前认领代际;非触发器调用为 {@code 0} + */ + public long currentClaimGeneration() { + return currentClaimGeneration(stateInstanceId); + } + + /** + * 获取当前线程持有的实例锁 fencing token。 + * + * @param instanceId 工作流实例 ID + * @return 当前锁 token;本地锁或未持锁时为 {@code 0} + */ + private long currentLockFencingToken(String instanceId) { + Map heldLocks = HELD_INSTANCE_LOCKS.get(); + ChainLock lock = heldLocks == null ? null : heldLocks.get(instanceId); + return lock == null ? 0L : lock.getFencingToken(); + } + + /** + * 获取当前线程持有的本实例锁 fencing token。 + * + * @return 当前锁 token;本地锁或未持锁时为 {@code 0} + */ + public long currentInstanceLockFencingToken() { + return currentLockFencingToken(stateInstanceId); + } + + /** + * 获取当前触发器认领 ID。 + * + * @param instanceId 工作流实例 ID + * @return 当前触发器 ID;非触发器调用为 {@code null} + */ + private String currentFencingClaimId(String instanceId) { + Trigger trigger = TriggerContext.getCurrentTrigger(); + return trigger != null && Objects.equals(instanceId, trigger.getStateInstanceId()) + ? trigger.getId() + : null; + } + + /** + * 获取当前触发器认领 ID,供循环结果等派生状态原子提交使用。 + * + * @return 当前触发器 ID;非触发器调用为 {@code null} + */ + public String currentFencingClaimId() { + return currentFencingClaimId(stateInstanceId); + } + + /** + * 在实例锁保护下原子追加循环结果。 + * + * @param resultId 循环结果 ID + * @param iterationIndex 迭代序号 + * @param outputValues 本轮输出 + */ + public void appendLoopResult( + String resultId, int iterationIndex, Map outputValues) { + executeWithLock(stateInstanceId, 10L, TimeUnit.SECONDS, () -> { + loopResultRepository.append( + stateInstanceId, + currentLockFencingToken(stateInstanceId), + currentFencingClaimId(stateInstanceId), + currentClaimGeneration(stateInstanceId), + resultId, + iterationIndex, + outputValues); + return null; + }); + } + + /** + * 使用当前实例锁和触发器认领守卫物化循环输入。 + * + * @param resultId 循环结果 ID + * @param items 原始输入 + * @param maxItems 最大元素数 + * @return 已物化元素数量 + */ + public int storeLoopInput( + String resultId, Iterable items, long maxItems) { + return loopResultRepository.storeInput( + stateInstanceId, + currentLockFencingToken(stateInstanceId), + currentFencingClaimId(stateInstanceId), + currentClaimGeneration(stateInstanceId), + resultId, + items, + maxItems); + } + + /** + * 使用稳定的触发器认领守卫在实例锁外物化循环输入。 + * + *

实例锁是短临界区资源,其他合法分支提交会推进其 fencing token, + * 因而锁外长 I/O 仅绑定触发器 claim。发布结果时会重新获取实例锁并校验 + * resultId 与物化代际。

+ * + * @param resultId 循环结果 ID + * @param items 原始输入 + * @param maxItems 最大元素数 + * @param claimId 当前触发器 ID + * @param claimGeneration 当前触发器认领代际 + * @return 已物化元素数量 + */ + public int storeLoopInputOutsideLock( + String resultId, + Iterable items, + long maxItems, + String claimId, + long claimGeneration) { + return loopResultRepository.storeInput( + stateInstanceId, + 0L, + claimId, + claimGeneration, + resultId, + items, + maxItems); + } + + /** + * 使用稳定触发器认领守卫,在实例锁外接收并分块保存推送式输入。 + * + * @param resultId 循环结果 ID + * @param producer 输入生产者 + * @param maxItems 最大元素数 + * @param claimId 当前触发器 ID + * @param claimGeneration 当前触发器认领代际 + * @return 已物化元素数量 + */ + public int storeProducedLoopInputOutsideLock( + String resultId, + LoopResultRepository.InputProducer producer, + long maxItems, + String claimId, + long claimGeneration) { + return loopResultRepository.storeProducedInput( + stateInstanceId, + 0L, + claimId, + claimGeneration, + resultId, + producer, + maxItems); + } + + /** + * 使用当前实例锁和触发器认领守卫清理循环输入。 + * + * @param resultId 循环结果 ID + */ + public void removeLoopInput(String resultId) { + loopResultRepository.removeInput( + stateInstanceId, + currentLockFencingToken(stateInstanceId), + currentFencingClaimId(stateInstanceId), + currentClaimGeneration(stateInstanceId), + resultId); + } + + /** + * 透明还原循环累计结果引用。 + * + * @param value 可能包含循环引用的值 + * @return 业务可见值 + */ + public Object resolveResultReferences(Object value) { + return loopResultRepository.resolveReferences(value); + } + + /** + * 判断当前工作流实例是否仍允许执行和推进。 + * + * @return 状态存在且为运行中时返回 {@code true} + */ + private boolean isExecutionActive() { + // 取消、超时等状态可能由其他线程提交,此处必须绕过执行期快照。 + ChainState state = loadFreshState(); + cacheExecutionState(stateInstanceId, state); + return state != null && state.getStatus() == ChainStatus.RUNNING; + } + + /** + * 从仓储读取最新状态,判断当前实例是否仍允许推进。 + * + * @return 最新状态为运行中时返回 {@code true} + */ + public boolean isExecutionActiveNow() { + return isExecutionActive(); + } + + /** + * 设置本实例派生触发器使用的执行通道。 + * + * @param executionLane 执行通道;{@code null} 表示默认通道 + */ + public void setExecutionLane(String executionLane) { + this.executionLane = executionLane; + } + + /** + * 设置由父子工作流调用链贡献的基础嵌套深度。 + * + * @param nestedDepthBase 非负基础深度 + */ + public void setNestedDepthBase(int nestedDepthBase) { + this.nestedDepthBase = Math.max(0, nestedDepthBase); + } + + /** + * 获取父子工作流调用链贡献的基础嵌套深度。 + * + * @return 非负基础深度 + */ + public int getNestedDepthBase() { + return nestedDepthBase; + } + + /** + * 生成当前节点副作用操作的稳定幂等键。 + * + *

同一持久化触发器发生租约恢复或重复投递时返回相同键;直接执行单节点且没有触发器 + * 上下文时返回 {@code null},保持原有每次调用均执行的语义。

+ * + * @param nodeId 当前节点 ID + * @return 稳定幂等键;无持久化触发器上下文时返回 {@code null} + */ + public String currentExecutionIdempotencyKey(String nodeId) { + Trigger trigger = TriggerContext.getCurrentTrigger(); + if (trigger == null || StringUtil.noText(trigger.getId())) { + return null; + } + String logicalExecutionId = StringUtil.hasText(trigger.getLogicalExecutionId()) + ? trigger.getLogicalExecutionId() + : trigger.getId(); + return stateInstanceId + ":" + nodeId + ":" + logicalExecutionId; + } + + /** + * 生成当前节点本次业务尝试的稳定键。 + * + *

基础设施重新投递同一触发器时键保持不变,业务重试生成新触发器时键随之变化, + * 可用于执行步骤等“每次业务尝试一条”的幂等记录。

+ * + * @param nodeId 当前节点 ID + * @return 当前业务尝试稳定键;无持久化触发器上下文时返回 {@code null} + */ + public String currentExecutionAttemptKey(String nodeId) { + Trigger trigger = TriggerContext.getCurrentTrigger(); + if (trigger == null || StringUtil.noText(trigger.getId())) { + return null; + } + return stateInstanceId + ":" + nodeId + ":" + trigger.getId(); + } + + /** + * 将当前工作流实例标记为取消。 + * + * @param message 取消原因 + * @return 本次是否将非终态实例转换为取消状态 + */ + public boolean cancel(String message) { + AtomicReference changed = new AtomicReference<>(false); + AtomicReference before = new AtomicReference<>(); + updateStateSafely(state -> { + if (state.getStatus() != null && state.getStatus().isTerminal()) { + return null; + } + before.set(state.getStatus()); + state.setStatus(ChainStatus.CANCELLED); + state.setMessage(message); + changed.set(true); + return EnumSet.of(ChainStateField.STATUS, ChainStateField.MESSAGE); + }); + if (changed.get()) { + notifyEvent(new ChainStatusChangeEvent( + this, ChainStatus.CANCELLED, before.get())); + } + return changed.get(); + } + + /** + * 将不可继续投递的实例幂等收敛为失败终态并发布统一终态事件。 + * + *

该入口用于触发器耗尽或不可恢复错误。状态、错误和消息先在实例锁内提交, + * 再发布状态、错误和结束事件,使同步等待、审计记录和定义快照清理观察到同一终态。

+ * + * @param cause 最终失败原因 + * @return 本次完成终态转换或实例此前已终止时为 {@code true} + */ + public boolean failTerminal(Throwable cause) { + Throwable failure = cause == null + ? new ChainException("Workflow execution failed") + : cause; + AtomicBoolean changed = new AtomicBoolean(); + AtomicReference before = + new AtomicReference<>(); + List deferredActions = new ArrayList<>(); + executeWithLock(stateInstanceId, 10L, TimeUnit.SECONDS, () -> { + updateStateSafely(state -> { + if (state.getStatus() != null + && state.getStatus().isTerminal()) { + return null; + } + before.set(state.getStatus()); + state.setStatus(ChainStatus.FAILED); + state.setError(new ExceptionSummary(failure)); + state.setMessage(failure.getMessage()); + changed.set(true); + return EnumSet.of( + ChainStateField.STATUS, + ChainStateField.ERROR, + ChainStateField.MESSAGE); + }); + if (changed.get()) { + DEFERRED_EVENT_ACTIONS.set(deferredActions); + try { + notifyEvent(new ChainStatusChangeEvent( + this, ChainStatus.FAILED, before.get())); + deferOrRun(() -> + eventManager.notifyChainError(failure, this)); + notifyEvent(new ChainEndEvent(this)); + } finally { + DEFERRED_EVENT_ACTIONS.remove(); + } + } + return null; + }); + deferredActions.forEach(Runnable::run); + ChainState state = chainStateRepository.load( + stateInstanceId); + return changed.get() + || (state != null + && state.getStatus() != null + && state.getStatus().isTerminal()); } @@ -676,10 +1687,24 @@ public class Chain { }); setStatusAndNotifyEvent(ChainStatus.FAILED); - eventManager.notifyChainError(throwable, this); + deferOrRun(() -> eventManager.notifyChainError(throwable, this)); return ChainStatus.FAILED; } + /** + * 在实例锁内仅记录事件动作,离开锁后按原顺序执行监听器 I/O。 + * + * @param action 事件动作 + */ + private void deferOrRun(Runnable action) { + List deferredActions = DEFERRED_EVENT_ACTIONS.get(); + if (deferredActions == null) { + action.run(); + } else { + deferredActions.add(action); + } + } + public void suspend() { setStatusAndNotifyEvent(ChainStatus.SUSPEND); } @@ -773,19 +1798,236 @@ public class Chain { this.nodeStateRepository = nodeStateRepository; } + /** + * 获取循环累计结果仓储。 + * + * @return 循环累计结果仓储 + */ + public LoopResultRepository getLoopResultRepository() { + return loopResultRepository; + } + + /** + * 设置循环累计结果仓储。 + * + * @param loopResultRepository 循环累计结果仓储;为空时使用进程内实现 + */ + public void setLoopResultRepository(LoopResultRepository loopResultRepository) { + this.loopResultRepository = loopResultRepository == null + ? new InMemoryLoopResultRepository() + : loopResultRepository; + } + public String getStateInstanceId() { return stateInstanceId; } + /** + * 获取当前工作流状态。 + * + * @return 仓储中的最新工作流状态 + */ public ChainState getState() { + return loadFreshState(); + } + + /** + * 获取当前节点执行入口捕获的状态视图。 + * + *

节点业务逻辑和参数解析应使用该方法,保证一次节点执行内引用同一个 + * point-in-time 状态并消除重复仓储 I/O。若当前线程不在节点执行上下文中, + * 则退化为读取仓储最新状态,不改变外部调用语义。

+ * + * @return 当前节点执行状态视图,或仓储中的最新状态 + */ + public ChainState getExecutionState() { + NodeExecutionStateView view = + NODE_EXECUTION_STATE_VIEW.get(); + return view != null && view.chain == this + ? view.state + : loadFreshState(); + } + + /** + * 获取节点审计归属的顶级执行实例 ID。 + * + *

新状态直接读取持久字段。兼容升级前仅保存 + * {@link ChainState#getParentInstanceId()} 的状态时,首次按父链解析并回填, + * 后续节点无需再次递归读取祖先状态。

+ * + * @return 顶级审计实例 ID + * @throws IllegalStateException 状态缺失、父链断裂或形成环时抛出 + */ + public String getAuditInstanceId() { + ChainState current = + getExecutionState(); + if (current == null) { + throw new IllegalStateException( + "Chain state not found: " + + stateInstanceId); + } + if (StringUtil.hasText( + current.getAuditInstanceId())) { + return current.getAuditInstanceId(); + } + + Set visited = + new HashSet<>(); + visited.add(current.getInstanceId()); + String resolvedInstanceId = + current.getInstanceId(); + while (StringUtil.hasText( + current.getParentInstanceId())) { + String parentInstanceId = + current.getParentInstanceId(); + if (!visited.add(parentInstanceId)) { + throw new IllegalStateException( + "Workflow parent state cycle detected: " + + parentInstanceId); + } + current = chainStateRepository.load( + parentInstanceId); + if (current == null) { + throw new IllegalStateException( + "Workflow parent state not found: " + + parentInstanceId); + } + if (StringUtil.hasText( + current.getAuditInstanceId())) { + resolvedInstanceId = + current.getAuditInstanceId(); + break; + } + resolvedInstanceId = + current.getInstanceId(); + } + + String rootInstanceId = + resolvedInstanceId; + ChainState updated = updateStateSafely(state -> { + if (StringUtil.hasText( + state.getAuditInstanceId())) { + return null; + } + state.setAuditInstanceId( + rootInstanceId); + return EnumSet.of( + ChainStateField.AUDIT_INSTANCE_ID); + }); + return updated.getAuditInstanceId(); + } + + /** + * 获取指定实例的最新工作流状态。 + * + * @param stateInstanceId 工作流实例 ID + * @return 指定实例的最新状态 + */ + public ChainState getState(String stateInstanceId) { return chainStateRepository.load(stateInstanceId); } - public ChainState getState(String stateInstanceId) { + /** + * 从仓储读取当前实例的最新状态。 + * + * @return 当前实例的最新状态 + */ + private ChainState loadFreshState() { return chainStateRepository.load(stateInstanceId); } + /** + * 在当前触发器执行线程内更新可复用状态快照。 + * + * @param instanceId 状态实例 ID + * @param state 最新状态 + */ + private void cacheExecutionState(String instanceId, ChainState state) { + if (EXECUTION_THREAD_LOCAL.get() == this + && Objects.equals(stateInstanceId, instanceId)) { + executionStateSnapshot = state; + executionStateSnapshotLock = + currentInstanceLock(instanceId); + NodeExecutionStateView view = + NODE_EXECUTION_STATE_VIEW.get(); + if (view != null && view.chain == this) { + view.state = state; + } + } + } + + /** + * 绑定当前线程单次节点执行所使用的状态视图。 + * + * @param state 节点入口的状态快照 + */ + private void bindNodeExecutionState(ChainState state) { + NODE_EXECUTION_STATE_VIEW.set( + new NodeExecutionStateView(this, state)); + } + + /** + * 单次节点执行的线程隔离状态视图。 + */ + private static final class NodeExecutionStateView { + private final Chain chain; + private ChainState state; + + /** + * 创建节点执行状态视图。 + * + * @param chain 所属执行链 + * @param state 节点入口状态 + */ + private NodeExecutionStateView( + Chain chain, ChainState state) { + this.chain = chain; + this.state = state; + } + } + + /** + * 获取当前执行线程可安全尝试提交的状态快照。 + * + *

仅复用由当前同一把实例锁读取或提交的快照。锁外读取和上一临界区快照均返回 + * {@code null},避免并发取消、超时或变量更新被旧状态遮蔽。

+ * + * @param instanceId 状态实例 ID + * @return 当前执行快照;不可复用时为 {@code null} + */ + private ChainState reusableExecutionState(String instanceId) { + ChainLock currentLock = + currentInstanceLock(instanceId); + return EXECUTION_THREAD_LOCAL.get() == this + && Objects.equals( + stateInstanceId, instanceId) + && currentLock != null + && currentLock.isValid() + && currentLock + == executionStateSnapshotLock + ? executionStateSnapshot + : null; + } + public void setStateInstanceId(String stateInstanceId) { this.stateInstanceId = stateInstanceId; } + + /** + * 获取当前实例的执行预算。 + * + * @return 执行预算 + */ + public ExecutionBudget getExecutionBudget() { + return executionBudget; + } + + /** + * 设置当前实例的执行预算。 + * + * @param executionBudget 执行预算;为空时恢复宽松缺省值 + */ + public void setExecutionBudget(ExecutionBudget executionBudget) { + this.executionBudget = executionBudget == null ? ExecutionBudget.defaults() : executionBudget; + } } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainDefinition.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainDefinition.java index a791cf0..4c94a73 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainDefinition.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainDefinition.java @@ -20,16 +20,25 @@ import com.easyagents.flow.core.util.StringUtil; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.UUID; public class ChainDefinition implements Serializable { + private static final long serialVersionUID = -3183115191738959423L; + protected String id; protected String name; protected String description; protected List nodes; protected List edges; + /** + * 由节点和边派生的只读图索引,不参与序列化。 + */ + private transient volatile GraphIndex graphIndex; public ChainDefinition() { } @@ -64,6 +73,7 @@ public class ChainDefinition implements Serializable { public void setNodes(List nodes) { this.nodes = nodes; + invalidateGraphIndex(); } public List getEdges() { @@ -72,27 +82,45 @@ public class ChainDefinition implements Serializable { public void setEdges(List edges) { this.edges = edges; + invalidateGraphIndex(); } + /** + * 获取指定节点的全部出边。 + * + * @param nodeId 节点 ID + * @return 保持定义顺序的出边副本 + */ public List getOutwardEdge(String nodeId) { - List result = new ArrayList<>(); - for (Edge edge : edges) { - if (nodeId.equals(edge.getSource())) { - result.add(edge); - } - } - return result; + List outwardEdges = graphIndex().outwardEdgesByNode.get(nodeId); + return outwardEdges == null ? Collections.emptyList() : new ArrayList<>(outwardEdges); } + /** + * 获取指定节点的全部入边。 + * + * @param nodeId 节点 ID + * @return 保持定义顺序的入边副本 + */ public List getInwardEdge(String nodeId) { - List result = new ArrayList<>(); - for (Edge edge : edges) { - if (nodeId.equals(edge.getTarget())) { - result.add(edge); - } - } - return result; + List inwardEdges = graphIndex().inwardEdgesByNode.get(nodeId); + return inwardEdges == null ? Collections.emptyList() : new ArrayList<>(inwardEdges); + } + + /** + * 获取循环节点已编译的直属分支调度描述。 + * + * @param loopNodeId 循环节点 ID + * @return 保持定义顺序的不可变调度描述 + */ + public List getLoopChildDispatches( + String loopNodeId) { + List dispatches = + graphIndex().loopChildrenByNode.get(loopNodeId); + return dispatches == null + ? Collections.emptyList() + : dispatches; } public void addNode(Node node) { @@ -105,31 +133,21 @@ public class ChainDefinition implements Serializable { } nodes.add(node); - -// if (this.edges != null) { -// for (Edge edge : edges) { -// if (node.getId().equals(edge.getSource())) { -// node.addOutwardEdge(edge); -// } else if (node.getId().equals(edge.getTarget())) { -// node.addInwardEdge(edge); -// } -// } -// } + invalidateGraphIndex(); } + /** + * 按 ID 获取节点。 + * + * @param id 节点 ID + * @return 对应节点,不存在时返回 {@code null} + */ public Node getNodeById(String id) { if (id == null || StringUtil.noText(id)) { return null; } - - for (Node node : this.nodes) { - if (id.equals(node.getId())) { - return node; - } - } - - return null; + return graphIndex().nodeById.get(id); } @@ -138,49 +156,33 @@ public class ChainDefinition implements Serializable { this.edges = new ArrayList<>(); } this.edges.add(edge); - -// boolean findSource = false, findTarget = false; -// for (Node node : this.nodes) { -// if (node.getId().equals(edge.getSource())) { -// node.addOutwardEdge(edge); -// findSource = true; -// } else if (node.getId().equals(edge.getTarget())) { -// node.addInwardEdge(edge); -// findTarget = true; -// } -// if (findSource && findTarget) { -// break; -// } -// } + invalidateGraphIndex(); } + /** + * 按 ID 获取边。 + * + * @param edgeId 边 ID + * @return 对应边,不存在时返回 {@code null} + */ public Edge getEdgeById(String edgeId) { - for (Edge edge : this.edges) { - if (edgeId.equals(edge.getId())) { - return edge; - } + if (StringUtil.noText(edgeId)) { + return null; } - return null; + return graphIndex().edgeById.get(edgeId); } + /** + * 获取没有入边的开始节点。 + * + * @return 保持定义顺序的开始节点副本 + */ public List getStartNodes() { if (nodes == null || nodes.isEmpty()) { return null; } - - List result = new ArrayList<>(); - - for (Node node : nodes) { -// if (CollectionUtil.noItems(node.getInwardEdges())) { -// result.add(node); -// } - List inwardEdge = getInwardEdge(node.getId()); - if (inwardEdge == null || inwardEdge.isEmpty()) { - result.add(node); - } - } - return result; + return new ArrayList<>(graphIndex().startNodes); } @@ -198,6 +200,210 @@ public class ChainDefinition implements Serializable { return parameters; } + /** + * 使派生图索引失效。 + */ + private void invalidateGraphIndex() { + graphIndex = null; + } + + /** + * 获取当前节点和边对应的只读图索引。 + * + * @return 图索引 + */ + private GraphIndex graphIndex() { + GraphIndex current = graphIndex; + if (current != null) { + return current; + } + synchronized (this) { + current = graphIndex; + if (current == null) { + current = GraphIndex.build(nodes, edges); + graphIndex = current; + } + return current; + } + } + + /** + * 工作流定义的派生图索引。 + */ + private static final class GraphIndex { + private final Map nodeById; + private final Map edgeById; + private final Map> outwardEdgesByNode; + private final Map> inwardEdgesByNode; + private final Map> + loopChildrenByNode; + private final List startNodes; + + /** + * 创建不可变图索引。 + * + * @param nodeById 节点索引 + * @param edgeById 边索引 + * @param outwardEdgesByNode 出边索引 + * @param inwardEdgesByNode 入边索引 + * @param startNodes 开始节点 + */ + private GraphIndex(Map nodeById, + Map edgeById, + Map> outwardEdgesByNode, + Map> inwardEdgesByNode, + Map> + loopChildrenByNode, + List startNodes) { + this.nodeById = nodeById; + this.edgeById = edgeById; + this.outwardEdgesByNode = outwardEdgesByNode; + this.inwardEdgesByNode = inwardEdgesByNode; + this.loopChildrenByNode = loopChildrenByNode; + this.startNodes = startNodes; + } + + /** + * 根据节点和边构建索引。 + * + * @param nodes 节点列表 + * @param edges 边列表 + * @return 构建完成的图索引 + */ + private static GraphIndex build(List nodes, List edges) { + Map nodeById = new HashMap<>(); + Map edgeById = new HashMap<>(); + Map> outwardEdgesByNode = new HashMap<>(); + Map> inwardEdgesByNode = new HashMap<>(); + + if (nodes != null) { + for (Node node : nodes) { + if (node != null && StringUtil.hasText(node.getId())) { + // 保持旧实现遇到重复 ID 时返回第一个节点的行为。 + nodeById.putIfAbsent(node.getId(), node); + } + } + } + + if (edges != null) { + for (Edge edge : edges) { + if (edge == null) { + continue; + } + if (StringUtil.hasText(edge.getId())) { + edgeById.putIfAbsent(edge.getId(), edge); + } + if (StringUtil.hasText(edge.getSource())) { + outwardEdgesByNode + .computeIfAbsent(edge.getSource(), ignored -> new ArrayList<>()) + .add(edge); + } + if (StringUtil.hasText(edge.getTarget())) { + inwardEdgesByNode + .computeIfAbsent(edge.getTarget(), ignored -> new ArrayList<>()) + .add(edge); + } + } + } + + List startNodes = new ArrayList<>(); + if (nodes != null) { + for (Node node : nodes) { + if (node != null && !inwardEdgesByNode.containsKey(node.getId())) { + startNodes.add(node); + } + } + } + + freezeEdgeLists(outwardEdgesByNode); + freezeEdgeLists(inwardEdgesByNode); + Map> + loopChildrenByNode = new HashMap<>(); + for (Map.Entry> entry : + outwardEdgesByNode.entrySet()) { + List dispatches = new ArrayList<>(); + for (Edge edge : entry.getValue()) { + Node child = nodeById.get(edge.getTarget()); + if (child != null + && Objects.equals( + entry.getKey(), child.getParentId())) { + String branchId = edge.getId() == null + ? child.getId() + : edge.getId(); + dispatches.add(new LoopChildDispatch( + child, edge.getId(), branchId)); + } + } + if (!dispatches.isEmpty()) { + loopChildrenByNode.put( + entry.getKey(), + Collections.unmodifiableList(dispatches)); + } + } + return new GraphIndex( + Collections.unmodifiableMap(nodeById), + Collections.unmodifiableMap(edgeById), + Collections.unmodifiableMap(outwardEdgesByNode), + Collections.unmodifiableMap(inwardEdgesByNode), + Collections.unmodifiableMap(loopChildrenByNode), + Collections.unmodifiableList(startNodes)); + } + + /** + * 将邻接表中的边列表转换为只读列表。 + * + * @param edgesByNode 邻接表 + */ + private static void freezeEdgeLists(Map> edgesByNode) { + edgesByNode.replaceAll((ignored, value) -> Collections.unmodifiableList(value)); + } + } + + /** + * 循环直属分支的预编译调度描述。 + */ + public static final class LoopChildDispatch { + + private final Node node; + private final String edgeId; + private final String branchId; + + /** + * 创建调度描述。 + * + * @param node 目标节点 + * @param edgeId 边 ID + * @param branchId 稳定分支 ID + */ + private LoopChildDispatch( + Node node, String edgeId, String branchId) { + this.node = node; + this.edgeId = edgeId; + this.branchId = branchId; + } + + /** + * @return 目标节点 + */ + public Node getNode() { + return node; + } + + /** + * @return 边 ID + */ + public String getEdgeId() { + return edgeId; + } + + /** + * @return 稳定分支 ID + */ + public String getBranchId() { + return branchId; + } + } + @Override public String toString() { diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainState.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainState.java index 21c0786..1370749 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainState.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainState.java @@ -37,8 +37,14 @@ import java.util.stream.Collectors; public class ChainState implements Serializable { + private static final long serialVersionUID = -7958235553581638052L; + private String instanceId; private String parentInstanceId; + /** + * 节点审计应归属的顶级执行实例 ID。 + */ + private String auditInstanceId; private String chainDefinitionId; private ConcurrentHashMap memory = new ConcurrentHashMap<>(); @@ -59,9 +65,18 @@ public class ChainState implements Serializable { private String message; private ExceptionSummary error; private long version; + /** + * 工作流实例首次启动时间,用于跨线程和跨进程执行时长保护。 + */ + private long startedAt; + /** + * 已进入业务执行的节点次数,用于全局执行预算。 + */ + private long childExecutionCount; public ChainState() { this.instanceId = UUID.randomUUID().toString(); + this.auditInstanceId = this.instanceId; this.status = ChainStatus.READY; this.computeCost = 0; } @@ -71,7 +86,15 @@ public class ChainState implements Serializable { } public void setInstanceId(String instanceId) { + String previousInstanceId = + this.instanceId; this.instanceId = instanceId; + if (auditInstanceId == null + || Objects.equals( + auditInstanceId, + previousInstanceId)) { + auditInstanceId = instanceId; + } } public String getParentInstanceId() { @@ -80,6 +103,30 @@ public class ChainState implements Serializable { public void setParentInstanceId(String parentInstanceId) { this.parentInstanceId = parentInstanceId; + if (StringUtil.hasText(parentInstanceId) + && Objects.equals( + auditInstanceId, instanceId)) { + auditInstanceId = null; + } + } + + /** + * 获取节点审计归属实例 ID。 + * + * @return 顶级审计实例 ID + */ + public String getAuditInstanceId() { + return auditInstanceId; + } + + /** + * 设置节点审计归属实例 ID。 + * + * @param auditInstanceId 顶级审计实例 ID + */ + public void setAuditInstanceId( + String auditInstanceId) { + this.auditInstanceId = auditInstanceId; } public String getChainDefinitionId() { @@ -127,7 +174,9 @@ public class ChainState implements Serializable { if (triggerEdgeIds == null) { triggerEdgeIds = new ArrayList<>(); } - triggerEdgeIds.add(edgeId); + if (!triggerEdgeIds.contains(edgeId)) { + triggerEdgeIds.add(edgeId); + } } public List getTriggerNodeIds() { @@ -142,7 +191,9 @@ public class ChainState implements Serializable { if (triggerNodeIds == null) { triggerNodeIds = new ArrayList<>(); } - triggerNodeIds.add(nodeId); + if (!triggerNodeIds.contains(nodeId)) { + triggerNodeIds.add(nodeId); + } } public List getUncheckedEdgeIds() { @@ -150,21 +201,31 @@ public class ChainState implements Serializable { } public void setUncheckedEdgeIds(List uncheckedEdgeIds) { - this.uncheckedEdgeIds = uncheckedEdgeIds; + this.uncheckedEdgeIds = uncheckedEdgeIds == null + ? new ArrayList<>() + : new ArrayList<>(new LinkedHashSet<>(uncheckedEdgeIds)); } - public void addUncheckedEdgeId(String edgeId) { + public boolean addUncheckedEdgeId(String edgeId) { if (uncheckedEdgeIds == null) { uncheckedEdgeIds = new ArrayList<>(); } + if (uncheckedEdgeIds.contains(edgeId)) { + return false; + } uncheckedEdgeIds.add(edgeId); + return true; } public boolean removeUncheckedEdgeId(String edgeId) { if (uncheckedEdgeIds == null) { return false; } - return uncheckedEdgeIds.remove(edgeId); + boolean removed = false; + while (uncheckedEdgeIds.remove(edgeId)) { + removed = true; + } + return removed; } public List getUncheckedNodeIds() { @@ -172,21 +233,31 @@ public class ChainState implements Serializable { } public void setUncheckedNodeIds(List uncheckedNodeIds) { - this.uncheckedNodeIds = uncheckedNodeIds; + this.uncheckedNodeIds = uncheckedNodeIds == null + ? new ArrayList<>() + : new ArrayList<>(new LinkedHashSet<>(uncheckedNodeIds)); } - public void addUncheckedNodeId(String nodeId) { + public boolean addUncheckedNodeId(String nodeId) { if (uncheckedNodeIds == null) { uncheckedNodeIds = new ArrayList<>(); } + if (uncheckedNodeIds.contains(nodeId)) { + return false; + } uncheckedNodeIds.add(nodeId); + return true; } public boolean removeUncheckedNodeId(String nodeId) { if (uncheckedNodeIds == null) { return false; } - return uncheckedNodeIds.remove(nodeId); + boolean removed = false; + while (uncheckedNodeIds.remove(nodeId)) { + removed = true; + } + return removed; } public Long getComputeCost() { @@ -281,6 +352,22 @@ public class ChainState implements Serializable { this.version = version; } + public long getStartedAt() { + return startedAt; + } + + public void setStartedAt(long startedAt) { + this.startedAt = startedAt; + } + + public long getChildExecutionCount() { + return childExecutionCount; + } + + public void setChildExecutionCount(long childExecutionCount) { + this.childExecutionCount = childExecutionCount; + } + public static ChainState fromJSON(String jsonString) { ParserConfig config = new ParserConfig(); config.putDeserializer(ChainState.class, new ChainDeserializer()); @@ -305,6 +392,8 @@ public class ChainState implements Serializable { this.status = ChainStatus.READY; this.message = null; this.error = null; + this.startedAt = 0L; + this.childExecutionCount = 0L; } @@ -340,10 +429,46 @@ public class ChainState implements Serializable { public Object resolveValue(String path) { + return resolveValue(path, false); + } + + /** + * 解析参数路径,并可在直接命中时保留大型结果引用。 + * + * @param path 参数路径 + * @param preserveDirectReference 是否保留直接命中的引用 + * @return 参数值 + */ + private Object resolveValue( + String path, + boolean preserveDirectReference) { Object result = MapUtil.getByPath(getMemory(), path); if (result == null) result = MapUtil.getByPath(getEnvironment(), path); // if (result == null) result = MapUtil.getByPath(getTriggerVariables(), path); - return result; + Chain chain = Chain.currentChain(); + if (result != null || chain == null || memory == null || path == null) { + return chain == null + || preserveDirectReference + ? result + : chain.resolveResultReferences(result); + } + + // MapUtil 无法直接穿透轻量引用,先解析最长命中的作用域值,再继续解析剩余路径。 + String[] parts = path.split("\\."); + for (int length = parts.length - 1; length > 0; length--) { + String prefix = String.join(".", Arrays.copyOf(parts, length)); + Object referenced = memory.get(prefix); + if (referenced == null) { + continue; + } + Object resolved = chain.resolveResultReferences(referenced); + String remaining = String.join( + ".", Arrays.copyOfRange(parts, length, parts.length)); + return MapUtil.getByPath( + Collections.singletonMap("value", resolved), + "value." + remaining); + } + return null; } public Map resolveParameters(Node node) { @@ -381,7 +506,28 @@ public class ChainState implements Serializable { * @return 模板渲染上下文列表 */ public List> buildTemplateRootMaps(Map formatArgs) { - return Arrays.asList(getMemory(), formatArgs, getEnvMap()); + Chain chain = Chain.currentChain(); + Map runtimeMemory = getMemory(); + if (chain != null && runtimeMemory != null && !runtimeMemory.isEmpty()) { + runtimeMemory = new LazyReferenceMap( + runtimeMemory, chain); + } + return Arrays.asList(runtimeMemory, formatArgs, getEnvMap()); + } + + /** + * 构建审计参数使用的惰性模板上下文。 + * + *

仅在模板实际读取某个 memory 顶级值时还原其中的轻量引用, + * 避免无关固定参数同步物化大型结果。

+ * + * @param formatArgs 当前节点参与模板渲染的参数 + * @return 惰性模板上下文列表 + */ + private List> + buildLazyTemplateRootMaps( + Map formatArgs) { + return buildTemplateRootMaps(formatArgs); } /** @@ -403,23 +549,76 @@ public class ChainState implements Serializable { } public Map resolveParameters(Node node, List parameters, Map formatArgs, boolean ignoreRequired) { + return resolveParameters( + node, + parameters, + formatArgs, + ignoreRequired, + false); + } + + /** + * 解析节点审计输入,直接引用保持轻量形式,由审计消费者异步还原。 + * + * @param node 当前节点 + * @return 兼容既有输入字段结构的参数快照 + */ + public Map resolveParametersPreservingReferences( + Node node) { + return resolveParameters( + node, + node.getParameters(), + null, + false, + true); + } + + /** + * 解析节点参数。 + * + * @param node 当前节点 + * @param parameters 参数定义 + * @param formatArgs 模板附加参数 + * @param ignoreRequired 是否忽略必填校验 + * @param preserveDirectReferences 是否保留直接结果引用 + * @return 已解析参数 + */ + private Map resolveParameters( + Node node, + List parameters, + Map formatArgs, + boolean ignoreRequired, + boolean preserveDirectReferences) { if (parameters == null || parameters.isEmpty()) { return Collections.emptyMap(); } Map variables = new LinkedHashMap<>(); List suspendParameters = null; + List> templateRootMaps = null; for (Parameter parameter : parameters) { RefType refType = parameter.getRefType(); Object value = null; if (refType == RefType.FIXED) { + if (templateRootMaps == null) { + templateRootMaps = + preserveDirectReferences + ? buildLazyTemplateRootMaps( + formatArgs) + : buildTemplateRootMaps( + formatArgs); + } value = TextTemplate.of(parameter.getValue()) - .formatToString(buildTemplateRootMaps(formatArgs)); + .formatToString(templateRootMaps); } else if (refType == RefType.REF) { - value = this.resolveValue(parameter.getRef()); + value = this.resolveValue( + parameter.getRef(), + preserveDirectReferences); } // 单节点执行时,参数只会传入 name 内容。 if (value == null) { - value = this.resolveValue(parameter.getName()); + value = this.resolveValue( + parameter.getName(), + preserveDirectReferences); } if (value == null && parameter.getDefaultValue() != null) { @@ -475,6 +674,166 @@ public class ChainState implements Serializable { return variables; } + /** + * 按实际访问惰性还原顶级 memory 值的只读映射。 + */ + private static final class LazyReferenceMap + extends AbstractMap { + + private final Map delegate; + private final Chain chain; + /** + * 同一模板渲染内已经还原的顶级值,避免重复引用触发重复分块读取。 + */ + private final Map resolvedValues = + new HashMap<>(); + + /** + * 创建惰性引用映射。 + * + * @param delegate 原始运行时 memory + * @param chain 当前工作流链路 + */ + private LazyReferenceMap( + Map delegate, + Chain chain) { + this.delegate = delegate; + this.chain = chain; + } + + /** + * {@inheritDoc} + */ + @Override + public synchronized Object get(Object key) { + if (!delegate.containsKey(key)) { + return null; + } + if (resolvedValues.containsKey(key)) { + return resolvedValues.get(key); + } + Object resolved = + chain.resolveResultReferences( + delegate.get(key)); + resolvedValues.put(key, resolved); + return resolved; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean containsKey(Object key) { + return delegate.containsKey(key); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + /** + * {@inheritDoc} + */ + @Override + public int size() { + return delegate.size(); + } + + /** + * {@inheritDoc} + */ + @Override + public Set keySet() { + return Collections.unmodifiableSet( + delegate.keySet()); + } + + /** + * {@inheritDoc} + */ + @Override + public Set> entrySet() { + Set keys = keySet(); + return new AbstractSet<>() { + @Override + public Iterator> + iterator() { + Iterator iterator = + keys.iterator(); + return new Iterator<>() { + @Override + public boolean hasNext() { + return iterator + .hasNext(); + } + + @Override + public Entry + next() { + String key = + iterator.next(); + return lazyEntry(key); + } + }; + } + + @Override + public int size() { + return keys.size(); + } + }; + } + + /** + * 创建仅在读取值时还原引用的不可变条目。 + * + * @param key memory 键 + * @return 惰性条目 + */ + private Entry lazyEntry( + String key) { + return new Entry<>() { + @Override + public String getKey() { + return key; + } + + @Override + public Object getValue() { + return LazyReferenceMap.this + .get(key); + } + + @Override + public Object setValue(Object value) { + throw new UnsupportedOperationException( + "read-only runtime memory"); + } + + @Override + public boolean equals(Object value) { + return value instanceof Entry entry + && Objects.equals( + key, entry.getKey()) + && Objects.equals( + getValue(), + entry.getValue()); + } + + @Override + public int hashCode() { + return Objects.hashCode(key) + ^ Objects.hashCode( + getValue()); + } + }; + } + } + public static class ChainSerializer implements ObjectSerializer { @Override @@ -513,6 +872,8 @@ public class ChainState implements Serializable { ", message='" + message + '\'' + ", error=" + error + ", version=" + version + + ", startedAt=" + startedAt + + ", childExecutionCount=" + childExecutionCount + '}'; } } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Edge.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Edge.java index b559e63..8ea7b31 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Edge.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Edge.java @@ -16,7 +16,14 @@ package com.easyagents.flow.core.chain; -public class Edge { +import java.io.Serializable; + +/** + * 工作流节点之间的有向边定义。 + */ +public class Edge implements Serializable { + private static final long serialVersionUID = 1L; + private String id; private String source; private String target; diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/EdgeCondition.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/EdgeCondition.java index 3629807..7a0c621 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/EdgeCondition.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/EdgeCondition.java @@ -16,10 +16,22 @@ package com.easyagents.flow.core.chain; +import java.io.Serializable; import java.util.Map; -public interface EdgeCondition { +/** + * 工作流边的执行条件。 + */ +public interface EdgeCondition extends Serializable { + /** + * 判断边是否允许继续执行。 + * + * @param chain 当前工作流实例 + * @param edge 待检查的边 + * @param executeResult 上游节点执行结果 + * @return 允许执行时返回 {@code true} + */ boolean check(Chain chain, Edge edge, Map executeResult); } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ExceptionSummary.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ExceptionSummary.java index 60eb033..8438cd5 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ExceptionSummary.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ExceptionSummary.java @@ -20,6 +20,7 @@ import java.io.Serializable; import java.io.StringWriter; public class ExceptionSummary implements Serializable { + private static final long serialVersionUID = 1L; private String exceptionClass; private String message; @@ -134,4 +135,3 @@ public class ExceptionSummary implements Serializable { this.timestamp = timestamp; } } - diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Node.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Node.java index 62fcc0b..643a1da 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Node.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Node.java @@ -25,7 +25,12 @@ import java.util.List; import java.util.Map; public abstract class Node implements Serializable { + private static final long serialVersionUID = 1L; private static final Logger log = org.slf4j.LoggerFactory.getLogger(Node.class); + /** 可配置的最小循环次数。 */ + public static final int MIN_LOOP_COUNT = 1; + /** 单个节点允许的最大循环次数。 */ + public static final int MAX_LOOP_COUNT = 300; protected String id; protected String parentId; @@ -42,7 +47,7 @@ public abstract class Node implements Serializable { protected boolean loopEnable = false; // 是否启用循环执行 protected long loopIntervalMs = 3000; // 循环间隔时间(毫秒) protected NodeCondition loopBreakCondition; // 跳出循环的条件 - protected int maxLoopCount = 0; // 0 表示不限制循环次数 + protected int maxLoopCount = MIN_LOOP_COUNT; // 循环总执行次数,取值范围 1~300 protected boolean retryEnable = false; protected boolean resetRetryCountAfterNormal = false; @@ -158,7 +163,22 @@ public abstract class Node implements Serializable { return maxLoopCount; } + /** + * 设置节点循环的总执行次数。 + * + * @param maxLoopCount 总执行次数,范围为 1~300 + * @throws IllegalArgumentException 循环次数超出允许范围 + */ public void setMaxLoopCount(int maxLoopCount) { + if (maxLoopCount < MIN_LOOP_COUNT || maxLoopCount > MAX_LOOP_COUNT) { + throw new IllegalArgumentException( + "maxLoopCount must be between " + + MIN_LOOP_COUNT + + " and " + + MAX_LOOP_COUNT + + ", but was " + + maxLoopCount); + } this.maxLoopCount = maxLoopCount; } @@ -237,7 +257,12 @@ public abstract class Node implements Serializable { protected long doCalculateComputeCost(String expr, Chain chain, Map result) { // Map parameterValues = chain.getState().getParameterValuesOnly(this, this.getParameters(), null); - Map parameterValues = chain.getState().resolveParameters(this, this.getParameters(), null,true); + Map parameterValues = + chain.getExecutionState().resolveParameters( + this, + this.getParameters(), + null, + true); Map newMap = new HashMap<>(result); newMap.putAll(parameterValues); return JsConditionUtil.evalLong(expr, chain, newMap); diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeCondition.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeCondition.java index f3adc40..e1e463a 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeCondition.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeCondition.java @@ -16,10 +16,22 @@ package com.easyagents.flow.core.chain; +import java.io.Serializable; import java.util.Map; -public interface NodeCondition { +/** + * 工作流节点的执行条件。 + */ +public interface NodeCondition extends Serializable { + /** + * 判断节点是否允许继续执行。 + * + * @param chain 当前工作流实例 + * @param context 当前节点状态 + * @param executeResult 上一次执行结果 + * @return 允许执行时返回 {@code true} + */ boolean check(Chain chain, NodeState context, Map executeResult); } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeState.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeState.java index ab1a7de..3965b29 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeState.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeState.java @@ -20,10 +20,11 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Collectors; public class NodeState implements Serializable { + private static final long serialVersionUID = -6727481826462129573L; + private String nodeId; private String chainInstanceId; @@ -39,6 +40,11 @@ public class NodeState implements Serializable { private AtomicInteger executeCount = new AtomicInteger(0); private List executeEdgeIds = new ArrayList<>(); + /** + * 当前节点生命周期对应的稳定业务尝试键。 + */ + private String executionAttemptKey; + ExceptionSummary error; private long version; @@ -135,6 +141,26 @@ public class NodeState implements Serializable { this.executeEdgeIds = executeEdgeIds; } + /** + * 获取当前节点生命周期的稳定业务尝试键。 + * + * @return 稳定业务尝试键 + */ + public String getExecutionAttemptKey() { + return executionAttemptKey; + } + + /** + * 设置当前节点生命周期的稳定业务尝试键。 + * + * @param executionAttemptKey 稳定业务尝试键 + */ + public void setExecutionAttemptKey( + String executionAttemptKey) { + this.executionAttemptKey = + executionAttemptKey; + } + public ExceptionSummary getError() { return error; } @@ -158,10 +184,16 @@ public class NodeState implements Serializable { return true; } - List shouldBeTriggerIds = inwardEdges.stream().map(Edge::getId).collect(Collectors.toList()); - List triggerEdgeIds = this.triggerEdgeIds; - return triggerEdgeIds.size() >= shouldBeTriggerIds.size() - && shouldBeTriggerIds.parallelStream().allMatch(triggerEdgeIds::contains); + if (triggerEdgeIds.size() < inwardEdges.size()) { + return false; + } + java.util.Set triggeredEdges = new java.util.HashSet<>(triggerEdgeIds); + for (Edge inwardEdge : inwardEdges) { + if (!triggeredEdges.contains(inwardEdge.getId())) { + return false; + } + } + return true; } public void recordTrigger(String fromEdgeId) { @@ -169,7 +201,9 @@ public class NodeState implements Serializable { if (fromEdgeId == null) { fromEdgeId = "none"; } - triggerEdgeIds.add(fromEdgeId); + if (!triggerEdgeIds.contains(fromEdgeId)) { + triggerEdgeIds.add(fromEdgeId); + } } public void recordExecute(String fromEdgeId) { @@ -177,6 +211,7 @@ public class NodeState implements Serializable { if (fromEdgeId == null) { fromEdgeId = "none"; } + executeEdgeIds.clear(); executeEdgeIds.add(fromEdgeId); } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeValidator.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeValidator.java index 0cb2a51..ac2e4d0 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeValidator.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeValidator.java @@ -16,6 +16,18 @@ package com.easyagents.flow.core.chain; -public interface NodeValidator { +import java.io.Serializable; + +/** + * 工作流节点定义校验器。 + */ +public interface NodeValidator extends Serializable { + + /** + * 校验节点定义。 + * + * @param node 待校验节点 + * @return 校验结果 + */ NodeValidResult validate(Node node); } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Parameter.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Parameter.java index d3eedf2..97f4bc9 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Parameter.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Parameter.java @@ -23,6 +23,7 @@ import java.util.Collection; import java.util.List; public class Parameter implements Serializable, Cloneable { + private static final long serialVersionUID = 1L; protected String id; protected String name; protected String description; diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeEndEvent.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeEndEvent.java index 97a1c30..cfecc8e 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeEndEvent.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeEndEvent.java @@ -18,40 +18,133 @@ package com.easyagents.flow.core.chain.event; import com.easyagents.flow.core.chain.Chain; import com.easyagents.flow.core.chain.Node; +import com.easyagents.flow.core.chain.NodeStatus; import java.util.Map; +/** + * 节点结束执行事件。 + */ public class NodeEndEvent extends BaseEvent { private final Node node; private final Map result; private final Throwable error; + private final NodeStatus status; + private final String executionAttemptKey; + /** + * 创建节点结束事件。 + * + * @param chain 当前工作流 + * @param node 当前节点 + * @param result 节点输出 + * @param error 节点异常 + */ public NodeEndEvent(Chain chain, Node node, Map result, Throwable error) { + this(chain, node, result, error, null, null); + } + + /** + * 创建携带不可变业务尝试键的节点结束事件。 + * + * @param chain 当前工作流 + * @param node 当前节点 + * @param result 节点输出 + * @param error 节点异常 + * @param executionAttemptKey 节点本次业务尝试键 + */ + public NodeEndEvent(Chain chain, + Node node, + Map result, + Throwable error, + String executionAttemptKey) { + this( + chain, + node, + result, + error, + null, + executionAttemptKey); + } + + /** + * 创建携带不可变节点终态和业务尝试键的节点结束事件。 + * + * @param chain 当前工作流 + * @param node 当前节点 + * @param result 节点输出 + * @param error 节点异常 + * @param status 节点本次业务尝试终态 + * @param executionAttemptKey 节点本次业务尝试键 + */ + public NodeEndEvent(Chain chain, + Node node, + Map result, + Throwable error, + NodeStatus status, + String executionAttemptKey) { super(chain); this.node = node; this.result = result; this.error = error; + this.status = status; + this.executionAttemptKey = executionAttemptKey; } + /** + * 获取当前节点。 + * + * @return 当前节点 + */ public Node getNode() { return node; } + /** + * 获取节点输出。 + * + * @return 节点输出 + */ public Map getResult() { return result; } + /** + * 获取节点异常。 + * + * @return 节点异常;成功时为 {@code null} + */ public Throwable getError() { return error; } + /** + * 获取事件创建时捕获的节点终态。 + * + * @return 节点终态;旧调用方未提供时为 {@code null} + */ + public NodeStatus getStatus() { + return status; + } + + /** + * 获取事件创建时捕获的业务尝试键。 + * + * @return 业务尝试键;旧调用方未提供时为 {@code null} + */ + public String getExecutionAttemptKey() { + return executionAttemptKey; + } + @Override public String toString() { return "NodeEndEvent{" + "node=" + node + ", result=" + result + ", error=" + error + + ", status=" + status + + ", executionAttemptKey='" + executionAttemptKey + '\'' + ", chain=" + chain + '}'; } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeStartEvent.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeStartEvent.java index 9e4358b..e54b4f6 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeStartEvent.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeStartEvent.java @@ -18,25 +18,123 @@ package com.easyagents.flow.core.chain.event; import com.easyagents.flow.core.chain.Chain; import com.easyagents.flow.core.chain.Node; +import com.easyagents.flow.core.chain.NodeStatus; +/** + * 节点开始执行事件。 + */ public class NodeStartEvent extends BaseEvent { private final Node node; + private final String executionAttemptKey; + private final NodeStatus status; + private final String auditInstanceId; + /** + * 创建节点开始事件。 + * + * @param chain 当前工作流 + * @param node 当前节点 + */ public NodeStartEvent(Chain chain, Node node) { - super(chain); - this.node = node; + this(chain, node, null, null, + chain.getStateInstanceId()); } + /** + * 创建携带不可变业务尝试键的节点开始事件。 + * + * @param chain 当前工作流 + * @param node 当前节点 + * @param executionAttemptKey 节点本次业务尝试键 + */ + public NodeStartEvent(Chain chain, + Node node, + String executionAttemptKey) { + this(chain, node, executionAttemptKey, null, + chain.getStateInstanceId()); + } + + /** + * 创建携带不可变业务尝试键和节点状态的开始事件。 + * + * @param chain 当前工作流 + * @param node 当前节点 + * @param executionAttemptKey 节点本次业务尝试键 + * @param status 事件创建时的节点状态 + */ + public NodeStartEvent(Chain chain, + Node node, + String executionAttemptKey, + NodeStatus status) { + this(chain, node, executionAttemptKey, status, + chain.getStateInstanceId()); + } + + /** + * 创建携带完整不可变审计上下文的节点开始事件。 + * + * @param chain 当前工作流 + * @param node 当前节点 + * @param executionAttemptKey 节点本次业务尝试键 + * @param status 事件创建时的节点状态 + * @param auditInstanceId 节点审计应关联的顶级执行实例 ID + */ + public NodeStartEvent(Chain chain, + Node node, + String executionAttemptKey, + NodeStatus status, + String auditInstanceId) { + super(chain); + this.node = node; + this.executionAttemptKey = executionAttemptKey; + this.status = status; + this.auditInstanceId = auditInstanceId; + } + + /** + * 获取当前节点。 + * + * @return 当前节点 + */ public Node getNode() { return node; } + /** + * 获取事件创建时捕获的业务尝试键。 + * + * @return 业务尝试键;旧调用方未提供时为 {@code null} + */ + public String getExecutionAttemptKey() { + return executionAttemptKey; + } + + /** + * 获取事件创建时捕获的节点状态。 + * + * @return 节点状态;旧调用方未提供时为 {@code null} + */ + public NodeStatus getStatus() { + return status; + } + + /** + * 获取节点审计关联的顶级执行实例 ID。 + * + * @return 顶级执行实例 ID + */ + public String getAuditInstanceId() { + return auditInstanceId; + } @Override public String toString() { return "NodeStartEvent{" + "node=" + node + + ", executionAttemptKey='" + executionAttemptKey + '\'' + + ", status=" + status + + ", auditInstanceId='" + auditInstanceId + '\'' + ", chain=" + chain + '}'; } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainDefinitionSnapshotRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainDefinitionSnapshotRepository.java new file mode 100644 index 0000000..5143e28 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainDefinitionSnapshotRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + * Licensed under the GNU Lesser General Public License (LGPL), Version 3.0. + */ +package com.easyagents.flow.core.chain.repository; + +import com.easyagents.flow.core.chain.ChainDefinition; + +/** + * 工作流实例级定义快照仓储。 + */ +public interface ChainDefinitionSnapshotRepository { + + /** + * 保存实例启动时的定义快照。 + * + * @param instanceId 工作流实例 ID + * @param definition 定义快照 + */ + void save(String instanceId, ChainDefinition definition); + + /** + * 加载实例启动时的定义快照。 + * + * @param instanceId 工作流实例 ID + * @return 定义快照;不存在时返回 null + */ + ChainDefinition load(String instanceId); + + /** + * 删除定义快照。 + * + * @param instanceId 工作流实例 ID + */ + void remove(String instanceId); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainLock.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainLock.java index 1bbcf17..95bf703 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainLock.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainLock.java @@ -25,9 +25,27 @@ public interface ChainLock extends AutoCloseable { */ boolean isAcquired(); + /** + * 锁是否仍由当前 owner 持有。 + * + * @return 锁仍有效时为 true + */ + default boolean isValid() { + return isAcquired(); + } + + /** + * 获取本次锁持有期对应的 fencing token。 + * + * @return 分布式仓储生成的单实例单调递增 token;本地锁返回 {@code 0} + */ + default long getFencingToken() { + return 0L; + } + /** * 释放锁(幂等) */ @Override void close(); -} \ No newline at end of file +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateField.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateField.java index 1509d23..b06cb9c 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateField.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateField.java @@ -31,8 +31,12 @@ public enum ChainStateField { ENVIRONMENT, CHILD_STATE_IDS, PARENT_INSTANCE_ID, + AUDIT_INSTANCE_ID, TRIGGER_NODE_IDS, TRIGGER_EDGE_IDS, UNCHECKED_EDGE_IDS, - UNCHECKED_NODE_IDS; + UNCHECKED_NODE_IDS, + STARTED_AT, + CHILD_EXECUTION_COUNT, + VERSION; } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateRepository.java index 45df7c7..5d2a543 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateRepository.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateRepository.java @@ -24,8 +24,70 @@ public interface ChainStateRepository { ChainState load(String instanceId); + /** + * 轻量读取工作流状态版本。 + * + *

分布式仓储应覆盖本方法并只读取版本字段,避免节点状态提交前反序列化完整 + * 工作流热状态。

+ * + * @param instanceId 工作流实例 ID + * @return 当前版本;状态不存在时返回 {@code null} + */ + default Long loadVersion(String instanceId) { + ChainState state = load(instanceId); + return state == null ? null : state.getVersion(); + } + + /** + * 创建工作流实例状态。 + * + * @param instanceId 工作流实例 ID + * @return 已存在或新创建的状态 + */ + default ChainState create(String instanceId) { + return load(instanceId); + } + boolean tryUpdate(ChainState newState, EnumSet fields); + /** + * 在当前实例锁 fencing token 仍有效时提交状态。 + * + *

单进程仓储可沿用普通乐观锁;分布式仓储应覆盖本方法并在同一原子操作中校验 + * token。

+ * + * @param newState 待提交状态 + * @param fields 变化字段 + * @param fencingToken 当前实例锁 token;本地仓储调用为 {@code 0} + * @return 提交成功时为 {@code true} + */ + default boolean tryUpdate( + ChainState newState, EnumSet fields, long fencingToken) { + return tryUpdate(newState, fields); + } + + /** + * 在实例锁和当前触发器认领租约均有效时提交状态。 + * + *

分布式仓储应在同一原子操作中校验实例锁 fencing token 与 claim generation, + * 同时拒绝锁过期后的旧执行者和租约过期后的旧 owner。

+ * + * @param newState 待提交状态 + * @param fields 变化字段 + * @param lockFencingToken 当前实例锁 token;本地仓储调用为 {@code 0} + * @param claimId 当前触发器 ID;非触发器调用为 {@code null} + * @param claimGeneration 当前认领代际;非触发器调用为 {@code 0} + * @return 提交成功时为 {@code true} + */ + default boolean tryUpdate( + ChainState newState, + EnumSet fields, + long lockFencingToken, + String claimId, + long claimGeneration) { + return tryUpdate(newState, fields, lockFencingToken); + } + /** * 获取指定 instanceId 的分布式锁 * diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryChainDefinitionSnapshotRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryChainDefinitionSnapshotRepository.java new file mode 100644 index 0000000..ea9cecc --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryChainDefinitionSnapshotRepository.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + * Licensed under the GNU Lesser General Public License (LGPL), Version 3.0. + */ +package com.easyagents.flow.core.chain.repository; + +import com.easyagents.flow.core.chain.ChainDefinition; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 进程内工作流定义快照仓储。 + */ +public class InMemoryChainDefinitionSnapshotRepository + implements ChainDefinitionSnapshotRepository { + + private final Map snapshots = new ConcurrentHashMap<>(); + + /** + * {@inheritDoc} + */ + @Override + public void save(String instanceId, ChainDefinition definition) { + snapshots.put(instanceId, definition); + } + + /** + * {@inheritDoc} + */ + @Override + public ChainDefinition load(String instanceId) { + return snapshots.get(instanceId); + } + + /** + * {@inheritDoc} + */ + @Override + public void remove(String instanceId) { + snapshots.remove(instanceId); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryChainStateRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryChainStateRepository.java index 65f1d08..5fb9755 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryChainStateRepository.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryChainStateRepository.java @@ -16,24 +16,41 @@ package com.easyagents.flow.core.chain.repository; import com.easyagents.flow.core.chain.ChainState; -import com.easyagents.flow.core.util.MapUtil; import java.util.EnumSet; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +/** + * 进程内工作流状态仓储。 + */ public class InMemoryChainStateRepository implements ChainStateRepository { private static final Map chainStateMap = new ConcurrentHashMap<>(); + /** + * {@inheritDoc} + */ @Override public ChainState load(String instanceId) { - return MapUtil.computeIfAbsent(chainStateMap, instanceId, k -> { + // 保留进程内仓储原有的惰性初始化语义,兼容直接构造 Chain 的调用方式。 + return create(instanceId); + } + + /** + * {@inheritDoc} + */ + @Override + public ChainState create(String instanceId) { + return chainStateMap.computeIfAbsent(instanceId, ignored -> { ChainState state = new ChainState(); state.setInstanceId(instanceId); return state; }); } + /** + * {@inheritDoc} + */ @Override public boolean tryUpdate(ChainState chainState, EnumSet fields) { chainStateMap.put(chainState.getInstanceId(), chainState); diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryLoopResultRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryLoopResultRepository.java new file mode 100644 index 0000000..55b0644 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryLoopResultRepository.java @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 进程内循环累计结果仓储,适用于单机运行和测试。 + */ +public class InMemoryLoopResultRepository implements LoopResultRepository { + + private final Map>> results = new ConcurrentHashMap<>(); + private final Map> inputs = new ConcurrentHashMap<>(); + + /** + * {@inheritDoc} + */ + @Override + public int storeInput(String resultId, Iterable items) { + List stored = new ArrayList<>(); + for (Object item : items) { + stored.add(item); + } + List existing = inputs.putIfAbsent(resultId, stored); + return existing == null ? stored.size() : existing.size(); + } + + /** + * {@inheritDoc} + */ + @Override + public Object loadInputItem(String resultId, int index) { + List stored = inputs.get(resultId); + if (stored == null) { + throw new IllegalStateException("Loop input not found: " + resultId); + } + return stored.get(index); + } + + /** + * {@inheritDoc} + */ + @Override + public void removeInput(String resultId) { + inputs.remove(resultId); + } + + /** + * {@inheritDoc} + */ + @Override + public void append(String resultId, int iterationIndex, Map outputValues) { + if (outputValues == null || outputValues.isEmpty()) { + return; + } + Map> result = results.computeIfAbsent( + resultId, ignored -> Collections.synchronizedMap(new LinkedHashMap<>())); + synchronized (result) { + for (Map.Entry entry : outputValues.entrySet()) { + List values = result.computeIfAbsent(entry.getKey(), ignored -> new ArrayList<>()); + if (values.size() != iterationIndex) { + throw new IllegalStateException("Unexpected loop result index: " + iterationIndex); + } + values.add(entry.getValue()); + } + } + } + + /** + * {@inheritDoc} + */ + @Override + public Map load(String resultId, int iterationCount, List outputNames) { + Map> result = results.get(resultId); + Map snapshot = new LinkedHashMap<>(); + if (iterationCount == 0 || outputNames == null || outputNames.isEmpty()) { + return snapshot; + } + if (result == null) { + throw new IllegalStateException("Loop result not found: " + resultId); + } + synchronized (result) { + for (String outputName : outputNames) { + List values = result.get(outputName); + if (values == null || values.size() != iterationCount) { + throw new IllegalStateException("Incomplete loop result: " + outputName); + } + snapshot.put(outputName, new ArrayList<>(values)); + } + } + return snapshot; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryNodeStateRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryNodeStateRepository.java index 3bbfd38..231991b 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryNodeStateRepository.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryNodeStateRepository.java @@ -16,20 +16,33 @@ package com.easyagents.flow.core.chain.repository; import com.easyagents.flow.core.chain.NodeState; -import com.easyagents.flow.core.util.MapUtil; import java.util.EnumSet; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +/** + * 进程内节点状态仓储。 + */ public class InMemoryNodeStateRepository implements NodeStateRepository { private static final Map chainStateMap = new ConcurrentHashMap<>(); + /** + * {@inheritDoc} + */ @Override public NodeState load(String instanceId, String nodeId) { - String key = instanceId + "." + nodeId; - return MapUtil.computeIfAbsent(chainStateMap, key, k -> { + // 保留进程内仓储原有的惰性初始化语义,避免改变既有直接读取行为。 + return create(instanceId, nodeId, 0L); + } + + /** + * {@inheritDoc} + */ + @Override + public NodeState create(String instanceId, String nodeId, long chainStateVersion) { + return chainStateMap.computeIfAbsent(key(instanceId, nodeId), ignored -> { NodeState nodeState = new NodeState(); nodeState.setChainInstanceId(instanceId); nodeState.setNodeId(nodeId); @@ -37,9 +50,23 @@ public class InMemoryNodeStateRepository implements NodeStateRepository { }); } + /** + * {@inheritDoc} + */ @Override public boolean tryUpdate(NodeState newState, EnumSet fields, long version) { - chainStateMap.put(newState.getChainInstanceId() + "." + newState.getNodeId(), newState); + chainStateMap.put(key(newState.getChainInstanceId(), newState.getNodeId()), newState); return true; } + + /** + * 构建进程内节点状态键。 + * + * @param instanceId 工作流实例 ID + * @param nodeId 节点 ID + * @return 节点状态键 + */ + private String key(String instanceId, String nodeId) { + return instanceId + "." + nodeId; + } } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LoopInputReference.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LoopInputReference.java new file mode 100644 index 0000000..73b921b --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LoopInputReference.java @@ -0,0 +1,62 @@ +package com.easyagents.flow.core.chain.repository; + +import java.io.Serializable; +import java.util.Objects; + +/** + * 已分块保存的循环输入轻量引用。 + * + *

循环节点按序读取分块;其他业务节点在参数读取边界会透明还原为与原输入等价的列表。

+ */ +public final class LoopInputReference implements Serializable { + + private static final long serialVersionUID = 1L; + private static final String REFERENCE_TYPE = + "easyflow.loop-input.v1"; + + private final String resultId; + private final int itemCount; + + /** + * 创建循环输入引用。 + * + * @param resultId 循环输入结果 ID + * @param itemCount 输入元素数量 + */ + public LoopInputReference(String resultId, int itemCount) { + this.resultId = Objects.requireNonNull( + resultId, "resultId must not be null"); + if (itemCount < 0) { + throw new IllegalArgumentException( + "itemCount must not be negative"); + } + this.itemCount = itemCount; + } + + /** + * 获取循环输入结果 ID。 + * + * @return 结果 ID + */ + public String getResultId() { + return resultId; + } + + /** + * 获取输入元素数量。 + * + * @return 元素数量 + */ + public int getItemCount() { + return itemCount; + } + + /** + * 获取跨异步审计边界使用的稳定引用类型。 + * + * @return 引用类型 + */ + public String getReferenceType() { + return REFERENCE_TYPE; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LoopResultReference.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LoopResultReference.java new file mode 100644 index 0000000..cd1a740 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LoopResultReference.java @@ -0,0 +1,52 @@ +package com.easyagents.flow.core.chain.repository; + +import java.io.Serializable; +import java.util.Objects; + +/** + * 循环累计输出的轻量引用,避免完整列表回写到高频热状态。 + */ +public final class LoopResultReference implements Serializable { + + private static final long serialVersionUID = 1L; + private static final String REFERENCE_TYPE = + "easyflow.loop-result.v1"; + + private final String resultId; + private final int iterationCount; + private final String outputName; + + /** + * 创建循环结果引用。 + * + * @param resultId 循环结果 ID + * @param iterationCount 迭代次数 + * @param outputName 输出名称 + */ + public LoopResultReference(String resultId, int iterationCount, String outputName) { + this.resultId = Objects.requireNonNull(resultId, "resultId must not be null"); + this.iterationCount = iterationCount; + this.outputName = Objects.requireNonNull(outputName, "outputName must not be null"); + } + + public String getResultId() { + return resultId; + } + + public int getIterationCount() { + return iterationCount; + } + + public String getOutputName() { + return outputName; + } + + /** + * 获取跨异步审计边界使用的稳定引用类型。 + * + * @return 引用类型 + */ + public String getReferenceType() { + return REFERENCE_TYPE; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LoopResultRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LoopResultRepository.java new file mode 100644 index 0000000..55cd40c --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LoopResultRepository.java @@ -0,0 +1,486 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +import com.easyagents.flow.core.chain.runtime.ExecutionBudgetExceededException; + +import java.util.Iterator; +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +/** + * 循环节点累计结果仓储。 + *

+ * 累计结果独立于高频更新的节点状态保存,避免每轮迭代重复序列化全部历史结果。 + */ +public interface LoopResultRepository { + + /** + * 流式保存不可随机访问的循环输入。 + * + * @param resultId 循环结果 ID + * @param items 原始输入 + * @return 输入元素数量 + */ + int storeInput(String resultId, Iterable items); + + /** + * 在已启用的迭代预算内流式保存循环输入。 + * + *

实现会在读取第 {@code maxItems + 1} 个元素前终止,避免超大或无限 Iterable + * 先产生无界 I/O。具体仓储应在下游写入异常时清理已落盘的部分分块。

+ * + * @param resultId 循环结果 ID + * @param items 原始输入 + * @param maxItems 最大元素数;小于等于零表示不限制 + * @return 输入元素数量 + */ + default int storeInput(String resultId, Iterable items, long maxItems) { + if (maxItems <= 0L) { + return storeInput(resultId, items); + } + Iterable bounded = () -> new Iterator() { + private final Iterator delegate = items.iterator(); + private long count; + + @Override + public boolean hasNext() { + return delegate.hasNext(); + } + + @Override + public Object next() { + if (count >= maxItems) { + throw new ExecutionBudgetExceededException( + "Loop iteration budget exceeded while storing input " + + resultId + + ": more than " + + maxItems); + } + count++; + return delegate.next(); + } + }; + return storeInput(resultId, bounded); + } + + /** + * 在实例锁和触发器认领均有效时流式保存循环输入。 + * + * @param instanceId 工作流实例 ID + * @param lockFencingToken 当前实例锁 token + * @param claimId 当前触发器 ID + * @param claimGeneration 当前触发器认领代际 + * @param resultId 循环结果 ID + * @param items 原始输入 + * @param maxItems 最大元素数;小于等于零表示不限制 + * @return 输入元素数量 + */ + default int storeInput( + String instanceId, + long lockFencingToken, + String claimId, + long claimGeneration, + String resultId, + Iterable items, + long maxItems) { + return storeInput(resultId, items, maxItems); + } + + /** + * 在生产者主动推送数据时流式保存循环输入。 + * + *

缺省实现用于本地兼容仓储;分布式仓储应覆盖此方法并边接收边分块写入, + * 避免先构造完整列表。

+ * + * @param instanceId 工作流实例 ID + * @param lockFencingToken 当前实例锁 token + * @param claimId 当前触发器 ID + * @param claimGeneration 当前触发器认领代际 + * @param resultId 循环结果 ID + * @param producer 输入生产者 + * @param maxItems 最大元素数;小于等于零表示不限制 + * @return 输入元素数量 + */ + default int storeProducedInput( + String instanceId, + long lockFencingToken, + String claimId, + long claimGeneration, + String resultId, + InputProducer producer, + long maxItems) { + List items = new java.util.ArrayList<>(); + producer.produce(item -> { + if (maxItems > 0L + && items.size() >= maxItems) { + throw new ExecutionBudgetExceededException( + "Loop iteration budget exceeded while storing input " + + resultId + + ": more than " + + maxItems); + } + items.add(item); + }); + return storeInput( + instanceId, + lockFencingToken, + claimId, + claimGeneration, + resultId, + items, + 0L); + } + + /** + * 主动向循环输入仓储推送元素的生产者。 + */ + @FunctionalInterface + interface InputProducer { + + /** + * 生产并按原顺序推送输入元素。 + * + * @param sink 单元素接收器 + */ + void produce(Consumer sink); + } + + /** + * 按序号读取已保存的循环输入。 + * + * @param resultId 循环结果 ID + * @param index 从零开始的序号 + * @return 输入元素 + */ + Object loadInputItem(String resultId, int index); + + /** + * 在业务参数读取边界透明还原完整循环输入。 + * + * @param reference 循环输入引用 + * @return 与原输入顺序一致的列表 + */ + default List loadInput(LoopInputReference reference) { + List items = + new java.util.ArrayList<>( + reference.getItemCount()); + for (int index = 0; + index < reference.getItemCount(); + index++) { + items.add(loadInputItem( + reference.getResultId(), index)); + } + return items; + } + + /** + * 清理循环输入。 + * + * @param resultId 循环结果 ID + */ + default void removeInput(String resultId) { + } + + /** + * 释放指定循环结果的进程内活跃缓存。 + * + *

该操作不得删除已经持久化的输入、输出分块或改变结果引用语义,仅用于在 + * 循环完成后及时归还本机缓存空间。

+ * + * @param resultId 循环结果 ID + */ + default void releaseActiveCache(String resultId) { + } + + /** + * 在实例锁和触发器认领均有效时清理循环输入。 + * + * @param instanceId 工作流实例 ID + * @param lockFencingToken 当前实例锁 token + * @param claimId 当前触发器 ID + * @param claimGeneration 当前触发器认领代际 + * @param resultId 循环结果 ID + */ + default void removeInput( + String instanceId, + long lockFencingToken, + String claimId, + long claimGeneration, + String resultId) { + removeInput(resultId); + } + + /** + * 追加一轮循环输出。 + * + * @param resultId 循环结果 ID + * @param iterationIndex 从零开始的迭代序号 + * @param outputValues 本轮输出 + */ + void append(String resultId, int iterationIndex, Map outputValues); + + /** + * 在当前触发器 fencing token 仍有效时追加循环输出。 + * + * @param instanceId 工作流实例 ID + * @param fencingToken 当前触发器 token;非持久化执行为 {@code 0} + * @param resultId 循环结果 ID + * @param iterationIndex 迭代序号 + * @param outputValues 本轮输出 + */ + default void append( + String instanceId, + long fencingToken, + String resultId, + int iterationIndex, + Map outputValues) { + append(resultId, iterationIndex, outputValues); + } + + /** + * 在实例锁和当前触发器认领租约均有效时追加循环输出。 + * + * @param instanceId 工作流实例 ID + * @param lockFencingToken 当前实例锁 token;本地仓储调用为 {@code 0} + * @param claimId 当前触发器 ID;非持久化执行为 {@code null} + * @param claimGeneration 当前认领代际;非持久化执行为 {@code 0} + * @param resultId 循环结果 ID + * @param iterationIndex 迭代序号 + * @param outputValues 本轮输出 + */ + default void append( + String instanceId, + long lockFencingToken, + String claimId, + long claimGeneration, + String resultId, + int iterationIndex, + Map outputValues) { + append(instanceId, lockFencingToken, resultId, iterationIndex, outputValues); + } + + /** + * 加载完整循环累计结果。 + * + * @param resultId 循环结果 ID + * @param iterationCount 已累计的迭代数 + * @param outputNames 输出名称,顺序与工作流定义一致 + * @return 按输出名称聚合的结果列表 + */ + Map load(String resultId, int iterationCount, List outputNames); + + /** + * 为每个循环输出创建轻量引用。 + * + * @param resultId 循环结果 ID + * @param iterationCount 已累计迭代数 + * @param outputNames 输出名称 + * @return 输出名称到轻量引用的映射 + */ + default Map references( + String resultId, int iterationCount, List outputNames) { + Map references = new LinkedHashMap<>(); + if (outputNames != null) { + for (String outputName : outputNames) { + references.put( + outputName, + new LoopResultReference(resultId, iterationCount, outputName)); + } + } + return references; + } + + /** + * 解析单个循环输出引用。 + * + * @param reference 循环输出引用 + * @return 与旧实现相同的累计列表 + */ + default Object resolve(LoopResultReference reference) { + return load( + reference.getResultId(), + reference.getIterationCount(), + List.of(reference.getOutputName())) + .get(reference.getOutputName()); + } + + /** + * 递归解析业务输出中的循环引用,供参数读取和 API 边界透明还原。 + * + * @param value 待解析值 + * @return 不包含循环引用的业务值 + */ + default Object resolveReferences(Object value) { + return ReferenceResolver.resolve(this, value); + } + + /** + * 单次递归解析中的批量读取器。 + */ + final class ReferenceResolver { + + private ReferenceResolver() { + } + + /** + * 收集同一循环结果的全部输出名称,并按结果组批量加载一次。 + * + * @param repository 循环结果仓储 + * @param value 待解析值 + * @return 已透明还原的值 + */ + static Object resolve(LoopResultRepository repository, Object value) { + Map> outputNames = + new LinkedHashMap<>(); + java.util.LinkedHashMap inputReferences = + new java.util.LinkedHashMap<>(); + collect(value, outputNames, inputReferences); + Map> loaded = new LinkedHashMap<>(); + outputNames.forEach((key, names) -> loaded.put( + key, + repository.load( + key.resultId, + key.iterationCount, + new java.util.ArrayList<>(names)))); + Map> loadedInputs = + new LinkedHashMap<>(); + inputReferences.forEach((resultId, reference) -> + loadedInputs.put( + resultId, + repository.loadInput(reference))); + return replace(value, loaded, loadedInputs); + } + + /** + * 递归收集循环结果引用。 + * + * @param value 当前值 + * @param outputNames 分组后的输出名称 + */ + private static void collect( + Object value, + Map> outputNames, + Map inputReferences) { + if (value instanceof LoopResultReference) { + LoopResultReference reference = (LoopResultReference) value; + GroupKey key = new GroupKey( + reference.getResultId(), reference.getIterationCount()); + outputNames.computeIfAbsent( + key, ignored -> new java.util.LinkedHashSet<>()) + .add(reference.getOutputName()); + return; + } + if (value instanceof LoopInputReference) { + LoopInputReference reference = + (LoopInputReference) value; + inputReferences.putIfAbsent( + reference.getResultId(), reference); + return; + } + if (value instanceof Map) { + ((Map) value).values().forEach( + item -> collect( + item, outputNames, inputReferences)); + return; + } + if (value instanceof List) { + ((List) value).forEach(item -> collect( + item, outputNames, inputReferences)); + } + } + + /** + * 使用已批量加载的结果递归替换引用。 + * + * @param value 当前值 + * @param loaded 已加载结果 + * @return 替换后的值 + */ + private static Object replace( + Object value, + Map> loaded, + Map> loadedInputs) { + if (value instanceof LoopResultReference) { + LoopResultReference reference = (LoopResultReference) value; + Map outputs = loaded.get(new GroupKey( + reference.getResultId(), reference.getIterationCount())); + return outputs == null ? null : outputs.get(reference.getOutputName()); + } + if (value instanceof LoopInputReference) { + return loadedInputs.get( + ((LoopInputReference) value) + .getResultId()); + } + if (value instanceof Map) { + Map resolved = new LinkedHashMap<>(); + ((Map) value).forEach( + (key, item) -> resolved.put( + key, + replace( + item, + loaded, + loadedInputs))); + return resolved; + } + if (value instanceof List) { + List list = (List) value; + java.util.ArrayList resolved = + new java.util.ArrayList<>(list.size()); + for (Object item : list) { + resolved.add(replace( + item, loaded, loadedInputs)); + } + return resolved; + } + return value; + } + + /** + * 循环结果批量读取分组键。 + */ + private static final class GroupKey { + + private final String resultId; + private final int iterationCount; + + private GroupKey(String resultId, int iterationCount) { + this.resultId = resultId; + this.iterationCount = iterationCount; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof GroupKey)) { + return false; + } + GroupKey that = (GroupKey) other; + return iterationCount == that.iterationCount + && java.util.Objects.equals(resultId, that.resultId); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(resultId, iterationCount); + } + } + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateField.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateField.java index e5d902c..95359ee 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateField.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateField.java @@ -27,5 +27,7 @@ public enum NodeStateField { SUSPEND_NODE_IDS, SUSPEND_FOR_PARAMETERS, EXECUTE_RESULT, - RETRY_COUNT, EXECUTE_COUNT, EXECUTE_EDGE_IDS, LOOP_COUNT, TRIGGER_COUNT, TRIGGER_EDGE_IDS, ENVIRONMENT + RETRY_COUNT, EXECUTE_COUNT, EXECUTE_EDGE_IDS, EXECUTION_ATTEMPT_KEY, + LOOP_COUNT, TRIGGER_COUNT, TRIGGER_EDGE_IDS, ENVIRONMENT, + VERSION } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateRepository.java index 369395d..0f1d779 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateRepository.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateRepository.java @@ -21,7 +21,123 @@ import java.util.EnumSet; public interface NodeStateRepository { + /** + * 加载已存在的节点状态。 + * + * @param instanceId 工作流实例 ID + * @param nodeId 节点 ID + * @return 节点状态;纯读取实现可在状态缺失时返回 {@code null},兼容实现可惰性创建 + */ NodeState load(String instanceId, String nodeId); + /** + * 显式创建节点状态。 + * + *

缺省实现兼容旧仓储中由 {@link #load(String, String)} 完成首次创建的行为。 + * 支持持久化或分布式执行的实现应覆盖本方法并原子创建状态。

+ * + * @param instanceId 工作流实例 ID + * @param nodeId 节点 ID + * @param chainStateVersion 创建时关联的工作流状态版本 + * @return 已存在或新创建的节点状态 + */ + default NodeState create(String instanceId, String nodeId, long chainStateVersion) { + NodeState existing = load(instanceId, nodeId); + if (existing != null) { + return existing; + } + NodeState created = new NodeState(); + created.setChainInstanceId(instanceId); + created.setNodeId(nodeId); + if (tryUpdate( + created, + EnumSet.noneOf(NodeStateField.class), + chainStateVersion)) { + return created; + } + return load(instanceId, nodeId); + } + + /** + * 在当前触发器 fencing token 仍有效时显式创建节点状态。 + * + * @param instanceId 工作流实例 ID + * @param nodeId 节点 ID + * @param chainStateVersion 工作流状态版本 + * @param fencingToken 当前触发器 token;非触发器调用为 {@code 0} + * @return 已存在或新创建的节点状态 + */ + default NodeState create( + String instanceId, String nodeId, long chainStateVersion, long fencingToken) { + return create(instanceId, nodeId, chainStateVersion); + } + + /** + * 在实例锁和当前触发器认领租约均有效时显式创建节点状态。 + * + * @param instanceId 工作流实例 ID + * @param nodeId 节点 ID + * @param chainStateVersion 工作流状态版本 + * @param lockFencingToken 当前实例锁 token;本地仓储调用为 {@code 0} + * @param claimId 当前触发器 ID;非触发器调用为 {@code null} + * @param claimGeneration 当前认领代际;非触发器调用为 {@code 0} + * @return 已存在或新创建的节点状态 + */ + default NodeState create( + String instanceId, + String nodeId, + long chainStateVersion, + long lockFencingToken, + String claimId, + long claimGeneration) { + return create(instanceId, nodeId, chainStateVersion, lockFencingToken); + } + + /** + * 按版本尝试提交节点状态。 + * + * @param newState 待提交的新状态 + * @param fields 本次变更字段 + * @param chainStateVersion 本次提交依赖的工作流状态版本 + * @return 提交成功时为 {@code true},版本冲突时为 {@code false} + */ boolean tryUpdate(NodeState newState, EnumSet fields, long chainStateVersion); + + /** + * 在工作流版本和 fencing token 同时有效时提交节点状态。 + * + * @param newState 待提交节点状态 + * @param fields 变化字段 + * @param chainStateVersion 工作流状态版本 + * @param fencingToken 当前触发器 token;非触发器调用为 {@code 0} + * @return 提交成功时为 {@code true} + */ + default boolean tryUpdate( + NodeState newState, + EnumSet fields, + long chainStateVersion, + long fencingToken) { + return tryUpdate(newState, fields, chainStateVersion); + } + + /** + * 在工作流版本、实例锁和当前触发器认领租约同时有效时提交节点状态。 + * + * @param newState 待提交节点状态 + * @param fields 变化字段 + * @param chainStateVersion 工作流状态版本 + * @param lockFencingToken 当前实例锁 token;本地仓储调用为 {@code 0} + * @param claimId 当前触发器 ID;非触发器调用为 {@code null} + * @param claimGeneration 当前认领代际;非触发器调用为 {@code 0} + * @return 提交成功时为 {@code true} + */ + default boolean tryUpdate( + NodeState newState, + EnumSet fields, + long chainStateVersion, + long lockFencingToken, + String claimId, + long claimGeneration) { + return tryUpdate(newState, fields, chainStateVersion, lockFencingToken); + } } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java index a63bfa3..130d1a8 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java @@ -25,6 +25,7 @@ import com.easyagents.flow.core.chain.repository.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.Serializable; import java.util.*; import java.util.concurrent.*; @@ -39,24 +40,61 @@ import java.util.concurrent.*; public class ChainExecutor { private static final Logger log = LoggerFactory.getLogger(ChainExecutor.class); + private static final String NESTED_DEPTH_MEMORY_KEY = + "__tinyflow.nesting.depth"; + private static final String DEFINITION_CALL_PATH_MEMORY_KEY = + "__tinyflow.nesting.definitionPath"; + /** + * 子工作流触发器使用的独立执行通道。 + */ + public static final String CHILD_WORKFLOW_EXECUTION_LANE_PREFIX = + "child-workflow:"; + private volatile Semaphore rootChildExecutionPermits = + new Semaphore(32, true); + private volatile long persistentOutcomePollMillis = 500L; + private volatile int maxChildExecutionLaneDepth = + Integer.MAX_VALUE; + private static final String CHILD_EXECUTION_REFERENCE_KEY = + "__tinyflow.workflowNode.childExecution"; + /** + * 进程内定义快照仅作为热点缓存;持久快照负责跨节点和跨进程恢复。 + */ + private static final int MAX_ACTIVE_DEFINITIONS = 1024; private final ChainDefinitionRepository definitionRepository; private final ChainStateRepository chainStateRepository; private final NodeStateRepository nodeStateRepository; + private final LoopResultRepository loopResultRepository; + private final ChainDefinitionSnapshotRepository definitionSnapshotRepository; private final TriggerScheduler triggerScheduler; + private final ExecutionBudget executionBudget; private final EventManager eventManager = new EventManager(); /** 等待同步执行结果的任务,按工作流实例 ID 进行常量时间路由。 */ private final ConcurrentMap>> pendingExecutions = new ConcurrentHashMap<>(); + /** + * 活跃工作流实例使用的定义快照,避免每个节点触发都重新加载和解析定义。 + */ + private final Map activeDefinitions = + Collections.synchronizedMap(new LinkedHashMap<>( + MAX_ACTIVE_DEFINITIONS + 1, 0.75F, true) { + @Override + protected boolean removeEldestEntry( + Map.Entry eldest) { + return size() > MAX_ACTIVE_DEFINITIONS; + } + }); public ChainExecutor(ChainDefinitionRepository definitionRepository , ChainStateRepository chainStateRepository , NodeStateRepository nodeStateRepository ) { - this.definitionRepository = definitionRepository; - this.chainStateRepository = chainStateRepository; - this.nodeStateRepository = nodeStateRepository; - this.triggerScheduler = ChainRuntime.triggerScheduler(); - registerRuntimeCallbacks(); + this(definitionRepository, + chainStateRepository, + nodeStateRepository, + new InMemoryLoopResultRepository(), + new InMemoryChainDefinitionSnapshotRepository(), + ChainRuntime.triggerScheduler(), + ExecutionBudget.defaults()); } @@ -64,10 +102,92 @@ public class ChainExecutor { , ChainStateRepository chainStateRepository , NodeStateRepository nodeStateRepository , TriggerScheduler triggerScheduler) { + this(definitionRepository, + chainStateRepository, + nodeStateRepository, + new InMemoryLoopResultRepository(), + new InMemoryChainDefinitionSnapshotRepository(), + triggerScheduler, + ExecutionBudget.defaults()); + } + + /** + * 创建使用指定调度器和资源预算的执行器。 + * + * @param definitionRepository 工作流定义仓储 + * @param chainStateRepository 工作流状态仓储 + * @param nodeStateRepository 节点状态仓储 + * @param triggerScheduler 触发调度器 + * @param executionBudget 平台资源保护预算 + */ + public ChainExecutor(ChainDefinitionRepository definitionRepository + , ChainStateRepository chainStateRepository + , NodeStateRepository nodeStateRepository + , TriggerScheduler triggerScheduler + , ExecutionBudget executionBudget) { + this(definitionRepository, + chainStateRepository, + nodeStateRepository, + new InMemoryLoopResultRepository(), + new InMemoryChainDefinitionSnapshotRepository(), + triggerScheduler, + executionBudget); + } + + /** + * 创建使用指定调度器、循环结果仓储和资源预算的执行器。 + * + * @param definitionRepository 工作流定义仓储 + * @param chainStateRepository 工作流状态仓储 + * @param nodeStateRepository 节点状态仓储 + * @param loopResultRepository 循环累计结果仓储 + * @param triggerScheduler 触发调度器 + * @param executionBudget 平台资源保护预算 + */ + public ChainExecutor(ChainDefinitionRepository definitionRepository + , ChainStateRepository chainStateRepository + , NodeStateRepository nodeStateRepository + , LoopResultRepository loopResultRepository + , TriggerScheduler triggerScheduler + , ExecutionBudget executionBudget) { + this(definitionRepository, + chainStateRepository, + nodeStateRepository, + loopResultRepository, + new InMemoryChainDefinitionSnapshotRepository(), + triggerScheduler, + executionBudget); + } + + /** + * 创建使用持久定义快照的执行器。 + * + * @param definitionRepository 工作流定义仓储 + * @param chainStateRepository 工作流状态仓储 + * @param nodeStateRepository 节点状态仓储 + * @param loopResultRepository 循环累计结果仓储 + * @param definitionSnapshotRepository 实例级定义快照仓储 + * @param triggerScheduler 触发调度器 + * @param executionBudget 平台资源保护预算 + */ + public ChainExecutor(ChainDefinitionRepository definitionRepository + , ChainStateRepository chainStateRepository + , NodeStateRepository nodeStateRepository + , LoopResultRepository loopResultRepository + , ChainDefinitionSnapshotRepository definitionSnapshotRepository + , TriggerScheduler triggerScheduler + , ExecutionBudget executionBudget) { this.definitionRepository = definitionRepository; this.chainStateRepository = chainStateRepository; this.nodeStateRepository = nodeStateRepository; + this.loopResultRepository = loopResultRepository == null + ? new InMemoryLoopResultRepository() + : loopResultRepository; + this.definitionSnapshotRepository = definitionSnapshotRepository == null + ? new InMemoryChainDefinitionSnapshotRepository() + : definitionSnapshotRepository; this.triggerScheduler = triggerScheduler; + this.executionBudget = executionBudget == null ? ExecutionBudget.defaults() : executionBudget; registerRuntimeCallbacks(); } @@ -80,33 +200,47 @@ public class ChainExecutor { public Map execute(String definitionId, Map variables, long timeout, TimeUnit unit) { Chain chain = createChain(definitionId); String stateInstanceId = chain.getStateInstanceId(); - CompletableFuture> future = new CompletableFuture<>(); - - CompletableFuture> existing = pendingExecutions.putIfAbsent(stateInstanceId, future); - if (existing != null) { - throw new IllegalStateException("Duplicate pending chain execution: " + stateInstanceId); - } try { chain.start(variables); - Map result = future.get(timeout, unit); + Map result = awaitPersistentOutcome( + stateInstanceId, timeout, unit, null); clearDefaultStates(result); return result; } catch (TimeoutException e) { - future.cancel(true); + cancel(stateInstanceId, "Execution timed out"); throw new RuntimeException("Execution timed out", e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - future.cancel(true); + cancel(stateInstanceId, "Execution interrupted"); throw new RuntimeException("Execution interrupted", e); } catch (Throwable e) { - future.cancel(true); - throw new RuntimeException("Execution failed", e.getCause()); + throw new RuntimeException("Execution failed", e); } finally { - pendingExecutions.remove(stateInstanceId, future); + activeDefinitions.remove(stateInstanceId); } } + /** + * 取消仍在运行的工作流实例。 + * + *

取消状态写入后,未开始的触发器会在执行入口短路;已经完成的外部 I/O 也会在提交 + * 结果前重新检查状态,避免继续推进下游。

+ * + * @param stateInstanceId 工作流实例 ID + * @param message 取消原因 + * @return 本次是否完成了非终态到取消状态的转换 + */ + public boolean cancel(String stateInstanceId, String message) { + ChainState state = chainStateRepository.load(stateInstanceId); + if (state == null) { + return false; + } + ChainDefinition definition = getDefinitionForInstance(state); + Chain chain = configureChain(definition, stateInstanceId); + return chain.cancel(message); + } + /** * 注册工作流调度和同步结果路由回调。 */ @@ -114,6 +248,79 @@ public class ChainExecutor { eventManager.addEventListener(ChainStatusChangeEvent.class, this::completePendingExecution); eventManager.addChainErrorListener(this::failPendingExecution); triggerScheduler.registerConsumer(this::accept); + triggerScheduler.registerFailureListener( + this::failDeadLetteredTrigger); + } + + /** + * 配置同步子工作流的容量和持久状态轮询参数。 + * + *

该方法应在执行器对外提供服务前调用。

+ * + * @param rootPermits 根级同步子流程最大并发 + * @param pollMillis 持久终态轮询间隔毫秒数 + * @param laneMaxDepth 已注册独立执行通道覆盖的最大深度 + */ + public void configureChildWorkflowRuntime( + int rootPermits, + long pollMillis, + int laneMaxDepth) { + if (rootPermits <= 0 || pollMillis <= 0L + || laneMaxDepth <= 0) { + throw new IllegalArgumentException( + "Child workflow runtime values must be positive"); + } + this.rootChildExecutionPermits = + new Semaphore(rootPermits, true); + this.persistentOutcomePollMillis = pollMillis; + this.maxChildExecutionLaneDepth = laneMaxDepth; + } + + /** + * 将已成功写入死信的触发器对应实例收敛为失败终态。 + * + *

状态更新使用实例锁 fencing token,避免旧节点在锁失效后覆盖新 owner 的 + * 业务终态;终态实例保持原状态。

+ * + * @param trigger 已死信触发器 + * @param failure 最后一次失败 + */ + private boolean failDeadLetteredTrigger( + Trigger trigger, Throwable failure) { + if (trigger == null + || trigger.getStateInstanceId() == null) { + return true; + } + String instanceId = trigger.getStateInstanceId(); + Throwable cause = failure == null + ? new ChainException( + "Workflow trigger delivery attempts exhausted: " + + trigger.getId()) + : failure; + try { + ChainState state = + chainStateRepository.load(instanceId); + if (state == null + || (state.getStatus() != null + && state.getStatus().isTerminal())) { + return true; + } + ChainDefinition definition = + getDefinitionForInstance(state); + if (definition == null) { + definition = new ChainDefinition(); + definition.setId(state.getChainDefinitionId()); + } + Chain chain = configureChain( + definition, instanceId); + return chain.failTerminal(cause); + } catch (Throwable terminalError) { + log.error( + "Failed to mark dead-lettered workflow terminal, " + + "instanceId={}, triggerId={}", + instanceId, trigger.getId(), terminalError); + return false; + } } /** @@ -130,19 +337,28 @@ public class ChainExecutor { String stateInstanceId = chain.getStateInstanceId(); CompletableFuture> future = pendingExecutions.get(stateInstanceId); - if (future == null) { - return; - } - try { - ChainState state = chainStateRepository.load(stateInstanceId); - if (state == null) { - throw new ChainException("Chain state not found: " + stateInstanceId); + if (future != null) { + ChainState state = chainStateRepository.load(stateInstanceId); + if (state == null) { + throw new ChainException("Chain state not found: " + stateInstanceId); + } + @SuppressWarnings("unchecked") + Map execResult = (Map) + loopResultRepository.resolveReferences(state.getExecuteResult()); + future.complete(execResult != null ? execResult : Collections.emptyMap()); } - Map execResult = state.getExecuteResult(); - future.complete(execResult != null ? execResult : Collections.emptyMap()); } catch (Exception error) { - future.completeExceptionally(error); + if (future != null) { + future.completeExceptionally(error); + } + log.error( + "Failed to complete workflow execution continuation, instanceId={}", + stateInstanceId, + error); + } finally { + activeDefinitions.remove(stateInstanceId); + definitionSnapshotRepository.remove(stateInstanceId); } } @@ -153,10 +369,16 @@ public class ChainExecutor { * @param chain 发生异常的工作流实例 */ private void failPendingExecution(Throwable error, Chain chain) { - CompletableFuture> future = pendingExecutions.get(chain.getStateInstanceId()); + String stateInstanceId = chain.getStateInstanceId(); + CompletableFuture> future = pendingExecutions.get(stateInstanceId); if (future != null) { future.completeExceptionally(error); } + activeDefinitions.remove(stateInstanceId); + ChainState state = chainStateRepository.load(stateInstanceId); + if (state != null && state.getStatus().isTerminal()) { + definitionSnapshotRepository.remove(stateInstanceId); + } } /** @@ -176,8 +398,416 @@ public class ChainExecutor { public String executeAsync(String definitionId, Map variables) { Chain chain = createChain(definitionId); - chain.start(variables); - return chain.getStateInstanceId(); + try { + chain.start(variables); + return chain.getStateInstanceId(); + } catch (RuntimeException | Error error) { + activeDefinitions.remove(chain.getStateInstanceId()); + definitionSnapshotRepository.remove(chain.getStateInstanceId()); + throw error; + } + } + + /** + * 在独立触发执行通道中同步执行子工作流。 + * + *

调用者继续获得与历史实现一致的同步结果;子流程自身的节点触发器在独立 + * worker lane 执行,因此父节点等待不会占满子流程所需的普通节点工作线程。 + * 根级子流程并发受宽松许可保护,防止异常调用一次创建过多等待线程。

+ * + * @param definitionId 子流程定义 ID + * @param variables 子流程输入 + * @param parentChain 父流程 + * @param parentNodeId 父工作流节点 ID + * @return 子流程输出 + */ + public Map executeChild( + String definitionId, + Map variables, + Chain parentChain, + String parentNodeId) { + Objects.requireNonNull(parentChain, "parentChain required"); + if (parentNodeId == null || parentNodeId.isBlank()) { + throw new IllegalArgumentException("parentNodeId required"); + } + ChainState parentState = chainStateRepository.load( + parentChain.getStateInstanceId()); + int parentDepth = readNestedDepth(parentState); + int childDepth = parentDepth + 1; + executionBudget.checkNestedDepth(parentNodeId, childDepth); + if (childDepth > maxChildExecutionLaneDepth) { + throw new ExecutionBudgetExceededException( + "Child workflow depth " + + childDepth + + " exceeds registered execution lanes " + + maxChildExecutionLaneDepth); + } + List callPath = readDefinitionCallPath( + parentState, parentChain.getDefinition().getId()); + String canonicalChildId = canonicalDefinitionId(definitionId); + if (callPath.contains(canonicalChildId)) { + throw new ExecutionBudgetExceededException( + "Recursive workflow call detected at " + + parentNodeId + + ": " + + String.join(" -> ", callPath) + + " -> " + + canonicalChildId); + } + List childCallPath = new ArrayList<>(callPath); + childCallPath.add(canonicalChildId); + Trigger currentTrigger = TriggerContext.getCurrentTrigger(); + String invocationId = currentTrigger == null + ? "direct:" + UUID.randomUUID() + : (currentTrigger.getLogicalExecutionId() == null + || currentTrigger.getLogicalExecutionId().isBlank() + ? currentTrigger.getId() + : currentTrigger.getLogicalExecutionId()); + boolean rootPermit = false; + try { + if (parentDepth == 0) { + if (!rootChildExecutionPermits.tryAcquire( + 1L, TimeUnit.SECONDS)) { + throw new RetryableTriggerException( + "子工作流并发繁忙,请稍后重试", null); + } + rootPermit = true; + } + ChildExecutionReference reference = + prepareChildExecution( + definitionId, + parentChain, + parentNodeId, + invocationId, + childDepth, + childCallPath); + startChildIfReady(reference, variables); + Map result = awaitPersistentOutcome( + reference.childInstanceId(), + Long.MAX_VALUE, + TimeUnit.SECONDS, + parentChain); + markChildExecutionCompleted( + parentChain, parentNodeId, reference); + clearDefaultStates(result); + return result; + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new RuntimeException( + "Child workflow execution interrupted", error); + } catch (TimeoutException impossible) { + throw new IllegalStateException( + "Unexpected child workflow timeout", impossible); + } finally { + if (rootPermit) { + rootChildExecutionPermits.release(); + } + } + } + + /** + * 在父实例短锁内复用或创建持久子流程关联。 + * + * @param definitionId 子流程定义 ID + * @param parentChain 父流程 + * @param parentNodeId 父节点 ID + * @param invocationId 父节点逻辑执行 ID + * @param childDepth 子流程综合深度 + * @param childCallPath 子流程调用链 + * @return 持久子流程关联 + */ + private ChildExecutionReference prepareChildExecution( + String definitionId, + Chain parentChain, + String parentNodeId, + String invocationId, + int childDepth, + List childCallPath) { + return parentChain.executeWithLock( + parentChain.getStateInstanceId(), + 10L, + TimeUnit.SECONDS, + () -> { + NodeState nodeState = + parentChain.updateNodeStateSafely( + parentNodeId, state -> null); + Object existingValue = nodeState.getMemory().get( + CHILD_EXECUTION_REFERENCE_KEY); + if (existingValue instanceof ChildExecutionReference) { + ChildExecutionReference existing = + (ChildExecutionReference) existingValue; + if (Objects.equals( + existing.invocationId(), invocationId)) { + return existing; + } + ChainState existingChild = chainStateRepository.load( + existing.childInstanceId()); + if (existingChild != null + && (existingChild.getStatus() == null + || !existingChild.getStatus().isTerminal())) { + throw new RetryableTriggerException( + "前一次子工作流仍在执行", null); + } + } + Chain child = createChain(definitionId); + child.updateStateSafely(state -> { + state.getMemory().put( + NESTED_DEPTH_MEMORY_KEY, childDepth); + state.getMemory().put( + DEFINITION_CALL_PATH_MEMORY_KEY, + new ArrayList<>(childCallPath)); + return EnumSet.of(ChainStateField.MEMORY); + }); + ChildExecutionReference created = + new ChildExecutionReference( + invocationId, + child.getStateInstanceId(), + childDepth, + false); + parentChain.updateNodeStateSafely( + parentNodeId, + state -> { + state.getMemory().put( + CHILD_EXECUTION_REFERENCE_KEY, + created); + return EnumSet.of(NodeStateField.MEMORY); + }); + return created; + }); + } + + /** + * 在子实例仍为 READY 时幂等启动。 + * + * @param reference 子流程关联 + * @param variables 子流程输入 + */ + private void startChildIfReady( + ChildExecutionReference reference, + Map variables) { + ChainState childState = chainStateRepository.load( + reference.childInstanceId()); + if (childState == null) { + throw new ChainException( + "Child chain state not found: " + + reference.childInstanceId()); + } + ChainDefinition definition = + getDefinitionForInstance(childState); + if (definition == null) { + throw new ChainException( + "Child chain definition not found: " + + reference.childInstanceId()); + } + Chain child = configureChain( + definition, reference.childInstanceId()); + child.setExecutionLane(childExecutionLane(reference.depth())); + child.setNestedDepthBase(reference.depth()); + child.executeWithLock( + reference.childInstanceId(), + 10L, + TimeUnit.SECONDS, + () -> { + ChainState latest = chainStateRepository.load( + reference.childInstanceId()); + if (latest != null + && (latest.getStatus() == ChainStatus.READY + || latest.getStatus() + == ChainStatus.RUNNING)) { + child.start(variables); + } + return null; + }); + } + + /** + * 标记当前父节点关联已观察到子流程终态。 + * + * @param parentChain 父流程 + * @param parentNodeId 父节点 ID + * @param reference 子流程关联 + */ + private void markChildExecutionCompleted( + Chain parentChain, + String parentNodeId, + ChildExecutionReference reference) { + parentChain.updateNodeStateSafely(parentNodeId, state -> { + Object current = state.getMemory().get( + CHILD_EXECUTION_REFERENCE_KEY); + if (!(current instanceof ChildExecutionReference) + || !Objects.equals( + ((ChildExecutionReference) current).invocationId(), + reference.invocationId())) { + return null; + } + state.getMemory().put( + CHILD_EXECUTION_REFERENCE_KEY, + new ChildExecutionReference( + reference.invocationId(), + reference.childInstanceId(), + reference.depth(), + true)); + return EnumSet.of(NodeStateField.MEMORY); + }); + } + + /** + * 按综合嵌套深度生成独立子流程通道。 + * + * @param depth 综合嵌套深度 + * @return 通道名 + */ + public static String childExecutionLane(int depth) { + return CHILD_WORKFLOW_EXECUTION_LANE_PREFIX + + Math.max(1, depth); + } + + /** + * 读取实例持久化的子工作流嵌套深度。 + * + * @param state 工作流状态 + * @return 非负嵌套深度 + */ + private int readNestedDepth(ChainState state) { + if (state == null) { + return 0; + } + Object value = state.getMemory().get(NESTED_DEPTH_MEMORY_KEY); + return value instanceof Number + ? Math.max(0, ((Number) value).intValue()) + : 0; + } + + /** + * 读取并规范化实例的工作流定义调用链。 + * + * @param state 工作流状态 + * @param currentDefinitionId 当前定义 ID + * @return 从根定义到当前定义的调用链 + */ + private List readDefinitionCallPath( + ChainState state, String currentDefinitionId) { + List path = new ArrayList<>(); + Object value = state == null + ? null + : state.getMemory().get(DEFINITION_CALL_PATH_MEMORY_KEY); + if (value instanceof Collection) { + for (Object item : (Collection) value) { + if (item != null) { + path.add(canonicalDefinitionId(String.valueOf(item))); + } + } + } + if (path.isEmpty() && currentDefinitionId != null) { + path.add(canonicalDefinitionId(currentDefinitionId)); + } + return path; + } + + /** + * 将发布态和草稿态的同一工作流规范化为统一调用身份。 + * + * @param definitionId 定义 ID + * @return 规范化定义 ID + */ + private String canonicalDefinitionId(String definitionId) { + if (definitionId == null) { + return ""; + } + String normalized = definitionId.trim(); + return normalized.startsWith("published:") + ? normalized.substring("published:".length()) + : normalized; + } + + /** + * 轮询持久状态等待工作流终态,允许任意集群实例执行实际触发器。 + * + * @param stateInstanceId 工作流实例 ID + * @param timeout 超时数值 + * @param unit 超时单位 + * @return 已解析业务结果 + * @throws InterruptedException 等待线程被中断 + * @throws TimeoutException 超时 + */ + @SuppressWarnings("unchecked") + private Map awaitPersistentOutcome( + String stateInstanceId, + long timeout, + TimeUnit unit, + Chain parentChain) + throws InterruptedException, TimeoutException { + Objects.requireNonNull(unit, "time unit required"); + long timeoutNanos = timeout == Long.MAX_VALUE + ? Long.MAX_VALUE + : Math.max(0L, unit.toNanos(timeout)); + long startedAt = System.nanoTime(); + while (true) { + if (parentChain != null) { + ChainState parentState = chainStateRepository.load( + parentChain.getStateInstanceId()); + if (parentState == null + || (parentState.getStatus() != null + && parentState.getStatus().isTerminal())) { + cancel( + stateInstanceId, + "Parent workflow is no longer running"); + throw new ChainException( + "Parent workflow ended while child was running"); + } + Trigger owner = TriggerContext.getCurrentTrigger(); + if (owner != null) { + // 认领丢失只终止旧 owner 的等待;durable child 留给新 owner 复用。 + triggerScheduler.assertClaimOwned(owner); + } + } + ChainState state = chainStateRepository.load(stateInstanceId); + if (state == null) { + throw new ChainException( + "Chain state not found: " + stateInstanceId); + } + ChainStatus status = state.getStatus(); + if (status != null && status.isTerminal()) { + if (!status.isSuccess()) { + ExceptionSummary error = state.getError(); + throw new ChainException( + error == null + ? "Workflow ended with status " + status + : error.getMessage()); + } + Map result = + (Map) + loopResultRepository.resolveReferences( + state.getExecuteResult()); + return result == null + ? Collections.emptyMap() + : result; + } + if (timeoutNanos != Long.MAX_VALUE + && System.nanoTime() - startedAt >= timeoutNanos) { + throw new TimeoutException( + "Workflow execution timed out: " + + stateInstanceId); + } + Thread.sleep(persistentOutcomePollMillis); + } + } + + /** + * 父工作流节点与子实例之间的持久关联。 + * + * @param invocationId 父节点逻辑执行 ID + * @param childInstanceId 子流程实例 ID + * @param depth 子流程综合嵌套深度 + * @param completed 是否已观察到终态 + */ + private record ChildExecutionReference( + String invocationId, + String childInstanceId, + int depth, + boolean completed) implements Serializable { + + private static final long serialVersionUID = 1L; } @@ -192,15 +822,21 @@ public class ChainExecutor { public Map executeNode(String definitionId, String nodeId, Map variables) { ChainDefinition chainDefinitionById = definitionRepository.getChainDefinitionById(definitionId); Node node = chainDefinitionById.getNodeById(nodeId); - Chain temp = createChain(definitionId); - if (variables != null && !variables.isEmpty()) { - temp.updateStateSafely(s -> { - s.getMemory().putAll(variables); - temp.applyStartParameterAliases(s.getMemory(), variables); - return EnumSet.of(ChainStateField.MEMORY); - }); + Chain temp = createChain(chainDefinitionById); + try { + temp.initializeState(); + if (variables != null && !variables.isEmpty()) { + temp.updateStateSafely(s -> { + s.getMemory().putAll(variables); + temp.applyStartParameterAliases(s.getMemory(), variables); + return EnumSet.of(ChainStateField.MEMORY); + }); + } + return node.execute(temp); + } finally { + activeDefinitions.remove(temp.getStateInstanceId()); + definitionSnapshotRepository.remove(temp.getStateInstanceId()); } - return node.execute(temp); } @@ -229,17 +865,12 @@ public class ChainExecutor { return; } - ChainDefinition definition = definitionRepository.getChainDefinitionById(state.getChainDefinitionId()); + ChainDefinition definition = getDefinitionForInstance(state); if (definition == null) { return; } - Chain chain = new Chain(definition, state.getInstanceId()); - chain.setTriggerScheduler(triggerScheduler); - chain.setChainStateRepository(chainStateRepository); - chain.setNodeStateRepository(nodeStateRepository); - chain.setEventManager(eventManager); - + Chain chain = configureChain(definition, state.getInstanceId()); chain.resume(variables); } @@ -249,44 +880,132 @@ public class ChainExecutor { if (definition == null) { throw new RuntimeException("Chain definition not found"); } + return createChain(definition); + } + /** + * 使用已加载的定义创建工作流实例,避免同一次调用重复读取定义。 + * + * @param definition 已加载的工作流定义 + * @return 已完成运行时依赖配置的工作流实例 + */ + private Chain createChain(ChainDefinition definition) { String stateInstanceId = UUID.randomUUID().toString(); + activeDefinitions.put(stateInstanceId, definition); + try { + definitionSnapshotRepository.save(stateInstanceId, definition); + Chain chain = configureChain(definition, stateInstanceId); + chain.initializeState(); + return chain; + } catch (RuntimeException | Error error) { + activeDefinitions.remove(stateInstanceId); + try { + definitionSnapshotRepository.remove(stateInstanceId); + } catch (RuntimeException cleanupError) { + error.addSuppressed(cleanupError); + } + throw error; + } + } + + /** + * 为工作流实例配置共享运行时依赖。 + * + * @param definition 工作流定义 + * @param stateInstanceId 状态实例 ID + * @return 配置完成的工作流实例 + */ + private Chain configureChain(ChainDefinition definition, String stateInstanceId) { + return configureChain(definition, stateInstanceId, null); + } + + /** + * 为工作流实例配置共享运行时依赖,并复用调用方已经加载的状态。 + * + * @param definition 工作流定义 + * @param stateInstanceId 状态实例 ID + * @param persistedState 已加载状态;为 {@code null} 时按需读取 + * @return 配置完成的工作流实例 + */ + private Chain configureChain( + ChainDefinition definition, + String stateInstanceId, + ChainState persistedState) { Chain chain = new Chain(definition, stateInstanceId); chain.setTriggerScheduler(triggerScheduler); chain.setChainStateRepository(chainStateRepository); chain.setNodeStateRepository(nodeStateRepository); + chain.setLoopResultRepository(loopResultRepository); chain.setEventManager(eventManager); - + chain.setExecutionBudget(executionBudget); + ChainState state = persistedState == null + ? chainStateRepository.load(stateInstanceId) + : persistedState; + int nestedDepth = readNestedDepth(state); + chain.setNestedDepthBase(nestedDepth); + if (nestedDepth > 0) { + chain.setExecutionLane(childExecutionLane(nestedDepth)); + } return chain; } + /** + * 获取工作流实例启动时使用的定义快照。 + * + * @param state 工作流状态 + * @return 活跃定义快照;当前实例首次由本节点接管时从仓储加载 + */ + private ChainDefinition getDefinitionForInstance(ChainState state) { + String stateInstanceId = state.getInstanceId(); + ChainDefinition definition = activeDefinitions.get(stateInstanceId); + if (definition != null) { + return definition; + } + ChainDefinition loaded = definitionSnapshotRepository.load(stateInstanceId); + if (loaded == null) { + // 兼容升级前已经启动、尚未持久化定义快照的实例。 + loaded = definitionRepository.getChainDefinitionById(state.getChainDefinitionId()); + } + if (loaded == null) { + return null; + } + synchronized (activeDefinitions) { + ChainDefinition existing = activeDefinitions.get(stateInstanceId); + if (existing != null) { + return existing; + } + activeDefinitions.put(stateInstanceId, loaded); + return loaded; + } + } + private void accept(Trigger trigger, ExecutorService worker) { ChainState state = chainStateRepository.load(trigger.getStateInstanceId()); if (state == null) { - throw new ChainException("Chain state not found"); + // 状态已过期或被清理时,该触发器已经失去业务目标,直接确认避免无限热重放。 + return; } - ChainDefinition definition = definitionRepository.getChainDefinitionById(state.getChainDefinitionId()); + ChainDefinition definition = getDefinitionForInstance(state); if (definition == null) { - throw new ChainException("Chain definition not found"); + throw new NonRetryableTriggerException( + "Chain definition not found: " + state.getChainDefinitionId()); } - Chain chain = new Chain(definition, trigger.getStateInstanceId()); - chain.setTriggerScheduler(triggerScheduler); - chain.setChainStateRepository(chainStateRepository); - chain.setNodeStateRepository(nodeStateRepository); - chain.setEventManager(eventManager); + Chain chain = configureChain( + definition, trigger.getStateInstanceId(), state); String nodeId = trigger.getNodeId(); if (nodeId == null) { - throw new ChainException("Node ID not found in trigger."); + throw new NonRetryableTriggerException("Node ID not found in trigger."); } Node node = definition.getNodeById(nodeId); if (node == null) { - throw new ChainException("Node not found in definition(id: " + definition.getId() + ")"); + throw new NonRetryableTriggerException( + "Node not found in definition(id: " + definition.getId() + ")"); } chain.executeNode(node, trigger); @@ -345,6 +1064,16 @@ public class ChainExecutor { return triggerScheduler; } + /** + * 在查询/API 边界透明还原循环结果引用。 + * + * @param value 可能包含引用的值 + * @return 业务可见值 + */ + public Object resolveResultReferences(Object value) { + return loopResultRepository.resolveReferences(value); + } + public EventManager getEventManager() { return eventManager; } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ExecutionBudget.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ExecutionBudget.java new file mode 100644 index 0000000..a48042e --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ExecutionBudget.java @@ -0,0 +1,188 @@ +package com.easyagents.flow.core.chain.runtime; + +import java.io.Serializable; + +/** + * 工作流执行的全局资源保护预算。 + *

+ * 所有默认值均为宽松的失控保护值。小于等于 {@code 0} 的配置表示关闭对应保护, + * 节点自身的循环次数、退出条件和重试配置仍按原有语义优先生效。 + */ +public final class ExecutionBudget implements Serializable { + private static final long serialVersionUID = 1L; + + + public static final long DEFAULT_MAX_ITERATIONS = 100_000L; + /** + * 缺省不限制墙钟时长,避免人工确认或长期挂起时间被误计为执行耗时。 + */ + public static final long DEFAULT_MAX_DURATION_MILLIS = 0L; + public static final long DEFAULT_MAX_CHILD_EXECUTIONS = 1_000_000L; + public static final long DEFAULT_MAX_ACCUMULATED_BYTES = 512L * 1024L * 1024L; + public static final int DEFAULT_MAX_NESTED_DEPTH = 32; + /** + * 热状态硬限制缺省关闭。循环历史已通过引用隔离,开启限制时由部署方按实际负载设置, + * 避免估算误差改变既有业务语义。 + */ + public static final long DEFAULT_MAX_HOT_STATE_BYTES = 0L; + + private final long maxIterations; + private final long maxDurationMillis; + private final long maxChildExecutions; + private final long maxAccumulatedBytes; + private final int maxNestedDepth; + private final long maxHotStateBytes; + + /** + * 创建执行预算。 + * + * @param maxIterations 单循环最大迭代次数 + * @param maxDurationMillis 单实例最大运行毫秒数 + * @param maxChildExecutions 单实例最大节点执行次数 + * @param maxAccumulatedBytes 单循环最大累计结果字节数 + * @param maxNestedDepth 最大循环嵌套深度 + * @param maxHotStateBytes 单实例热状态建议最大字节数 + */ + public ExecutionBudget(long maxIterations, + long maxDurationMillis, + long maxChildExecutions, + long maxAccumulatedBytes, + int maxNestedDepth, + long maxHotStateBytes) { + this.maxIterations = maxIterations; + this.maxDurationMillis = maxDurationMillis; + this.maxChildExecutions = maxChildExecutions; + this.maxAccumulatedBytes = maxAccumulatedBytes; + this.maxNestedDepth = maxNestedDepth; + this.maxHotStateBytes = maxHotStateBytes; + } + + /** + * 创建使用宽松缺省值的执行预算。 + * + * @return 默认执行预算 + */ + public static ExecutionBudget defaults() { + return new ExecutionBudget( + DEFAULT_MAX_ITERATIONS, + DEFAULT_MAX_DURATION_MILLIS, + DEFAULT_MAX_CHILD_EXECUTIONS, + DEFAULT_MAX_ACCUMULATED_BYTES, + DEFAULT_MAX_NESTED_DEPTH, + DEFAULT_MAX_HOT_STATE_BYTES); + } + + public long getMaxIterations() { + return maxIterations; + } + + public long getMaxDurationMillis() { + return maxDurationMillis; + } + + public long getMaxChildExecutions() { + return maxChildExecutions; + } + + public long getMaxAccumulatedBytes() { + return maxAccumulatedBytes; + } + + public int getMaxNestedDepth() { + return maxNestedDepth; + } + + public long getMaxHotStateBytes() { + return maxHotStateBytes; + } + + /** + * 校验循环迭代总数。 + * + * @param nodeId 循环节点 ID + * @param iterations 计划迭代次数 + * @throws ExecutionBudgetExceededException 超过启用的迭代预算时抛出 + */ + public void checkIterations(String nodeId, long iterations) { + if (maxIterations > 0 && iterations > maxIterations) { + throw new ExecutionBudgetExceededException( + "Loop iteration budget exceeded for node " + nodeId + + ": " + iterations + " > " + maxIterations); + } + } + + /** + * 校验循环嵌套深度。 + * + * @param nodeId 节点 ID + * @param depth 当前深度 + * @throws ExecutionBudgetExceededException 超过启用的深度预算时抛出 + */ + public void checkNestedDepth(String nodeId, int depth) { + if (maxNestedDepth > 0 && depth > maxNestedDepth) { + throw new ExecutionBudgetExceededException( + "Loop nested depth budget exceeded for node " + nodeId + + ": " + depth + " > " + maxNestedDepth); + } + } + + /** + * 校验循环累计结果大小。 + * + * @param nodeId 循环节点 ID + * @param accumulatedBytes 当前累计估算字节数 + * @throws ExecutionBudgetExceededException 超过启用的累计结果预算时抛出 + */ + public void checkAccumulatedBytes(String nodeId, long accumulatedBytes) { + if (maxAccumulatedBytes > 0 && accumulatedBytes > maxAccumulatedBytes) { + throw new ExecutionBudgetExceededException( + "Loop accumulated result budget exceeded for node " + nodeId + + ": " + accumulatedBytes + " > " + maxAccumulatedBytes); + } + } + + /** + * 校验单实例节点执行次数。 + * + * @param executions 当前节点执行次数 + * @throws ExecutionBudgetExceededException 超过启用的执行预算时抛出 + */ + public void checkChildExecutions(long executions) { + if (maxChildExecutions > 0 && executions > maxChildExecutions) { + throw new ExecutionBudgetExceededException( + "Workflow child execution budget exceeded: " + + executions + " > " + maxChildExecutions); + } + } + + /** + * 校验单实例运行时长。 + * + * @param startedAtMillis 实例开始时间 + * @param nowMillis 当前时间 + * @throws ExecutionBudgetExceededException 超过启用的时长预算时抛出 + */ + public void checkDuration(long startedAtMillis, long nowMillis) { + if (maxDurationMillis > 0 + && startedAtMillis > 0 + && nowMillis - startedAtMillis > maxDurationMillis) { + throw new ExecutionBudgetExceededException( + "Workflow duration budget exceeded: " + + (nowMillis - startedAtMillis) + "ms > " + maxDurationMillis + "ms"); + } + } + + /** + * 校验工作流热状态估算大小。 + * + * @param estimatedBytes 当前热状态估算字节数 + * @throws ExecutionBudgetExceededException 超过启用的热状态预算时抛出 + */ + public void checkHotStateBytes(long estimatedBytes) { + if (maxHotStateBytes > 0 && estimatedBytes > maxHotStateBytes) { + throw new ExecutionBudgetExceededException( + "Workflow hot state budget exceeded: " + + estimatedBytes + " > " + maxHotStateBytes); + } + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ExecutionBudgetExceededException.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ExecutionBudgetExceededException.java new file mode 100644 index 0000000..44d5ef7 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ExecutionBudgetExceededException.java @@ -0,0 +1,18 @@ +package com.easyagents.flow.core.chain.runtime; + +import com.easyagents.flow.core.chain.ChainException; + +/** + * 工作流实例超过平台资源保护预算时抛出的异常。 + */ +public class ExecutionBudgetExceededException extends ChainException { + + /** + * 创建预算超限异常。 + * + * @param message 可审计的超限原因 + */ + public ExecutionBudgetExceededException(String message) { + super(message); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/InMemoryTriggerStore.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/InMemoryTriggerStore.java index 550b4d6..c7634f9 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/InMemoryTriggerStore.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/InMemoryTriggerStore.java @@ -17,13 +17,16 @@ package com.easyagents.flow.core.chain.runtime; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; public class InMemoryTriggerStore implements TriggerStore { private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final ConcurrentHashMap fencingTokens = new ConcurrentHashMap<>(); @Override public Trigger save(Trigger trigger) { @@ -34,6 +37,18 @@ public class InMemoryTriggerStore implements TriggerStore { return trigger; } + /** + * {@inheritDoc} + */ + @Override + public boolean saveIfAbsent(Trigger trigger) { + if (trigger.getId() == null || trigger.getId().isBlank()) { + throw new IllegalArgumentException("Stable trigger ID required"); + } + return store.putIfAbsent( + trigger.getId(), trigger) == null; + } + @Override public boolean remove(String triggerId) { return store.remove(triggerId) != null; @@ -46,12 +61,35 @@ public class InMemoryTriggerStore implements TriggerStore { @Override public List findDue(long uptoTimestamp) { - return null; + List due = new ArrayList<>(); + for (Trigger trigger : store.values()) { + if (trigger.getTriggerAt() <= uptoTimestamp) { + due.add(trigger); + } + } + due.sort(Comparator.comparingLong(Trigger::getTriggerAt)); + return due; } @Override public List findAllPending() { return new ArrayList<>(store.values()); } -} + /** + * {@inheritDoc} + */ + @Override + public Trigger claim(String triggerId, long leaseMillis) { + Trigger trigger = store.remove(triggerId); + if (trigger != null) { + String fencingScope = trigger.getStateInstanceId() == null + ? "__trigger__:" + trigger.getId() + : trigger.getStateInstanceId(); + trigger.setFencingToken(fencingTokens + .computeIfAbsent(fencingScope, ignored -> new AtomicLong()) + .incrementAndGet()); + } + return trigger; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/NonRetryableTriggerException.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/NonRetryableTriggerException.java new file mode 100644 index 0000000..2fab07d --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/NonRetryableTriggerException.java @@ -0,0 +1,16 @@ +package com.easyagents.flow.core.chain.runtime; + +/** + * 表示触发器内容已经无法继续执行,应进入死信而非无限重放。 + */ +public class NonRetryableTriggerException extends RuntimeException { + + /** + * 创建不可重试触发器异常。 + * + * @param message 异常说明 + */ + public NonRetryableTriggerException(String message) { + super(message); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/RetryableTriggerException.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/RetryableTriggerException.java new file mode 100644 index 0000000..b792710 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/RetryableTriggerException.java @@ -0,0 +1,17 @@ +package com.easyagents.flow.core.chain.runtime; + +/** + * 表示当前节点遇到短暂基础设施冲突,应重新投递同一触发器且不消耗业务重试次数。 + */ +public class RetryableTriggerException extends RuntimeException { + + /** + * 创建可重新投递异常。 + * + * @param message 异常说明 + * @param cause 原始异常 + */ + public RetryableTriggerException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/Trigger.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/Trigger.java index 702e97c..dae7b08 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/Trigger.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/Trigger.java @@ -16,14 +16,65 @@ package com.easyagents.flow.core.chain.runtime; import java.io.Serializable; +import java.util.LinkedHashMap; +import java.util.Map; public class Trigger implements Serializable { + private static final long serialVersionUID = 3165037658498721088L; + private String id; private String stateInstanceId; private String edgeId; private String nodeId; // 可以为 null,代表触发整个 chain private TriggerType type; private long triggerAt; // epoch ms + /** + * 当前运行时分配的触发器认领代际。 + * + *

字段名为兼容既有序列化数据保留。分布式仓储在触发器认领成功时分配, + * 并与该触发器租约共同续期和失效;该值不代表实例锁 fencing token。

+ */ + private long fencingToken; + /** + * 创建派生触发器时必须仍然有效的父触发器 fencing token。 + */ + private long requiredFencingToken; + /** + * 创建派生触发器时必须仍然有效的父实例锁 fencing token。 + */ + private long requiredLockFencingToken; + /** + * 创建派生触发器时必须仍然有效的父触发器 claim ID。 + */ + private String requiredFencingClaimId; + /** + * 基础设施投递失败次数,不占用业务节点重试次数。 + */ + private int deliveryAttempt; + /** + * 已完成业务终态收敛、等待可靠写入死信的标记。 + */ + private boolean deadLetterPending; + /** + * 待写入死信的稳定失败原因。 + */ + private String deadLetterReason; + /** + * 跨重试保持不变的逻辑执行 ID,用于副作用幂等键。 + */ + private String logicalExecutionId; + /** + * 可选执行通道,用于把会同步等待的子工作流与普通节点工作线程隔离。 + */ + private String executionLane; + /** + * 首个稳定入口意图携带的初始变量。 + * + *

仅用于实例仍处于 READY 时的崩溃恢复;正常启动提交后,运行时变量仍以 + * {@code ChainState.memory} 为唯一业务数据源。

+ */ + private Map startVariables; + private Map loopCursors; public Trigger() { } @@ -77,6 +128,260 @@ public class Trigger implements Serializable { this.triggerAt = triggerAt; } + /** + * 获取当前节点逻辑执行 ID。 + * + * @return 跨重试保持不变的逻辑执行 ID + */ + public String getLogicalExecutionId() { + return logicalExecutionId; + } + + /** + * 设置当前节点逻辑执行 ID。 + * + * @param logicalExecutionId 跨重试保持不变的逻辑执行 ID + */ + public void setLogicalExecutionId(String logicalExecutionId) { + this.logicalExecutionId = logicalExecutionId; + } + + /** + * 获取执行通道。 + * + * @return 通道名;{@code null} 表示默认通道 + */ + public String getExecutionLane() { + return executionLane; + } + + /** + * 设置执行通道。 + * + * @param executionLane 通道名 + */ + public void setExecutionLane(String executionLane) { + this.executionLane = executionLane; + } + + /** + * 获取崩溃恢复所需的初始变量。 + * + * @return 初始变量快照;未携带时为 {@code null} + */ + public Map getStartVariables() { + return startVariables; + } + + /** + * 设置崩溃恢复所需的初始变量。 + * + * @param startVariables 初始变量;仅首个稳定入口意图需要携带 + */ + public void setStartVariables(Map startVariables) { + this.startVariables = startVariables == null + ? null + : new LinkedHashMap<>(startVariables); + } + + /** + * 获取本次认领代际。 + * + * @return 单触发器认领代际;未认领时为 {@code 0} + */ + public long getFencingToken() { + return fencingToken; + } + + /** + * 设置本次认领代际。 + * + * @param fencingToken 单触发器认领代际 + */ + public void setFencingToken(long fencingToken) { + this.fencingToken = fencingToken; + } + + /** + * 获取保存派生触发器所依赖的父实例锁 fencing token。 + * + * @return 父实例锁 token;无锁约束时为 {@code 0} + */ + public long getRequiredLockFencingToken() { + return requiredLockFencingToken; + } + + /** + * 设置保存派生触发器所依赖的父实例锁 fencing token。 + * + * @param requiredLockFencingToken 父实例锁 token + */ + public void setRequiredLockFencingToken(long requiredLockFencingToken) { + this.requiredLockFencingToken = requiredLockFencingToken; + } + + /** + * 获取保存派生触发器所依赖的父 fencing token。 + * + * @return 父 fencing token;无父认领约束时为 {@code 0} + */ + public long getRequiredFencingToken() { + return requiredFencingToken; + } + + /** + * 设置保存派生触发器所依赖的父 fencing token。 + * + * @param requiredFencingToken 父 fencing token + */ + public void setRequiredFencingToken(long requiredFencingToken) { + this.requiredFencingToken = requiredFencingToken; + } + + /** + * 获取保存派生触发器所依赖的父 claim ID。 + * + * @return 父触发器 ID;无父认领约束时为 {@code null} + */ + public String getRequiredFencingClaimId() { + return requiredFencingClaimId; + } + + /** + * 设置保存派生触发器所依赖的父 claim ID。 + * + * @param requiredFencingClaimId 父触发器 ID + */ + public void setRequiredFencingClaimId(String requiredFencingClaimId) { + this.requiredFencingClaimId = requiredFencingClaimId; + } + + /** + * 获取基础设施投递失败次数。 + * + * @return 失败次数 + */ + public int getDeliveryAttempt() { + return deliveryAttempt; + } + + /** + * 设置基础设施投递失败次数。 + * + * @param deliveryAttempt 失败次数 + */ + public void setDeliveryAttempt(int deliveryAttempt) { + this.deliveryAttempt = deliveryAttempt; + } + + /** + * 判断触发器是否正在补写死信终态。 + * + * @return 等待死信持久化时为 {@code true} + */ + public boolean isDeadLetterPending() { + return deadLetterPending; + } + + /** + * 设置死信补写标记。 + * + * @param deadLetterPending 是否等待死信持久化 + */ + public void setDeadLetterPending(boolean deadLetterPending) { + this.deadLetterPending = deadLetterPending; + } + + /** + * 获取稳定死信原因。 + * + * @return 死信原因 + */ + public String getDeadLetterReason() { + return deadLetterReason; + } + + /** + * 设置稳定死信原因。 + * + * @param deadLetterReason 死信原因 + */ + public void setDeadLetterReason(String deadLetterReason) { + this.deadLetterReason = deadLetterReason; + } + + /** + * 获取触发器携带的循环代际游标。 + * + * @return 循环节点 ID 到游标的映射 + */ + public Map getLoopCursors() { + if (loopCursors == null) { + loopCursors = new LinkedHashMap<>(); + } + return loopCursors; + } + + /** + * 设置循环代际游标。 + * + * @param loopCursors 循环节点 ID 到游标的映射 + */ + public void setLoopCursors(Map loopCursors) { + this.loopCursors = loopCursors; + } + + /** + * 循环分支代际游标,用于拒绝过期或重复的父节点回调。 + */ + public static class LoopCursor implements Serializable { + private static final long serialVersionUID = 1L; + + private String resultId; + private int iterationIndex; + private String branchId; + + public LoopCursor() { + } + + /** + * 创建循环游标。 + * + * @param resultId 循环代际 ID + * @param iterationIndex 迭代序号 + * @param branchId 直属分支 ID + */ + public LoopCursor(String resultId, int iterationIndex, String branchId) { + this.resultId = resultId; + this.iterationIndex = iterationIndex; + this.branchId = branchId; + } + + public String getResultId() { + return resultId; + } + + public void setResultId(String resultId) { + this.resultId = resultId; + } + + public int getIterationIndex() { + return iterationIndex; + } + + public void setIterationIndex(int iterationIndex) { + this.iterationIndex = iterationIndex; + } + + public String getBranchId() { + return branchId; + } + + public void setBranchId(String branchId) { + this.branchId = branchId; + } + } + @Override public String toString() { return "Trigger{" + @@ -89,4 +394,3 @@ public class Trigger implements Serializable { '}'; } } - diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerClaimLostException.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerClaimLostException.java new file mode 100644 index 0000000..38c185c --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerClaimLostException.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + * Licensed under the GNU Lesser General Public License (LGPL), Version 3.0. + */ +package com.easyagents.flow.core.chain.runtime; + +/** + * 表示当前工作线程已经失去触发器租约,不再允许提交执行结果。 + */ +public class TriggerClaimLostException extends RuntimeException { + + /** + * 创建租约丢失异常。 + * + * @param triggerId 已失去租约的触发器 ID + */ + public TriggerClaimLostException(String triggerId) { + super("Workflow trigger claim ownership lost: " + triggerId); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerScheduler.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerScheduler.java index 7d07a03..82bff43 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerScheduler.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerScheduler.java @@ -39,19 +39,49 @@ import java.util.concurrent.atomic.AtomicBoolean; public class TriggerScheduler { private static final Logger log = LoggerFactory.getLogger(TriggerScheduler.class); + private static final long CLAIM_LEASE_MS = TimeUnit.MINUTES.toMillis(1); + private static final long CLAIM_RENEW_INTERVAL_MS = TimeUnit.SECONDS.toMillis(20); + private static final int MAX_DELIVERY_ATTEMPTS = 20; + private static final long MAX_REDELIVERY_DELAY_MS = TimeUnit.MINUTES.toMillis(1); + /** + * 本地仅缓存一批定时任务;容量溢出时持久仓储仍是恢复与补偿来源。 + */ + private static final int MAX_LOCAL_SCHEDULED = 1024; + /** + * 精确定时只预热宽松近期限窗口,远期任务继续由持久仓储保管。 + */ + private static final long MIN_LOCAL_SCHEDULE_HORIZON_MS = + TimeUnit.MINUTES.toMillis(1); private final TriggerStore store; private final ScheduledExecutorService scheduler; private final ExecutorService worker; + private final Semaphore dispatchPermits; + private final ConcurrentMap laneWorkers = + new ConcurrentHashMap<>(); + private final ConcurrentMap laneDispatchPermits = + new ConcurrentHashMap<>(); private final AtomicBoolean closed = new AtomicBoolean(false); // map 用于管理取消:triggerId -> ScheduledFuture private final ConcurrentMap> scheduledFutures = new ConcurrentHashMap<>(); + /** + * 本地 Future 对应的绝对触发时间,用于容量满时保留更早到期任务。 + */ + private final ConcurrentMap scheduledTriggerTimes = + new ConcurrentHashMap<>(); + private final ConcurrentMap> claimRenewals = new ConcurrentHashMap<>(); + /** + * 串行化本地 Future 的容量检查与登记,确保并发调度时仍严格受容量上限约束。 + */ + private final Object localScheduleMonitor = new Object(); // consumer 来把 trigger 交给 ChainExecutor(或 ChainRuntime)去处理 private volatile TriggerConsumer consumer; + private volatile TriggerFailureListener failureListener; // 周期扫查间隔(ms) private final long scanIntervalMs; + private final long localScheduleHorizonMs; // 扫描任务 future private ScheduledFuture scanFuture; @@ -60,13 +90,34 @@ public class TriggerScheduler { void accept(Trigger trigger, ExecutorService worker); } + /** + * 触发器不可恢复失败监听器。 + */ + public interface TriggerFailureListener { + /** + * 在触发器 claim 仍有效时通知业务运行时收敛实例终态。 + * + * @param trigger 已死信触发器 + * @param failure 最后一次执行失败 + */ + boolean onDeadLetter(Trigger trigger, Throwable failure); + } + public TriggerScheduler(TriggerStore store, ScheduledExecutorService scheduler, ExecutorService worker, long scanIntervalMs) { this.store = Objects.requireNonNull(store, "TriggerStore required"); this.scheduler = Objects.requireNonNull(scheduler, "ScheduledExecutorService required"); this.worker = Objects.requireNonNull(worker, "ExecutorService required"); + this.dispatchPermits = createDispatchPermits(worker); this.scanIntervalMs = Math.max(1000, scanIntervalMs); + long scanHorizon = this.scanIntervalMs + > Long.MAX_VALUE / 3L + ? Long.MAX_VALUE + : this.scanIntervalMs * 3L; + this.localScheduleHorizonMs = Math.max( + MIN_LOCAL_SCHEDULE_HORIZON_MS, + scanHorizon); - // 恢复并 schedule + // 启动时只恢复已经到期的任务,避免把全部远期任务复制到本机 DelayQueue。 recoverAndSchedulePending(); // 启动周期扫查 findDue @@ -78,6 +129,40 @@ public class TriggerScheduler { this.consumer = consumer; } + /** + * 注册触发器不可恢复失败监听器。 + * + * @param listener 失败监听器 + */ + public void registerFailureListener(TriggerFailureListener listener) { + this.failureListener = listener; + } + + /** + * 注册独立执行通道。 + * + * @param lane 通道名 + * @param laneWorker 通道工作线程池 + */ + public void registerWorker( + String lane, ExecutorService laneWorker) { + if (lane == null || lane.isBlank()) { + throw new IllegalArgumentException("lane required"); + } + ExecutorService workerToRegister = + Objects.requireNonNull(laneWorker, "laneWorker required"); + ExecutorService previous = + laneWorkers.putIfAbsent(lane, workerToRegister); + if (previous != null && previous != workerToRegister) { + throw new IllegalStateException( + "Trigger worker lane already registered: " + lane); + } + Semaphore permits = createDispatchPermits(workerToRegister); + if (permits != null) { + laneDispatchPermits.putIfAbsent(lane, permits); + } + } + /** * schedule a trigger: persist -> schedule (单机语义) */ @@ -86,20 +171,55 @@ public class TriggerScheduler { if (trigger.getId() == null) { trigger.setId(UUID.randomUUID().toString()); } + if (trigger.getLogicalExecutionId() == null || trigger.getLogicalExecutionId().isBlank()) { + trigger.setLogicalExecutionId(trigger.getId()); + } store.save(trigger); scheduleInternal(trigger); return trigger; } + /** + * 仅在持久仓储中不存在同 ID 触发器时保存并调度。 + * + *

调用方需持有工作流实例锁;该方法用于可重放启动协议,稳定 ID 可避免 + * READY 到入口触发器持久化之间的崩溃窗口产生重复待执行任务。

+ * + * @param trigger 带稳定 ID 的触发器 + * @return 已存在或新保存的触发器 + */ + public Trigger scheduleIfAbsent(Trigger trigger) { + if (closed.get()) { + throw new IllegalStateException("TriggerScheduler closed"); + } + if (trigger == null || trigger.getId() == null + || trigger.getId().isBlank()) { + throw new IllegalArgumentException( + "Stable trigger ID required"); + } + if (trigger.getLogicalExecutionId() == null + || trigger.getLogicalExecutionId().isBlank()) { + trigger.setLogicalExecutionId( + trigger.getId()); + } + if (store.saveIfAbsent(trigger)) { + scheduleInternal(trigger); + return trigger; + } + Trigger existing = store.find( + trigger.getId()); + // 已有触发器可能刚好被其他 owner 认领;稳定入口仍视为已成功登记。 + return existing == null + ? trigger + : existing; + } + /** * cancel trigger (从 store 删除并尝试取消已 schedule 的 future) */ public boolean cancel(String triggerId) { boolean removed = store.remove(triggerId); - ScheduledFuture f = scheduledFutures.remove(triggerId); - if (f != null) { - f.cancel(false); - } + removeLocalSchedule(triggerId, true); return removed; } @@ -107,83 +227,185 @@ public class TriggerScheduler { * 主动触发(webhook/event/manual 场景) */ public boolean fire(String triggerId) { - if (closed.get()) return false; - Trigger t = store.find(triggerId); - if (t == null) return false; - if (consumer == null) { - // 无 consumer,仍从 store 中移除 - store.remove(triggerId); + if (closed.get()) { return false; } - // 在 worker 线程触发 consumer - worker.submit(() -> { - try { - consumer.accept(t, worker); - } catch (Exception e) { - log.error(e.toString(), e); - } finally { - // 默认语义:触发后移除 - store.remove(triggerId); - ScheduledFuture sf = scheduledFutures.remove(triggerId); - if (sf != null) sf.cancel(false); - } - }); - return true; + Trigger candidate = store.find(triggerId); + if (candidate == null) { + return false; + } + removeLocalSchedule(triggerId, true); + return claimAndDispatch(candidate); } /** * internal scheduling for a trigger (单机 scheduled semantics) */ private void scheduleInternal(Trigger trigger) { - if (closed.get()) return; - - long delay = Math.max(0, trigger.getTriggerAt() - System.currentTimeMillis()); - - // cancel any existing scheduled future for same id - ScheduledFuture prev = scheduledFutures.remove(trigger.getId()); - if (prev != null) prev.cancel(false); - - ScheduledFuture future = scheduler.schedule(() -> { - // double-check existence in store (可能已被 cancel) - Trigger existing = store.find(trigger.getId()); - if (existing == null) { - scheduledFutures.remove(trigger.getId()); + if (closed.get()) { + return; + } + long now = System.currentTimeMillis(); + if (trigger.getTriggerAt() + > scheduleHorizonTimestamp(now)) { + return; + } + synchronized (localScheduleMonitor) { + if (closed.get()) { return; } - - if (consumer != null) { - worker.submit(() -> { - try { - TriggerContext.setCurrentTrigger(existing); - consumer.accept(existing, worker); - } catch (Throwable e) { - log.error(e.toString(), e); - } finally { - TriggerContext.clearCurrentTrigger(); - store.remove(existing.getId()); - scheduledFutures.remove(existing.getId()); - } - }); - } else { - // 无 consumer,则移除 - store.remove(existing.getId()); - scheduledFutures.remove(existing.getId()); + ScheduledFuture existing; + while ((existing = scheduledFutures.get(trigger.getId())) != null) { + if (!existing.isDone() && !existing.isCancelled()) { + return; + } + // 已完成或取消的占位必须先原子移除,否则 putIfAbsent 会永久阻断重新调度。 + if (!scheduledFutures.remove(trigger.getId(), existing)) { + continue; + } + scheduledTriggerTimes.remove( + trigger.getId()); } - }, delay, TimeUnit.MILLISECONDS); + if (!hasLocalScheduleCapacity( + trigger.getTriggerAt())) { + return; + } + long delayMillis = Math.max( + 0L, + trigger.getTriggerAt() + - System.currentTimeMillis()); + ScheduledFuture future = scheduler.schedule( + () -> { + // 回调先在同一登记锁下同时移除两张索引,消除零延迟任务 + // 在 Future 与时间索引分步登记之间完成所造成的孤儿记录。 + removeLocalSchedule( + trigger.getId(), false); + claimAndDispatch(trigger); + }, + delayMillis, + TimeUnit.MILLISECONDS); + ScheduledFuture concurrent = + scheduledFutures.putIfAbsent( + trigger.getId(), future); + if (concurrent != null) { + future.cancel(false); + return; + } + scheduledTriggerTimes.put( + trigger.getId(), + trigger.getTriggerAt()); + // 零延迟任务可能在 put 前完成;完成态二次清理避免残留无效 Future。 + if (future.isDone() || future.isCancelled()) { + scheduledFutures.remove( + trigger.getId(), future); + scheduledTriggerTimes.remove( + trigger.getId()); + } + } + } - scheduledFutures.put(trigger.getId(), future); + /** + * 检查本地调度容量,并在容量耗尽时清理已完成或已取消的占位。 + * + *

正常路径只执行常量时间判断;达到上限时最多扫描 + * {@link #MAX_LOCAL_SCHEDULED} 个条目,避免极窄竞态残留导致永久停摆。

+ * + * @param triggerAt 待登记任务的绝对触发时间 + * @return 仍可接收本地到期任务时为 {@code true} + */ + private boolean hasLocalScheduleCapacity( + long triggerAt) { + if (scheduledFutures.size() < MAX_LOCAL_SCHEDULED) { + return true; + } + for (Map.Entry> entry : scheduledFutures.entrySet()) { + ScheduledFuture future = entry.getValue(); + if (future.isDone() || future.isCancelled()) { + if (scheduledFutures.remove( + entry.getKey(), future)) { + scheduledTriggerTimes.remove( + entry.getKey()); + } + } + } + if (scheduledFutures.size() + < MAX_LOCAL_SCHEDULED) { + return true; + } + Map.Entry latest = null; + for (Map.Entry entry + : scheduledTriggerTimes.entrySet()) { + if (latest == null + || entry.getValue() + > latest.getValue()) { + latest = entry; + } + } + if (latest == null + || latest.getValue() <= triggerAt) { + return false; + } + removeLocalSchedule( + latest.getKey(), true); + return scheduledFutures.size() + < MAX_LOCAL_SCHEDULED; + } + + /** + * 计算本轮应预热到本机的最远触发时间。 + * + * @return 当前时间加本地近期限窗口;溢出时为最大时间戳 + */ + private long scheduleHorizonTimestamp() { + return scheduleHorizonTimestamp( + System.currentTimeMillis()); + } + + /** + * 基于给定时间计算本机预热边界。 + * + * @param now 当前时间戳 + * @return 当前时间加近期限窗口;溢出时为最大时间戳 + */ + private long scheduleHorizonTimestamp( + long now) { + return now > Long.MAX_VALUE + - localScheduleHorizonMs + ? Long.MAX_VALUE + : now + localScheduleHorizonMs; + } + + /** + * 同时移除本地 Future 与其触发时间索引。 + * + * @param triggerId 触发器 ID + * @param cancel 是否取消尚未完成的 Future + * @return 被移除的 Future;不存在时为 {@code null} + */ + private ScheduledFuture removeLocalSchedule( + String triggerId, + boolean cancel) { + synchronized (localScheduleMonitor) { + ScheduledFuture scheduled = + scheduledFutures.remove(triggerId); + scheduledTriggerTimes.remove(triggerId); + if (cancel && scheduled != null) { + scheduled.cancel(false); + } + return scheduled; + } } private void recoverAndSchedulePending() { try { - List list = store.findAllPending(); + List list = store.findDue( + scheduleHorizonTimestamp()); if (list == null || list.isEmpty()) return; for (Trigger t : list) { scheduleInternal(t); } } catch (Throwable t) { - // 忽略单次恢复错误,继续运行 - t.printStackTrace(); + log.error("Failed to recover pending workflow triggers", t); } } @@ -191,7 +413,7 @@ public class TriggerScheduler { if (closed.get()) return; scanFuture = scheduler.scheduleAtFixedRate(() -> { try { - long upto = System.currentTimeMillis(); + long upto = scheduleHorizonTimestamp(); List due = store.findDue(upto); if (due == null || due.isEmpty()) return; for (Trigger t : due) { @@ -200,27 +422,363 @@ public class TriggerScheduler { if (sf != null && !sf.isDone() && !sf.isCancelled()) { continue; } - // 直接提交到 worker,让 consumer 处理;并从 store 中移除 - if (consumer != null) { - worker.submit(() -> { - try { - consumer.accept(t, worker); - } finally { - store.remove(t.getId()); - ScheduledFuture f2 = scheduledFutures.remove(t.getId()); - if (f2 != null) f2.cancel(false); - } - }); - } else { - store.remove(t.getId()); - } + scheduleInternal(t); } } catch (Throwable tt) { - tt.printStackTrace(); + log.error("Failed to scan due workflow triggers", tt); } }, scanIntervalMs, scanIntervalMs, TimeUnit.MILLISECONDS); } + /** + * 原子认领触发器并提交给工作线程。 + * + * @param candidate 已加载的候选触发器 + * @return 成功认领并提交时为 true + */ + private boolean claimAndDispatch(Trigger candidate) { + if (candidate == null || candidate.getId() == null) { + return false; + } + String triggerId = candidate.getId(); + ExecutorService dispatchWorker = resolveWorker(candidate); + Semaphore selectedPermits = resolveDispatchPermits(candidate); + if (selectedPermits != null && !selectedPermits.tryAcquire()) { + // 保留持久化触发器并清理本地占位,让后续扫描能够重新调度。 + removeLocalSchedule(triggerId, false); + return false; + } + Trigger claimed; + try { + claimed = store.claim(candidate, CLAIM_LEASE_MS); + } catch (RuntimeException | Error error) { + removeLocalSchedule(triggerId, false); + releaseDispatchPermit(selectedPermits); + throw error; + } + if (claimed == null) { + releaseDispatchPermit(selectedPermits); + removeLocalSchedule(triggerId, false); + return false; + } + TriggerConsumer currentConsumer = consumer; + if (currentConsumer == null) { + releaseClaimBestEffort(claimed, "consumer is unavailable"); + releaseDispatchPermit(selectedPermits); + removeLocalSchedule(triggerId, false); + return false; + } + + ScheduledFuture renewal; + try { + renewal = scheduler.scheduleAtFixedRate( + () -> renewClaim(claimed), + CLAIM_RENEW_INTERVAL_MS, + CLAIM_RENEW_INTERVAL_MS, + TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException error) { + releaseClaimBestEffort(claimed, "claim renewal scheduling was rejected"); + releaseDispatchPermit(selectedPermits); + removeLocalSchedule(triggerId, false); + return false; + } + ScheduledFuture existingRenewal = claimRenewals.put(claimed, renewal); + if (existingRenewal != null) { + existingRenewal.cancel(false); + } + + try { + dispatchWorker.submit(() -> consumeClaimedTrigger( + claimed, + currentConsumer, + dispatchWorker, + selectedPermits)); + return true; + } catch (RejectedExecutionException error) { + cancelClaimRenewal(claimed); + releaseClaimBestEffort(claimed, "worker submission was rejected"); + releaseDispatchPermit(selectedPermits); + removeLocalSchedule(triggerId, false); + log.error("Workflow trigger worker rejected task, triggerId={}", triggerId, error); + return false; + } + } + + /** + * 执行已认领触发器,并按执行结果确认或释放。 + * + * @param trigger 已认领触发器 + * @param currentConsumer 本次执行使用的消费者快照 + */ + private void consumeClaimedTrigger( + Trigger trigger, + TriggerConsumer currentConsumer, + ExecutorService dispatchWorker, + Semaphore selectedPermits) { + boolean succeeded = false; + boolean deadLetter = false; + boolean retryWithoutDeadLetter = false; + boolean rescheduleReleasedTrigger = false; + Throwable failure = null; + try { + TriggerContext.setCurrentTrigger(trigger); + if (trigger.isDeadLetterPending()) { + deadLetter = true; + failure = new NonRetryableTriggerException( + trigger.getDeadLetterReason()); + } else { + currentConsumer.accept(trigger, dispatchWorker); + succeeded = true; + } + } catch (NonRetryableTriggerException error) { + failure = error; + deadLetter = true; + log.error("Workflow trigger is not retryable, triggerId={}", trigger.getId(), error); + } catch (RetryableTriggerException error) { + failure = error; + retryWithoutDeadLetter = true; + log.warn( + "Workflow trigger hit transient infrastructure contention, triggerId={}", + trigger.getId()); + } catch (Throwable error) { + failure = error; + log.error("Workflow trigger execution failed, triggerId={}", trigger.getId(), error); + } finally { + try { + if (succeeded) { + store.acknowledge(trigger); + } else { + int deliveryAttempt = trigger.isDeadLetterPending() + ? Math.max(1, trigger.getDeliveryAttempt()) + : incrementDeliveryAttempt(trigger); + if (retryWithoutDeadLetter) { + trigger.setTriggerAt( + System.currentTimeMillis() + + redeliveryDelayMillis( + deliveryAttempt)); + store.release(trigger); + rescheduleReleasedTrigger = true; + } else if (deadLetter + || deliveryAttempt + >= MAX_DELIVERY_ATTEMPTS) { + String reason = failure == null + ? "delivery attempts exhausted" + : failure.getClass().getName() + + ": " + + failure.getMessage(); + if (trigger.isDeadLetterPending() + && trigger.getDeadLetterReason() != null) { + reason = trigger.getDeadLetterReason(); + } else { + trigger.setDeadLetterPending(true); + trigger.setDeadLetterReason(reason); + store.markDeadLetterPending( + trigger); + } + if (!store.renewClaim( + trigger, CLAIM_LEASE_MS)) { + throw new TriggerClaimLostException( + trigger.getId()); + } + TriggerFailureListener currentFailureListener = + failureListener; + if (currentFailureListener != null + && !currentFailureListener.onDeadLetter( + trigger, failure)) { + trigger.setTriggerAt( + System.currentTimeMillis() + + MAX_REDELIVERY_DELAY_MS); + store.release(trigger); + rescheduleReleasedTrigger = true; + return; + } + store.deadLetter(trigger, reason); + } else { + trigger.setTriggerAt( + System.currentTimeMillis() + + redeliveryDelayMillis( + deliveryAttempt)); + store.release(trigger); + rescheduleReleasedTrigger = true; + } + } + } catch (Throwable terminalStoreError) { + if (trigger.isDeadLetterPending()) { + releaseDeadLetterPendingBestEffort(trigger); + } + log.error( + "Failed to finalize workflow trigger, triggerId={}", + trigger.getId(), + terminalStoreError); + } finally { + TriggerContext.clearCurrentTrigger(); + cancelClaimRenewal(trigger); + removeLocalSchedule( + trigger.getId(), false); + releaseDispatchPermit(selectedPermits); + if (rescheduleReleasedTrigger) { + scheduleInternal(trigger); + } + } + } + } + + /** + * 在仍持有 claim 时保存待补写死信标记,避免业务终态成功后被普通 ACK 吞掉。 + * + * @param trigger 待补写死信的触发器 + */ + private void releaseDeadLetterPendingBestEffort( + Trigger trigger) { + try { + if (!store.renewClaim(trigger, CLAIM_LEASE_MS)) { + return; + } + trigger.setTriggerAt( + System.currentTimeMillis() + + MAX_REDELIVERY_DELAY_MS); + store.release(trigger); + } catch (Throwable releaseError) { + log.error( + "Failed to persist pending dead-letter finalization, triggerId={}", + trigger.getId(), + releaseError); + } + } + + /** + * 尽力释放已认领触发器。 + * + *

释放失败时保留 Redis 中的触发器和租约,等待租约自然过期恢复。调用方仍可继续 + * 清理本地调度状态和归还容量许可。

+ * + * @param trigger 已认领触发器 + * @param reason 释放原因 + */ + private void releaseClaimBestEffort(Trigger trigger, String reason) { + try { + store.release(trigger); + } catch (Throwable error) { + log.error( + "Failed to release workflow trigger, triggerId={}, reason={}", + trigger == null ? null : trigger.getId(), + reason, + error); + } + } + + /** + * 增加基础设施投递失败次数。 + * + * @param trigger 当前触发器 + * @return 增加后的次数 + */ + private int incrementDeliveryAttempt(Trigger trigger) { + int attempt = Math.max(0, trigger.getDeliveryAttempt()) + 1; + trigger.setDeliveryAttempt(attempt); + return attempt; + } + + /** + * 计算带上限的指数退避,避免缺失定义或短暂冲突形成热循环。 + * + * @param attempt 已失败次数 + * @return 下次投递延迟毫秒数 + */ + private long redeliveryDelayMillis(int attempt) { + int shift = Math.min(16, Math.max(0, attempt - 1)); + long delay = 1_000L << shift; + return Math.min(MAX_REDELIVERY_DELAY_MS, delay); + } + + /** + * 根据工作线程池的真实容量建立领取前背压。 + * + * @param executor 工作线程池 + * @return 容量信号量;无法识别容量时返回 null + */ + private Semaphore createDispatchPermits(ExecutorService executor) { + if (!(executor instanceof ThreadPoolExecutor)) { + return null; + } + ThreadPoolExecutor pool = (ThreadPoolExecutor) executor; + long capacity = (long) pool.getMaximumPoolSize() + pool.getQueue().remainingCapacity(); + return new Semaphore((int) Math.max(1L, Math.min(Integer.MAX_VALUE, capacity))); + } + + /** + * 释放一个工作线程容量许可。 + */ + private void releaseDispatchPermit(Semaphore permits) { + if (permits != null) { + permits.release(); + } + } + + /** + * 解析触发器执行线程池。 + * + * @param trigger 触发器 + * @return 默认或独立通道线程池 + */ + private ExecutorService resolveWorker(Trigger trigger) { + String lane = trigger == null ? null : trigger.getExecutionLane(); + return lane == null ? worker : laneWorkers.getOrDefault(lane, worker); + } + + /** + * 解析所选线程池对应的领取前背压许可。 + * + * @param trigger 触发器 + * @return 容量许可;无法识别时为 {@code null} + */ + private Semaphore resolveDispatchPermits(Trigger trigger) { + String lane = trigger == null ? null : trigger.getExecutionLane(); + return lane == null + ? dispatchPermits + : laneDispatchPermits.getOrDefault(lane, dispatchPermits); + } + + /** + * 续期触发器认领租约。 + * + * @param trigger 已认领触发器 + */ + private void renewClaim(Trigger trigger) { + try { + if (!store.renewClaim(trigger, CLAIM_LEASE_MS)) { + log.warn("Workflow trigger claim renewal lost ownership, triggerId={}", trigger.getId()); + cancelClaimRenewal(trigger); + } + } catch (Throwable error) { + log.error("Workflow trigger claim renewal failed, triggerId={}", trigger.getId(), error); + } + } + + /** + * 在业务状态提交前验证具体触发器仍由当前工作线程持有。 + * + * @param trigger 已认领触发器 + * @throws TriggerClaimLostException 租约已失效或已转移 + */ + public void assertClaimOwned(Trigger trigger) { + if (trigger != null && !store.renewClaim(trigger, CLAIM_LEASE_MS)) { + throw new TriggerClaimLostException(trigger.getId()); + } + } + + /** + * 取消触发器租约续期任务。 + * + * @param trigger 已认领触发器 + */ + private void cancelClaimRenewal(Trigger trigger) { + ScheduledFuture renewal = claimRenewals.remove(trigger); + if (renewal != null) { + renewal.cancel(false); + } + } + public void shutdown() { if (closed.compareAndSet(false, true)) { if (scanFuture != null) scanFuture.cancel(false); @@ -232,6 +790,11 @@ public class TriggerScheduler { } } scheduledFutures.clear(); + scheduledTriggerTimes.clear(); + for (ScheduledFuture renewal : claimRenewals.values()) { + renewal.cancel(false); + } + claimRenewals.clear(); try { scheduler.shutdownNow(); @@ -241,6 +804,18 @@ public class TriggerScheduler { worker.shutdownNow(); } catch (Throwable ignored) { } + for (ExecutorService laneWorker : + new java.util.HashSet<>(laneWorkers.values())) { + if (laneWorker == worker) { + continue; + } + try { + laneWorker.shutdownNow(); + } catch (Throwable ignored) { + } + } + laneWorkers.clear(); + laneDispatchPermits.clear(); } } } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerStore.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerStore.java index fdb3dd9..02a2562 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerStore.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerStore.java @@ -18,8 +18,27 @@ package com.easyagents.flow.core.chain.runtime; import java.util.List; public interface TriggerStore { + Trigger save(Trigger trigger); + /** + * 仅在同 ID 触发器尚不存在时原子保存。 + * + *

缺省实现保证同一仓储实例内原子;分布式仓储必须覆盖为跨进程原子操作。

+ * + * @param trigger 带稳定 ID 的触发器 + * @return 本次成功创建时为 {@code true},已存在时为 {@code false} + */ + default boolean saveIfAbsent(Trigger trigger) { + synchronized (this) { + if (find(trigger.getId()) != null) { + return false; + } + save(trigger); + return true; + } + } + boolean remove(String triggerId); Trigger find(String triggerId); @@ -27,4 +46,116 @@ public interface TriggerStore { List findDue(long uptoTimestamp); List findAllPending(); + + /** + * 原子认领待执行触发器。 + *

+ * 缺省实现适用于单进程仓储:先读取再以删除结果作为认领成功标志。 + * + * @param triggerId 触发器 ID + * @param leaseMillis 认领租约毫秒数 + * @return 认领成功时返回触发器,否则返回 null + */ + default Trigger claim(String triggerId, long leaseMillis) { + Trigger trigger = find(triggerId); + return trigger != null && remove(triggerId) ? trigger : null; + } + + /** + * 原子认领已加载的待执行触发器。 + * + *

分布式仓储可使用候选触发器中的实例 ID 构建与本次 claim 绑定的执行守卫, + * 避免认领前额外读取完整触发器负载。

+ * + * @param candidate 扫描或主动触发阶段已加载的候选触发器 + * @param leaseMillis 认领租约毫秒数 + * @return 认领成功时返回触发器,否则返回 {@code null} + */ + default Trigger claim(Trigger candidate, long leaseMillis) { + return candidate == null ? null : claim(candidate.getId(), leaseMillis); + } + + /** + * 仅按 ID 续期触发器租约。 + * + *

分布式仓储无法仅凭 ID 验证 owner token。该兼容入口不应再用于运行时提交路径, + * 调用方必须保留 {@link Trigger} 认领对象并使用 + * {@link #renewClaim(Trigger, long)}。

+ * + * @param triggerId 触发器 ID + * @param leaseMillis 新租约毫秒数 + * @return 不适用 + * @throws UnsupportedOperationException 始终抛出,防止无 owner token 的不安全续期 + */ + @Deprecated + default boolean renewClaim(String triggerId, long leaseMillis) { + throw new UnsupportedOperationException("Trigger claim token is required"); + } + + /** + * 续期调用方持有的具体触发器租约。 + * + * @param trigger 已认领触发器对象 + * @param leaseMillis 新租约毫秒数 + * @return 续期成功时为 true + */ + default boolean renewClaim(Trigger trigger, long leaseMillis) { + // 单进程缺省仓储在 claim 时已经移除触发器,不需要租约续期。 + return true; + } + + /** + * 仅按 ID 确认触发器。 + * + *

分布式仓储无法仅凭 ID 验证 owner token,运行时必须使用 + * {@link #acknowledge(Trigger)}。

+ * + * @param triggerId 触发器 ID + * @throws UnsupportedOperationException 始终抛出,防止旧 owner 删除新 owner 的任务 + */ + @Deprecated + default void acknowledge(String triggerId) { + throw new UnsupportedOperationException("Claimed trigger object is required"); + } + + /** + * 确认调用方持有的具体触发器执行成功。 + * + * @param trigger 已认领触发器对象 + */ + default void acknowledge(Trigger trigger) { + // 单进程缺省认领已经移除触发器,无需再次处理。 + } + + /** + * 释放失败执行的触发器,使其可以再次被认领。 + * + * @param trigger 执行失败的触发器 + */ + default void release(Trigger trigger) { + save(trigger); + } + + /** + * 在保持当前 claim 的同时持久化待补写死信标记。 + * + *

分布式仓储必须校验具体 owner token;进程崩溃后,新 owner 依靠该标记 + * 跳过业务执行并继续终态协议。

+ * + * @param trigger 已认领且标记为待补写死信的触发器 + */ + default void markDeadLetterPending( + Trigger trigger) { + // 单进程仓储的 claimed trigger 仅存在于当前调用栈,无需额外持久化。 + } + + /** + * 将不可继续执行或超过投递上限的触发器移入死信。 + * + * @param trigger 已认领触发器 + * @param reason 死信原因 + */ + default void deadLetter(Trigger trigger, String reason) { + acknowledge(trigger); + } } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/impl/JavascriptRuntimeEngine.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/impl/JavascriptRuntimeEngine.java index 9d94608..040087e 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/impl/JavascriptRuntimeEngine.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/impl/JavascriptRuntimeEngine.java @@ -16,6 +16,8 @@ package com.easyagents.flow.core.code.impl; import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainState; +import com.easyagents.flow.core.chain.NodeState; import com.easyagents.flow.core.code.CodeRuntimeEngine; import com.easyagents.flow.core.node.CodeNode; import com.easyagents.flow.core.util.graalvm.JsInteropUtils; @@ -39,8 +41,13 @@ public class JavascriptRuntimeEngine implements CodeRuntimeEngine { public Map execute(String code, CodeNode node, Chain chain) { try (Context context = CONTEXT_BUILDER.build()) { Value bindings = context.getBindings("js"); + ChainState chainState = + chain.getExecutionState(); + NodeState nodeState = + chain.getNodeState(node.getId()); - Map all = chain.getState().getMemory(); + Map all = + chainState.getMemory(); all.forEach((key, value) -> { if (!key.contains(".")) { bindings.putMember(key, JsInteropUtils.wrapJavaValueForJS(context, value)); @@ -48,23 +55,20 @@ public class JavascriptRuntimeEngine implements CodeRuntimeEngine { }); // 注入参数 - Map parameterValues = chain.getState().resolveParameters(node); + Map parameterValues = + chainState.resolveParameters(node); if (parameterValues != null) { for (Map.Entry entry : parameterValues.entrySet()) { bindings.putMember(entry.getKey(), JsInteropUtils.wrapJavaValueForJS(context, entry.getValue())); } } - bindings.putMember("_chain", chain); - bindings.putMember("_state", chain.getNodeState(node.getId())); - - // 在 JS 中创建 _result 对象 context.eval("js", "var _result = {};"); // 注入 _chain 和 _context bindings.putMember("_chain", chain); - bindings.putMember("_state", chain.getNodeState(node.getId())); + bindings.putMember("_state", nodeState); // 执行用户脚本 context.eval("js", code); diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/Llm.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/Llm.java index 551cc33..d681115 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/Llm.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/Llm.java @@ -85,6 +85,7 @@ public interface Llm { * 实现了Serializable接口,支持序列化 */ class ChatOptions implements Serializable { + private static final long serialVersionUID = 1L; private String seed; private Float temperature = 0.8f; diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/BaseNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/BaseNode.java index c8ca930..205dd81 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/BaseNode.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/BaseNode.java @@ -23,6 +23,7 @@ import java.util.Collection; import java.util.List; public abstract class BaseNode extends Node { + private static final long serialVersionUID = 1L; protected List parameters; protected List outputDefs; diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/CodeNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/CodeNode.java index 65f5201..09e1f3f 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/CodeNode.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/CodeNode.java @@ -27,6 +27,8 @@ import java.util.List; import java.util.Map; public class CodeNode extends BaseNode { + private static final long serialVersionUID = 1L; + protected String engine; protected String code; @@ -52,7 +54,8 @@ public class CodeNode extends BaseNode { throw new IllegalArgumentException("code is empty"); } - ChainState chainState = chain.getState(); + ChainState chainState = + chain.getExecutionState(); Map parameterValues = chainState.resolveParameters(this); String newCode = TextTemplate.of(code).formatToString(chainState.buildTemplateRootMaps(parameterValues)); diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/ConfirmNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/ConfirmNode.java index 52682c1..c1f17ba 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/ConfirmNode.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/ConfirmNode.java @@ -25,6 +25,8 @@ import com.easyagents.flow.core.chain.repository.ChainStateField; import java.util.*; public class ConfirmNode extends BaseNode { + private static final long serialVersionUID = 1L; + private String message; private List confirms; @@ -70,7 +72,8 @@ public class ConfirmNode extends BaseNode { Map values; try { - values = chain.getState().resolveParameters(this, confirmParameters); + values = chain.getExecutionState() + .resolveParameters(this, confirmParameters); // 移除 confirm 参数,方便在其他节点二次确认,或者在 for 循环中第二次获取 chain.updateStateSafely(state -> { for (Parameter confirmParameter : confirmParameters) { @@ -94,7 +97,12 @@ public class ConfirmNode extends BaseNode { } // 获取参数值,不会触发 ChainSuspendException 错误 - Map parameterValues = chain.getState().resolveParameters(this, newParameters, null, true); + Map parameterValues = + chain.getExecutionState().resolveParameters( + this, + newParameters, + null, + true); // 设置 enums,方便前端给用户进行选择 for (Parameter confirmParameter : confirmParameters) { diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/EndNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/EndNode.java index c6e39f2..4a645ce 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/EndNode.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/EndNode.java @@ -22,6 +22,8 @@ import java.util.HashMap; import java.util.Map; public class EndNode extends BaseNode { + private static final long serialVersionUID = 1L; + private boolean normal = true; private String message; @@ -47,7 +49,8 @@ public class EndNode extends BaseNode { @Override public Map execute(Chain chain) { - + ChainState chainState = + chain.getExecutionState(); Map output = new HashMap<>(); if (normal) { output.put(ChainConsts.CHAIN_STATE_STATUS_KEY, ChainStatus.SUCCEEDED); @@ -62,7 +65,7 @@ public class EndNode extends BaseNode { if (this.outputDefs != null) { for (Parameter outputDef : this.outputDefs) { if (outputDef.getRefType() == RefType.REF) { - output.put(outputDef.getName(), chain.getState().resolveValue(outputDef.getRef())); + output.put(outputDef.getName(), chainState.resolveValue(outputDef.getRef())); } else if (outputDef.getRefType() == RefType.INPUT) { output.put(outputDef.getName(), outputDef.getRef()); } else if (outputDef.getRefType() == RefType.FIXED) { @@ -70,7 +73,7 @@ public class EndNode extends BaseNode { } // default is ref type else if (StringUtil.hasText(outputDef.getRef())) { - output.put(outputDef.getName(), chain.getState().resolveValue(outputDef.getRef())); + output.put(outputDef.getName(), chainState.resolveValue(outputDef.getRef())); } } } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/HttpNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/HttpNode.java index dfbc5d3..f051de7 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/HttpNode.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/HttpNode.java @@ -22,21 +22,33 @@ import com.easyagents.flow.core.chain.DataType; import com.easyagents.flow.core.chain.Parameter; import com.easyagents.flow.core.filestoreage.FileStorage; import com.easyagents.flow.core.filestoreage.FileStorageManager; +import com.easyagents.flow.core.util.IoBulkhead; import com.easyagents.flow.core.util.OkHttpClientUtil; import com.easyagents.flow.core.util.StringUtil; import com.easyagents.flow.core.util.TextTemplate; import okhttp3.*; +import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; public class HttpNode extends BaseNode { + private static final long serialVersionUID = 1L; + + + private static final long DEFAULT_MAX_TEXT_RESPONSE_BYTES = 64L * 1024L * 1024L; + private static final long DEFAULT_MAX_FILE_RESPONSE_BYTES = 2L * 1024L * 1024L * 1024L; + private static final String MAX_RESPONSE_BYTES_PROPERTY = "tinyflow.http.max-response-bytes"; + private static final String MAX_FILE_RESPONSE_BYTES_PROPERTY = "tinyflow.http.max-file-response-bytes"; private String url; private String method; @@ -145,7 +157,7 @@ public class HttpNode extends BaseNode { @Override public Map execute(Chain chain) { - int maxRetry = 5; + int maxRetry = supportsAutomaticRetry(method) ? 5 : 1; long retryInterval = 2000L; int attempt = 0; @@ -161,7 +173,7 @@ public class HttpNode extends BaseNode { lastError = ex; // 判断是否需要重试 - if (!shouldRetry(ex)) { + if (attempt >= maxRetry || !shouldRetry(ex)) { throw wrapAsRuntime(ex, attempt); } @@ -199,6 +211,19 @@ public class HttpNode extends BaseNode { return cause instanceof IOException; } + /** + * 判断当前方法是否允许节点内部自动重试。 + * + * @param requestMethod HTTP 方法 + * @return 仅无业务副作用的读取类方法返回 {@code true} + */ + protected boolean supportsAutomaticRetry(String requestMethod) { + return StringUtil.noText(requestMethod) + || "GET".equalsIgnoreCase(requestMethod) + || "HEAD".equalsIgnoreCase(requestMethod) + || "OPTIONS".equalsIgnoreCase(requestMethod); + } + private RuntimeException wrapAsRuntime(Throwable ex, int attempt) { if (ex instanceof RuntimeException) { return (RuntimeException) ex; @@ -212,13 +237,18 @@ public class HttpNode extends BaseNode { public Map doExecute(Chain chain) throws IOException { - Map argsMap = chain.getState().resolveParameters(this); + Map argsMap = + chain.getExecutionState().resolveParameters(this); String newUrl = TextTemplate.of(url) - .formatToString(chain.getState().buildTemplateRootMaps(argsMap)); + .formatToString( + chain.getExecutionState() + .buildTemplateRootMaps(argsMap)); Request.Builder reqBuilder = new Request.Builder().url(newUrl); - Map headersMap = chain.getState().resolveParameters(this, headers, argsMap); + Map headersMap = + chain.getExecutionState().resolveParameters( + this, headers, argsMap); headersMap.forEach((s, o) -> reqBuilder.addHeader(s, String.valueOf(o))); if (StringUtil.noText(method) || "GET".equalsIgnoreCase(method)) { @@ -227,8 +257,12 @@ public class HttpNode extends BaseNode { reqBuilder.method(method.toUpperCase(), getRequestBody(chain, argsMap)); } - OkHttpClient okHttpClient = OkHttpClientUtil.buildDefaultClient(); - try (Response response = okHttpClient.newCall(reqBuilder.build()).execute()) { + // 节点层统一计算总尝试次数,禁用客户端隐式重试,避免乘法式放大。 + OkHttpClient okHttpClient = + OkHttpClientUtil.buildNoRetryClient(); + // 共享客户端拦截器持有请求许可直到响应体关闭,避免同一请求重复领取许可。 + try (Response response = okHttpClient.newCall( + reqBuilder.build()).execute()) { // 服务器异常 if (response.code() >= 500 && response.code() < 600) { @@ -263,17 +297,39 @@ public class HttpNode extends BaseNode { } if (bodyDataType == null) { - result.put("body", body.string()); + try (IoBulkhead.Permit ignored = + IoBulkhead.responseAggregation().acquire( + IoBulkhead.targetForUrl(newUrl))) { + result.put("body", readTextBody( + body, resolveMaxTextResponseBytes())); + } } else if (bodyDataType == DataType.Object || bodyDataType.getValue().startsWith("Array")) { - result.put("body", JSON.parse(body.string())); + try (IoBulkhead.Permit ignored = + IoBulkhead.responseAggregation().acquire( + IoBulkhead.targetForUrl(newUrl))) { + result.put("body", JSON.parse(readTextBody( + body, resolveMaxTextResponseBytes()))); + } } else if (bodyDataType == DataType.File) { - try (InputStream stream = body.byteStream()) { + long maxFileResponseBytes = resolveMaxFileResponseBytes(); + validateDeclaredResponseSize(body, maxFileResponseBytes); + try (InputStream stream = limitResponseStream(body.byteStream(), maxFileResponseBytes)) { FileStorage fileStorage = FileStorageManager.getInstance().getFileStorage(); - String fileUrl = fileStorage.saveFile(stream, responseHeaders, this, chain); - result.put("body", fileUrl); + try (IoBulkhead.Permit ignored = + IoBulkhead.storage().acquire( + "storage:http-response")) { + String fileUrl = fileStorage.saveFile( + stream, responseHeaders, this, chain); + result.put("body", fileUrl); + } } } else { - result.put("body", body.string()); + try (IoBulkhead.Permit ignored = + IoBulkhead.responseAggregation().acquire( + IoBulkhead.targetForUrl(newUrl))) { + result.put("body", readTextBody( + body, resolveMaxTextResponseBytes())); + } } return result; } @@ -282,19 +338,26 @@ public class HttpNode extends BaseNode { private RequestBody getRequestBody(Chain chain, Map formatArgs) { if ("json".equals(bodyType)) { String bodyJsonString = TextTemplate.of(bodyJson) - .formatToString(chain.getState().buildTemplateContextMap(formatArgs), true); + .formatToString( + chain.getExecutionState() + .buildTemplateRootMaps(formatArgs), + true); JSONObject jsonObject = JSON.parseObject(bodyJsonString); return RequestBody.create(jsonObject.toString(), MediaType.parse("application/json")); } if ("x-www-form-urlencoded".equals(bodyType)) { - Map formUrlencodedMap = chain.getState().resolveParameters(this, formUrlencoded); + Map formUrlencodedMap = + chain.getExecutionState().resolveParameters( + this, formUrlencoded); String bodyString = mapToQueryString(formUrlencodedMap); return RequestBody.create(bodyString, MediaType.parse("application/x-www-form-urlencoded")); } if ("form-data".equals(bodyType)) { - Map formDataMap = chain.getState().resolveParameters(this, formData, formatArgs); + Map formDataMap = + chain.getExecutionState().resolveParameters( + this, formData, formatArgs); MultipartBody.Builder builder = new MultipartBody.Builder() .setType(MultipartBody.FORM); @@ -320,13 +383,97 @@ public class HttpNode extends BaseNode { if ("raw".equals(bodyType)) { String rawBodyString = TextTemplate.of(rawBody) - .formatToString(chain.getState().buildTemplateRootMaps(formatArgs)); + .formatToString( + chain.getExecutionState() + .buildTemplateRootMaps(formatArgs)); return RequestBody.create(rawBodyString, null); } //none return RequestBody.create("", null); } + /** + * 在宽松响应大小保护下读取文本响应。 + * + * @param body HTTP 响应体 + * @return 响应文本 + * @throws IOException 响应读取失败或超出限制时抛出 + */ + protected String readTextBody(ResponseBody body, long maxResponseBytes) throws IOException { + validateDeclaredResponseSize(body, maxResponseBytes); + Charset charset = body.contentType() == null + ? StandardCharsets.UTF_8 + : body.contentType().charset(StandardCharsets.UTF_8); + int initialCapacity = body.contentLength() > 0L + ? (int) Math.min(body.contentLength(), 64L * 1024L) + : 8 * 1024; + try (InputStream input = limitResponseStream(body.byteStream(), maxResponseBytes); + ByteArrayOutputStream output = new ByteArrayOutputStream(initialCapacity)) { + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return output.toString(charset); + } + } + + /** + * 校验响应声明长度,避免继续处理已知超限内容。 + * + * @param body HTTP 响应体 + * @param maxResponseBytes 最大允许字节数 + * @throws IOException 声明长度超过限制时抛出 + */ + private void validateDeclaredResponseSize(ResponseBody body, long maxResponseBytes) throws IOException { + long contentLength = body.contentLength(); + if (maxResponseBytes > 0L && contentLength > maxResponseBytes) { + throw responseSizeExceeded(maxResponseBytes); + } + } + + /** + * 为响应流增加按实际读取字节数执行的限制。 + * + * @param inputStream 原始响应流 + * @param maxResponseBytes 最大允许字节数 + * @return 受限响应流 + */ + private InputStream limitResponseStream(InputStream inputStream, long maxResponseBytes) { + if (maxResponseBytes <= 0L) { + return inputStream; + } + return new LimitedResponseInputStream(inputStream, maxResponseBytes); + } + + /** + * 获取文本或 JSON 响应字节数上限。 + * + * @return 最大允许字节数 + */ + private long resolveMaxTextResponseBytes() { + return Long.getLong(MAX_RESPONSE_BYTES_PROPERTY, DEFAULT_MAX_TEXT_RESPONSE_BYTES); + } + + /** + * 获取文件响应字节数上限。 + * + * @return 最大允许字节数 + */ + private long resolveMaxFileResponseBytes() { + return Long.getLong(MAX_FILE_RESPONSE_BYTES_PROPERTY, DEFAULT_MAX_FILE_RESPONSE_BYTES); + } + + /** + * 创建统一的响应超限异常。 + * + * @param maxResponseBytes 最大允许字节数 + * @return 响应超限异常 + */ + private IOException responseSizeExceeded(long maxResponseBytes) { + return new IOException("HTTP response body exceeds limit: " + maxResponseBytes + " bytes"); + } + public static class HttpServerErrorException extends IOException { private final int statusCode; @@ -340,6 +487,81 @@ public class HttpNode extends BaseNode { } } + /** + * 按实际读取量限制 HTTP 响应大小的输入流。 + */ + private final class LimitedResponseInputStream extends FilterInputStream { + + private final long maxBytes; + private long consumed; + + /** + * 创建受限响应流。 + * + * @param inputStream 原始输入流 + * @param maxBytes 最大允许字节数 + */ + private LimitedResponseInputStream(InputStream inputStream, long maxBytes) { + super(inputStream); + this.maxBytes = maxBytes; + } + + /** + * 读取单个字节并校验累计读取量。 + * + * @return 读取的字节或 {@code -1} + * @throws IOException 读取失败或超过限制时抛出 + */ + @Override + public int read() throws IOException { + int value = super.read(); + if (value >= 0) { + recordRead(1L); + } + return value; + } + + /** + * 批量读取并校验累计读取量。 + * + * @param buffer 目标缓冲区 + * @param offset 写入偏移 + * @param length 最大读取长度 + * @return 实际读取长度或 {@code -1} + * @throws IOException 读取失败或超过限制时抛出 + */ + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + long remainingWithProbe = maxBytes == Long.MAX_VALUE + ? Long.MAX_VALUE + : maxBytes - consumed + 1L; + int allowed = (int) Math.min( + Math.max(0L, remainingWithProbe), + (long) length); + if (allowed <= 0) { + throw responseSizeExceeded(maxBytes); + } + int count = super.read(buffer, offset, allowed); + if (count > 0) { + recordRead(count); + } + return count; + } + + /** + * 记录实际读取量。 + * + * @param count 本次读取字节数 + * @throws IOException 超过限制时抛出 + */ + private void recordRead(long count) throws IOException { + consumed += count; + if (consumed > maxBytes) { + throw responseSizeExceeded(maxBytes); + } + } + } + @Override public String toString() { diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/KnowledgeNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/KnowledgeNode.java index a92bcfa..e834b46 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/KnowledgeNode.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/KnowledgeNode.java @@ -16,6 +16,7 @@ package com.easyagents.flow.core.node; import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainState; import com.easyagents.flow.core.knowledge.Knowledge; import com.easyagents.flow.core.knowledge.KnowledgeManager; import com.easyagents.flow.core.util.Maps; @@ -29,6 +30,8 @@ import java.util.List; import java.util.Map; public class KnowledgeNode extends BaseNode { + private static final long serialVersionUID = 1L; + private static final Logger logger = org.slf4j.LoggerFactory.getLogger(KnowledgeNode.class); @@ -71,11 +74,16 @@ public class KnowledgeNode extends BaseNode { @Override public Map execute(Chain chain) { - Map argsMap = chain.getState().resolveParameters(this); + ChainState chainState = + chain.getExecutionState(); + Map argsMap = + chainState.resolveParameters(this); + List> templateRootMaps = + chainState.buildTemplateRootMaps(argsMap); String realKeyword = TextTemplate.of(keyword) - .formatToString(chain.getState().buildTemplateRootMaps(argsMap)); + .formatToString(templateRootMaps); String realLimitString = TextTemplate.of(limit) - .formatToString(chain.getState().buildTemplateRootMaps(argsMap)); + .formatToString(templateRootMaps); int realLimit = 10; if (StringUtil.hasText(realLimitString)) { try { diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LlmNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LlmNode.java index 177a713..e62e31b 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LlmNode.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LlmNode.java @@ -17,6 +17,7 @@ package com.easyagents.flow.core.node; import com.alibaba.fastjson.JSON; import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainState; import com.easyagents.flow.core.chain.Parameter; import com.easyagents.flow.core.llm.Llm; import com.easyagents.flow.core.llm.LlmManager; @@ -26,6 +27,8 @@ import java.io.File; import java.util.*; public class LlmNode extends BaseNode { + private static final long serialVersionUID = 1L; + protected String llmId; protected Llm.ChatOptions chatOptions; @@ -88,14 +91,20 @@ public class LlmNode extends BaseNode { @Override public Map execute(Chain chain) { - Map parameterValues = chain.getState().resolveParameters(this); + ChainState chainState = + chain.getExecutionState(); + Map parameterValues = + chainState.resolveParameters(this); if (StringUtil.noText(userPrompt)) { throw new RuntimeException("Can not find user prompt"); } + List> templateRootMaps = + chainState.buildTemplateRootMaps( + parameterValues); String userPromptString = TextTemplate.of(userPrompt) - .formatToString(chain.getState().buildTemplateRootMaps(parameterValues)); + .formatToString(templateRootMaps); Llm llm = LlmManager.getInstance().getChatModel(this.llmId); @@ -104,14 +113,16 @@ public class LlmNode extends BaseNode { } String systemPromptString = TextTemplate.of(this.systemPrompt) - .formatToString(chain.getState().buildTemplateRootMaps(parameterValues)); + .formatToString(templateRootMaps); Llm.MessageInfo messageInfo = new Llm.MessageInfo(); messageInfo.setMessage(userPromptString); messageInfo.setSystemMessage(systemPromptString); if (images != null && !images.isEmpty()) { - Map filesMap = chain.getState().resolveParameters(this, images); + Map filesMap = + chainState.resolveParameters( + this, images); List imagesUrls = new ArrayList<>(); filesMap.forEach((s, o) -> { if (o instanceof String) { diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LoopNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LoopNode.java index f7c0ddc..ff7f2bf 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LoopNode.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LoopNode.java @@ -18,19 +18,30 @@ package com.easyagents.flow.core.node; import com.easyagents.flow.core.chain.*; import com.easyagents.flow.core.chain.repository.ChainStateField; +import com.easyagents.flow.core.chain.repository.LoopInputReference; import com.easyagents.flow.core.chain.repository.NodeStateField; import com.easyagents.flow.core.chain.runtime.Trigger; import com.easyagents.flow.core.chain.runtime.TriggerContext; import com.easyagents.flow.core.chain.runtime.TriggerType; -import com.easyagents.flow.core.util.IterableUtil; +import com.easyagents.flow.core.chain.runtime.ExecutionBudgetExceededException; import com.easyagents.flow.core.util.Maps; import com.easyagents.flow.core.util.StringUtil; import java.io.Serializable; +import java.lang.reflect.Array; +import java.math.BigDecimal; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; public class LoopNode extends BaseNode { + private static final long serialVersionUID = 1L; + + private static final int DIRECT_INDEX_MAX_ITEMS = Math.max( + 1, + Integer.getInteger( + "tinyflow.loop.direct-index.max-items", 64)); private Parameter loopVar; @@ -44,6 +55,43 @@ public class LoopNode extends BaseNode { @Override public Map execute(Chain chain) { + MaterializationPlan[] planHolder = new MaterializationPlan[1]; + Map initialResult = chain.executeWithLock( + chain.getStateInstanceId(), + 10, + TimeUnit.SECONDS, + () -> executeLocked(chain, false, planHolder)); + MaterializationPlan plan = planHolder[0]; + if (plan == null) { + return initialResult; + } + + int iterableSize = chain.storeLoopInputOutsideLock( + plan.resultId, + plan.items, + materializationLimit(chain), + plan.claimId, + plan.claimGeneration); + return chain.executeWithLock( + chain.getStateInstanceId(), + 10, + TimeUnit.SECONDS, + () -> publishMaterializedInputAndContinue( + chain, plan, iterableSize)); + } + + /** + * 在实例锁内推进一次循环状态机。 + * + * @param chain 当前工作流 + * @param resumeAfterMaterialization 是否在本次调用中完成了锁外物化 + * @param planHolder 待锁外执行的物化计划容器 + * @return 节点执行结果 + */ + private Map executeLocked( + Chain chain, + boolean resumeAfterMaterialization, + MaterializationPlan[] planHolder) { Trigger prevTrigger = TriggerContext.getCurrentTrigger(); Deque loopStack = getOrCreateLoopStack(chain); @@ -51,15 +99,22 @@ public class LoopNode extends BaseNode { // 判断是否是首次进入该 LoopNode(即不是由子节点返回) TriggerType triggerType = prevTrigger.getType(); - boolean isFirstEntry = triggerType != TriggerType.PARENT && triggerType != TriggerType.SELF; + boolean isFirstEntry = !resumeAfterMaterialization + && triggerType != TriggerType.PARENT + && triggerType != TriggerType.SELF; if (isFirstEntry) { // 首次触发:创建新的 LoopContext 并压入堆栈 loopContext = new LoopContext(); loopContext.currentIndex = 0; - loopContext.subResult = new HashMap<>(); + loopContext.resultId = chain.getStateInstanceId() + ":" + UUID.randomUUID(); // 保存原始触发上下文(用于循环结束后恢复) loopStack.offerLast(loopContext); + int nestedDepth = chain.getNestedDepthBase() + + (prevTrigger == null + ? 1 + : prevTrigger.getLoopCursors().size() + 1); + chain.getExecutionBudget().checkNestedDepth(this.id, nestedDepth); chain.updateNodeStateSafely(this.id, state -> { state.getMemory().put(buildLoopStackId(), loopStack); @@ -83,33 +138,114 @@ public class LoopNode extends BaseNode { loopContext = loopStack.peekFirst(); } + if (loopContext.materializingInput && !resumeAfterMaterialization) { + String currentClaimId = chain.currentFencingClaimId(); + long currentGeneration = chain.currentClaimGeneration(); + if (Objects.equals(loopContext.materializationClaimId, currentClaimId) + && loopContext.materializationClaimGeneration == currentGeneration) { + return waitingResult(); + } + // 原 owner 已失去 claim,由新代际使用全新 resultId 接管。 + loopContext.resultId = + chain.getStateInstanceId() + ":" + UUID.randomUUID(); + loopContext.materializationClaimId = currentClaimId; + loopContext.materializationClaimGeneration = currentGeneration; + persistLoopStack(chain, loopStack); + } -// LoopContext loopContext = getLoopContext(prevTrigger, chain); -// int triggerLoopIndex = getTriggerLoopIndex(prevTrigger); -// -// if (loopContext.currentIndex != triggerLoopIndex) { -// // 不执行,子流程有分叉,已经被其他的分叉节点触发了 -// return Maps.of(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY, true) -// .set(ChainConsts.NODE_STATE_STATUS_KEY, NodeStatus.RUNNING); -// } - - Map loopVars = chain.getState().resolveParameters(this, Collections.singletonList(loopVar)); - Object loopValue = loopVars.get(loopVar.getName()); + migrateLegacyResult(chain, loopContext); + if (!acceptParentBranch(prevTrigger, loopContext, chain, loopStack)) { + return Maps.of(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY, true) + .set(ChainConsts.NODE_STATE_STATUS_KEY, NodeStatus.RUNNING); + } int shouldLoopCount; - if (loopValue instanceof Iterable) { - shouldLoopCount = IterableUtil.size((Iterable) loopValue); - } else if (loopValue instanceof Number || (loopValue instanceof String && StringUtil.isNumeric(loopValue.toString()))) { - shouldLoopCount = loopValue instanceof Number ? ((Number) loopValue).intValue() : Integer.parseInt(loopValue.toString().trim()); + boolean storedIterable = false; + boolean directlyIndexed = false; + boolean numericLoop = false; + Object loopValue = null; + if (loopContext.iterableInputStored) { + // 已物化输入不再解析原始参数,避免大集合在每轮反序列化和路径解析。 + shouldLoopCount = loopContext.iterableSize; + storedIterable = true; } else { - throw new IllegalArgumentException("loopValue must be Iterable or Number or String, but loopValue is \"" + loopValue + "\""); + LoopInputReference storedInput = + resolveStoredInputReference(chain); + if (storedInput != null) { + loopContext.resultId = + storedInput.getResultId(); + loopContext.iterableSize = + storedInput.getItemCount(); + loopContext.iterableInputStored = true; + loopContext.inputExternalized = true; + shouldLoopCount = + storedInput.getItemCount(); + storedIterable = true; + persistLoopStack(chain, loopStack); + } else { + Map loopVars = + chain.getExecutionState().resolveParameters( + this, + Collections.singletonList( + loopVar)); + loopValue = loopVars.get(loopVar.getName()); + Iterable iterableInput = + toIterableInput(loopValue); + if (iterableInput != null) { + int knownSize = knownInputSize(loopValue); + if (knownSize >= 0) { + checkExplicitLoopIterations(chain, knownSize, true); + } + if (knownSize >= 0 + && knownSize <= DIRECT_INDEX_MAX_ITEMS) { + shouldLoopCount = knownSize; + directlyIndexed = true; + } else { + /* + * 每次未完成的物化尝试使用新结果 ID。失锁 owner 的旧分块仅会按 TTL + * 回收,接管者不会复用或删除其部分数据。 + */ + loopContext.resultId = + chain.getStateInstanceId() + ":" + UUID.randomUUID(); + loopContext.materializingInput = true; + loopContext.materializationClaimId = + chain.currentFencingClaimId(); + loopContext.materializationClaimGeneration = + chain.currentClaimGeneration(); + persistLoopStack(chain, loopStack); + if (planHolder == null) { + throw new IllegalStateException( + "Loop materialization plan holder is unavailable"); + } + planHolder[0] = new MaterializationPlan( + loopContext.resultId, + iterableInput, + loopContext.materializationClaimId, + loopContext.materializationClaimGeneration); + return waitingResult(); + } + } else if (loopValue instanceof Number + || loopValue instanceof String) { + shouldLoopCount = parseNumericLoopCount(loopValue); + numericLoop = true; + } else { + throw invalidLoopValue(loopValue); + } + } } + checkExplicitLoopIterations(chain, shouldLoopCount, !numericLoop); // 不是第一次执行,合并结果到 subResult if (loopContext.currentIndex != 0) { - ChainState subState = chain.getState(); + ChainState subState = + chain.getExecutionState(); Map currentOutputs = collectCurrentOutputValues(subState); - mergeResult(loopContext.subResult, currentOutputs); + loopContext.accumulatedBytes += estimateBytes(currentOutputs, new IdentityHashMap<>()); + chain.getExecutionBudget().checkAccumulatedBytes(this.id, loopContext.accumulatedBytes); + chain.appendLoopResult( + loopContext.resultId, + loopContext.currentIndex - 1, + currentOutputs); // 将上一轮最新输出同步到循环节点作用域,供下一轮循环体读取。 publishLoopProgress(chain, currentOutputs); } @@ -128,25 +264,37 @@ public class LoopNode extends BaseNode { if (!loopStack.isEmpty()) { chain.scheduleNode(this, null, TriggerType.SELF, 0); } - return loopContext.subResult; + if (prevTrigger != null) { + prevTrigger.getLoopCursors().remove(this.id); + } + Map completedResult = chain.getLoopResultRepository().references( + loopContext.resultId, loopContext.currentIndex, getOutputNames()); + chain.getLoopResultRepository().releaseActiveCache( + loopContext.resultId); + if (!loopContext.inputExternalized) { + chain.removeLoopInput(loopContext.resultId); + } + return completedResult; } int loopIndex = loopContext.currentIndex; loopContext.currentIndex++; - chain.updateNodeStateSafely(this.id, state -> { - state.getMemory().put(buildLoopStackId(), loopStack); - return EnumSet.of(NodeStateField.MEMORY); - }); + persistLoopStack(chain, loopStack); - if (loopValue instanceof Iterable) { - Object loopItem = IterableUtil.get((Iterable) loopValue, loopIndex); + if (storedIterable) { + Object loopItem = chain.getLoopResultRepository().loadInputItem(loopContext.resultId, loopIndex); executeLoopChain(chain, loopContext, loopItem); - } else if (loopValue instanceof Number || (loopValue instanceof String && StringUtil.isNumeric(loopValue.toString()))) { + } else if (directlyIndexed) { + executeLoopChain( + chain, + loopContext, + directInputItem(loopValue, loopIndex)); + } else if (numericLoop) { executeLoopChain(chain, loopContext, loopIndex); } else { - throw new IllegalArgumentException("loopValue must be Iterable or Number or String, but loopValue is \"" + loopValue + "\""); + throw invalidLoopValue(loopValue); } // 禁用调度下个节点 @@ -154,6 +302,281 @@ public class LoopNode extends BaseNode { .set(ChainConsts.NODE_STATE_STATUS_KEY, NodeStatus.RUNNING); } + /** + * 在短实例锁内发布锁外物化结果并继续循环状态机。 + * + * @param chain 当前工作流 + * @param plan 已完成的物化计划 + * @param iterableSize 物化元素数 + * @return 本轮循环执行结果 + */ + private Map publishMaterializedInputAndContinue( + Chain chain, MaterializationPlan plan, int iterableSize) { + if (!chain.isExecutionActiveNow()) { + throw new com.easyagents.flow.core.chain.runtime.TriggerClaimLostException( + "loop-materialization-cancelled:" + plan.resultId); + } + Deque loopStack = getOrCreateLoopStack(chain); + LoopContext context = loopStack.peekFirst(); + if (context == null + || !context.materializingInput + || !Objects.equals(context.resultId, plan.resultId) + || !Objects.equals( + context.materializationClaimId, plan.claimId) + || context.materializationClaimGeneration + != plan.claimGeneration) { + throw new com.easyagents.flow.core.chain.runtime.TriggerClaimLostException( + "loop-materialization:" + plan.resultId); + } + context.iterableSize = iterableSize; + context.iterableInputStored = true; + context.materializingInput = false; + context.inputExternalized = externalizeMaterializedInput( + chain, plan.resultId, iterableSize); + persistLoopStack(chain, loopStack); + return executeLocked(chain, true, null); + } + + /** + * 将热状态中的大型原始输入替换为轻量引用。 + * + *

仅替换参数直接对应的扁平内存键;无法准确定位的嵌套路径保持原值, + * 以业务兼容性优先。其他节点读取该引用时由仓储透明还原完整列表。

+ * + * @param chain 当前工作流 + * @param resultId 已物化输入 ID + * @param iterableSize 输入元素数 + * @return 已替换热状态值时为 {@code true} + */ + private boolean externalizeMaterializedInput( + Chain chain, String resultId, int iterableSize) { + String ref = loopVar == null ? null : loopVar.getRef(); + String name = loopVar == null ? null : loopVar.getName(); + AtomicBoolean replaced = new AtomicBoolean(); + chain.updateStateSafely(state -> { + ConcurrentHashMap memory = + state.getMemory(); + String key = StringUtil.hasText(ref) + && memory.containsKey(ref) + ? ref + : (StringUtil.hasText(name) + && memory.containsKey(name) + ? name + : null); + if (key == null) { + return null; + } + Object current = memory.get(key); + if (current instanceof LoopInputReference) { + replaced.set(true); + return null; + } + memory.put( + key, + new LoopInputReference( + resultId, iterableSize)); + replaced.set(true); + return EnumSet.of(ChainStateField.MEMORY); + }); + return replaced.get(); + } + + /** + * 直接识别上游已分页物化的输入引用,避免参数解析边界先还原完整列表。 + * + * @param chain 当前工作流 + * @return 已物化输入引用;不存在时为 {@code null} + */ + private LoopInputReference resolveStoredInputReference( + Chain chain) { + if (loopVar == null) { + return null; + } + Map memory = + chain.getExecutionState().getMemory(); + String ref = loopVar.getRef(); + if (StringUtil.hasText(ref) + && memory.get(ref) + instanceof LoopInputReference) { + return (LoopInputReference) memory.get(ref); + } + String name = loopVar.getName(); + return StringUtil.hasText(name) + && memory.get(name) + instanceof LoopInputReference + ? (LoopInputReference) memory.get(name) + : null; + } + + /** + * 构造不推进下游的循环等待结果。 + * + * @return 运行态控制结果 + */ + private Map waitingResult() { + return Maps.of(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY, true) + .set(ChainConsts.NODE_STATE_STATUS_KEY, NodeStatus.RUNNING); + } + + /** + * 将集合或数组统一转换为单次消费的 Iterable。 + * + * @param loopValue 循环输入 + * @return 可迭代输入;数值循环返回 {@code null} + */ + private Iterable toIterableInput(Object loopValue) { + if (loopValue instanceof Iterable) { + return (Iterable) loopValue; + } + if (loopValue != null && loopValue.getClass().isArray()) { + int length = Array.getLength(loopValue); + return () -> new Iterator() { + private int index; + + @Override + public boolean hasNext() { + return index < length; + } + + @Override + public Object next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return Array.get(loopValue, index++); + } + }; + } + return null; + } + + /** + * 获取无需遍历即可确定的输入元素数量。 + * + * @param loopValue 循环输入 + * @return 已知数量;未知时返回 {@code -1} + */ + private int knownInputSize(Object loopValue) { + if (loopValue instanceof Collection) { + return ((Collection) loopValue).size(); + } + if (loopValue != null && loopValue.getClass().isArray()) { + return Array.getLength(loopValue); + } + return -1; + } + + /** + * 解析数值型循环次数,循环索引从 0 开始并执行到 {@code count - 1}。 + * + * @param loopValue 数值或数值字符串 + * @return 1~300 的循环总次数 + * @throws IllegalArgumentException 输入不是范围内整数 + */ + private int parseNumericLoopCount(Object loopValue) { + final int count; + try { + count = new BigDecimal(String.valueOf(loopValue).trim()) + .intValueExact(); + } catch (ArithmeticException | NumberFormatException exception) { + throw new IllegalArgumentException( + "Loop count must be an integer between " + + Node.MIN_LOOP_COUNT + + " and " + + Node.MAX_LOOP_COUNT, + exception); + } + if (count < Node.MIN_LOOP_COUNT || count > Node.MAX_LOOP_COUNT) { + throw new IllegalArgumentException( + "Loop count must be between " + + Node.MIN_LOOP_COUNT + + " and " + + Node.MAX_LOOP_COUNT + + ", but was " + + count); + } + return count; + } + + /** + * 校验显式循环节点的本层迭代次数。 + * + * @param chain 当前工作流 + * @param iterations 本层计划迭代次数 + * @param allowEmpty 是否允许空集合产生零次迭代 + * @throws IllegalArgumentException 不允许的负数或零次数 + * @throws ExecutionBudgetExceededException 超过 300 次或部署预算 + */ + private void checkExplicitLoopIterations( + Chain chain, long iterations, boolean allowEmpty) { + if (iterations < 0L || (!allowEmpty && iterations == 0L)) { + throw new IllegalArgumentException( + "Loop count must be at least " + Node.MIN_LOOP_COUNT); + } + if (iterations > Node.MAX_LOOP_COUNT) { + throw new ExecutionBudgetExceededException( + "Loop iteration limit exceeded for node " + + this.id + + ": " + + iterations + + " > " + + Node.MAX_LOOP_COUNT); + } + chain.getExecutionBudget().checkIterations(this.id, iterations); + } + + /** + * 计算未知 Iterable 的物化上限,同时服从平台预算和 300 次硬上限。 + * + * @param chain 当前工作流 + * @return 正数物化上限 + */ + private long materializationLimit(Chain chain) { + long budgetLimit = chain.getExecutionBudget().getMaxIterations(); + if (budgetLimit <= 0L) { + return Node.MAX_LOOP_COUNT; + } + return Math.min(budgetLimit, Node.MAX_LOOP_COUNT); + } + + /** + * 按序号读取小型集合或数组,避免额外 Redis 分块读写。 + * + * @param loopValue 小型循环输入 + * @param index 元素序号 + * @return 对应元素 + */ + private Object directInputItem(Object loopValue, int index) { + if (loopValue instanceof List) { + return ((List) loopValue).get(index); + } + if (loopValue != null && loopValue.getClass().isArray()) { + return Array.get(loopValue, index); + } + if (loopValue instanceof Collection) { + Iterator iterator = + ((Collection) loopValue).iterator(); + for (int current = 0; current < index; current++) { + iterator.next(); + } + return iterator.next(); + } + throw invalidLoopValue(loopValue); + } + + /** + * 创建循环输入类型错误。 + * + * @param loopValue 非法输入 + * @return 参数异常 + */ + private IllegalArgumentException invalidLoopValue(Object loopValue) { + return new IllegalArgumentException( + "loopValue must be Iterable, array, Number or String, but loopValue is \"" + + loopValue + + "\""); + } + /** * 获取或创建当前节点的 LoopContext 堆栈(每个 LoopNode 实例独立) @@ -168,15 +591,10 @@ public class LoopNode extends BaseNode { stack = (Deque) stackObj; } else { stack = new ArrayDeque<>(); - chain.updateNodeStateSafely(this.id, state -> { - state.getMemory().put(key, stack); - return EnumSet.of(NodeStateField.MEMORY); - }); } return stack; } - private void executeLoopChain(Chain chain, LoopContext loopContext, Object loopItem) { chain.updateStateSafely(state -> { @@ -188,13 +606,116 @@ public class LoopNode extends BaseNode { ChainDefinition definition = chain.getDefinition(); - List outwardEdges = definition.getOutwardEdge(this.id); - for (Edge edge : outwardEdges) { - Node childNode = definition.getNodeById(edge.getTarget()); - if (childNode.getParentId() != null && childNode.getParentId().equals(this.id)) { - chain.scheduleNode(childNode, edge.getId(), TriggerType.CHILD, 0); + List childDispatches = + definition.getLoopChildDispatches(this.id); + if (childDispatches.isEmpty()) { + throw new IllegalStateException("Loop node has no executable child branch: " + this.id); + } + loopContext.expectedReturnCount = childDispatches.size(); + loopContext.getCompletedBranchIds().clear(); + for (ChainDefinition.LoopChildDispatch dispatch : + childDispatches) { + Trigger.LoopCursor cursor = new Trigger.LoopCursor( + loopContext.resultId, + loopContext.currentIndex - 1, + dispatch.getBranchId()); + chain.scheduleLoopChild( + dispatch.getNode(), + dispatch.getEdgeId(), + 0, + this.id, + cursor); + } + } + + /** + * 校验父分支回调的代际和分支屏障。 + * + * @param trigger 当前触发器 + * @param context 当前循环上下文 + * @param chain 当前工作流 + * @param loopStack 循环上下文栈 + * @return 当前回调是否为本轮最后一个有效分支 + */ + private boolean acceptParentBranch(Trigger trigger, + LoopContext context, + Chain chain, + Deque loopStack) { + if (trigger == null || trigger.getType() != TriggerType.PARENT) { + return true; + } + Trigger.LoopCursor cursor = trigger.getLoopCursors().get(this.id); + if (cursor == null) { + return true; + } + if (!Objects.equals(context.resultId, cursor.getResultId())) { + return false; + } + int expectedIndex = context.currentIndex - 1; + if (cursor.getIterationIndex() < expectedIndex) { + return false; + } + if (cursor.getIterationIndex() > expectedIndex) { + throw new IllegalStateException("Future loop callback index: " + cursor.getIterationIndex()); + } + if (!context.getCompletedBranchIds().add(cursor.getBranchId())) { + return false; + } + persistLoopStack(chain, loopStack); + if (context.getCompletedBranchIds().size() < Math.max(1, context.expectedReturnCount)) { + return false; + } + context.getCompletedBranchIds().clear(); + return true; + } + + /** + * 将旧版本内嵌累计结果惰性迁移到分块仓储。 + * + * @param chain 当前工作流 + * @param context 循环上下文 + */ + @SuppressWarnings("unchecked") + private void migrateLegacyResult(Chain chain, LoopContext context) { + if (context.resultId != null) { + return; + } + context.resultId = chain.getStateInstanceId() + ":" + UUID.randomUUID(); + if (context.subResult != null && !context.subResult.isEmpty()) { + int migratedCount = 0; + for (Object value : context.subResult.values()) { + if (value instanceof List) { + migratedCount = Math.max(migratedCount, ((List) value).size()); + } + } + for (int index = 0; index < migratedCount; index++) { + Map outputs = new LinkedHashMap<>(); + for (Map.Entry entry : context.subResult.entrySet()) { + if (entry.getValue() instanceof List + && index < ((List) entry.getValue()).size()) { + outputs.put(entry.getKey(), ((List) entry.getValue()).get(index)); + } + } + chain.appendLoopResult( + context.resultId, + index, + outputs); } } + context.subResult = null; + } + + /** + * 保存紧凑循环上下文。 + * + * @param chain 当前工作流 + * @param loopStack 循环上下文栈 + */ + private void persistLoopStack(Chain chain, Deque loopStack) { + chain.updateNodeStateSafely(this.id, state -> { + state.getMemory().put(buildLoopStackId(), loopStack); + return EnumSet.of(NodeStateField.MEMORY); + }); } @@ -227,20 +748,69 @@ public class LoopNode extends BaseNode { } - private void mergeResult(Map toResult, Map currentOutputs) { + /** + * 获取循环输出名称并保持定义顺序。 + * + * @return 输出名称列表 + */ + private List getOutputNames() { + List outputNames = new ArrayList<>(); List outputDefs = getOutputDefs(); if (outputDefs != null) { for (Parameter outputDef : outputDefs) { - Object value = currentOutputs.get(outputDef.getName()); - - @SuppressWarnings("unchecked") List existList = (List) toResult.get(outputDef.getName()); - if (existList == null) { - existList = new ArrayList<>(); - } - existList.add(value); - toResult.put(outputDef.getName(), existList); + outputNames.add(outputDef.getName()); } } + return outputNames; + } + + /** + * 估算本轮输出占用字节数,用于宽松的累计结果失控保护。 + * + * @param value 待估算值 + * @param visited 已访问对象集合,防止循环引用 + * @return 估算字节数 + */ + private long estimateBytes(Object value, IdentityHashMap visited) { + if (value == null) { + return 0L; + } + if (value instanceof String) { + return (long) ((String) value).length() * Character.BYTES; + } + if (value instanceof byte[]) { + return ((byte[]) value).length; + } + if (value instanceof Number || value instanceof Date) { + return 16L; + } + if (value instanceof Boolean || value instanceof Character) { + return 2L; + } + if (visited.put(value, Boolean.TRUE) != null) { + return 0L; + } + long bytes = 0L; + if (value instanceof Map) { + for (Map.Entry entry : ((Map) value).entrySet()) { + bytes += estimateBytes(entry.getKey(), visited); + bytes += estimateBytes(entry.getValue(), visited); + } + } else if (value instanceof Collection) { + for (Object item : (Collection) value) { + bytes += estimateBytes(item, visited); + } + } else if (value instanceof Iterable) { + bytes = 64L; + } else if (value.getClass().isArray()) { + int length = Array.getLength(value); + for (int index = 0; index < length; index++) { + bytes += estimateBytes(Array.get(value, index), visited); + } + } else { + bytes = 64L; + } + return bytes; } @@ -274,8 +844,20 @@ public class LoopNode extends BaseNode { public static class LoopContext implements Serializable { + private static final long serialVersionUID = 5258356831772776243L; + int currentIndex; + String resultId; Map subResult; + boolean iterableInputStored; + boolean inputExternalized; + boolean materializingInput; + String materializationClaimId; + long materializationClaimGeneration; + int iterableSize; + long accumulatedBytes; + int expectedReturnCount = 1; + Set completedBranchIds = new LinkedHashSet<>(); public int getCurrentIndex() { return currentIndex; @@ -285,6 +867,14 @@ public class LoopNode extends BaseNode { this.currentIndex = currentIndex; } + public String getResultId() { + return resultId; + } + + public void setResultId(String resultId) { + this.resultId = resultId; + } + public Map getSubResult() { return subResult; } @@ -293,5 +883,111 @@ public class LoopNode extends BaseNode { this.subResult = subResult; } + public long getAccumulatedBytes() { + return accumulatedBytes; + } + + public void setAccumulatedBytes(long accumulatedBytes) { + this.accumulatedBytes = accumulatedBytes; + } + + public boolean isIterableInputStored() { + return iterableInputStored; + } + + public void setIterableInputStored(boolean iterableInputStored) { + this.iterableInputStored = iterableInputStored; + } + + /** + * @return 原始输入是否已从热状态替换为轻量引用 + */ + public boolean isInputExternalized() { + return inputExternalized; + } + + /** + * 设置原始输入外置状态。 + * + * @param inputExternalized 是否已替换为轻量引用 + */ + public void setInputExternalized( + boolean inputExternalized) { + this.inputExternalized = inputExternalized; + } + + /** + * @return 是否正在锁外物化输入 + */ + public boolean isMaterializingInput() { + return materializingInput; + } + + /** + * 设置锁外物化状态。 + * + * @param materializingInput 是否正在物化 + */ + public void setMaterializingInput(boolean materializingInput) { + this.materializingInput = materializingInput; + } + + public int getIterableSize() { + return iterableSize; + } + + public void setIterableSize(int iterableSize) { + this.iterableSize = iterableSize; + } + + public int getExpectedReturnCount() { + return expectedReturnCount; + } + + public void setExpectedReturnCount(int expectedReturnCount) { + this.expectedReturnCount = expectedReturnCount; + } + + public Set getCompletedBranchIds() { + if (completedBranchIds == null) { + completedBranchIds = new LinkedHashSet<>(); + } + return completedBranchIds; + } + + public void setCompletedBranchIds(Set completedBranchIds) { + this.completedBranchIds = completedBranchIds; + } + + } + + /** + * 一次锁外循环输入物化所需的不可变守卫快照。 + */ + private static final class MaterializationPlan { + + private final String resultId; + private final Iterable items; + private final String claimId; + private final long claimGeneration; + + /** + * 创建物化计划。 + * + * @param resultId 唯一结果 ID + * @param items 输入元素 + * @param claimId 触发器 ID + * @param claimGeneration 触发器代际 + */ + private MaterializationPlan( + String resultId, + Iterable items, + String claimId, + long claimGeneration) { + this.resultId = resultId; + this.items = items; + this.claimId = claimId; + this.claimGeneration = claimGeneration; + } } } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/SearchEngineNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/SearchEngineNode.java index a3ed2cf..1246fb2 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/SearchEngineNode.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/SearchEngineNode.java @@ -16,6 +16,7 @@ package com.easyagents.flow.core.node; import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainState; import com.easyagents.flow.core.searchengine.SearchEngine; import com.easyagents.flow.core.searchengine.SearchEngineManager; import com.easyagents.flow.core.util.Maps; @@ -29,6 +30,8 @@ import java.util.List; import java.util.Map; public class SearchEngineNode extends BaseNode { + private static final long serialVersionUID = 1L; + private static final Logger logger = org.slf4j.LoggerFactory.getLogger(SearchEngineNode.class); @@ -62,11 +65,16 @@ public class SearchEngineNode extends BaseNode { @Override public Map execute(Chain chain) { - Map argsMap = chain.getState().resolveParameters(this); + ChainState chainState = + chain.getExecutionState(); + Map argsMap = + chainState.resolveParameters(this); + List> templateRootMaps = + chainState.buildTemplateRootMaps(argsMap); String realKeyword = TextTemplate.of(keyword) - .formatToString(chain.getState().buildTemplateRootMaps(argsMap)); + .formatToString(templateRootMaps); String realLimitString = TextTemplate.of(limit) - .formatToString(chain.getState().buildTemplateRootMaps(argsMap)); + .formatToString(templateRootMaps); int realLimit = 10; if (StringUtil.hasText(realLimitString)) { try { diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/StartNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/StartNode.java index 566ca31..298fd1c 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/StartNode.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/StartNode.java @@ -21,9 +21,12 @@ import com.easyagents.flow.core.chain.Chain; import java.util.Map; public class StartNode extends BaseNode { + private static final long serialVersionUID = 1L; + @Override public Map execute(Chain chain) { - return chain.getState().resolveParameters(this); + return chain.getExecutionState() + .resolveParameters(this); } @Override diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/TemplateNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/TemplateNode.java index 3709cac..d6006cb 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/TemplateNode.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/TemplateNode.java @@ -27,6 +27,8 @@ import java.util.List; import java.util.Map; public class TemplateNode extends BaseNode { + private static final long serialVersionUID = 1L; + private static final Engine engine; private String template; @@ -48,7 +50,8 @@ public class TemplateNode extends BaseNode { @Override public Map execute(Chain chain) { - Map parameters = chain.getState().resolveParameters(this); + Map parameters = + chain.getExecutionState().resolveParameters(this); ByteArrayOutputStream result = new ByteArrayOutputStream(); diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/BaseNodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/BaseNodeParser.java index 2ca2c8a..046bb32 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/BaseNodeParser.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/BaseNodeParser.java @@ -20,11 +20,13 @@ import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.easyagents.flow.core.chain.DataType; import com.easyagents.flow.core.chain.JsCodeCondition; +import com.easyagents.flow.core.chain.Node; import com.easyagents.flow.core.chain.Parameter; import com.easyagents.flow.core.chain.RefType; import com.easyagents.flow.core.node.BaseNode; import com.easyagents.flow.core.util.StringUtil; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -156,10 +158,7 @@ public abstract class BaseNodeParser implements NodeParser implements NodeParser targetSemaphores = new ConcurrentHashMap<>(); + private final Object targetRegistryLock = new Object(); + private final Semaphore overflowTargetSemaphore; + private final LongAdder acquiredCount = new LongAdder(); + private final LongAdder rejectedCount = new LongAdder(); + private final LongAdder inFlightCount = new LongAdder(); + private final LongAdder totalWaitNanos = new LongAdder(); + private final AtomicLong lastRejectionLogNanos = new AtomicLong(); + + /** + * 创建 I/O 隔离器。 + * + * @param globalConcurrency 当前进程允许的 I/O 总并发 + * @param perTargetConcurrency 单个目标允许的 I/O 并发 + * @param acquireTimeout 等待许可的最长时间 + * @param maxTrackedTargets 最多单独跟踪的目标数量 + * @throws IllegalArgumentException 参数不合法时抛出 + */ + public IoBulkhead( + int globalConcurrency, + int perTargetConcurrency, + Duration acquireTimeout, + int maxTrackedTargets) { + if (globalConcurrency <= 0 + || perTargetConcurrency <= 0 + || acquireTimeout == null + || acquireTimeout.isNegative() + || maxTrackedTargets <= 0) { + throw new IllegalArgumentException("I/O bulkhead configuration must be positive"); + } + this.globalSemaphore = new Semaphore(globalConcurrency, true); + this.perTargetConcurrency = perTargetConcurrency; + this.acquireTimeoutNanos = acquireTimeout.toNanos(); + this.maxTrackedTargets = maxTrackedTargets; + this.overflowTargetSemaphore = new Semaphore(perTargetConcurrency, true); + } + + /** + * 获取进程内共享 I/O 隔离器。 + * + * @return 共享隔离器 + */ + public static IoBulkhead shared() { + return SHARED; + } + + /** + * 获取数据集读写独立隔离器。 + * + * @return 数据集隔离器 + */ + public static IoBulkhead dataset() { + return DATASET; + } + + /** + * 获取对象存储读写独立隔离器。 + * + * @return 对象存储隔离器 + */ + public static IoBulkhead storage() { + return STORAGE; + } + + /** + * 获取文档解析独立隔离器。 + * + * @return 文档解析隔离器 + */ + public static IoBulkhead documentParse() { + return DOCUMENT_PARSE; + } + + /** + * 获取必须完整物化响应的独立小并发隔离器。 + * + * @return 响应聚合隔离器 + */ + public static IoBulkhead responseAggregation() { + return RESPONSE_AGGREGATION; + } + + /** + * 原子替换全部工作流 I/O 隔离器配置。 + * + *

应用应在开始处理工作流前调用。既有许可继续由旧实例释放, + * 后续请求读取新实例,不会中断正在执行的 I/O。

+ * + * @param http HTTP 请求配置 + * @param dataset 数据集配置 + * @param storage 对象存储配置 + * @param documentParse 文档解析配置 + * @param responseAggregation 响应聚合配置 + */ + public static synchronized void configure( + Settings http, + Settings dataset, + Settings storage, + Settings documentParse, + Settings responseAggregation) { + SHARED = create(http); + DATASET = create(dataset); + STORAGE = create(storage); + DOCUMENT_PARSE = create(documentParse); + RESPONSE_AGGREGATION = + create(responseAggregation); + } + + /** + * 根据不可变配置创建隔离器。 + * + * @param settings 隔离配置 + * @return 新隔离器 + */ + private static IoBulkhead create(Settings settings) { + Settings value = java.util.Objects.requireNonNull( + settings, "I/O bulkhead settings required"); + return new IoBulkhead( + value.globalConcurrency(), + value.perTargetConcurrency(), + value.acquireTimeout(), + value.maxTrackedTargets()); + } + + /** + * I/O 隔离器的不可变配置。 + * + * @param globalConcurrency 当前进程允许的 I/O 总并发 + * @param perTargetConcurrency 单个目标允许的 I/O 并发 + * @param acquireTimeout 等待许可的最长时间 + * @param maxTrackedTargets 最多单独跟踪的目标数量 + */ + public record Settings( + int globalConcurrency, + int perTargetConcurrency, + Duration acquireTimeout, + int maxTrackedTargets) { + + /** + * 校验配置,避免应用启动后才暴露无效容量。 + * + * @throws IllegalArgumentException 配置值无效时抛出 + */ + public Settings { + if (globalConcurrency <= 0 + || perTargetConcurrency <= 0 + || acquireTimeout == null + || acquireTimeout.isNegative() + || acquireTimeout.isZero() + || maxTrackedTargets <= 0) { + throw new IllegalArgumentException( + "I/O bulkhead settings must be positive"); + } + } + } + + /** + * 将 URL 转换为稳定的协议和主机目标键。 + * + * @param url 请求 URL + * @return 目标键;URL 不合法时返回通用 HTTP 目标 + */ + public static String targetForUrl(String url) { + if (!StringUtil.hasText(url)) { + return "http:unknown"; + } + try { + URI uri = URI.create(url.trim()); + String host = uri.getHost(); + if (!StringUtil.hasText(host)) { + return "http:unknown"; + } + int port = uri.getPort(); + return "http:" + + host.toLowerCase(Locale.ROOT) + + (port < 0 ? "" : ":" + port); + } catch (IllegalArgumentException exception) { + return "http:unknown"; + } + } + + /** + * 在给定目标上申请一次阻塞 I/O 许可。 + * + * @param target 目标标识 + * @return 使用完成后必须关闭的许可 + * @throws RetryableTriggerException 等待超时或线程被中断时抛出 + */ + public Permit acquire(String target) { + String normalizedTarget = normalizeTarget(target); + Semaphore targetSemaphore = targetSemaphore(normalizedTarget); + long startedAt = System.nanoTime(); + boolean targetAcquired = false; + boolean globalAcquired = false; + try { + targetAcquired = targetSemaphore.tryAcquire(acquireTimeoutNanos, TimeUnit.NANOSECONDS); + if (!targetAcquired) { + throw rejection(normalizedTarget, startedAt, null); + } + long remainingNanos = Math.max( + 0L, + acquireTimeoutNanos - (System.nanoTime() - startedAt)); + globalAcquired = globalSemaphore.tryAcquire(remainingNanos, TimeUnit.NANOSECONDS); + if (!globalAcquired) { + throw rejection(normalizedTarget, startedAt, null); + } + totalWaitNanos.add(System.nanoTime() - startedAt); + acquiredCount.increment(); + inFlightCount.increment(); + return new Permit(this, targetSemaphore); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw rejection(normalizedTarget, startedAt, exception); + } finally { + if (!globalAcquired && targetAcquired) { + targetSemaphore.release(); + } + } + } + + /** + * 获取当前隔离器的轻量运行指标。 + * + * @return 指标快照 + */ + public Snapshot snapshot() { + return new Snapshot( + acquiredCount.sum(), + rejectedCount.sum(), + inFlightCount.sum(), + totalWaitNanos.sum(), + globalSemaphore.availablePermits(), + targetSemaphores.size()); + } + + /** + * 释放成功申请的全局和目标许可。 + * + * @param targetSemaphore 已申请的目标信号量 + */ + private void release(Semaphore targetSemaphore) { + inFlightCount.decrement(); + globalSemaphore.release(); + targetSemaphore.release(); + } + + /** + * 获取目标对应的信号量,并限制目标注册表体积。 + * + * @param target 规范化目标 + * @return 目标信号量 + */ + private Semaphore targetSemaphore(String target) { + Semaphore existing = targetSemaphores.get(target); + if (existing != null) { + return existing; + } + synchronized (targetRegistryLock) { + existing = targetSemaphores.get(target); + if (existing != null) { + return existing; + } + if (targetSemaphores.size() >= maxTrackedTargets) { + return overflowTargetSemaphore; + } + Semaphore created = new Semaphore(perTargetConcurrency, true); + targetSemaphores.put(target, created); + return created; + } + } + + /** + * 构造可重新投递的限流异常并记录指标。 + * + * @param target 目标标识 + * @param startedAt 等待开始时间 + * @param cause 原始异常 + * @return 可重新投递异常 + */ + private RetryableTriggerException rejection(String target, long startedAt, Throwable cause) { + long waitedNanos = System.nanoTime() - startedAt; + totalWaitNanos.add(waitedNanos); + rejectedCount.increment(); + TimeoutException timeoutException = new TimeoutException( + "I/O bulkhead is saturated for target " + target); + if (cause != null) { + timeoutException.initCause(cause); + } + logRejectionIfDue(target, waitedNanos); + return new RetryableTriggerException("I/O 资源繁忙,请稍后重试", timeoutException); + } + + /** + * 对饱和日志限频,避免过载期间放大日志 I/O。 + * + * @param target 目标标识 + * @param waitedNanos 本次等待纳秒数 + */ + private void logRejectionIfDue(String target, long waitedNanos) { + long now = System.nanoTime(); + long previous = lastRejectionLogNanos.get(); + if ((previous != 0L && now - previous < REJECTION_LOG_INTERVAL_NANOS) + || !lastRejectionLogNanos.compareAndSet(previous, now)) { + return; + } + log.warn( + "工作流 I/O 隔离器拒绝请求,target={}, waitedMs={}, inFlight={}, rejected={}", + target, + TimeUnit.NANOSECONDS.toMillis(waitedNanos), + inFlightCount.sum(), + rejectedCount.sum()); + } + + /** + * 规范化目标键。 + * + * @param target 原始目标 + * @return 非空目标键 + */ + private static String normalizeTarget(String target) { + return StringUtil.hasText(target) ? target.trim() : "io:unknown"; + } + + /** + * 读取正整数系统属性。 + * + * @param name 属性名 + * @param defaultValue 默认值 + * @return 有效属性值或默认值 + */ + private static int positiveIntProperty(String name, int defaultValue) { + try { + int value = Integer.parseInt(System.getProperty(name, String.valueOf(defaultValue))); + return value > 0 ? value : defaultValue; + } catch (NumberFormatException exception) { + return defaultValue; + } + } + + /** + * 读取正长整数系统属性。 + * + * @param name 属性名 + * @param defaultValue 默认值 + * @return 有效属性值或默认值 + */ + private static long positiveLongProperty(String name, long defaultValue) { + try { + long value = Long.parseLong(System.getProperty(name, String.valueOf(defaultValue))); + return value > 0L ? value : defaultValue; + } catch (NumberFormatException exception) { + return defaultValue; + } + } + + /** + * 按资源类别创建具有独立容量的隔离器。 + * + * @param prefix 系统属性前缀 + * @param globalConcurrency 默认总并发 + * @param targetConcurrency 默认单目标并发 + * @param timeoutMillis 默认等待毫秒数 + * @param trackedTargets 默认目标上限 + * @return 独立隔离器 + */ + private static IoBulkhead lane( + String prefix, + int globalConcurrency, + int targetConcurrency, + long timeoutMillis, + int trackedTargets) { + return new IoBulkhead( + positiveIntProperty( + prefix + ".max-concurrency", + globalConcurrency), + positiveIntProperty( + prefix + ".per-target-max-concurrency", + targetConcurrency), + Duration.ofMillis(positiveLongProperty( + prefix + ".acquire-timeout-ms", + timeoutMillis)), + positiveIntProperty( + prefix + ".max-tracked-targets", + trackedTargets)); + } + + /** + * 一次 I/O 许可,关闭时幂等释放资源。 + */ + public static final class Permit implements AutoCloseable { + + private final IoBulkhead owner; + private final Semaphore targetSemaphore; + private final AtomicBoolean closed = new AtomicBoolean(); + + /** + * 创建许可。 + * + * @param owner 所属隔离器 + * @param targetSemaphore 目标信号量 + */ + private Permit(IoBulkhead owner, Semaphore targetSemaphore) { + this.owner = owner; + this.targetSemaphore = targetSemaphore; + } + + /** + * 幂等释放许可。 + */ + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + owner.release(targetSemaphore); + } + } + } + + /** + * I/O 隔离器的不可变运行指标快照。 + * + * @param acquiredCount 已获取许可次数 + * @param rejectedCount 被拒绝次数 + * @param inFlightCount 当前执行中数量 + * @param totalWaitNanos 累计等待纳秒数 + * @param availableGlobalPermits 当前可用全局许可数 + * @param trackedTargetCount 当前独立跟踪目标数 + */ + public record Snapshot( + long acquiredCount, + long rejectedCount, + long inFlightCount, + long totalWaitNanos, + int availableGlobalPermits, + int trackedTargetCount) { + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/IterableUtil.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/IterableUtil.java index 4759226..1c5745d 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/IterableUtil.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/IterableUtil.java @@ -1,5 +1,6 @@ package com.easyagents.flow.core.util; +import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import java.util.List; @@ -61,4 +62,25 @@ public class IterableUtil { throw new IndexOutOfBoundsException("index >= size: " + index); } + + /** + * 单次遍历并物化 Iterable,避免后续按索引从头重复扫描。 + * + * @param iterable 可迭代对象 + * @param 元素类型 + * @return 保持原始迭代顺序的列表 + */ + public static List toList(Iterable iterable) { + if (iterable == null) { + return new ArrayList<>(); + } + if (iterable instanceof Collection) { + return new ArrayList<>((Collection) iterable); + } + List result = new ArrayList<>(); + for (T item : iterable) { + result.add(item); + } + return result; + } } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/JsConditionUtil.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/JsConditionUtil.java index 45d1341..0df612c 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/JsConditionUtil.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/JsConditionUtil.java @@ -18,21 +18,84 @@ package com.easyagents.flow.core.util; import com.easyagents.flow.core.chain.Chain; import com.easyagents.flow.core.util.graalvm.JsInteropUtils; import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Engine; import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.Source; import org.graalvm.polyglot.Value; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +/** + * 在隔离的 GraalVM JavaScript 上下文中执行工作流条件表达式。 + */ public class JsConditionUtil { - // 使用 Context.Builder 构建上下文,线程安全 - private static final Context.Builder CONTEXT_BUILDER = Context.newBuilder("js") + private static final int SOURCE_CACHE_LIMIT = 2048; + /** + * 单条超长动态表达式不进入共享缓存,限制源码字符串和编译元数据总占用。 + */ + private static final int MAX_CACHEABLE_SOURCE_CHARS = + 16 * 1024; + /** + * Engine 跨 Context 共享编译缓存,Context 仍按每次求值独立创建。 + */ + private static final Engine ENGINE = Engine.newBuilder() .option("engine.WarnInterpreterOnly", "false") + .build(); + private static final Map SOURCE_CACHE = + Collections.synchronizedMap(new LinkedHashMap<>( + SOURCE_CACHE_LIMIT + 1, 0.75F, true) { + @Override + protected boolean removeEldestEntry( + Map.Entry eldest) { + return size() > SOURCE_CACHE_LIMIT; + } + }); + + /** + * 工具类禁止实例化。 + */ + private JsConditionUtil() { + } + + /** + * 创建一次隔离的 JavaScript 执行上下文。 + * + * @return 新的执行上下文 + */ + private static Context createContext() { + return Context.newBuilder("js") + .engine(ENGINE) .allowHostAccess(HostAccess.ALL) // 允许访问 Java 对象的方法和字段 .allowHostClassLookup(className -> false) // 禁止动态加载任意 Java 类 - .option("js.ecmascript-version", "2021"); // 使用较新的 ECMAScript 版本 + .option("js.ecmascript-version", "2021") + .build(); + } + + /** + * 获取复用的条件表达式源对象。 + * + * @param code 原始表达式 + * @return 包含结果赋值逻辑的 JavaScript 源 + */ + private static Source source(String code) { + if (code.length() + > MAX_CACHEABLE_SOURCE_CHARS) { + return Source.create( + "js", + "_result.value = " + code); + } + synchronized (SOURCE_CACHE) { + return SOURCE_CACHE.computeIfAbsent( + code, + expression -> Source.create( + "js", + "_result.value = " + expression)); + } + } /** * 执行 JavaScript 表达式并返回 boolean 结果 @@ -43,7 +106,7 @@ public class JsConditionUtil { * @return true 表示满足条件,继续执行;false 表示跳过 */ public static boolean eval(String code, Chain chain, Map initMap) { - try (Context context = CONTEXT_BUILDER.build()) { + try (Context context = createContext()) { Map _result = new HashMap<>(); Value bindings = context.getBindings("js"); @@ -54,9 +117,8 @@ public class JsConditionUtil { }); bindings.putMember("_result", _result); - code = "_result.value = " + code; - context.eval("js", code); + context.eval(source(code)); Object value = _result.get("value"); return toBoolean(value); } catch (Exception e) { @@ -65,8 +127,16 @@ public class JsConditionUtil { } + /** + * 执行 JavaScript 表达式并转换为长整数。 + * + * @param code JS 表达式 + * @param chain Chain 上下文对象 + * @param initMap 初始变量映射 + * @return 转换后的长整数 + */ public static long evalLong(String code, Chain chain, Map initMap) { - try (Context context = CONTEXT_BUILDER.build()) { + try (Context context = createContext()) { Map _result = new HashMap<>(); Value bindings = context.getBindings("js"); @@ -77,9 +147,8 @@ public class JsConditionUtil { }); bindings.putMember("_result", _result); - code = "_result.value = " + code; - context.eval("js", code); + context.eval(source(code)); Object value = _result.get("value"); return toLong(value); } catch (Exception e) { @@ -89,7 +158,10 @@ public class JsConditionUtil { /** - * 将任意对象安全转换为 long 类型 + * 将任意对象安全转换为 long 类型。 + * + * @param value 待转换值 + * @return 长整数结果 */ private static long toLong(Object value) { if (value == null) { @@ -136,13 +208,17 @@ public class JsConditionUtil { } /** - * 收集上下文中的变量 + * 收集上下文中的变量。 + * + * @param chain 当前工作流 + * @param initMap 初始变量 + * @return JavaScript 变量映射 */ private static Map collectContextVariables(Chain chain, Map initMap) { - Map variables = new ConcurrentHashMap<>(); + Map variables = new HashMap<>(); // 添加 Chain Memory 中的变量(去掉前缀) - chain.getState().getMemory().forEach((key, value) -> { + chain.getExecutionState().getMemory().forEach((key, value) -> { int dotIndex = key.indexOf("."); String varName = (dotIndex >= 0) ? key.substring(dotIndex + 1) : key; variables.put(varName, value); @@ -155,7 +231,10 @@ public class JsConditionUtil { } /** - * 将任意对象转换为布尔值 + * 将任意对象转换为布尔值。 + * + * @param value 待转换值 + * @return 布尔结果 */ private static boolean toBoolean(Object value) { if (value == null) { diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/OkHttpClientUtil.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/OkHttpClientUtil.java index 3182dbf..38fda73 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/OkHttpClientUtil.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/OkHttpClientUtil.java @@ -16,6 +16,13 @@ package com.easyagents.flow.core.util; import okhttp3.OkHttpClient; +import okhttp3.Interceptor; +import okhttp3.MediaType; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.BufferedSource; +import okio.ForwardingSource; +import okio.Okio; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; @@ -43,6 +50,8 @@ public final class OkHttpClientUtil { private static final Logger LOGGER = Logger.getLogger(OkHttpClientUtil.class.getName()); private static volatile OkHttpClient.Builder customBuilder; + private static volatile OkHttpClient sharedClient; + private static volatile OkHttpClient sharedNoRetryClient; private static final Object LOCK = new Object(); // Prevent instantiation @@ -58,7 +67,11 @@ public final class OkHttpClientUtil { if (builder == null) { throw new IllegalArgumentException("Builder must not be null"); } - customBuilder = builder; + synchronized (LOCK) { + customBuilder = builder; + sharedClient = null; + sharedNoRetryClient = null; + } } /** @@ -70,31 +83,73 @@ public final class OkHttpClientUtil { *

*/ public static OkHttpClient buildDefaultClient() { - OkHttpClient.Builder builder = customBuilder; - if (builder != null) { - return builder.build(); + OkHttpClient client = sharedClient; + if (client != null) { + return client; } synchronized (LOCK) { - // Double-check in case another thread set it while waiting - builder = customBuilder; - if (builder != null) { - return builder.build(); + client = sharedClient; + if (client != null) { + return client; } - builder = new OkHttpClient.Builder() - .connectTimeout(1, TimeUnit.MINUTES) - .readTimeout(5, TimeUnit.MINUTES); + OkHttpClient.Builder builder = customBuilder; + if (builder == null) { + builder = new OkHttpClient.Builder() + .connectTimeout(1, TimeUnit.MINUTES) + .readTimeout(5, TimeUnit.MINUTES); - // Optional insecure mode (for development/testing only) - if (isInsecureModeEnabled()) { - LOGGER.warning("OkHttpClient is running in INSECURE mode (trust-all SSL). " + - "This is dangerous and should not be used in production."); - enableInsecureSsl(builder); + // Optional insecure mode (for development/testing only) + if (isInsecureModeEnabled()) { + LOGGER.warning("OkHttpClient is running in INSECURE mode (trust-all SSL). " + + "This is dangerous and should not be used in production."); + enableInsecureSsl(builder); + } + + configureProxy(builder); } + configureIoBulkhead(builder); + sharedClient = builder.build(); + return sharedClient; + } + } - configureProxy(builder); - return builder.build(); + /** + * 返回关闭 OkHttp 隐式连接重试的共享客户端。 + * + *

该客户端与默认客户端复用连接池和调度器,用于可能产生业务副作用的请求。

+ * + * @return 禁用连接失败自动重试的共享客户端 + */ + public static OkHttpClient buildNoRetryClient() { + OkHttpClient client = sharedNoRetryClient; + if (client != null) { + return client; + } + synchronized (LOCK) { + client = sharedNoRetryClient; + if (client == null) { + client = buildDefaultClient() + .newBuilder() + .retryOnConnectionFailure(false) + .build(); + sharedNoRetryClient = client; + } + return client; + } + } + + /** + * 为共享客户端安装一次工作流 I/O 隔离拦截器。 + * + * @param builder HTTP 客户端构建器 + */ + private static void configureIoBulkhead(OkHttpClient.Builder builder) { + boolean configured = builder.interceptors().stream() + .anyMatch(interceptor -> interceptor instanceof IoBulkheadInterceptor); + if (!configured) { + builder.addInterceptor(new IoBulkheadInterceptor()); } } @@ -162,4 +217,99 @@ public final class OkHttpClientUtil { } return port; } -} \ No newline at end of file + + /** + * 在 HTTP 响应体关闭前持有 I/O 许可的拦截器。 + */ + private static final class IoBulkheadInterceptor implements Interceptor { + + /** + * 对单个 HTTP 目标施加并发隔离。 + * + * @param chain OkHttp 拦截链 + * @return HTTP 响应 + * @throws java.io.IOException 网络请求失败时抛出 + */ + @Override + public Response intercept(Chain chain) throws java.io.IOException { + IoBulkhead.Permit permit = IoBulkhead.shared() + .acquire(IoBulkhead.targetForUrl(chain.request().url().toString())); + boolean transferred = false; + try { + Response response = chain.proceed(chain.request()); + ResponseBody body = response.body(); + if (body == null) { + return response; + } + Response wrapped = response.newBuilder() + .body(new PermitReleasingResponseBody(body, permit)) + .build(); + transferred = true; + return wrapped; + } finally { + if (!transferred) { + permit.close(); + } + } + } + } + + /** + * 在响应体关闭时释放 I/O 许可的响应体包装器。 + */ + private static final class PermitReleasingResponseBody extends ResponseBody { + + private final ResponseBody delegate; + private final BufferedSource source; + + /** + * 创建响应体包装器。 + * + * @param delegate 原始响应体 + * @param permit 待释放的 I/O 许可 + */ + private PermitReleasingResponseBody(ResponseBody delegate, IoBulkhead.Permit permit) { + this.delegate = delegate; + this.source = Okio.buffer(new ForwardingSource(delegate.source()) { + @Override + public void close() throws java.io.IOException { + try { + super.close(); + } finally { + permit.close(); + } + } + }); + } + + /** + * 获取响应媒体类型。 + * + * @return 响应媒体类型 + */ + @Override + public MediaType contentType() { + return delegate.contentType(); + } + + /** + * 获取响应声明长度。 + * + * @return 响应声明长度 + */ + @Override + public long contentLength() { + return delegate.contentLength(); + } + + /** + * 获取带许可释放逻辑的响应源。 + * + * @return 响应源 + */ + @Override + public BufferedSource source() { + return source; + } + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/TextTemplate.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/TextTemplate.java index 909b84f..eb0a3c7 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/TextTemplate.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/TextTemplate.java @@ -46,12 +46,16 @@ public class TextTemplate { /** * 模板缓存(按原始模板字符串) */ - private static final Map TEMPLATE_CACHE = new ConcurrentHashMap<>(); + private static final int TEMPLATE_CACHE_LIMIT = 4096; + private static final int JSONPATH_CACHE_LIMIT = 2048; + private static final Map TEMPLATE_CACHE = + Collections.synchronizedMap(new BoundedLruMap<>(TEMPLATE_CACHE_LIMIT)); /** * JSONPath 编译缓存,避免重复编译 */ - private static final Map JSONPATH_CACHE = new ConcurrentHashMap<>(); + private static final Map JSONPATH_CACHE = + Collections.synchronizedMap(new BoundedLruMap<>(JSONPATH_CACHE_LIMIT)); /** * 原始模板字符串 @@ -73,7 +77,9 @@ public class TextTemplate { */ public static TextTemplate of(String template) { String finalTemplate = template != null ? template : ""; - return MapUtil.computeIfAbsent(TEMPLATE_CACHE, finalTemplate, k -> new TextTemplate(finalTemplate)); + synchronized (TEMPLATE_CACHE) { + return TEMPLATE_CACHE.computeIfAbsent(finalTemplate, TextTemplate::new); + } } /** @@ -86,13 +92,42 @@ public class TextTemplate { public String formatToString(List> rootMaps) { - Map rootMap = new HashMap<>(); - for (Map m : rootMaps) { - if (m != null) { - rootMap.putAll(m); - } + return formatToString(rootMaps, false); + } + + /** + * 使用分层上下文格式化模板,避免合并复制完整工作流状态。 + *

+ * 后面的上下文层优先级更高,与原有 Map 合并顺序一致。 + * + * @param rootMaps 分层模板上下文 + * @param escapeForJsonOutput 是否对结果进行 JSON 字符串转义 + * @return 格式化结果 + */ + public String formatToString(List> rootMaps, boolean escapeForJsonOutput) { + if (tokens.isEmpty()) { + return originalTemplate; } - return formatToString(rootMap, false); + List> contexts = rootMaps == null ? Collections.emptyList() : rootMaps; + Map layeredContext = new LayeredContextMap(contexts); + StringBuilder result = new StringBuilder(originalTemplate.length() + 64); + for (TemplateToken token : tokens) { + if (token.isStatic) { + result.append(token.content); + continue; + } + EvaluationResult evaluationResult = evaluate( + token.parseResult, layeredContext, escapeForJsonOutput); + if (!token.explicitEmptyFallback && !evaluationResult.resolved) { + throw new IllegalArgumentException(String.format( + "Missing value for expression: \"%s\"%nTemplate: %s%nProvided context layers: %d", + token.rawExpression, + originalTemplate, + contexts.size())); + } + result.append(evaluationResult.value); + } + return result.toString(); } /** @@ -236,7 +271,10 @@ public class TextTemplate { } String fullPath = path.startsWith("$") ? path : "$." + path; - JSONPath compiled = MapUtil.computeIfAbsent(JSONPATH_CACHE, fullPath, JSONPath::compile); + JSONPath compiled; + synchronized (JSONPATH_CACHE) { + compiled = JSONPATH_CACHE.computeIfAbsent(fullPath, JSONPath::compile); + } Object value = compiled.eval(root); if (escapeForJsonOutput && value instanceof String) { return escapeJsonString((String) value); @@ -384,4 +422,159 @@ public class TextTemplate { return new EvaluationResult(false, ""); } } + + /** + * 保持 putAll 覆盖语义、但不复制底层数据的只读分层 Map。 + */ + private static final class LayeredContextMap extends AbstractMap { + + private final List> layers; + + private LayeredContextMap(List> layers) { + this.layers = layers; + } + + @Override + public Object get(Object key) { + for (int index = layers.size() - 1; index >= 0; index--) { + Map layer = layers.get(index); + if (layer != null && layer.containsKey(key)) { + return layer.get(key); + } + } + return null; + } + + @Override + public boolean containsKey(Object key) { + for (int index = layers.size() - 1; index >= 0; index--) { + Map layer = layers.get(index); + if (layer != null && layer.containsKey(key)) { + return true; + } + } + return false; + } + + @Override + public boolean isEmpty() { + for (Map layer : layers) { + if (layer != null && !layer.isEmpty()) { + return false; + } + } + return true; + } + + @Override + public int size() { + return keySet().size(); + } + + @Override + public Set keySet() { + Set keys = new LinkedHashSet<>(); + for (Map layer : layers) { + if (layer != null) { + keys.addAll(layer.keySet()); + } + } + return Collections.unmodifiableSet(keys); + } + + @Override + public Set> entrySet() { + Set keys = keySet(); + return new AbstractSet<>() { + @Override + public Iterator> iterator() { + Iterator iterator = + keys.iterator(); + return new Iterator<>() { + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public Entry next() { + String key = iterator.next(); + return lazyEntry(key); + } + }; + } + + @Override + public int size() { + return keys.size(); + } + }; + } + + /** + * 创建仅在读取值时访问底层上下文的不可变条目。 + * + * @param key 上下文键 + * @return 惰性条目 + */ + private Entry lazyEntry( + String key) { + return new Entry<>() { + @Override + public String getKey() { + return key; + } + + @Override + public Object getValue() { + return LayeredContextMap.this + .get(key); + } + + @Override + public Object setValue(Object value) { + throw new UnsupportedOperationException( + "read-only template context"); + } + + @Override + public boolean equals(Object value) { + return value instanceof Entry entry + && Objects.equals( + key, entry.getKey()) + && Objects.equals( + getValue(), + entry.getValue()); + } + + @Override + public int hashCode() { + return Objects.hashCode(key) + ^ Objects.hashCode( + getValue()); + } + }; + } + } + + /** + * 固定容量的最近最少使用缓存。 + * + * @param 键类型 + * @param 值类型 + */ + private static final class BoundedLruMap extends LinkedHashMap { + + private final int limit; + + private BoundedLruMap(int limit) { + super(16, 0.75F, true); + this.limit = limit; + } + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > limit; + } + } } diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/node/HttpNodePerformanceSafetyTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/node/HttpNodePerformanceSafetyTest.java new file mode 100644 index 0000000..b5d6e13 --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/node/HttpNodePerformanceSafetyTest.java @@ -0,0 +1,126 @@ +package com.easyagents.flow.core.node; + +import com.easyagents.flow.core.chain.Chain; +import okhttp3.MediaType; +import okhttp3.ResponseBody; +import okio.Buffer; +import okio.BufferedSource; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.util.Map; + +/** + * {@link HttpNode} 性能保护与重试安全回归测试。 + */ +public class HttpNodePerformanceSafetyTest { + + /** + * 验证非幂等请求不会执行节点内部隐式自动重试。 + */ + @Test + public void shouldNotAutomaticallyRetryNonIdempotentMethod() { + FailingHttpNode node = new FailingHttpNode(); + node.setMethod("POST"); + + try { + node.execute(null); + Assert.fail("request should fail"); + } catch (RuntimeException expected) { + Assert.assertEquals(1, node.attempts); + } + } + + /** + * 验证仅读取类 HTTP 方法允许节点内部自动重试。 + */ + @Test + public void shouldOnlyRetrySafeReadMethods() { + FailingHttpNode node = new FailingHttpNode(); + + Assert.assertTrue(node.allowsAutomaticRetry("GET")); + Assert.assertTrue(node.allowsAutomaticRetry("HEAD")); + Assert.assertTrue(node.allowsAutomaticRetry("OPTIONS")); + Assert.assertFalse(node.allowsAutomaticRetry("POST")); + Assert.assertFalse(node.allowsAutomaticRetry("PUT")); + Assert.assertFalse(node.allowsAutomaticRetry("PATCH")); + Assert.assertFalse(node.allowsAutomaticRetry("DELETE")); + } + + /** + * 验证响应头未声明长度时仍按实际读取量拒绝超限文本。 + * + * @throws Exception 响应读取失败时抛出 + */ + @Test + public void shouldEnforceActualTextResponseSize() throws Exception { + FailingHttpNode node = new FailingHttpNode(); + ResponseBody body = new ResponseBody() { + @Override + public MediaType contentType() { + return MediaType.parse("text/plain; charset=utf-8"); + } + + @Override + public long contentLength() { + return -1L; + } + + @Override + public BufferedSource source() { + return new Buffer().writeUtf8("12345"); + } + }; + + try { + node.read(body, 4L); + Assert.fail("actual response bytes should be limited"); + } catch (IOException exception) { + Assert.assertTrue(exception.getMessage().contains("4 bytes")); + } + } + + /** + * 始终返回 I/O 异常的测试节点。 + */ + private static final class FailingHttpNode extends HttpNode { + + private int attempts; + + /** + * 模拟单次 HTTP 调用失败。 + * + * @param chain 工作流链 + * @return 不会正常返回 + * @throws IOException 固定抛出模拟异常 + */ + @Override + public Map doExecute(Chain chain) throws IOException { + attempts++; + throw new IOException("simulated failure"); + } + + /** + * 暴露自动重试判断供测试使用。 + * + * @param method HTTP 方法 + * @return 是否允许自动重试 + */ + private boolean allowsAutomaticRetry(String method) { + return supportsAutomaticRetry(method); + } + + /** + * 暴露文本响应读取供测试使用。 + * + * @param body 响应体 + * @param maxBytes 最大允许字节数 + * @return 响应文本 + * @throws IOException 读取失败或超限时抛出 + */ + private String read(ResponseBody body, long maxBytes) throws IOException { + return readTextBody(body, maxBytes); + } + } +} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainDefinitionIndexTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainDefinitionIndexTest.java new file mode 100644 index 0000000..2ff3af0 --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainDefinitionIndexTest.java @@ -0,0 +1,128 @@ +package com.easyagents.flow.core.test; + +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.chain.Edge; +import com.easyagents.flow.core.node.EndNode; +import com.easyagents.flow.core.node.StartNode; +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.Collections; + +/** + * 验证工作流定义图索引的查询结果和变更失效行为。 + */ +public class ChainDefinitionIndexTest { + + /** + * 验证节点、边、邻接表和开始节点保持原有查询语义。 + */ + @Test + public void shouldQueryDefinitionThroughGraphIndex() { + ChainDefinition definition = new ChainDefinition(); + StartNode startNode = node(new StartNode(), "start"); + EndNode endNode = node(new EndNode(), "end"); + Edge edge = edge("start-to-end", "start", "end"); + + definition.addNode(startNode); + definition.addNode(endNode); + definition.addEdge(edge); + + Assert.assertSame(startNode, definition.getNodeById("start")); + Assert.assertSame(edge, definition.getEdgeById("start-to-end")); + Assert.assertEquals(Collections.singletonList(edge), definition.getOutwardEdge("start")); + Assert.assertEquals(Collections.singletonList(edge), definition.getInwardEdge("end")); + Assert.assertEquals(Collections.singletonList(startNode), definition.getStartNodes()); + } + + /** + * 验证定义修改后会重建图索引,不返回过期结果。 + */ + @Test + public void shouldInvalidateGraphIndexAfterDefinitionChanges() { + ChainDefinition definition = new ChainDefinition(); + StartNode startNode = node(new StartNode(), "start"); + EndNode firstEnd = node(new EndNode(), "end-1"); + definition.addNode(startNode); + definition.addNode(firstEnd); + definition.addEdge(edge("edge-1", "start", "end-1")); + + Assert.assertEquals(1, definition.getOutwardEdge("start").size()); + + EndNode secondEnd = node(new EndNode(), "end-2"); + definition.addNode(secondEnd); + definition.addEdge(edge("edge-2", "start", "end-2")); + + Assert.assertSame(secondEnd, definition.getNodeById("end-2")); + Assert.assertEquals(2, definition.getOutwardEdge("start").size()); + Assert.assertEquals(Collections.singletonList(startNode), definition.getStartNodes()); + } + + /** + * 验证包含节点、边和条件的定义可作为实例快照完整序列化。 + * + * @throws Exception 序列化或反序列化失败 + */ + @Test + public void shouldRoundTripDefinitionSnapshot() throws Exception { + ChainDefinition definition = new ChainDefinition(); + StartNode startNode = node(new StartNode(), "start"); + EndNode endNode = node(new EndNode(), "end"); + Edge edge = edge("start-to-end", "start", "end"); + edge.setCondition((chain, currentEdge, result) -> true); + definition.addNode(startNode); + definition.addNode(endNode); + definition.addEdge(edge); + + byte[] bytes; + try (ByteArrayOutputStream output = new ByteArrayOutputStream(); + ObjectOutputStream objectOutput = new ObjectOutputStream(output)) { + objectOutput.writeObject(definition); + bytes = output.toByteArray(); + } + + ChainDefinition restored; + try (ObjectInputStream objectInput = + new ObjectInputStream(new ByteArrayInputStream(bytes))) { + restored = (ChainDefinition) objectInput.readObject(); + } + + Assert.assertEquals(2, restored.getNodes().size()); + Assert.assertEquals(1, restored.getEdges().size()); + Assert.assertEquals("end", restored.getOutwardEdge("start").get(0).getTarget()); + Assert.assertNotNull(restored.getOutwardEdge("start").get(0).getCondition()); + } + + /** + * 为测试节点设置 ID。 + * + * @param node 节点 + * @param id 节点 ID + * @param 节点类型 + * @return 设置完成的节点 + */ + private T node(T node, String id) { + node.setId(id); + return node; + } + + /** + * 创建测试边。 + * + * @param id 边 ID + * @param source 来源节点 ID + * @param target 目标节点 ID + * @return 测试边 + */ + private Edge edge(String id, String source, String target) { + Edge edge = new Edge(); + edge.setId(id); + edge.setSource(source); + edge.setTarget(target); + return edge; + } +} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainExecutorConcurrencyTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainExecutorConcurrencyTest.java index 482736c..f1547eb 100644 --- a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainExecutorConcurrencyTest.java +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainExecutorConcurrencyTest.java @@ -15,20 +15,31 @@ */ package com.easyagents.flow.core.test; +import com.easyagents.flow.core.chain.Chain; import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.chain.ChainStatus; import com.easyagents.flow.core.chain.Edge; +import com.easyagents.flow.core.chain.ChainState; +import com.easyagents.flow.core.chain.repository.ChainDefinitionSnapshotRepository; +import com.easyagents.flow.core.chain.repository.ChainStateField; +import com.easyagents.flow.core.chain.repository.ChainStateRepository; +import com.easyagents.flow.core.chain.repository.InMemoryChainDefinitionSnapshotRepository; import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository; import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository; import com.easyagents.flow.core.chain.runtime.ChainExecutor; import com.easyagents.flow.core.chain.runtime.InMemoryTriggerStore; import com.easyagents.flow.core.chain.runtime.TriggerScheduler; import com.easyagents.flow.core.node.EndNode; +import com.easyagents.flow.core.node.BaseNode; import com.easyagents.flow.core.node.StartNode; import org.junit.Assert; import org.junit.Test; +import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; @@ -37,6 +48,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; /** * {@link ChainExecutor} 并发同步执行测试。 @@ -85,6 +97,298 @@ public class ChainExecutorConcurrencyTest { } } + /** + * 验证一个工作流实例的多个节点触发会复用启动时的定义快照。 + */ + @Test + public void shouldReuseDefinitionSnapshotAcrossNodeTriggers() { + ScheduledExecutorService schedulerPool = Executors.newSingleThreadScheduledExecutor(); + ExecutorService workerPool = Executors.newFixedThreadPool(2); + TriggerScheduler triggerScheduler = new TriggerScheduler( + new InMemoryTriggerStore(), schedulerPool, workerPool, 1000L); + ChainDefinition definition = createDefinition(); + AtomicInteger definitionLoadCount = new AtomicInteger(); + ChainExecutor chainExecutor = new ChainExecutor( + id -> { + definitionLoadCount.incrementAndGet(); + return definition; + }, + new InMemoryChainStateRepository(), + new InMemoryNodeStateRepository(), + triggerScheduler); + + try { + Map result = chainExecutor.execute( + definition.getId(), Collections.emptyMap(), 10, TimeUnit.SECONDS); + + Assert.assertNotNull(result); + Assert.assertEquals(1, definitionLoadCount.get()); + } finally { + triggerScheduler.shutdown(); + } + } + + /** + * 验证取消期间已经完成的节点 I/O 不会继续推进下游节点。 + * + * @throws Exception 等待异步节点进入或退出失败时抛出 + */ + @Test + public void shouldNotAdvanceAfterCancellationDuringNodeIo() throws Exception { + ScheduledExecutorService schedulerPool = Executors.newScheduledThreadPool(2); + ExecutorService workerPool = Executors.newFixedThreadPool(2); + TriggerScheduler triggerScheduler = new TriggerScheduler( + new InMemoryTriggerStore(), schedulerPool, workerPool, 1000L); + InMemoryChainStateRepository chainStateRepository = new InMemoryChainStateRepository(); + CountDownLatch ioStarted = new CountDownLatch(1); + CountDownLatch allowIoCompletion = new CountDownLatch(1); + AtomicInteger downstreamExecutions = new AtomicInteger(); + ChainDefinition definition = createCancellationDefinition( + ioStarted, allowIoCompletion, downstreamExecutions); + ChainExecutor chainExecutor = new ChainExecutor( + id -> definition, + chainStateRepository, + new InMemoryNodeStateRepository(), + triggerScheduler); + + try { + String instanceId = chainExecutor.executeAsync( + definition.getId(), Collections.emptyMap()); + Assert.assertTrue(ioStarted.await(2, TimeUnit.SECONDS)); + Assert.assertTrue(chainExecutor.cancel(instanceId, "test cancellation")); + allowIoCompletion.countDown(); + + Thread.sleep(200L); + Assert.assertEquals(0, downstreamExecutions.get()); + Assert.assertEquals( + ChainStatus.CANCELLED, + chainStateRepository.load(instanceId).getStatus()); + } finally { + allowIoCompletion.countDown(); + triggerScheduler.shutdown(); + } + } + + /** + * 验证同一节点执行期间重复读取状态会复用执行快照。 + */ + @Test + public void shouldKeepPublicStateReadsFreshDuringNodeExecution() { + ScheduledExecutorService schedulerPool = Executors.newSingleThreadScheduledExecutor(); + ExecutorService workerPool = Executors.newSingleThreadExecutor(); + TriggerScheduler triggerScheduler = new TriggerScheduler( + new InMemoryTriggerStore(), schedulerPool, workerPool, 1000L); + CountingChainStateRepository stateRepository = new CountingChainStateRepository(); + AtomicInteger nodeStateLoads = new AtomicInteger(-1); + ChainDefinition definition = createStateReadDefinition( + stateRepository.loadCount, + nodeStateLoads, + false); + ChainExecutor chainExecutor = new ChainExecutor( + id -> definition, + stateRepository, + new InMemoryNodeStateRepository(), + triggerScheduler); + + try { + Map result = chainExecutor.execute( + definition.getId(), Collections.emptyMap(), 10, TimeUnit.SECONDS); + + Assert.assertNotNull(result); + Assert.assertEquals(10, nodeStateLoads.get()); + } finally { + triggerScheduler.shutdown(); + } + } + + /** + * 验证节点内部执行视图重复读取不会访问状态仓储。 + */ + @Test + public void shouldReusePointInTimeStateDuringNodeExecution() { + ScheduledExecutorService schedulerPool = + Executors.newSingleThreadScheduledExecutor(); + ExecutorService workerPool = + Executors.newSingleThreadExecutor(); + TriggerScheduler triggerScheduler = + new TriggerScheduler( + new InMemoryTriggerStore(), + schedulerPool, + workerPool, + 1000L); + CountingChainStateRepository stateRepository = + new CountingChainStateRepository(); + AtomicInteger nodeStateLoads = + new AtomicInteger(-1); + ChainDefinition definition = + createStateReadDefinition( + stateRepository.loadCount, + nodeStateLoads, + true); + ChainExecutor chainExecutor = + new ChainExecutor( + id -> definition, + stateRepository, + new InMemoryNodeStateRepository(), + triggerScheduler); + + try { + Map result = + chainExecutor.execute( + definition.getId(), + Collections.emptyMap(), + 10, + TimeUnit.SECONDS); + + Assert.assertNotNull(result); + Assert.assertEquals( + 0, nodeStateLoads.get()); + } finally { + triggerScheduler.shutdown(); + } + } + + /** + * 验证升级前 parent-linked 状态首次解析顶级审计 ID 后会持久复用。 + */ + @Test + public void shouldBackfillLegacyAuditInstanceIdOnce() { + CountingChainStateRepository repository = + new CountingChainStateRepository(); + ChainState root = + repository.create("audit-root"); + ChainState child = + repository.create("audit-child"); + child.setParentInstanceId( + root.getInstanceId()); + child.setAuditInstanceId(null); + ChainDefinition definition = + new ChainDefinition(); + definition.setId("audit-definition"); + Chain chain = + new Chain( + definition, + child.getInstanceId()); + chain.setChainStateRepository( + repository); + + Assert.assertEquals( + root.getInstanceId(), + chain.getAuditInstanceId()); + int loadsAfterBackfill = + repository.loadCount.get(); + Assert.assertEquals( + root.getInstanceId(), + chain.getAuditInstanceId()); + + Assert.assertEquals( + loadsAfterBackfill + 1, + repository.loadCount.get()); + Assert.assertEquals( + root.getInstanceId(), + repository.load( + child.getInstanceId()) + .getAuditInstanceId()); + } + + /** + * 验证实例状态初始化失败时会清理进程缓存和持久定义快照。 + */ + @Test + public void shouldCleanupDefinitionSnapshotWhenInitializationFails() throws Exception { + ScheduledExecutorService schedulerPool = Executors.newSingleThreadScheduledExecutor(); + ExecutorService workerPool = Executors.newSingleThreadExecutor(); + TriggerScheduler triggerScheduler = new TriggerScheduler( + new InMemoryTriggerStore(), schedulerPool, workerPool, 1000L); + TrackingSnapshotRepository snapshotRepository = new TrackingSnapshotRepository(); + ChainStateRepository failingStateRepository = new ChainStateRepository() { + @Override + public ChainState load(String instanceId) { + return null; + } + + @Override + public ChainState create(String instanceId) { + throw new IllegalStateException("initialization failed"); + } + + @Override + public boolean tryUpdate( + ChainState newState, EnumSet fields) { + return false; + } + }; + ChainExecutor executor = new ChainExecutor( + id -> createDefinition(), + failingStateRepository, + new InMemoryNodeStateRepository(), + null, + snapshotRepository, + triggerScheduler, + null); + + try { + executor.executeAsync("concurrent-sync-test", Collections.emptyMap()); + Assert.fail("initialization failure must be propagated"); + } catch (IllegalStateException expected) { + Assert.assertEquals("initialization failed", expected.getMessage()); + } finally { + triggerScheduler.shutdown(); + } + + Assert.assertTrue(snapshotRepository.snapshots.isEmpty()); + Assert.assertTrue(activeDefinitions(executor).isEmpty()); + } + + /** + * 验证进程内定义热点缓存有固定上限,持久快照仍保留恢复能力。 + */ + @Test + public void shouldBoundActiveDefinitionCache() throws Exception { + ScheduledExecutorService schedulerPool = Executors.newSingleThreadScheduledExecutor(); + ExecutorService workerPool = Executors.newSingleThreadExecutor(); + TriggerScheduler triggerScheduler = new TriggerScheduler( + new InMemoryTriggerStore(), schedulerPool, workerPool, 1000L); + InMemoryChainDefinitionSnapshotRepository snapshotRepository = + new InMemoryChainDefinitionSnapshotRepository(); + ChainExecutor executor = new ChainExecutor( + id -> createDefinition(), + new InMemoryChainStateRepository(), + new InMemoryNodeStateRepository(), + null, + snapshotRepository, + triggerScheduler, + null); + java.lang.reflect.Method createChain = ChainExecutor.class.getDeclaredMethod( + "createChain", ChainDefinition.class); + createChain.setAccessible(true); + + try { + for (int index = 0; index < 1100; index++) { + createChain.invoke(executor, createDefinition()); + } + } finally { + triggerScheduler.shutdown(); + } + + Assert.assertEquals(1024, activeDefinitions(executor).size()); + } + + /** + * 读取执行器内部定义缓存,供资源上限回归验证。 + * + * @param executor 工作流执行器 + * @return 当前定义缓存 + * @throws Exception 反射访问失败时抛出 + */ + @SuppressWarnings("unchecked") + private Map activeDefinitions( + ChainExecutor executor) throws Exception { + Field field = ChainExecutor.class.getDeclaredField("activeDefinitions"); + field.setAccessible(true); + return (Map) field.get(executor); + } + /** * 创建仅包含开始和结束节点的测试工作流。 * @@ -109,4 +413,155 @@ public class ChainExecutorConcurrencyTest { definition.addEdge(edge); return definition; } + + /** + * 创建用于取消传播验证的工作流。 + * + * @param ioStarted I/O 节点进入信号 + * @param allowIoCompletion I/O 节点退出信号 + * @param downstreamExecutions 下游执行计数 + * @return 测试工作流定义 + */ + private ChainDefinition createCancellationDefinition(CountDownLatch ioStarted, + CountDownLatch allowIoCompletion, + AtomicInteger downstreamExecutions) { + ChainDefinition definition = new ChainDefinition(); + definition.setId("cancellation-test"); + + StartNode start = new StartNode(); + start.setId("start"); + BaseNode blocking = new BaseNode() { + @Override + public Map execute(Chain chain) { + ioStarted.countDown(); + try { + if (!allowIoCompletion.await(2, TimeUnit.SECONDS)) { + throw new IllegalStateException("test I/O release timed out"); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("test I/O interrupted", error); + } + return Collections.singletonMap("value", "completed"); + } + }; + blocking.setId("blocking"); + BaseNode downstream = new BaseNode() { + @Override + public Map execute(Chain chain) { + downstreamExecutions.incrementAndGet(); + return Collections.emptyMap(); + } + }; + downstream.setId("downstream"); + EndNode end = new EndNode(); + end.setId("end"); + + definition.addNode(start); + definition.addNode(blocking); + definition.addNode(downstream); + definition.addNode(end); + definition.addEdge(edge("start-blocking", "start", "blocking")); + definition.addEdge(edge("blocking-downstream", "blocking", "downstream")); + definition.addEdge(edge("downstream-end", "downstream", "end")); + return definition; + } + + /** + * 创建包含重复状态读取节点的测试工作流。 + * + * @param repositoryLoadCount 仓储读取计数 + * @param nodeStateLoads 节点执行期间新增的读取次数 + * @param useExecutionView 是否读取节点 point-in-time 执行视图 + * @return 测试工作流定义 + */ + private ChainDefinition createStateReadDefinition(AtomicInteger repositoryLoadCount, + AtomicInteger nodeStateLoads, + boolean useExecutionView) { + ChainDefinition definition = new ChainDefinition(); + definition.setId("state-snapshot-test"); + + StartNode start = new StartNode(); + start.setId("start"); + BaseNode repeatedReader = new BaseNode() { + @Override + public Map execute(Chain chain) { + int before = repositoryLoadCount.get(); + for (int index = 0; index < 10; index++) { + Assert.assertNotNull( + useExecutionView + ? chain.getExecutionState() + : chain.getState()); + } + nodeStateLoads.set(repositoryLoadCount.get() - before); + return Collections.emptyMap(); + } + }; + repeatedReader.setId("reader"); + EndNode end = new EndNode(); + end.setId("end"); + + definition.addNode(start); + definition.addNode(repeatedReader); + definition.addNode(end); + definition.addEdge(edge("start-reader", "start", "reader")); + definition.addEdge(edge("reader-end", "reader", "end")); + return definition; + } + + /** + * 创建测试边。 + * + * @param id 边 ID + * @param source 源节点 ID + * @param target 目标节点 ID + * @return 测试边 + */ + private Edge edge(String id, String source, String target) { + Edge edge = new Edge(); + edge.setId(id); + edge.setSource(source); + edge.setTarget(target); + return edge; + } + + /** + * 记录定义快照生命周期的测试仓储。 + */ + private static final class TrackingSnapshotRepository + implements ChainDefinitionSnapshotRepository { + + private final Map snapshots = + new LinkedHashMap<>(); + + @Override + public void save(String instanceId, ChainDefinition definition) { + snapshots.put(instanceId, definition); + } + + @Override + public ChainDefinition load(String instanceId) { + return snapshots.get(instanceId); + } + + @Override + public void remove(String instanceId) { + snapshots.remove(instanceId); + } + } + + /** + * 记录状态读取次数的测试仓储。 + */ + private static final class CountingChainStateRepository + extends InMemoryChainStateRepository { + + private final AtomicInteger loadCount = new AtomicInteger(); + + @Override + public ChainState load(String instanceId) { + loadCount.incrementAndGet(); + return super.load(instanceId); + } + } } diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainRecoverableStartTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainRecoverableStartTest.java new file mode 100644 index 0000000..08c0138 --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainRecoverableStartTest.java @@ -0,0 +1,313 @@ +package com.easyagents.flow.core.test; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.chain.ChainState; +import com.easyagents.flow.core.chain.ChainStatus; +import com.easyagents.flow.core.chain.EventManager; +import com.easyagents.flow.core.chain.NodeState; +import com.easyagents.flow.core.chain.NodeStatus; +import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository; +import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository; +import com.easyagents.flow.core.node.StartNode; +import com.easyagents.flow.core.chain.runtime.InMemoryTriggerStore; +import com.easyagents.flow.core.chain.runtime.Trigger; +import com.easyagents.flow.core.chain.runtime.TriggerScheduler; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Collections; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * 验证入口触发器先行持久化和崩溃重放协议。 + */ +public class ChainRecoverableStartTest { + + /** + * 验证 RUNNING 状态提交前已经存在入口意图,且 RUNNING 节点允许重放。 + */ + @Test + public void shouldPersistIntentBeforeRunningAndReplayInterruptedStart() { + InMemoryChainStateRepository stateRepository = + new InMemoryChainStateRepository(); + ObservingTriggerStore triggerStore = + new ObservingTriggerStore(stateRepository, "recoverable-1"); + ScheduledExecutorService schedulerExecutor = + Executors.newSingleThreadScheduledExecutor(); + java.util.concurrent.ExecutorService worker = + Executors.newSingleThreadExecutor(); + TriggerScheduler scheduler = new TriggerScheduler( + triggerStore, schedulerExecutor, worker, 60_000L); + try { + Chain chain = chain( + stateRepository, triggerStore, scheduler); + chain.start(Collections.singletonMap("value", "v")); + + Assert.assertEquals( + ChainStatus.READY, + triggerStore.statusObservedAtFirstSave); + ChainState running = stateRepository.load("recoverable-1"); + Assert.assertEquals(ChainStatus.RUNNING, running.getStatus()); + Trigger pending = triggerStore.find( + triggerStore.lastTriggerId); + Assert.assertNotNull(pending); + + NodeState startState = chain.getNodeState("start"); + startState.setStatus(NodeStatus.RUNNING); + startState.getExecuteCount().incrementAndGet(); + chain.start(Collections.emptyMap()); + + Assert.assertNotNull( + "interrupted RUNNING start must remain replayable", + triggerStore.find(triggerStore.lastTriggerId)); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证入口意图保存后、RUNNING 提交前崩溃时,真实调度消费可恢复变量并继续执行。 + * + * @throws Exception 等待异步调度被中断 + */ + @Test + public void shouldRecoverReadyStartThroughRealScheduler() + throws Exception { + InMemoryChainStateRepository stateRepository = + new InMemoryChainStateRepository(); + FailAfterSaveTriggerStore triggerStore = + new FailAfterSaveTriggerStore(); + ScheduledExecutorService schedulerExecutor = + Executors.newSingleThreadScheduledExecutor(); + java.util.concurrent.ExecutorService worker = + Executors.newSingleThreadExecutor(); + TriggerScheduler scheduler = new TriggerScheduler( + triggerStore, schedulerExecutor, worker, 1_000L); + try { + Chain chain = chain( + stateRepository, triggerStore, scheduler, + "recoverable-crash"); + StartNode startNode = (StartNode) chain.getDefinition() + .getNodeById("start"); + scheduler.registerConsumer( + (trigger, ignored) -> + chain.executeNode(startNode, trigger)); + + try { + chain.start(Collections.singletonMap( + "value", "persisted-input")); + Assert.fail("simulated crash must interrupt start"); + } catch (SimulatedCrashException expected) { + // 触发器已经保存,异常模拟进程在 RUNNING 提交前退出。 + } + Assert.assertEquals( + ChainStatus.READY, + stateRepository.load( + "recoverable-crash").getStatus()); + + long deadline = System.nanoTime() + + TimeUnit.SECONDS.toNanos(5L); + while (System.nanoTime() < deadline) { + ChainState recovered = stateRepository.load( + "recoverable-crash"); + NodeState nodeState = chain.getNodeState("start"); + if (recovered.getStatus() != ChainStatus.READY + && nodeState.getStatus() + == NodeStatus.SUCCEEDED) { + break; + } + Thread.sleep(25L); + } + + ChainState recovered = stateRepository.load( + "recoverable-crash"); + Assert.assertNotEquals( + ChainStatus.READY, recovered.getStatus()); + Assert.assertEquals( + "persisted-input", + recovered.getMemory().get("value")); + Assert.assertEquals( + NodeStatus.SUCCEEDED, + chain.getNodeState("start").getStatus()); + Assert.assertNull(triggerStore.find( + triggerStore.lastTriggerId)); + } finally { + scheduler.shutdown(); + } + } + + /** + * 创建仅含入口节点的测试链。 + * + * @param stateRepository 状态仓储 + * @param triggerStore 触发器仓储 + * @param scheduler 调度器 + * @return 已配置链 + */ + private Chain chain( + InMemoryChainStateRepository stateRepository, + InMemoryTriggerStore triggerStore, + TriggerScheduler scheduler) { + ChainDefinition definition = new ChainDefinition(); + definition.setId("recoverable-definition"); + StartNode start = new StartNode(); + start.setId("start"); + definition.addNode(start); + definition.setEdges(Collections.emptyList()); + + Chain chain = new Chain(definition, "recoverable-1"); + chain.setChainStateRepository(stateRepository); + chain.setNodeStateRepository( + new InMemoryNodeStateRepository()); + chain.setTriggerScheduler(scheduler); + chain.setEventManager(new EventManager()); + return chain; + } + + /** + * 创建指定实例 ID 的仅入口测试链。 + * + * @param stateRepository 状态仓储 + * @param triggerStore 触发器仓储 + * @param scheduler 调度器 + * @param instanceId 实例 ID + * @return 已配置链 + */ + private Chain chain( + InMemoryChainStateRepository stateRepository, + InMemoryTriggerStore triggerStore, + TriggerScheduler scheduler, + String instanceId) { + ChainDefinition definition = new ChainDefinition(); + definition.setId("recoverable-definition"); + StartNode start = new StartNode(); + start.setId("start"); + definition.addNode(start); + definition.setEdges(Collections.emptyList()); + + Chain chain = new Chain(definition, instanceId); + chain.setChainStateRepository(stateRepository); + chain.setNodeStateRepository( + new InMemoryNodeStateRepository()); + chain.setTriggerScheduler(scheduler); + chain.setEventManager(new EventManager()); + return chain; + } + + /** + * 记录首次触发器保存时的实例状态。 + */ + private static final class ObservingTriggerStore + extends InMemoryTriggerStore { + + private final InMemoryChainStateRepository stateRepository; + private final String instanceId; + private ChainStatus statusObservedAtFirstSave; + private String lastTriggerId; + + /** + * 创建观察仓储。 + * + * @param stateRepository 状态仓储 + * @param instanceId 实例 ID + */ + private ObservingTriggerStore( + InMemoryChainStateRepository stateRepository, + String instanceId) { + this.stateRepository = stateRepository; + this.instanceId = instanceId; + } + + /** + * {@inheritDoc} + */ + @Override + public Trigger save(Trigger trigger) { + observe(trigger); + return super.save(trigger); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean saveIfAbsent( + Trigger trigger) { + observe(trigger); + return super.saveIfAbsent(trigger); + } + + /** + * 记录首次稳定入口保存时的状态。 + * + * @param trigger 待保存触发器 + */ + private void observe(Trigger trigger) { + if (statusObservedAtFirstSave == null) { + ChainState state = stateRepository.load(instanceId); + statusObservedAtFirstSave = + state == null ? null : state.getStatus(); + } + lastTriggerId = trigger.getId(); + } + } + + /** + * 首次保存成功后抛错,用于模拟状态提交前进程退出。 + */ + private static final class FailAfterSaveTriggerStore + extends InMemoryTriggerStore { + + private boolean fail = true; + private String lastTriggerId; + + /** + * {@inheritDoc} + */ + @Override + public Trigger save(Trigger trigger) { + Trigger saved = super.save(trigger); + failAfterFirstSave(trigger); + return saved; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean saveIfAbsent( + Trigger trigger) { + boolean saved = + super.saveIfAbsent(trigger); + if (saved) { + failAfterFirstSave(trigger); + } + return saved; + } + + /** + * 首次成功保存后抛出模拟崩溃。 + * + * @param trigger 已保存触发器 + */ + private void failAfterFirstSave( + Trigger trigger) { + lastTriggerId = trigger.getId(); + if (fail) { + fail = false; + throw new SimulatedCrashException(); + } + } + } + + /** + * 测试专用崩溃异常。 + */ + private static final class SimulatedCrashException + extends RuntimeException { + private static final long serialVersionUID = 1L; + } +} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainTemplateContextTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainTemplateContextTest.java index 04a1e0e..d29f8ec 100644 --- a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainTemplateContextTest.java +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainTemplateContextTest.java @@ -1,13 +1,19 @@ package com.easyagents.flow.core.test; +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainDefinition; import com.easyagents.flow.core.chain.ChainState; import com.easyagents.flow.core.chain.Parameter; import com.easyagents.flow.core.chain.RefType; +import com.easyagents.flow.core.chain.repository.InMemoryLoopResultRepository; +import com.easyagents.flow.core.chain.repository.LoopInputReference; import com.easyagents.flow.core.node.StartNode; import org.junit.Assert; import org.junit.Test; +import java.lang.reflect.Field; import java.util.Collections; +import java.util.List; import java.util.Map; /** @@ -32,4 +38,229 @@ public class ChainTemplateContextTest { Assert.assertEquals("7", result.get("nextValue")); } + + /** + * 验证审计参数快照保留直接大型引用,不在工作流线程读取完整输入。 + * + * @throws Exception 线程上下文反射失败时抛出 + */ + @Test + public void shouldPreserveDirectReferenceForAuditWithoutLoadingInput() + throws Exception { + CountingLoopRepository repository = + new CountingLoopRepository(); + Chain chain = new Chain( + new ChainDefinition(), + "instance-audit"); + chain.setLoopResultRepository(repository); + Field currentChain = Chain.class + .getDeclaredField( + "EXECUTION_THREAD_LOCAL"); + currentChain.setAccessible(true); + @SuppressWarnings("unchecked") + ThreadLocal context = + (ThreadLocal) currentChain.get(null); + LoopInputReference reference = + new LoopInputReference( + "instance-audit:dataset", + 100_000); + ChainState state = new ChainState(); + state.getMemory().put( + "dataset.data", reference); + Parameter parameter = new Parameter(); + parameter.setName("items"); + parameter.setRefType(RefType.REF); + parameter.setRef("dataset.data"); + StartNode node = new StartNode(); + node.setParameters(List.of(parameter)); + + context.set(chain); + try { + Map result = + state.resolveParametersPreservingReferences( + node); + + Assert.assertSame( + reference, result.get("items")); + Assert.assertEquals( + 0, repository.loadInputCalls); + } finally { + context.remove(); + } + } + + /** + * 验证无关固定参数不会触发 memory 中大型引用的物化。 + * + * @throws Exception 线程上下文反射失败时抛出 + */ + @Test + public void shouldNotLoadUnrelatedReferenceForFixedAuditParameter() + throws Exception { + CountingLoopRepository repository = + new CountingLoopRepository(); + Chain chain = new Chain( + new ChainDefinition(), + "instance-fixed-audit"); + chain.setLoopResultRepository(repository); + Field currentChain = Chain.class + .getDeclaredField( + "EXECUTION_THREAD_LOCAL"); + currentChain.setAccessible(true); + @SuppressWarnings("unchecked") + ThreadLocal context = + (ThreadLocal) currentChain.get(null); + ChainState state = new ChainState(); + state.getMemory().put( + "dataset.data", + new LoopInputReference( + "instance-fixed-audit:dataset", + 100_000)); + state.getMemory().put( + "small.value", "ok"); + Parameter parameter = new Parameter(); + parameter.setName("description"); + parameter.setRefType(RefType.FIXED); + parameter.setValue("{{small.value}}"); + StartNode node = new StartNode(); + node.setParameters(List.of(parameter)); + + context.set(chain); + try { + Map result = + state.resolveParametersPreservingReferences( + node); + + Assert.assertEquals( + "ok", + result.get("description")); + Assert.assertEquals( + 0, repository.loadInputCalls); + } finally { + context.remove(); + } + } + + /** + * 验证普通节点固定参数同样只还原模板实际读取的大型引用。 + * + * @throws Exception 线程上下文反射失败时抛出 + */ + @Test + public void shouldNotLoadUnrelatedReferenceForNormalFixedParameter() + throws Exception { + CountingLoopRepository repository = + new CountingLoopRepository(); + Chain chain = new Chain( + new ChainDefinition(), + "instance-fixed-normal"); + chain.setLoopResultRepository(repository); + Field currentChain = Chain.class + .getDeclaredField( + "EXECUTION_THREAD_LOCAL"); + currentChain.setAccessible(true); + @SuppressWarnings("unchecked") + ThreadLocal context = + (ThreadLocal) currentChain.get(null); + ChainState state = new ChainState(); + state.getMemory().put( + "dataset.data", + new LoopInputReference( + "instance-fixed-normal:dataset", + 100_000)); + state.getMemory().put( + "small.value", "ok"); + Parameter parameter = new Parameter(); + parameter.setName("description"); + parameter.setRefType(RefType.FIXED); + parameter.setValue("{{small.value}}"); + StartNode node = new StartNode(); + node.setParameters(List.of(parameter)); + + context.set(chain); + try { + Map result = + state.resolveParameters(node); + + Assert.assertEquals( + "ok", + result.get("description")); + Assert.assertEquals( + 0, repository.loadInputCalls); + } finally { + context.remove(); + } + } + + /** + * 验证同一模板重复读取一个大型引用时只执行一次仓储还原。 + * + * @throws Exception 线程上下文反射失败时抛出 + */ + @Test + public void shouldMemoizeRepeatedReferenceWithinOneTemplate() + throws Exception { + CountingLoopRepository repository = + new CountingLoopRepository(); + String resultId = + "instance-fixed-repeat:dataset"; + repository.storeInput( + resultId, List.of("item")); + Chain chain = new Chain( + new ChainDefinition(), + "instance-fixed-repeat"); + chain.setLoopResultRepository(repository); + Field currentChain = Chain.class + .getDeclaredField( + "EXECUTION_THREAD_LOCAL"); + currentChain.setAccessible(true); + @SuppressWarnings("unchecked") + ThreadLocal context = + (ThreadLocal) currentChain.get(null); + ChainState state = new ChainState(); + state.getMemory().put( + "dataset.data", + new LoopInputReference( + resultId, 1)); + Parameter parameter = new Parameter(); + parameter.setName("description"); + parameter.setRefType(RefType.FIXED); + parameter.setValue( + "{{dataset.data}}/{{dataset.data}}"); + StartNode node = new StartNode(); + node.setParameters(List.of(parameter)); + + context.set(chain); + try { + Map result = + state.resolveParameters(node); + + Assert.assertEquals( + "[item]/[item]", + result.get("description")); + Assert.assertEquals( + 1, repository.loadInputCalls); + } finally { + context.remove(); + } + } + + /** + * 记录大型输入加载次数的仓储。 + */ + private static final class CountingLoopRepository + extends InMemoryLoopResultRepository { + + private int loadInputCalls; + + /** + * {@inheritDoc} + */ + @Override + public List loadInput( + LoopInputReference reference) { + loadInputCalls++; + return super.loadInput(reference); + } + } } diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ExecutionBudgetTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ExecutionBudgetTest.java new file mode 100644 index 0000000..70274d6 --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ExecutionBudgetTest.java @@ -0,0 +1,63 @@ +package com.easyagents.flow.core.test; + +import com.easyagents.flow.core.chain.runtime.ExecutionBudget; +import com.easyagents.flow.core.chain.runtime.ExecutionBudgetExceededException; +import org.junit.Assert; +import org.junit.Test; + +/** + * 验证工作流执行预算的宽松默认值和边界检查。 + */ +public class ExecutionBudgetTest { + + /** + * 验证缺省预算与 XL12 约定一致。 + */ + @Test + public void shouldUseGenerousDefaultBudgets() { + ExecutionBudget budget = ExecutionBudget.defaults(); + + Assert.assertEquals(100_000L, budget.getMaxIterations()); + // 人工确认和长期挂起不应被墙钟时间误伤,时长预算默认关闭。 + Assert.assertEquals(0L, budget.getMaxDurationMillis()); + Assert.assertEquals(1_000_000L, budget.getMaxChildExecutions()); + Assert.assertEquals(512L * 1024L * 1024L, budget.getMaxAccumulatedBytes()); + Assert.assertEquals(32, budget.getMaxNestedDepth()); + // 热状态估算可能误伤既有业务,默认关闭并允许部署方显式配置。 + Assert.assertEquals(0L, budget.getMaxHotStateBytes()); + } + + /** + * 验证达到阈值时仍允许执行,超过阈值后才阻止。 + */ + @Test + public void shouldRejectOnlyAfterBudgetIsExceeded() { + ExecutionBudget budget = new ExecutionBudget(3, 1000, 5, 10, 2, 20); + + budget.checkIterations("loop", 3); + budget.checkDuration(1000, 2000); + budget.checkChildExecutions(5); + budget.checkAccumulatedBytes("loop", 10); + budget.checkNestedDepth("loop", 2); + + assertExceeded(() -> budget.checkIterations("loop", 4)); + assertExceeded(() -> budget.checkDuration(1000, 2001)); + assertExceeded(() -> budget.checkChildExecutions(6)); + assertExceeded(() -> budget.checkAccumulatedBytes("loop", 11)); + assertExceeded(() -> budget.checkNestedDepth("loop", 3)); + } + + /** + * 断言动作触发预算超限异常。 + * + * @param action 待执行动作 + */ + private void assertExceeded(Runnable action) { + try { + action.run(); + Assert.fail("Expected ExecutionBudgetExceededException"); + } catch (ExecutionBudgetExceededException expected) { + Assert.assertNotNull(expected.getMessage()); + } + } +} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/GenericNodeLoopCountTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/GenericNodeLoopCountTest.java new file mode 100644 index 0000000..1c2838e --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/GenericNodeLoopCountTest.java @@ -0,0 +1,325 @@ +package com.easyagents.flow.core.test; + +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.chain.Edge; +import com.easyagents.flow.core.chain.Node; +import com.easyagents.flow.core.chain.Parameter; +import com.easyagents.flow.core.chain.RefType; +import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository; +import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository; +import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository; +import com.easyagents.flow.core.chain.runtime.ChainExecutor; +import com.easyagents.flow.core.node.BaseNode; +import com.easyagents.flow.core.node.EndNode; +import com.easyagents.flow.core.node.LoopNode; +import com.easyagents.flow.core.node.StartNode; +import com.easyagents.flow.core.parser.ChainParser; +import com.easyagents.flow.core.parser.impl.EndNodeParser; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +/** + * 验证普通节点循环的总次数语义和嵌套计数隔离。 + */ +public class GenericNodeLoopCountTest { + + /** + * 验证默认次数为 1,且零值和超过 300 的值均被拒绝。 + */ + @Test + public void shouldValidateConfiguredLoopCount() { + ProbeNode node = new ProbeNode(); + + Assert.assertEquals(Node.MIN_LOOP_COUNT, node.getMaxLoopCount()); + assertInvalidLoopCount(node, 0); + assertInvalidLoopCount(node, Node.MAX_LOOP_COUNT + 1); + } + + /** + * 验证 JSON 解析缺省为 1,并拒绝零值、小数和超过上限的配置。 + */ + @Test + public void shouldValidateParsedLoopCount() { + ChainParser parser = ChainParser.builder() + .withDefaultParsers(true) + .build(); + Node defaultNode = parseLoopNodeConfiguration(parser, null); + + Assert.assertEquals( + Node.MIN_LOOP_COUNT, + defaultNode.getMaxLoopCount()); + assertInvalidParsedLoopCount(parser, "0"); + assertInvalidParsedLoopCount(parser, "1.5"); + assertInvalidParsedLoopCount( + parser, + String.valueOf(Node.MAX_LOOP_COUNT + 1)); + } + + /** + * 验证填写 1 执行 1 次,填写 3 执行 3 次。 + */ + @Test + public void shouldExecuteConfiguredTotalCount() { + Map once = createExecutor( + createGenericDefinition("generic-once", 1)) + .execute("generic-once", Collections.emptyMap()); + Map threeTimes = createExecutor( + createGenericDefinition("generic-three", 3)) + .execute("generic-three", Collections.emptyMap()); + + Assert.assertEquals(0, once.get("loopIndex")); + Assert.assertEquals(2, threeTimes.get("loopIndex")); + } + + /** + * 验证显式外层循环每一轮都会让普通内层节点从索引 0 重新开始。 + */ + @Test + public void shouldResetInnerGenericLoopForEveryOuterIteration() { + ChainExecutor executor = createExecutor(createNestedDefinition()); + + Map result = executor.execute( + "nested-loop-count", + Collections.singletonMap("times", 3)); + + Assert.assertEquals( + Arrays.asList(1, 1, 1), + result.get("loopIndexes")); + } + + /** + * 创建普通循环定义。 + * + * @param id 定义 ID + * @param loopCount 总执行次数 + * @return 工作流定义 + */ + private ChainDefinition createGenericDefinition( + String id, int loopCount) { + ChainDefinition definition = new ChainDefinition(); + definition.setId(id); + + StartNode start = new StartNode(); + start.setId("start"); + + ProbeNode probe = new ProbeNode(); + probe.setId("probe"); + probe.setLoopEnable(true); + probe.setLoopIntervalMs(0L); + probe.setMaxLoopCount(loopCount); + + EndNode end = new EndNode(); + end.setId("end"); + Parameter output = new Parameter(); + output.setName("loopIndex"); + output.setRef("probe.loopIndex"); + output.setRefType(RefType.REF); + end.setOutputDefs(Collections.singletonList(output)); + + definition.addNode(start); + definition.addNode(probe); + definition.addNode(end); + definition.addEdge(edge("e1", "start", "probe")); + definition.addEdge(edge("e2", "probe", "end")); + return definition; + } + + /** + * 创建显式外层循环嵌套普通内层循环的定义。 + * + * @return 工作流定义 + */ + private ChainDefinition createNestedDefinition() { + ChainDefinition definition = new ChainDefinition(); + definition.setId("nested-loop-count"); + + StartNode start = new StartNode(); + start.setId("start"); + start.setParameters(Collections.singletonList(inputParameter("times"))); + + LoopNode loop = new LoopNode(); + loop.setId("loop"); + Parameter loopVar = new Parameter(); + loopVar.setName("times"); + loopVar.setRef("times"); + loopVar.setRefType(RefType.REF); + loop.setLoopVar(loopVar); + + ProbeNode probe = new ProbeNode(); + probe.setId("probe"); + probe.setParentId("loop"); + probe.setLoopEnable(true); + probe.setLoopIntervalMs(0L); + probe.setMaxLoopCount(2); + + Parameter loopOutput = new Parameter(); + loopOutput.setName("loopIndex"); + loopOutput.setRef("probe.loopIndex"); + loopOutput.setRefType(RefType.REF); + loop.setOutputDefs(Collections.singletonList(loopOutput)); + + EndNode end = new EndNode(); + end.setId("end"); + Parameter result = new Parameter(); + result.setName("loopIndexes"); + result.setRef("loop.loopIndex"); + result.setRefType(RefType.REF); + end.setOutputDefs(Collections.singletonList(result)); + + definition.addNode(start); + definition.addNode(loop); + definition.addNode(probe); + definition.addNode(end); + definition.addEdge(edge("e1", "start", "loop")); + definition.addEdge(edge("e2", "loop", "probe")); + definition.addEdge(edge("e3", "loop", "end")); + return definition; + } + + /** + * 创建执行器。 + * + * @param definition 工作流定义 + * @return 测试执行器 + */ + private ChainExecutor createExecutor(ChainDefinition definition) { + return new ChainExecutor( + new FixedDefinitionRepository(definition), + new InMemoryChainStateRepository(), + new InMemoryNodeStateRepository()); + } + + /** + * 断言设置非法循环次数时抛出参数异常。 + * + * @param node 测试节点 + * @param loopCount 非法次数 + */ + private void assertInvalidLoopCount(Node node, int loopCount) { + try { + node.setMaxLoopCount(loopCount); + Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + Assert.assertTrue(expected.getMessage().contains("maxLoopCount")); + } + } + + /** + * 断言解析非法循环次数时失败。 + * + * @param parser 工作流解析器 + * @param loopCount 原始次数 JSON 值 + */ + private void assertInvalidParsedLoopCount( + ChainParser parser, String loopCount) { + try { + parseLoopNodeConfiguration(parser, loopCount); + Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + Assert.assertTrue(expected.getMessage().contains("maxLoopCount")); + } + } + + /** + * 解析一个启用普通循环的结束节点。 + * + * @param parser 工作流解析器 + * @param loopCount 循环次数;为空时省略字段 + * @return 解析后的节点 + */ + private Node parseLoopNodeConfiguration( + ChainParser parser, String loopCount) { + JSONObject data = new JSONObject(); + data.put("loopEnable", true); + if (loopCount != null) { + data.put("maxLoopCount", loopCount); + } + JSONObject nodeJson = new JSONObject(); + nodeJson.put("id", "end"); + nodeJson.put("type", "endNode"); + nodeJson.put("data", data); + return new EndNodeParser().parse( + nodeJson, new JSONObject(), parser); + } + + /** + * 创建输入参数。 + * + * @param name 参数名 + * @return 输入参数 + */ + private Parameter inputParameter(String name) { + Parameter parameter = new Parameter(); + parameter.setName(name); + parameter.setRefType(RefType.INPUT); + parameter.setRequired(true); + return parameter; + } + + /** + * 创建连线。 + * + * @param id 连线 ID + * @param source 源节点 + * @param target 目标节点 + * @return 连线 + */ + private Edge edge(String id, String source, String target) { + Edge edge = new Edge(); + edge.setId(id); + edge.setSource(source); + edge.setTarget(target); + return edge; + } + + /** + * 输出当前普通循环的零基索引。 + */ + private static final class ProbeNode extends BaseNode { + + /** + * 执行探针节点。 + * + * @param chain 当前工作流 + * @return 当前零基循环索引 + */ + @Override + public Map execute(Chain chain) { + return Collections.singletonMap( + "loopIndex", + chain.getNodeState(getId()).getLoopCount()); + } + } + + /** + * 固定返回测试定义的仓储。 + */ + private static final class FixedDefinitionRepository + implements ChainDefinitionRepository { + + private final ChainDefinition definition; + + /** + * 创建定义仓储。 + * + * @param definition 工作流定义 + */ + private FixedDefinitionRepository(ChainDefinition definition) { + this.definition = definition; + } + + /** + * {@inheritDoc} + */ + @Override + public ChainDefinition getChainDefinitionById(String id) { + return definition; + } + } +} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/IoBulkheadTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/IoBulkheadTest.java new file mode 100644 index 0000000..09852a2 --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/IoBulkheadTest.java @@ -0,0 +1,82 @@ +package com.easyagents.flow.core.test; + +import com.easyagents.flow.core.chain.runtime.RetryableTriggerException; +import com.easyagents.flow.core.util.IoBulkhead; +import org.junit.Assert; +import org.junit.Test; + +import java.time.Duration; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +/** + * {@link IoBulkhead} 有界并发与可观测性回归测试。 + */ +public class IoBulkheadTest { + + /** + * 验证单目标饱和时快速拒绝,同时其他目标仍可使用剩余全局容量。 + * + * @throws Exception 并发测试执行失败时抛出 + */ + @Test + public void shouldIsolateSaturatedTargetWithoutExhaustingGlobalCapacity() throws Exception { + IoBulkhead bulkhead = new IoBulkhead(2, 1, Duration.ofMillis(50), 4); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (IoBulkhead.Permit first = bulkhead.acquire("http:first"); + IoBulkhead.Permit second = bulkhead.acquire("http:second")) { + Future rejected = executor.submit(() -> bulkhead.acquire("http:first")); + try { + rejected.get(); + Assert.fail("same target should be rejected"); + } catch (ExecutionException exception) { + Assert.assertTrue(exception.getCause() instanceof RetryableTriggerException); + } + + IoBulkhead.Snapshot snapshot = bulkhead.snapshot(); + Assert.assertEquals(2L, snapshot.acquiredCount()); + Assert.assertEquals(1L, snapshot.rejectedCount()); + Assert.assertEquals(2L, snapshot.inFlightCount()); + Assert.assertEquals(0, snapshot.availableGlobalPermits()); + Assert.assertEquals(2, snapshot.trackedTargetCount()); + } finally { + executor.shutdownNow(); + } + + Assert.assertEquals(0L, bulkhead.snapshot().inFlightCount()); + Assert.assertEquals(2, bulkhead.snapshot().availableGlobalPermits()); + } + + /** + * 验证 URL 目标键只保留协议无关的主机和显式端口。 + */ + @Test + public void shouldResolveStableHttpTarget() { + Assert.assertEquals( + "http:example.com:8443", + IoBulkhead.targetForUrl("https://EXAMPLE.com:8443/path?q=1")); + Assert.assertEquals("http:unknown", IoBulkhead.targetForUrl("not a url")); + } + + /** + * 验证高基数目标不会突破目标信号量注册上限。 + */ + @Test + public void shouldKeepTrackedTargetRegistryBounded() { + IoBulkhead bulkhead = new IoBulkhead(4, 2, Duration.ofMillis(50), 2); + + try (IoBulkhead.Permit ignored = bulkhead.acquire("target:first")) { + // 仅触发目标注册。 + } + try (IoBulkhead.Permit ignored = bulkhead.acquire("target:second")) { + // 仅触发目标注册。 + } + try (IoBulkhead.Permit ignored = bulkhead.acquire("target:overflow")) { + // 超额目标统一使用有界的 overflow 信号量。 + } + + Assert.assertEquals(2, bulkhead.snapshot().trackedTargetCount()); + } +} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/JsConditionUtilTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/JsConditionUtilTest.java new file mode 100644 index 0000000..c6c8e5d --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/JsConditionUtilTest.java @@ -0,0 +1,91 @@ +package com.easyagents.flow.core.test; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository; +import com.easyagents.flow.core.util.JsConditionUtil; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * GraalVM 条件表达式编译复用与上下文隔离回归测试。 + */ +public class JsConditionUtilTest { + + /** + * 验证相同 Source 在不同 Context 中不会残留上一次变量。 + */ + @Test + public void shouldIsolateVariablesWhenReusingSource() { + Chain chain = chain("js-isolation"); + + Assert.assertTrue(JsConditionUtil.eval( + "value > 10", chain, Map.of("value", 11))); + Assert.assertFalse(JsConditionUtil.eval( + "value > 10", chain, Map.of("value", 9))); + } + + /** + * 验证共享 Engine 和 Source 可被多个隔离 Context 并发使用。 + * + * @throws Exception 等待并发任务被中断时抛出 + */ + @Test + public void shouldEvaluateSharedSourceConcurrently() + throws Exception { + Chain chain = chain("js-concurrent"); + ExecutorService workers = + Executors.newFixedThreadPool(4); + CountDownLatch completed = + new CountDownLatch(20); + AtomicInteger failures = + new AtomicInteger(); + try { + for (int value = 0; value < 20; value++) { + int current = value; + workers.submit(() -> { + try { + boolean result = JsConditionUtil.eval( + "value % 2 === 0", + chain, + Map.of("value", current)); + if (result != (current % 2 == 0)) { + failures.incrementAndGet(); + } + } catch (Throwable error) { + failures.incrementAndGet(); + } finally { + completed.countDown(); + } + }); + } + Assert.assertTrue(completed.await( + 10L, TimeUnit.SECONDS)); + Assert.assertEquals(0, failures.get()); + } finally { + workers.shutdownNow(); + } + } + + /** + * 创建带初始化状态的工作流。 + * + * @param instanceId 实例 ID + * @return 测试工作流 + */ + private Chain chain(String instanceId) { + Chain chain = new Chain( + new ChainDefinition(), instanceId); + chain.setChainStateRepository( + new InMemoryChainStateRepository()); + chain.initializeState(); + return chain; + } +} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/LegacySerializationCompatibilityTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/LegacySerializationCompatibilityTest.java new file mode 100644 index 0000000..9302a64 --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/LegacySerializationCompatibilityTest.java @@ -0,0 +1,83 @@ +package com.easyagents.flow.core.test; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.chain.ChainState; +import com.easyagents.flow.core.chain.NodeState; +import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository; +import com.easyagents.flow.core.chain.repository.InMemoryLoopResultRepository; +import com.easyagents.flow.core.node.LoopNode; +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ObjectInputStream; +import java.io.ObjectStreamClass; +import java.lang.reflect.Method; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +/** + * 校验工作流 Java 序列化状态的滚动升级兼容性。 + */ +public class LegacySerializationCompatibilityTest { + + private static final String LEGACY_LOOP_CONTEXT = + "rO0ABXNyADJjb20uZWFzeWFnZW50cy5mbG93LmNvcmUubm9kZS5Mb29wTm9k" + + "ZSRMb29wQ29udGV4dEj5b620EJczAgACSQAMY3VycmVudEluZGV4TAAJc3Vi" + + "UmVzdWx0dAAPTGphdmEvdXRpbC9NYXA7eHAAAAADc3IAF2phdmEudXRpbC5M" + + "aW5rZWRIYXNoTWFwNMBOXBBswPsCAAFaAAthY2Nlc3NPcmRlcnhyABFqYXZh" + + "LnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVz" + + "aG9sZHhwP0AAAAAAAAx3CAAAABAAAAABdAAFdmFsdWVzcgATamF2YS51dGls" + + "LkFycmF5TGlzdHiB0h2Zx2GdAwABSQAEc2l6ZXhwAAAAAncEAAAAAnQAAWF0" + + "AAFieHgA"; + + /** + * 保护历史类的默认序列化 UID,避免缓存状态在滚动升级时失效。 + */ + @Test + public void shouldKeepLegacySerialVersionUids() { + Assert.assertEquals(-7958235553581638052L, + ObjectStreamClass.lookup(ChainState.class).getSerialVersionUID()); + Assert.assertEquals(-6727481826462129573L, + ObjectStreamClass.lookup(NodeState.class).getSerialVersionUID()); + Assert.assertEquals(5258356831772776243L, + ObjectStreamClass.lookup(LoopNode.LoopContext.class).getSerialVersionUID()); + } + + /** + * 旧 LoopContext 中的内嵌结果应惰性迁移到分块结果仓储。 + * + * @throws Exception 反序列化或反射调用失败 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldMigrateLegacyLoopResultWithoutDataLoss() throws Exception { + LoopNode.LoopContext context; + byte[] bytes = Base64.getDecoder().decode(LEGACY_LOOP_CONTEXT); + try (ObjectInputStream input = new ObjectInputStream(new ByteArrayInputStream(bytes))) { + context = (LoopNode.LoopContext) input.readObject(); + } + Assert.assertEquals(3, context.getCurrentIndex()); + Assert.assertEquals(List.of("a", "b"), context.getSubResult().get("value")); + Assert.assertNull(context.getResultId()); + + InMemoryLoopResultRepository resultRepository = new InMemoryLoopResultRepository(); + Chain chain = new Chain(new ChainDefinition(), "legacy-chain"); + chain.setChainStateRepository(new InMemoryChainStateRepository()); + chain.setLoopResultRepository(resultRepository); + + LoopNode loopNode = new LoopNode(); + Method migrate = LoopNode.class.getDeclaredMethod( + "migrateLegacyResult", Chain.class, LoopNode.LoopContext.class); + migrate.setAccessible(true); + migrate.invoke(loopNode, chain, context); + + Assert.assertNotNull(context.getResultId()); + Assert.assertNull(context.getSubResult()); + Map migrated = + resultRepository.load(context.getResultId(), 2, List.of("value")); + Assert.assertEquals(List.of("a", "b"), migrated.get("value")); + } +} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/LoopNodeProgressContextTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/LoopNodeProgressContextTest.java index bfa2969..30ea78d 100644 --- a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/LoopNodeProgressContextTest.java +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/LoopNodeProgressContextTest.java @@ -3,8 +3,18 @@ package com.easyagents.flow.core.test; import com.easyagents.flow.core.chain.*; import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository; import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository; +import com.easyagents.flow.core.chain.repository.InMemoryLoopResultRepository; import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository; +import com.easyagents.flow.core.chain.repository.LoopInputReference; +import com.easyagents.flow.core.chain.repository.NodeStateField; +import com.easyagents.flow.core.chain.repository.NodeStateRepository; import com.easyagents.flow.core.chain.runtime.ChainExecutor; +import com.easyagents.flow.core.chain.runtime.ExecutionBudget; +import com.easyagents.flow.core.chain.runtime.ExecutionBudgetExceededException; +import com.easyagents.flow.core.chain.runtime.InMemoryTriggerStore; +import com.easyagents.flow.core.chain.runtime.TriggerScheduler; +import com.easyagents.flow.core.chain.event.NodeEndEvent; +import com.easyagents.flow.core.chain.event.NodeStartEvent; import com.easyagents.flow.core.node.BaseNode; import com.easyagents.flow.core.node.EndNode; import com.easyagents.flow.core.node.LoopNode; @@ -12,9 +22,19 @@ import com.easyagents.flow.core.node.StartNode; import org.junit.Assert; import org.junit.Test; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectOutputStream; +import java.util.Arrays; +import java.util.AbstractList; import java.util.Collections; +import java.util.EnumSet; import java.util.HashMap; +import java.util.Iterator; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.Executors; /** * 验证循环体内可以读取父循环节点上一轮的最新输出。 @@ -23,6 +43,287 @@ public class LoopNodeProgressContextTest { @Test public void shouldExposeLatestLoopOutputInsideLoopBody() { + ChainDefinition definition = createDefinition(); + ChainExecutor executor = createExecutor(definition); + Map variables = new HashMap<>(); + variables.put("times", 2); + + Map resultMap = executor.execute("loop-progress-test", variables); + + Assert.assertEquals(Arrays.asList("1", "2"), resultMap.get("result")); + } + + /** + * 验证循环节点跨子触发器完成时复用同一稳定业务尝试键。 + */ + @Test + public void shouldKeepLoopExecutionAttemptKeyStable() { + ChainDefinition definition = + createDefinition(); + ChainExecutor executor = + createExecutor(definition); + java.util.concurrent.atomic.AtomicReference + startedKey = + new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicReference + endedKey = + new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicReference + endedStatus = + new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicReference + endedError = + new java.util.concurrent.atomic.AtomicReference<>(); + executor.addEventListener( + NodeStartEvent.class, + (event, chain) -> { + NodeStartEvent startEvent = + (NodeStartEvent) event; + if ("loop".equals( + startEvent.getNode().getId())) { + startedKey.set( + startEvent + .getExecutionAttemptKey()); + } + }); + executor.addEventListener( + NodeEndEvent.class, + (event, chain) -> { + NodeEndEvent endEvent = + (NodeEndEvent) event; + if ("loop".equals( + endEvent.getNode().getId())) { + // 模拟下一轮执行已抢先覆盖可变节点状态。 + chain.updateNodeStateSafely( + "loop", + state -> { + state.setExecutionAttemptKey( + "next-attempt"); + state.setStatus( + NodeStatus.RUNNING); + state.setError( + new ExceptionSummary( + new IllegalStateException( + "next-error"))); + return EnumSet.of( + NodeStateField.STATUS, + NodeStateField.ERROR, + NodeStateField + .EXECUTION_ATTEMPT_KEY); + }); + endedKey.set( + endEvent + .getExecutionAttemptKey()); + endedStatus.set( + endEvent.getStatus()); + endedError.set( + endEvent.getError()); + } + }); + + executor.execute( + "loop-attempt-key-test", + Collections.singletonMap( + "times", 3)); + + Assert.assertNotNull(startedKey.get()); + Assert.assertEquals( + startedKey.get(), + endedKey.get()); + Assert.assertEquals( + NodeStatus.SUCCEEDED, + endedStatus.get()); + Assert.assertNull( + endedError.get()); + } + + /** + * 验证普通 Iterable 只物化一次,后续循环不会从头重复遍历。 + */ + @Test + public void shouldMaterializeNonListIterableOnlyOnce() { + ChainDefinition definition = createDefinition(); + ChainExecutor executor = createExecutor(definition); + OneShotIterable iterable = new OneShotIterable(); + Map variables = new HashMap<>(); + variables.put("times", iterable); + + Map resultMap = executor.execute("loop-progress-test", variables); + + Assert.assertEquals(Arrays.asList("1", "2", "3"), resultMap.get("result")); + Assert.assertEquals(1, iterable.getIteratorCount()); + } + + /** + * 验证超过直接索引阈值的 List 首轮物化后不再回读原集合。 + */ + @Test + public void shouldMaterializeListOnlyOnce() { + ChainDefinition definition = createDefinition(); + ChainExecutor executor = createExecutor(definition); + Integer[] values = new Integer[128]; + Arrays.fill(values, 1); + CountingList list = new CountingList(values); + + Map resultMap = executor.execute( + "loop-list-test", Collections.singletonMap("times", list)); + + Assert.assertEquals(128, ((java.util.List) resultMap.get("result")).size()); + Assert.assertTrue( + "list input should not be re-read for every iteration", + list.getReadCount() <= values.length * 5); + } + + /** + * 验证循环直接消费上游已分页物化的轻量引用,不先还原完整列表。 + */ + @Test + public void shouldConsumePreMaterializedInputReference() { + ChainDefinition definition = createDefinition(); + InMemoryLoopResultRepository loopRepository = + new InMemoryLoopResultRepository(); + String resultId = "loop-reference-input"; + loopRepository.storeInput( + resultId, Arrays.asList(10, 20, 30)); + TriggerScheduler scheduler = + new TriggerScheduler( + new InMemoryTriggerStore(), + Executors.newScheduledThreadPool(2), + Executors.newFixedThreadPool(2), + 1_000L); + try { + ChainExecutor executor = new ChainExecutor( + new FixedDefinitionRepository( + definition), + new InMemoryChainStateRepository(), + new InMemoryNodeStateRepository(), + loopRepository, + scheduler, + ExecutionBudget.defaults()); + + Map resultMap = + executor.execute( + "loop-reference-test", + Collections.singletonMap( + "times", + new LoopInputReference( + resultId, 3))); + + Assert.assertEquals( + Arrays.asList("1", "2", "3"), + resultMap.get("result")); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证对象数组和基础类型数组均可作为循环输入。 + */ + @Test + public void shouldSupportArrayInputs() { + ChainDefinition definition = createDefinition(); + ChainExecutor executor = createExecutor(definition); + + Map objectArrayResult = executor.execute( + "loop-object-array-test", + Collections.singletonMap("times", new String[]{"a", "b"})); + Map primitiveArrayResult = executor.execute( + "loop-primitive-array-test", + Collections.singletonMap("times", new int[]{1, 2, 3})); + + Assert.assertEquals(Arrays.asList("1", "2"), objectArrayResult.get("result")); + Assert.assertEquals( + Arrays.asList("1", "2", "3"), primitiveArrayResult.get("result")); + } + + /** + * 验证显式循环节点接受 300 次,并拒绝 301 次的数值输入。 + */ + @Test + public void shouldEnforceNumericLoopLimit() { + ChainExecutor maximumExecutor = createExecutor(createDefinition()); + Map maximumResult = maximumExecutor.execute( + "loop-maximum-test", + Collections.singletonMap("times", Node.MAX_LOOP_COUNT)); + + Assert.assertEquals( + Node.MAX_LOOP_COUNT, + ((java.util.List) maximumResult.get("result")).size()); + + ChainExecutor exceededExecutor = createExecutor(createDefinition()); + assertLoopFailure( + exceededExecutor, + "loop-exceeded-test", + Collections.singletonMap( + "times", Node.MAX_LOOP_COUNT + 1), + IllegalArgumentException.class); + } + + /** + * 验证未知大小 Iterable 在物化第 301 个元素前终止。 + */ + @Test + public void shouldStopOversizedIterableDuringMaterialization() { + ChainExecutor executor = createExecutor(createDefinition()); + + assertLoopFailure( + executor, + "loop-iterable-exceeded-test", + Collections.singletonMap( + "times", + new RangeIterable( + Node.MAX_LOOP_COUNT + 1)), + ExecutionBudgetExceededException.class); + } + + /** + * 验证大循环累计结果不会进入每轮持久化的节点热状态。 + */ + @Test + public void shouldKeepNodeStateCompactWhenCollectingLargeLoopResult() { + ChainDefinition definition = createDefinition(); + TrackingNodeStateRepository nodeStateRepository = new TrackingNodeStateRepository(); + ChainExecutor executor = new ChainExecutor(new FixedDefinitionRepository(definition), + new InMemoryChainStateRepository(), + nodeStateRepository); + Map variables = new HashMap<>(); + variables.put("times", Node.MAX_LOOP_COUNT); + + Map resultMap = executor.execute("loop-progress-test", variables); + + @SuppressWarnings("unchecked") + java.util.List result = (java.util.List) resultMap.get("result"); + Assert.assertEquals(Node.MAX_LOOP_COUNT, result.size()); + Assert.assertEquals("1", result.get(0)); + Assert.assertEquals( + String.valueOf(Node.MAX_LOOP_COUNT), + result.get(Node.MAX_LOOP_COUNT - 1)); + Assert.assertTrue("loop node hot state should remain bounded", + nodeStateRepository.getMaxSerializedBytes() < 16 * 1024); + } + + /** + * 验证循环体存在多个直属分支时,必须等待全部分支完成后再推进下一轮。 + */ + @Test + public void shouldWaitForAllDirectLoopBranchesBeforeAdvancing() { + ChainDefinition definition = createMultiBranchDefinition(); + ChainExecutor executor = createExecutor(definition); + + Map resultMap = executor.execute( + "loop-multi-branch-test", Collections.singletonMap("times", 3)); + + Assert.assertEquals(Arrays.asList("a0", "a1", "a2"), resultMap.get("a")); + Assert.assertEquals(Arrays.asList("b0", "b1", "b2"), resultMap.get("b")); + } + + /** + * 创建循环进度测试定义。 + * + * @return 工作流定义 + */ + private ChainDefinition createDefinition() { ChainDefinition definition = new ChainDefinition(); definition.setId("loop-progress-test"); @@ -67,17 +368,115 @@ public class LoopNodeProgressContextTest { definition.addEdge(edge("e1", "start", "loop")); definition.addEdge(edge("e2", "loop", "acc")); definition.addEdge(edge("e3", "loop", "end")); + return definition; + } - ChainExecutor executor = new ChainExecutor(new FixedDefinitionRepository(definition), + /** + * 创建包含两个并行直属循环分支的定义。 + * + * @return 工作流定义 + */ + private ChainDefinition createMultiBranchDefinition() { + ChainDefinition definition = new ChainDefinition(); + definition.setId("loop-multi-branch-test"); + + StartNode startNode = new StartNode(); + startNode.setId("start"); + startNode.setParameters(Collections.singletonList(inputParameter("times"))); + + LoopNode loopNode = new LoopNode(); + loopNode.setId("loop"); + Parameter loopVar = new Parameter(); + loopVar.setName("times"); + loopVar.setRef("times"); + loopVar.setRefType(RefType.REF); + loopNode.setLoopVar(loopVar); + + BranchNode branchA = new BranchNode("a", 0L); + branchA.setId("branch-a"); + branchA.setParentId("loop"); + BranchNode branchB = new BranchNode("b", 20L); + branchB.setId("branch-b"); + branchB.setParentId("loop"); + + Parameter outputA = new Parameter(); + outputA.setName("a"); + outputA.setRef("branch-a.value"); + outputA.setRefType(RefType.REF); + Parameter outputB = new Parameter(); + outputB.setName("b"); + outputB.setRef("branch-b.value"); + outputB.setRefType(RefType.REF); + loopNode.setOutputDefs(Arrays.asList(outputA, outputB)); + + EndNode endNode = new EndNode(); + endNode.setId("end"); + Parameter resultA = new Parameter(); + resultA.setName("a"); + resultA.setRef("loop.a"); + resultA.setRefType(RefType.REF); + Parameter resultB = new Parameter(); + resultB.setName("b"); + resultB.setRef("loop.b"); + resultB.setRefType(RefType.REF); + endNode.setOutputDefs(Arrays.asList(resultA, resultB)); + + definition.addNode(startNode); + definition.addNode(loopNode); + definition.addNode(branchA); + definition.addNode(branchB); + definition.addNode(endNode); + definition.addEdge(edge("e1", "start", "loop")); + definition.addEdge(edge("e2", "loop", "branch-a")); + definition.addEdge(edge("e3", "loop", "branch-b")); + definition.addEdge(edge("e4", "loop", "end")); + return definition; + } + + /** + * 创建使用内存状态仓储的测试执行器。 + * + * @param definition 工作流定义 + * @return 测试执行器 + */ + private ChainExecutor createExecutor(ChainDefinition definition) { + return new ChainExecutor(new FixedDefinitionRepository(definition), new InMemoryChainStateRepository(), new InMemoryNodeStateRepository()); + } - Map variables = new HashMap<>(); - variables.put("times", 2); - - Map resultMap = executor.execute("loop-progress-test", variables); - - Assert.assertEquals(java.util.Arrays.asList("1", "2"), resultMap.get("result")); + /** + * 断言循环节点通过结束事件报告指定异常类型。 + * + * @param executor 测试执行器 + * @param definitionId 定义 ID + * @param variables 输入变量 + * @param expectedType 期望异常类型 + */ + private static void assertLoopFailure( + ChainExecutor executor, + String definitionId, + Map variables, + Class expectedType) { + AtomicReference nodeError = new AtomicReference<>(); + executor.addEventListener( + NodeEndEvent.class, + (event, chain) -> { + NodeEndEvent endEvent = (NodeEndEvent) event; + if ("loop".equals(endEvent.getNode().getId()) + && endEvent.getError() != null) { + nodeError.set(endEvent.getError()); + } + }); + try { + executor.execute(definitionId, variables); + Assert.fail("Expected exception: " + expectedType.getSimpleName()); + } catch (RuntimeException expected) { + Assert.assertNotNull("Loop node error event is missing", nodeError.get()); + Assert.assertTrue( + "Unexpected loop node error: " + nodeError.get(), + expectedType.isInstance(nodeError.get())); + } } private static Parameter inputParameter(String name) { @@ -109,6 +508,171 @@ public class LoopNodeProgressContextTest { } } + /** + * 第二次获取迭代器时直接失败,用于识别重复遍历。 + */ + private static class OneShotIterable implements Iterable { + private final AtomicInteger iteratorCount = new AtomicInteger(); + + /** + * 获取唯一可用的迭代器。 + * + * @return 测试迭代器 + */ + @Override + public Iterator iterator() { + if (iteratorCount.incrementAndGet() > 1) { + throw new IllegalStateException("Iterable must not be traversed more than once"); + } + return Arrays.asList(10, 20, 30).iterator(); + } + + /** + * 获取迭代器创建次数。 + * + * @return 迭代器创建次数 + */ + private int getIteratorCount() { + return iteratorCount.get(); + } + } + + /** + * 生成指定数量元素且无法提前获知大小的 Iterable。 + */ + private static final class RangeIterable implements Iterable { + + private final int itemCount; + + /** + * 创建范围输入。 + * + * @param itemCount 元素数量 + */ + private RangeIterable(int itemCount) { + this.itemCount = itemCount; + } + + /** + * {@inheritDoc} + */ + @Override + public Iterator iterator() { + return new Iterator() { + private int index; + + @Override + public boolean hasNext() { + return index < itemCount; + } + + @Override + public Integer next() { + if (!hasNext()) { + throw new java.util.NoSuchElementException(); + } + return index++; + } + }; + } + } + + /** + * 记录元素读取次数的列表。 + */ + private static final class CountingList extends AbstractList { + + private final java.util.List values; + private final AtomicInteger readCount = new AtomicInteger(); + + /** + * 创建计数列表。 + * + * @param values 列表元素 + */ + private CountingList(Integer... values) { + this.values = Arrays.asList(values); + } + + /** + * {@inheritDoc} + */ + @Override + public Integer get(int index) { + readCount.incrementAndGet(); + return values.get(index); + } + + /** + * {@inheritDoc} + */ + @Override + public int size() { + return values.size(); + } + + /** + * 获取元素读取次数。 + * + * @return 元素读取次数 + */ + private int getReadCount() { + return readCount.get(); + } + } + + /** + * 记录节点状态序列化体积的测试仓储。 + */ + private static final class TrackingNodeStateRepository implements NodeStateRepository { + + private final InMemoryNodeStateRepository delegate = new InMemoryNodeStateRepository(); + private final AtomicInteger maxSerializedBytes = new AtomicInteger(); + + /** + * {@inheritDoc} + */ + @Override + public NodeState load(String instanceId, String nodeId) { + return delegate.load(instanceId, nodeId); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean tryUpdate(NodeState newState, EnumSet fields, long chainStateVersion) { + maxSerializedBytes.accumulateAndGet(serializedSize(newState), Math::max); + return delegate.tryUpdate(newState, fields, chainStateVersion); + } + + /** + * 获取观测到的最大序列化字节数。 + * + * @return 最大序列化字节数 + */ + private int getMaxSerializedBytes() { + return maxSerializedBytes.get(); + } + + /** + * 计算节点状态的 Java 序列化字节数。 + * + * @param state 节点状态 + * @return 序列化字节数 + */ + private int serializedSize(NodeState state) { + try (ByteArrayOutputStream output = new ByteArrayOutputStream(); + ObjectOutputStream objectOutput = new ObjectOutputStream(output)) { + objectOutput.writeObject(state); + objectOutput.flush(); + return output.size(); + } catch (IOException error) { + throw new IllegalStateException("Failed to serialize node state", error); + } + } + } + /** * 每一轮都读取父循环节点上一轮的 current,再计算新的结果。 */ @@ -142,4 +706,31 @@ public class LoopNodeProgressContextTest { return result; } } + + /** + * 输出当前循环序号的测试分支。 + */ + private static class BranchNode extends BaseNode { + private final String prefix; + private final long delayMs; + + private BranchNode(String prefix, long delayMs) { + this.prefix = prefix; + this.delayMs = delayMs; + } + + @Override + public Map execute(Chain chain) { + if (delayMs > 0) { + try { + Thread.sleep(delayMs); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Branch execution interrupted", error); + } + } + Object index = chain.getState().resolveValue("loop.index"); + return Collections.singletonMap("value", prefix + index); + } + } } diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/LoopResultReferenceResolverTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/LoopResultReferenceResolverTest.java new file mode 100644 index 0000000..b60318a --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/LoopResultReferenceResolverTest.java @@ -0,0 +1,123 @@ +package com.easyagents.flow.core.test; + +import com.easyagents.flow.core.chain.repository.InMemoryLoopResultRepository; +import com.easyagents.flow.core.chain.repository.LoopInputReference; +import com.easyagents.flow.core.chain.repository.LoopResultReference; +import com.easyagents.flow.core.chain.runtime.ExecutionBudgetExceededException; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Iterator; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 循环结果引用批量还原回归测试。 + */ +public class LoopResultReferenceResolverTest { + + /** + * 验证同一循环的多个输出只触发一次仓储加载。 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldBatchOutputsFromSameLoopResult() { + CountingLoopResultRepository repository = new CountingLoopResultRepository(); + repository.append("result-1", 0, Map.of("a", "a0", "b", "b0")); + repository.append("result-1", 1, Map.of("a", "a1", "b", "b1")); + Map value = new LinkedHashMap<>(); + value.put("a", new LoopResultReference("result-1", 2, "a")); + value.put("nested", List.of(new LoopResultReference("result-1", 2, "b"))); + + Map resolved = + (Map) repository.resolveReferences(value); + + Assert.assertEquals(Arrays.asList("a0", "a1"), resolved.get("a")); + Assert.assertEquals( + Arrays.asList("b0", "b1"), + ((List) resolved.get("nested")).get(0)); + Assert.assertEquals(1, repository.getLoadCount()); + } + + /** + * 验证无限 Iterable 在达到预算后立即停止物化。 + */ + @Test + public void shouldStopInfiniteInputAtIterationBudget() { + InMemoryLoopResultRepository repository = new InMemoryLoopResultRepository(); + AtomicInteger reads = new AtomicInteger(); + Iterable infinite = () -> new Iterator() { + @Override + public boolean hasNext() { + return true; + } + + @Override + public Integer next() { + return reads.incrementAndGet(); + } + }; + + try { + repository.storeInput("bounded-input", infinite, 3L); + Assert.fail("infinite input should exceed the iteration budget"); + } catch (ExecutionBudgetExceededException expected) { + Assert.assertTrue(expected.getMessage().contains("more than 3")); + } + + Assert.assertEquals(3, reads.get()); + } + + /** + * 验证外置循环输入在业务读取边界仍还原为原顺序列表。 + */ + @Test + public void shouldResolveExternalizedLoopInput() { + InMemoryLoopResultRepository repository = + new InMemoryLoopResultRepository(); + String resultId = "input-reference"; + Assert.assertEquals( + 3, + repository.storeInput( + resultId, + List.of("first", "second", "third"))); + + Object resolved = repository.resolveReferences( + new LoopInputReference(resultId, 3)); + + Assert.assertEquals( + List.of("first", "second", "third"), + resolved); + } + + /** + * 记录加载次数的循环结果仓储。 + */ + private static final class CountingLoopResultRepository + extends InMemoryLoopResultRepository { + + private final AtomicInteger loadCount = new AtomicInteger(); + + /** + * {@inheritDoc} + */ + @Override + public Map load( + String resultId, int iterationCount, List outputNames) { + loadCount.incrementAndGet(); + return super.load(resultId, iterationCount, outputNames); + } + + /** + * 获取仓储加载次数。 + * + * @return 加载次数 + */ + private int getLoadCount() { + return loadCount.get(); + } + } +} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/OkHttpClientUtilTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/OkHttpClientUtilTest.java new file mode 100644 index 0000000..ecdb2df --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/OkHttpClientUtilTest.java @@ -0,0 +1,116 @@ +package com.easyagents.flow.core.test; + +import com.easyagents.flow.core.util.IoBulkhead; +import com.easyagents.flow.core.util.OkHttpClientUtil; +import com.sun.net.httpserver.HttpServer; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.junit.Assert; +import org.junit.Test; + +import java.net.InetSocketAddress; +import java.time.Duration; + +/** + * {@link OkHttpClientUtil} 共享连接池回归测试。 + */ +public class OkHttpClientUtilTest { + + /** + * 验证默认客户端跨节点调用复用同一实例。 + */ + @Test + public void shouldReuseDefaultClientInstance() { + OkHttpClient first = OkHttpClientUtil.buildDefaultClient(); + OkHttpClient second = OkHttpClientUtil.buildDefaultClient(); + + Assert.assertSame(first, second); + Assert.assertSame(first.connectionPool(), second.connectionPool()); + Assert.assertSame(first.dispatcher(), second.dispatcher()); + Assert.assertEquals( + 1L, + first.interceptors().stream() + .filter(interceptor -> interceptor.getClass().getSimpleName() + .equals("IoBulkheadInterceptor")) + .count()); + } + + /** + * 验证非幂等请求客户端关闭底层隐式重试且继续复用连接资源。 + */ + @Test + public void shouldReuseNoRetryClientAndConnectionPool() { + OkHttpClient defaultClient = OkHttpClientUtil.buildDefaultClient(); + OkHttpClient first = OkHttpClientUtil.buildNoRetryClient(); + OkHttpClient second = OkHttpClientUtil.buildNoRetryClient(); + + Assert.assertSame(first, second); + Assert.assertFalse(first.retryOnConnectionFailure()); + Assert.assertSame(defaultClient.connectionPool(), first.connectionPool()); + Assert.assertSame(defaultClient.dispatcher(), first.dispatcher()); + } + + /** + * 验证单目标并发为一时一次 HTTP 请求只申请一次许可且不会自锁。 + * + * @throws Exception 启动本地 HTTP 服务或请求失败时抛出 + */ + @Test + public void shouldAcquireOnePermitPerHttpRequest() throws Exception { + IoBulkhead.Settings restricted = + new IoBulkhead.Settings(1, 1, Duration.ofMillis(200), 16); + IoBulkhead.configure( + restricted, + new IoBulkhead.Settings(32, 8, Duration.ofSeconds(1), 512), + new IoBulkhead.Settings(24, 12, Duration.ofSeconds(2), 256), + new IoBulkhead.Settings(8, 4, Duration.ofSeconds(2), 128), + new IoBulkhead.Settings(8, 4, Duration.ofSeconds(2), 1_024)); + HttpServer server = HttpServer.create( + new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + byte[] body = "ok".getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + long acquiredBefore = IoBulkhead.shared() + .snapshot() + .acquiredCount(); + Request request = new Request.Builder() + .url("http://127.0.0.1:" + + server.getAddress().getPort() + + "/") + .build(); + try (Response response = OkHttpClientUtil + .buildNoRetryClient() + .newCall(request) + .execute()) { + Assert.assertEquals(200, response.code()); + } + Assert.assertEquals( + acquiredBefore + 1, + IoBulkhead.shared().snapshot().acquiredCount()); + Assert.assertEquals( + 0L, + IoBulkhead.shared().snapshot().inFlightCount()); + } finally { + server.stop(0); + restoreDefaultBulkheads(); + } + } + + /** + * 恢复测试进程的宽松默认隔离配置,避免污染其他测试。 + */ + private void restoreDefaultBulkheads() { + IoBulkhead.configure( + new IoBulkhead.Settings(64, 16, Duration.ofSeconds(1), 1_024), + new IoBulkhead.Settings(32, 8, Duration.ofSeconds(1), 512), + new IoBulkhead.Settings(24, 12, Duration.ofSeconds(2), 256), + new IoBulkhead.Settings(8, 4, Duration.ofSeconds(2), 128), + new IoBulkhead.Settings(8, 4, Duration.ofSeconds(2), 1_024)); + } +} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/TextTemplatePathTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/TextTemplatePathTest.java index 38f98f6..e2f0c3f 100644 --- a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/TextTemplatePathTest.java +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/TextTemplatePathTest.java @@ -44,4 +44,66 @@ public class TextTemplatePathTest { Assert.assertEquals("value=", result); } + + /** + * 验证分层上下文保持后层覆盖前层的原有优先级。 + */ + @Test + public void shouldResolveLayeredContextUsingLastMapPrecedence() { + Map memory = new HashMap<>(); + memory.put("name", "memory"); + Map parameters = new HashMap<>(); + parameters.put("name", "parameter"); + + String result = TextTemplate.of("{{name}}") + .formatToString(Arrays.asList(memory, parameters)); + + Assert.assertEquals("parameter", result); + } + + /** + * 验证分层上下文支持跨层兜底并保持 JSON 转义。 + */ + @Test + public void shouldResolveFallbackAcrossLayersWithJsonEscaping() { + Map memory = new HashMap<>(); + memory.put("fallback", "a\"b"); + + String result = TextTemplate.of("{\"value\":\"{{missing ?? fallback}}\"}") + .formatToString(Arrays.asList(memory, Collections.emptyMap()), true); + + Assert.assertEquals("{\"value\":\"a\\\"b\"}", result); + } + + /** + * 后层拥有顶级键时,空对象应完整遮蔽前层同名对象。 + */ + @Test + public void shouldPreservePutAllShadowingForNestedObject() { + Map memory = new HashMap<>(); + memory.put("user", Collections.singletonMap("name", "legacy")); + Map parameters = new HashMap<>(); + parameters.put("user", Collections.emptyMap()); + + String result = TextTemplate.of("{{user.name ?? \"fallback\"}}") + .formatToString(Arrays.asList(memory, parameters)); + + Assert.assertEquals("fallback", result); + } + + /** + * 后层显式 null 应遮蔽前层同名对象并进入模板兜底。 + */ + @Test + public void shouldPreservePutAllShadowingForExplicitNull() { + Map memory = new HashMap<>(); + memory.put("user", Collections.singletonMap("name", "legacy")); + Map parameters = new HashMap<>(); + parameters.put("user", null); + + String result = TextTemplate.of("{{user.name ?? \"fallback\"}}") + .formatToString(Arrays.asList(memory, parameters)); + + Assert.assertEquals("fallback", result); + } } diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/TriggerSchedulerReliabilityTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/TriggerSchedulerReliabilityTest.java new file mode 100644 index 0000000..e60bc2d --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/TriggerSchedulerReliabilityTest.java @@ -0,0 +1,973 @@ +package com.easyagents.flow.core.test; + +import com.easyagents.flow.core.chain.runtime.InMemoryTriggerStore; +import com.easyagents.flow.core.chain.runtime.NonRetryableTriggerException; +import com.easyagents.flow.core.chain.runtime.RetryableTriggerException; +import com.easyagents.flow.core.chain.runtime.Trigger; +import com.easyagents.flow.core.chain.runtime.TriggerScheduler; +import com.easyagents.flow.core.chain.runtime.TriggerStore; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * {@link TriggerScheduler} 认领、确认和失败释放语义回归测试。 + */ +public class TriggerSchedulerReliabilityTest { + + /** + * 验证消费者尚未注册时主动触发不会丢失待执行任务。 + */ + @Test + public void shouldKeepTriggerPendingWhenConsumerIsUnavailable() { + InMemoryTriggerStore store = new InMemoryTriggerStore(); + Trigger trigger = futureTrigger("consumer-unavailable"); + store.save(trigger); + TriggerScheduler scheduler = scheduler(store); + try { + Assert.assertFalse(scheduler.fire(trigger.getId())); + Assert.assertNotNull(store.find(trigger.getId())); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证执行失败后触发器被释放,并能再次认领成功。 + * + * @throws Exception 等待异步执行被中断时抛出 + */ + @Test + public void shouldReleaseFailedTriggerForRetry() throws Exception { + InMemoryTriggerStore store = new InMemoryTriggerStore(); + Trigger trigger = futureTrigger("retry-after-failure"); + store.save(trigger); + TriggerScheduler scheduler = scheduler(store); + AtomicInteger attempts = new AtomicInteger(); + CountDownLatch firstAttempt = new CountDownLatch(1); + CountDownLatch succeeded = new CountDownLatch(1); + scheduler.registerConsumer((current, worker) -> { + if (attempts.incrementAndGet() == 1) { + firstAttempt.countDown(); + throw new IllegalStateException("expected first failure"); + } + succeeded.countDown(); + }); + + try { + Assert.assertTrue(scheduler.fire(trigger.getId())); + Assert.assertTrue(firstAttempt.await(2, TimeUnit.SECONDS)); + awaitPending(store, trigger.getId()); + Assert.assertTrue(scheduler.fire(trigger.getId())); + Assert.assertTrue(succeeded.await(2, TimeUnit.SECONDS)); + Assert.assertEquals(2, attempts.get()); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证并发认领同一触发器时只有一个消费者获得执行权。 + * + * @throws Exception 并发任务等待失败时抛出 + */ + @Test + public void shouldDispatchClaimedTriggerOnlyOnce() throws Exception { + InMemoryTriggerStore store = new InMemoryTriggerStore(); + Trigger trigger = futureTrigger("single-claim"); + store.save(trigger); + TriggerScheduler scheduler = scheduler(store); + CountDownLatch consumed = new CountDownLatch(1); + AtomicInteger executions = new AtomicInteger(); + scheduler.registerConsumer((current, worker) -> { + executions.incrementAndGet(); + consumed.countDown(); + }); + ExecutorService callers = Executors.newFixedThreadPool(2); + + try { + List> results = callers.invokeAll(Arrays.asList( + () -> scheduler.fire(trigger.getId()), + () -> scheduler.fire(trigger.getId()))); + int accepted = 0; + for (java.util.concurrent.Future result : results) { + if (result.get()) { + accepted++; + } + } + Assert.assertEquals(1, accepted); + Assert.assertTrue(consumed.await(2, TimeUnit.SECONDS)); + Assert.assertEquals(1, executions.get()); + } finally { + callers.shutdownNow(); + scheduler.shutdown(); + } + } + + /** + * 验证缺少认领对象时旧 ID 接口不会静默执行不安全确认。 + */ + @Test + public void shouldRejectClaimMutationWithoutClaimedTrigger() { + InMemoryTriggerStore store = new InMemoryTriggerStore(); + + try { + store.renewClaim("unsafe-id-only", 1000L); + Assert.fail("ID-only renewal must be rejected"); + } catch (UnsupportedOperationException expected) { + Assert.assertTrue(expected.getMessage().contains("token")); + } + + try { + store.acknowledge("unsafe-id-only"); + Assert.fail("ID-only acknowledge must be rejected"); + } catch (UnsupportedOperationException expected) { + Assert.assertTrue(expected.getMessage().contains("trigger")); + } + } + + /** + * 验证不可重试错误会移出待执行集合,避免形成永久热重放。 + * + * @throws Exception 等待异步执行被中断时抛出 + */ + @Test + public void shouldDeadLetterNonRetryableTrigger() throws Exception { + InMemoryTriggerStore store = new InMemoryTriggerStore(); + Trigger trigger = futureTrigger("non-retryable"); + store.save(trigger); + TriggerScheduler scheduler = scheduler(store); + CountDownLatch attempted = new CountDownLatch(1); + scheduler.registerConsumer((current, worker) -> { + attempted.countDown(); + throw new NonRetryableTriggerException("invalid definition"); + }); + + try { + Assert.assertTrue(scheduler.fire(trigger.getId())); + Assert.assertTrue(attempted.await(2, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (store.find(trigger.getId()) != null && System.nanoTime() < deadline) { + Thread.sleep(10L); + } + Assert.assertNull(store.find(trigger.getId())); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证瞬时基础设施冲突即使超过普通投递上限也继续保留,避免幂等 owner 存活期内误死信。 + * + * @throws Exception 等待异步执行被中断时抛出 + */ + @Test + public void shouldNotDeadLetterTransientContention() + throws Exception { + InMemoryTriggerStore store = new InMemoryTriggerStore(); + Trigger trigger = futureTrigger("transient-contention"); + trigger.setDeliveryAttempt(100); + store.save(trigger); + TriggerScheduler scheduler = scheduler(store); + CountDownLatch attempted = new CountDownLatch(1); + scheduler.registerConsumer((current, worker) -> { + attempted.countDown(); + throw new RetryableTriggerException( + "operation still owned", null); + }); + + try { + Assert.assertTrue(scheduler.fire(trigger.getId())); + Assert.assertTrue(attempted.await(2, TimeUnit.SECONDS)); + awaitPending(store, trigger.getId()); + Assert.assertEquals( + 101, + store.find(trigger.getId()) + .getDeliveryAttempt()); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证业务终态尚未持久化时不得先移除触发器。 + * + * @throws Exception 等待异步执行被中断时抛出 + */ + @Test + public void shouldRetryTerminalConvergenceBeforeDeadLetter() + throws Exception { + InMemoryTriggerStore store = new InMemoryTriggerStore(); + Trigger trigger = futureTrigger( + "terminal-convergence-retry"); + store.save(trigger); + TriggerScheduler scheduler = scheduler(store); + AtomicInteger convergenceAttempts = new AtomicInteger(); + AtomicInteger executions = new AtomicInteger(); + CountDownLatch consumed = new CountDownLatch(1); + scheduler.registerConsumer((current, worker) -> { + executions.incrementAndGet(); + consumed.countDown(); + throw new NonRetryableTriggerException( + "invalid definition"); + }); + scheduler.registerFailureListener((current, failure) -> + convergenceAttempts.incrementAndGet() >= 2); + + try { + Assert.assertTrue(scheduler.fire(trigger.getId())); + awaitPending(store, trigger.getId()); + Assert.assertNotNull(store.find(trigger.getId())); + Assert.assertTrue(scheduler.fire(trigger.getId())); + Assert.assertTrue(consumed.await(2, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + + TimeUnit.SECONDS.toNanos(2); + while ((store.find(trigger.getId()) != null + || convergenceAttempts.get() < 2) + && System.nanoTime() < deadline) { + Thread.sleep(10L); + } + Assert.assertNull(store.find(trigger.getId())); + Assert.assertEquals(2, convergenceAttempts.get()); + Assert.assertEquals(1, executions.get()); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证死信写入失败后只重放终态协议,不重复执行业务消费者。 + * + * @throws Exception 等待异步执行被中断时抛出 + */ + @Test + public void shouldResumeDeadLetterFinalizationWithoutBusinessReplay() + throws Exception { + FailingTerminalStore store = + new FailingTerminalStore( + TerminalOperation.DEAD_LETTER); + Trigger trigger = futureTrigger( + "dead-letter-finalization-retry"); + store.save(trigger); + TriggerScheduler scheduler = scheduler(store); + AtomicInteger executions = new AtomicInteger(); + AtomicInteger convergenceAttempts = + new AtomicInteger(); + CountDownLatch convergenceCompleted = + new CountDownLatch(2); + scheduler.registerConsumer((current, worker) -> { + executions.incrementAndGet(); + throw new NonRetryableTriggerException( + "invalid definition"); + }); + scheduler.registerFailureListener( + (current, failure) -> { + convergenceAttempts.incrementAndGet(); + convergenceCompleted.countDown(); + return true; + }); + + try { + Assert.assertTrue(scheduler.fire( + trigger.getId())); + Assert.assertTrue(store.terminalAttempted.await( + 2, TimeUnit.SECONDS)); + awaitPending(store, trigger.getId()); + Assert.assertTrue(store.find(trigger.getId()) + .isDeadLetterPending()); + + store.fail = false; + Assert.assertTrue(scheduler.fire( + trigger.getId())); + Assert.assertTrue(convergenceCompleted.await( + 2, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + + TimeUnit.SECONDS.toNanos(2); + while (store.find(trigger.getId()) != null + && System.nanoTime() < deadline) { + Thread.sleep(10L); + } + + Assert.assertNull(store.find(trigger.getId())); + Assert.assertEquals(1, executions.get()); + Assert.assertEquals( + 2, convergenceAttempts.get()); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证旧 owner 失去 claim 后不能提交业务失败终态或移动新 owner 的触发器。 + * + * @throws Exception 等待异步执行被中断时抛出 + */ + @Test + public void shouldRejectDeadLetterFinalizationAfterClaimTransfer() + throws Exception { + LostClaimStore store = new LostClaimStore(); + Trigger trigger = futureTrigger( + "claim-transferred"); + store.save(trigger); + TriggerScheduler scheduler = scheduler(store); + AtomicInteger convergenceAttempts = + new AtomicInteger(); + CountDownLatch executed = new CountDownLatch(1); + scheduler.registerConsumer((current, worker) -> { + executed.countDown(); + throw new NonRetryableTriggerException( + "stale owner"); + }); + scheduler.registerFailureListener( + (current, failure) -> { + convergenceAttempts.incrementAndGet(); + return true; + }); + + try { + Assert.assertTrue(scheduler.fire( + trigger.getId())); + Assert.assertTrue(executed.await( + 2, TimeUnit.SECONDS)); + Assert.assertTrue(store.finalizationRejected.await( + 2, TimeUnit.SECONDS)); + Assert.assertEquals( + 0, convergenceAttempts.get()); + Assert.assertEquals( + 0, store.deadLetterAttempts.get()); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证远期触发器只在固定容量内建立精确定时,溢出任务继续由持久仓储保留。 + * + * @throws Exception 反射读取调度状态失败时抛出 + */ + @Test + public void shouldBoundFutureTriggerLocalSchedules() throws Exception { + InMemoryTriggerStore store = new InMemoryTriggerStore(); + TriggerScheduler scheduler = scheduler(store); + try { + for (int index = 0; index < 2000; index++) { + scheduler.schedule(nearFutureTrigger( + "future-" + index, + TimeUnit.SECONDS.toMillis(50))); + } + Field field = TriggerScheduler.class.getDeclaredField( + "scheduledFutures"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map localFutures = + (Map) field.get(scheduler); + Assert.assertEquals(1024, localFutures.size()); + Assert.assertEquals(2000, store.findAllPending().size()); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证并发登记远期触发器时本地定时表仍严格受容量上限约束。 + * + * @throws Exception 并发调度或反射读取状态失败时抛出 + */ + @Test + public void shouldBoundConcurrentFutureTriggerLocalSchedules() + throws Exception { + InMemoryTriggerStore store = new InMemoryTriggerStore(); + TriggerScheduler scheduler = scheduler(store); + ExecutorService callers = + Executors.newFixedThreadPool(16); + List> tasks = + new ArrayList<>(); + for (int index = 0; index < 2000; index++) { + int triggerIndex = index; + tasks.add(() -> { + scheduler.schedule(nearFutureTrigger( + "concurrent-future-" + triggerIndex, + TimeUnit.SECONDS.toMillis(50))); + return null; + }); + } + try { + callers.invokeAll(tasks).forEach(result -> { + try { + result.get(); + } catch (Exception error) { + throw new AssertionError( + "concurrent schedule failed", + error); + } + }); + Field field = TriggerScheduler.class.getDeclaredField( + "scheduledFutures"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map localFutures = + (Map) field.get(scheduler); + Assert.assertEquals(1024, localFutures.size()); + Assert.assertEquals( + 2000, + store.findAllPending().size()); + } finally { + callers.shutdownNow(); + scheduler.shutdown(); + } + } + + /** + * 验证高并发零延迟触发完成后 Future 与时间索引同时清空。 + * + * @throws Exception 等待任务完成或反射读取索引失败时抛出 + */ + @Test + public void shouldNotLeakImmediateTriggerTimeIndex() + throws Exception { + InMemoryTriggerStore store = + new InMemoryTriggerStore(); + TriggerScheduler scheduler = + scheduler(store); + int triggerCount = 1000; + CountDownLatch consumed = + new CountDownLatch(triggerCount); + scheduler.registerConsumer( + (current, worker) -> + consumed.countDown()); + + try { + for (int index = 0; + index < triggerCount; + index++) { + scheduler.schedule( + dueTrigger( + "immediate-" + index)); + } + Assert.assertTrue( + consumed.await( + 5L, + TimeUnit.SECONDS)); + awaitLocalScheduleIndexEmpty( + scheduler, + "scheduledFutures"); + awaitLocalScheduleIndexEmpty( + scheduler, + "scheduledTriggerTimes"); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证近未来触发器按目标时间执行,不附加完整扫描周期。 + * + * @throws Exception 等待异步触发被中断时抛出 + */ + @Test + public void shouldDispatchFutureTriggerBeforeNextScan() throws Exception { + InMemoryTriggerStore store = new InMemoryTriggerStore(); + TriggerScheduler scheduler = scheduler(store); + CountDownLatch consumed = new CountDownLatch(1); + scheduler.registerConsumer((current, worker) -> + consumed.countDown()); + Trigger trigger = new Trigger(); + trigger.setId("future-exact"); + trigger.setTriggerAt( + System.currentTimeMillis() + 200L); + + try { + scheduler.schedule(trigger); + Assert.assertTrue(consumed.await( + 700L, TimeUnit.MILLISECONDS)); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证较晚任务占满本地定时容量时,更早到期的任务仍获得精确定时槽位。 + * + * @throws Exception 等待异步触发被中断时抛出 + */ + @Test + public void shouldPreferNearTriggerWhenLocalScheduleIsFull() + throws Exception { + InMemoryTriggerStore store = + new InMemoryTriggerStore(); + TriggerScheduler scheduler = scheduler(store); + CountDownLatch consumed = new CountDownLatch(1); + scheduler.registerConsumer((current, worker) -> { + if ("near-deadline".equals( + current.getId())) { + consumed.countDown(); + } + }); + try { + for (int index = 0; index < 1024; index++) { + scheduler.schedule(nearFutureTrigger( + "later-" + index, + TimeUnit.SECONDS.toMillis(50))); + } + scheduler.schedule(nearFutureTrigger( + "near-deadline", 200L)); + + Assert.assertTrue( + "near trigger should replace a later local timer", + consumed.await( + 700L, + TimeUnit.MILLISECONDS)); + Assert.assertNull( + store.find("near-deadline")); + Assert.assertEquals( + 1024, + store.findAllPending().size()); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证两个调度器并发登记同一稳定入口时,仓储只创建一份待执行触发器。 + * + * @throws Exception 并发调用失败时抛出 + */ + @Test + public void shouldAtomicallyScheduleStableTriggerOnce() + throws Exception { + InMemoryTriggerStore store = + new InMemoryTriggerStore(); + TriggerScheduler first = scheduler(store); + TriggerScheduler second = scheduler(store); + ExecutorService callers = + Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + java.util.concurrent.Future + firstResult = callers.submit(() -> { + ready.countDown(); + start.await(); + return first.scheduleIfAbsent( + futureTrigger( + "stable-entry")); + }); + java.util.concurrent.Future + secondResult = callers.submit(() -> { + ready.countDown(); + start.await(); + return second.scheduleIfAbsent( + futureTrigger( + "stable-entry")); + }); + Assert.assertTrue( + ready.await(1L, TimeUnit.SECONDS)); + start.countDown(); + for (java.util.concurrent.Future result + : Arrays.asList( + firstResult, + secondResult)) { + Assert.assertEquals( + "stable-entry", + result.get().getId()); + } + Assert.assertEquals( + 1, + store.findAllPending().size()); + } finally { + start.countDown(); + callers.shutdownNow(); + first.shutdown(); + second.shutdown(); + } + } + + /** + * 验证工作线程容量暂时耗尽后,持久化触发器可由后续扫描自动恢复执行。 + * + * @throws Exception 等待异步执行被中断时抛出 + */ + @Test + public void shouldRescheduleTriggerAfterDispatchCapacityRecovers() throws Exception { + InMemoryTriggerStore store = new InMemoryTriggerStore(); + ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(2); + ThreadPoolExecutor worker = new ThreadPoolExecutor( + 1, + 1, + 0L, + TimeUnit.MILLISECONDS, + new SynchronousQueue<>()); + TriggerScheduler scheduler = + new TriggerScheduler(store, scheduledExecutor, worker, 1000L); + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch secondConsumed = new CountDownLatch(1); + scheduler.registerConsumer((current, executor) -> { + if ("capacity-first".equals(current.getId())) { + firstStarted.countDown(); + try { + releaseFirst.await(2, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("test consumer interrupted", exception); + } + } else if ("capacity-second".equals(current.getId())) { + secondConsumed.countDown(); + } + }); + + try { + scheduler.schedule(dueTrigger("capacity-first")); + Assert.assertTrue(firstStarted.await(2, TimeUnit.SECONDS)); + scheduler.schedule(dueTrigger("capacity-second")); + Thread.sleep(100L); + Assert.assertNotNull(store.find("capacity-second")); + + releaseFirst.countDown(); + Assert.assertTrue( + "pending trigger should be rescheduled after capacity recovers", + secondConsumed.await(3, TimeUnit.SECONDS)); + } finally { + releaseFirst.countDown(); + scheduler.shutdown(); + } + } + + /** + * 验证本地调度表被已完成占位填满后,会先清扫并继续接收新任务。 + * + * @throws Exception 反射或异步等待失败时抛出 + */ + @Test + public void shouldPruneCompletedSchedulesBeforeCapacityCheck() throws Exception { + InMemoryTriggerStore store = new InMemoryTriggerStore(); + ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(2); + ExecutorService worker = Executors.newFixedThreadPool(2); + TriggerScheduler scheduler = + new TriggerScheduler(store, scheduledExecutor, worker, 1000L); + CountDownLatch consumed = new CountDownLatch(1); + scheduler.registerConsumer((current, executor) -> consumed.countDown()); + + Field field = TriggerScheduler.class.getDeclaredField("scheduledFutures"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map> localFutures = + (Map>) field.get(scheduler); + ScheduledFuture completed = scheduledExecutor.schedule( + () -> { + }, + 0L, + TimeUnit.MILLISECONDS); + completed.get(1, TimeUnit.SECONDS); + for (int index = 0; index < 1024; index++) { + localFutures.put("completed-" + index, completed); + } + + try { + scheduler.schedule(dueTrigger("after-completed-capacity")); + Assert.assertTrue( + "completed local schedules should not block new due triggers", + consumed.await(2, TimeUnit.SECONDS)); + Assert.assertTrue(localFutures.size() < 1024); + } finally { + scheduler.shutdown(); + } + } + + /** + * 验证确认成功状态写入异常时仍归还调度容量许可。 + * + * @throws Exception 等待异步执行被中断时抛出 + */ + @Test + public void shouldReturnDispatchPermitWhenAcknowledgeFails() throws Exception { + assertTerminalStoreFailureDoesNotLeakPermit(TerminalOperation.ACKNOWLEDGE); + } + + /** + * 验证失败重投状态写入异常时仍归还调度容量许可。 + * + * @throws Exception 等待异步执行被中断时抛出 + */ + @Test + public void shouldReturnDispatchPermitWhenReleaseFails() throws Exception { + assertTerminalStoreFailureDoesNotLeakPermit(TerminalOperation.RELEASE); + } + + /** + * 验证死信状态写入异常时仍归还调度容量许可。 + * + * @throws Exception 等待异步执行被中断时抛出 + */ + @Test + public void shouldReturnDispatchPermitWhenDeadLetterFails() throws Exception { + assertTerminalStoreFailureDoesNotLeakPermit(TerminalOperation.DEAD_LETTER); + } + + /** + * 执行终态仓储异常后的容量许可回归场景。 + * + * @param operation 需要模拟失败的终态操作 + * @throws Exception 等待异步执行被中断时抛出 + */ + private void assertTerminalStoreFailureDoesNotLeakPermit( + TerminalOperation operation) throws Exception { + FailingTerminalStore store = new FailingTerminalStore(operation); + ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(2); + ThreadPoolExecutor worker = new ThreadPoolExecutor( + 1, + 1, + 0L, + TimeUnit.MILLISECONDS, + new SynchronousQueue<>()); + TriggerScheduler scheduler = + new TriggerScheduler(store, scheduledExecutor, worker, 1000L); + CountDownLatch secondConsumed = new CountDownLatch(1); + scheduler.registerConsumer((current, executor) -> { + if ("second".equals(current.getId())) { + secondConsumed.countDown(); + return; + } + if (operation == TerminalOperation.DEAD_LETTER) { + throw new NonRetryableTriggerException("expected dead-letter failure"); + } + if (operation == TerminalOperation.RELEASE) { + throw new IllegalStateException("expected release failure"); + } + }); + + try { + Trigger first = futureTrigger("first"); + store.save(first); + Assert.assertTrue(scheduler.fire(first.getId())); + Assert.assertTrue(store.terminalAttempted.await(2, TimeUnit.SECONDS)); + + store.fail = false; + Trigger second = futureTrigger("second"); + store.save(second); + boolean accepted = false; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (!accepted && System.nanoTime() < deadline) { + accepted = scheduler.fire(second.getId()); + if (!accepted) { + Thread.sleep(10L); + } + } + Assert.assertTrue("dispatch permit should be returned", accepted); + Assert.assertTrue(secondConsumed.await(2, TimeUnit.SECONDS)); + } finally { + scheduler.shutdown(); + } + } + + /** + * 创建测试调度器。 + * + * @param store 触发器仓储 + * @return 测试调度器 + */ + private TriggerScheduler scheduler(TriggerStore store) { + ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(2); + ExecutorService worker = Executors.newFixedThreadPool(2); + return new TriggerScheduler(store, scheduledExecutor, worker, 1000L); + } + + /** + * 等待指定本地调度索引清空。 + * + * @param scheduler 调度器 + * @param fieldName 索引字段名 + * @throws Exception 反射读取失败或等待超时时抛出 + */ + private void awaitLocalScheduleIndexEmpty( + TriggerScheduler scheduler, + String fieldName) throws Exception { + Field field = + TriggerScheduler.class.getDeclaredField( + fieldName); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map index = + (Map) field.get(scheduler); + long deadline = + System.nanoTime() + + TimeUnit.SECONDS.toNanos(2L); + while (!index.isEmpty() + && System.nanoTime() < deadline) { + Thread.sleep(10L); + } + Assert.assertTrue( + fieldName + " should be empty", + index.isEmpty()); + } + + /** + * 创建远期触发器,避免定时任务干扰主动认领测试。 + * + * @param id 触发器 ID + * @return 测试触发器 + */ + private Trigger futureTrigger(String id) { + return nearFutureTrigger( + id, + TimeUnit.MINUTES.toMillis(5)); + } + + /** + * 创建指定延迟的测试触发器。 + * + * @param id 触发器 ID + * @param delayMillis 延迟毫秒数 + * @return 测试触发器 + */ + private Trigger nearFutureTrigger( + String id, + long delayMillis) { + Trigger trigger = new Trigger(); + trigger.setId(id); + trigger.setTriggerAt( + System.currentTimeMillis() + + delayMillis); + return trigger; + } + + /** + * 创建立即到期的测试触发器。 + * + * @param id 触发器 ID + * @return 测试触发器 + */ + private Trigger dueTrigger(String id) { + Trigger trigger = new Trigger(); + trigger.setId(id); + trigger.setTriggerAt(System.currentTimeMillis()); + return trigger; + } + + /** + * 等待失败触发器重新进入待执行状态。 + * + * @param store 触发器仓储 + * @param triggerId 触发器 ID + * @throws InterruptedException 等待被中断时抛出 + */ + private void awaitPending(InMemoryTriggerStore store, String triggerId) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (store.find(triggerId) == null && System.nanoTime() < deadline) { + Thread.sleep(10L); + } + Assert.assertNotNull(store.find(triggerId)); + } + + /** + * 需要模拟失败的触发器终态操作。 + */ + private enum TerminalOperation { + ACKNOWLEDGE, + RELEASE, + DEAD_LETTER + } + + /** + * 在指定终态操作上抛出异常的进程内触发器仓储。 + */ + private static final class FailingTerminalStore extends InMemoryTriggerStore { + + private final TerminalOperation operation; + private final CountDownLatch terminalAttempted = new CountDownLatch(1); + private volatile boolean fail = true; + + /** + * 创建失败仓储。 + * + * @param operation 需要失败的终态操作 + */ + private FailingTerminalStore(TerminalOperation operation) { + this.operation = operation; + } + + /** + * {@inheritDoc} + */ + @Override + public void acknowledge(Trigger trigger) { + failIfNeeded(TerminalOperation.ACKNOWLEDGE); + super.acknowledge(trigger); + } + + /** + * {@inheritDoc} + */ + @Override + public void release(Trigger trigger) { + failIfNeeded(TerminalOperation.RELEASE); + super.release(trigger); + } + + /** + * {@inheritDoc} + */ + @Override + public void deadLetter(Trigger trigger, String reason) { + failIfNeeded(TerminalOperation.DEAD_LETTER); + super.deadLetter(trigger, reason); + } + + /** + * 在当前测试操作上抛出预期异常。 + * + * @param current 当前终态操作 + */ + private void failIfNeeded(TerminalOperation current) { + if (fail && operation == current) { + terminalAttempted.countDown(); + throw new IllegalStateException("expected terminal store failure: " + current); + } + } + } + + /** + * 模拟 claim 已转移给其他 owner 的触发器仓储。 + */ + private static final class LostClaimStore + extends InMemoryTriggerStore { + + private final CountDownLatch finalizationRejected = + new CountDownLatch(1); + private final AtomicInteger deadLetterAttempts = + new AtomicInteger(); + + /** + * {@inheritDoc} + */ + @Override + public boolean renewClaim( + Trigger trigger, long leaseMillis) { + finalizationRejected.countDown(); + return false; + } + + /** + * {@inheritDoc} + */ + @Override + public void deadLetter( + Trigger trigger, String reason) { + deadLetterAttempts.incrementAndGet(); + } + } +}