feat: persist process history events, listeners, and ACTION executions
Give hosts an append-only timeline, post-commit OrdoEventListener hooks, and durable ACTION results without blocking the approval flow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+28
-23
@@ -3,38 +3,43 @@
|
|||||||
记录当前已完成能力之后,后续要做的开发计划。按优先级分组,供后续排期/立项参考。
|
记录当前已完成能力之后,后续要做的开发计划。按优先级分组,供后续排期/立项参考。
|
||||||
|
|
||||||
## 已完成(背景,非本文档重点)
|
## 已完成(背景,非本文档重点)
|
||||||
|
|
||||||
- ANY/ALL 多候选人会签/或签(见 `/memories/repo/any-all-multi-approval.md`)
|
- ANY/ALL 多候选人会签/或签(见 `/memories/repo/any-all-multi-approval.md`)
|
||||||
- JDBC 存储模块 + Flyway 迁移(V1~V4)
|
- JDBC 存储模块 + Flyway 迁移(V1~V4)
|
||||||
- 条件路由 `StepTransition` + `RoutingCondition`
|
- 条件路由 `StepTransition` + `RoutingCondition`
|
||||||
- ACTION 步骤 + `ActionHandler`
|
- ACTION 步骤 + `ActionHandler`
|
||||||
- 流程撤回(`WITHDRAWN`)
|
- 流程撤回(`WITHDRAWN`)
|
||||||
- **任务/实例/流程定义分页过滤查询 API**(2026-09-15 完成):`ApprovalTaskRepository.query`、
|
- **任务/实例/流程定义分页过滤查询 API**(2026-09-15 完成):`ApprovalTaskRepository.query`、
|
||||||
`ProcessInstanceRepository.query`、`ProcessDefinitionRepository.findAll` + `OrdoEngine` 对应的
|
`ProcessInstanceRepository.query`、`ProcessDefinitionRepository.findAll` + `OrdoEngine` 对应的
|
||||||
`queryTasks`/`queryInstances`/`listDefinitions`,均支持 `PageRequest`/`Page` 分页与按
|
`queryTasks`/`queryInstances`/`listDefinitions`,均支持 `PageRequest`/`Page` 分页与按
|
||||||
assignee/instanceId/definitionId/status/initiator/时间范围过滤,默认按时间降序(最新优先)。
|
assignee/instanceId/definitionId/status/initiator/时间范围过滤,默认按时间降序(最新优先)。
|
||||||
|
|
||||||
## P0 — 审计与扩展点
|
## P0 — 审计与扩展点
|
||||||
1. **独立历史/审计事件模型**:现在历史只能靠 `ApprovalTask.action` 字段拼凑,没有独立的流程事件表
|
|
||||||
(谁在何时对哪个实例做了什么)。建议新增 `ProcessEvent`/`ProcessHistoryRepository`。
|
- [x] **独立历史/审计事件模型**:现在历史只能靠 `ApprovalTask.action` 字段拼凑,没有独立的流程事件表
|
||||||
2. **状态变更事件监听器**:`OrdoEngine` 目前没有任何 listener/hook,无法在任务创建、审批、实例完成时
|
(谁在何时对哪个实例做了什么)。建议新增 `ProcessEvent`/`ProcessHistoryRepository`。
|
||||||
被外部感知(做通知、写审计日志等)。可加 `OrdoEventListener` 扩展点,风格与 `AssigneeResolver`/
|
- [x] **状态变更事件监听器**:`OrdoEngine` 目前没有任何 listener/hook,无法在任务创建、审批、实例完成时
|
||||||
`RoutingCondition` 一致。
|
被外部感知(做通知、写审计日志等)。可加 `OrdoEventListener` 扩展点,风格与 `AssigneeResolver`/
|
||||||
3. **ACTION 步骤执行记录持久化**:目前 `ActionHandler` 执行结果只在宿主内存里记(如 rhizome 的
|
`RoutingCondition` 一致。
|
||||||
`LeaveActionHandler`),重启即丢失,且失败只打日志不影响流程状态,需要设计重试/失败处理策略。
|
- [x] **ACTION 步骤执行记录持久化**:目前 `ActionHandler` 执行结果只在宿主内存里记(如 rhizome 的
|
||||||
|
`LeaveActionHandler`),重启即丢失,且失败只打日志不影响流程状态,需要设计重试/失败处理策略。
|
||||||
|
|
||||||
## P1 — 任务生命周期完善
|
## P1 — 任务生命周期完善
|
||||||
4. **任务委托/转派(delegate/reassign)**:任务创建后 assignee 不可变,无法转交他人处理。
|
|
||||||
5. **超时/升级(SLA/escalation)**:无到期时间、定时器、自动升级机制。
|
- [ ] **任务委托/转派(delegate/reassign)**:任务创建后 assignee 不可变,无法转交他人处理。
|
||||||
6. **流程实例取消 vs 撤回**:目前只有 `WITHDRAWN`(仅发起人可操作),没有管理员/系统层面的
|
- [ ] **超时/升级(SLA/escalation)**:无到期时间、定时器、自动升级机制。
|
||||||
`CANCELLED` 语义。
|
- [ ] **流程实例取消 vs 撤回**:目前只有 `WITHDRAWN`(仅发起人可操作),没有管理员/系统层面的
|
||||||
|
`CANCELLED` 语义。
|
||||||
|
|
||||||
## P2 — 架构级演进(范围较大,放在后面)
|
## P2 — 架构级演进(范围较大,放在后面)
|
||||||
7. **流程定义版本化**:目前同 id 直接整体替换(`replace`),建议演进为不可变多版本 + 运行中实例
|
|
||||||
锁定所用版本。
|
- [ ] **流程定义版本化**:目前同 id 直接整体替换(`replace`),建议演进为不可变多版本 + 运行中实例
|
||||||
8. **多租户支持**:数据模型无 tenant 隔离字段。
|
锁定所用版本。
|
||||||
9. **JDBC 多方言支持**:目前 DDL/实现明显偏向 PostgreSQL(唯一键冲突处理等),无 MySQL/Testcontainers
|
- [ ] **多租户支持**:数据模型无 tenant 隔离字段。
|
||||||
测试,若要支持更多数据库需要抽象 dialect 层。
|
- [ ] **JDBC 多方言支持**:目前 DDL/实现明显偏向 PostgreSQL(唯一键冲突处理等),无 MySQL/Testcontainers
|
||||||
10. **通用 REST Starter**:现在 REST 层完全是 rhizome 自己写的 demo,可考虑提供一个可选的
|
测试,若要支持更多数据库需要抽象 dialect 层。
|
||||||
`ordo-spring-boot-starter-web` 暴露标准 REST 接口。
|
- [ ] **通用 REST Starter**:现在 REST 层完全是 rhizome 自己写的 demo,可考虑提供一个可选的
|
||||||
11. **表单/UI schema、子流程、并行 fork-join**:属于更大的引擎能力扩展,优先级最低,等基础能力稳定后
|
`ordo-spring-boot-starter-web` 暴露标准 REST 接口。
|
||||||
再评估是否需要。
|
- [ ] **表单/UI schema、子流程、并行 fork-join**:属于更大的引擎能力扩展,优先级最低,等基础能力稳定后
|
||||||
|
再评估是否需要。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Persisted record of an ACTION step invocation. */
|
||||||
|
public record ActionExecution(String id, String instanceId, String stepId, String actionKey,
|
||||||
|
ActionExecutionStatus status, String errorMessage, Instant startedAt,
|
||||||
|
Instant finishedAt) {
|
||||||
|
public ActionExecution {
|
||||||
|
requireText(id, "id");
|
||||||
|
requireText(instanceId, "instanceId");
|
||||||
|
requireText(stepId, "stepId");
|
||||||
|
requireText(actionKey, "actionKey");
|
||||||
|
Objects.requireNonNull(status, "status must not be null");
|
||||||
|
Objects.requireNonNull(startedAt, "startedAt must not be null");
|
||||||
|
errorMessage = errorMessage == null || errorMessage.isBlank() ? null : errorMessage.strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireText(String value, String name) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException(name + " must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
|
/** Lifecycle of a persisted ACTION-step execution. */
|
||||||
|
public enum ActionExecutionStatus {
|
||||||
|
PENDING,
|
||||||
|
SUCCESS,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
@@ -47,4 +47,10 @@ public interface OrdoEngine {
|
|||||||
|
|
||||||
/** Paginated listing of all registered process definitions. */
|
/** Paginated listing of all registered process definitions. */
|
||||||
Page<ProcessDefinition> listDefinitions(PageRequest pageRequest);
|
Page<ProcessDefinition> listDefinitions(PageRequest pageRequest);
|
||||||
|
|
||||||
|
/** Instance timeline, oldest-first; see {@link com.jetlumen.ordo.api.repository.ProcessHistoryRepository#query}. */
|
||||||
|
Page<ProcessEvent> queryHistory(String instanceId, PageRequest pageRequest);
|
||||||
|
|
||||||
|
/** ACTION executions for an instance, oldest-first. */
|
||||||
|
Page<ActionExecution> queryActionExecutions(String instanceId, PageRequest pageRequest);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host hook invoked after a process mutation has been committed. Implementations must not throw
|
||||||
|
* in a way that affects the engine: the runtime isolates listener failures.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface OrdoEventListener {
|
||||||
|
void onEvent(ProcessEvent event);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Immutable audit record of something that happened to a process instance. */
|
||||||
|
public record ProcessEvent(String id, String instanceId, String taskId, String stepId, ProcessEventType type,
|
||||||
|
String actor, String detail, Instant occurredAt) {
|
||||||
|
public ProcessEvent {
|
||||||
|
requireText(id, "id");
|
||||||
|
requireText(instanceId, "instanceId");
|
||||||
|
Objects.requireNonNull(type, "type must not be null");
|
||||||
|
Objects.requireNonNull(occurredAt, "occurredAt must not be null");
|
||||||
|
taskId = blankToNull(taskId);
|
||||||
|
stepId = blankToNull(stepId);
|
||||||
|
actor = blankToNull(actor);
|
||||||
|
detail = blankToNull(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value.strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireText(String value, String name) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException(name + " must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
|
/** Kinds of append-only process history events. */
|
||||||
|
public enum ProcessEventType {
|
||||||
|
INSTANCE_STARTED,
|
||||||
|
TASK_CREATED,
|
||||||
|
TASK_APPROVED,
|
||||||
|
TASK_REJECTED,
|
||||||
|
TASK_SKIPPED,
|
||||||
|
INSTANCE_APPROVED,
|
||||||
|
INSTANCE_REJECTED,
|
||||||
|
INSTANCE_WITHDRAWN,
|
||||||
|
ACTION_SUCCEEDED,
|
||||||
|
ACTION_FAILED
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package com.jetlumen.ordo.api.repository;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionExecution;
|
||||||
|
import com.jetlumen.ordo.api.ActionExecutionStatus;
|
||||||
|
import com.jetlumen.ordo.api.query.Page;
|
||||||
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** Storage port for ACTION-step execution records. */
|
||||||
|
public interface ActionExecutionRepository {
|
||||||
|
void insert(ActionExecution execution);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes a pending execution.
|
||||||
|
*
|
||||||
|
* @return true if the row was still pending and was updated
|
||||||
|
*/
|
||||||
|
boolean complete(String executionId, ActionExecutionStatus status, String errorMessage, Instant finishedAt);
|
||||||
|
|
||||||
|
/** Executions for one instance, oldest-first ({@code started_at}, then {@code id}). */
|
||||||
|
Page<ActionExecution> query(String instanceId, PageRequest pageRequest);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.jetlumen.ordo.api.repository;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ProcessEvent;
|
||||||
|
import com.jetlumen.ordo.api.query.Page;
|
||||||
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
|
|
||||||
|
/** Storage port for append-only process history. */
|
||||||
|
public interface ProcessHistoryRepository {
|
||||||
|
void append(ProcessEvent event);
|
||||||
|
|
||||||
|
/** Timeline for one instance, oldest-first ({@code occurred_at}, then {@code id}). */
|
||||||
|
Page<ProcessEvent> query(String instanceId, PageRequest pageRequest);
|
||||||
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
package com.jetlumen.ordo.core;
|
package com.jetlumen.ordo.core;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionExecution;
|
||||||
|
import com.jetlumen.ordo.api.ActionExecutionStatus;
|
||||||
import com.jetlumen.ordo.api.ActionHandler;
|
import com.jetlumen.ordo.api.ActionHandler;
|
||||||
import com.jetlumen.ordo.api.ApprovalPolicy;
|
import com.jetlumen.ordo.api.ApprovalPolicy;
|
||||||
import com.jetlumen.ordo.api.ApprovalStep;
|
import com.jetlumen.ordo.api.ApprovalStep;
|
||||||
import com.jetlumen.ordo.api.ApprovalTask;
|
import com.jetlumen.ordo.api.ApprovalTask;
|
||||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||||
import com.jetlumen.ordo.api.OrdoEngine;
|
import com.jetlumen.ordo.api.OrdoEngine;
|
||||||
|
import com.jetlumen.ordo.api.OrdoEventListener;
|
||||||
import com.jetlumen.ordo.api.ProcessContext;
|
import com.jetlumen.ordo.api.ProcessContext;
|
||||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||||
|
import com.jetlumen.ordo.api.ProcessEvent;
|
||||||
|
import com.jetlumen.ordo.api.ProcessEventType;
|
||||||
import com.jetlumen.ordo.api.ProcessInstance;
|
import com.jetlumen.ordo.api.ProcessInstance;
|
||||||
import com.jetlumen.ordo.api.ProcessStatus;
|
import com.jetlumen.ordo.api.ProcessStatus;
|
||||||
import com.jetlumen.ordo.api.RoutingCondition;
|
import com.jetlumen.ordo.api.RoutingCondition;
|
||||||
@@ -30,8 +35,10 @@ import com.jetlumen.ordo.api.query.InstanceQuery;
|
|||||||
import com.jetlumen.ordo.api.query.Page;
|
import com.jetlumen.ordo.api.query.Page;
|
||||||
import com.jetlumen.ordo.api.query.PageRequest;
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
import com.jetlumen.ordo.api.query.TaskQuery;
|
import com.jetlumen.ordo.api.query.TaskQuery;
|
||||||
|
import com.jetlumen.ordo.api.repository.ActionExecutionRepository;
|
||||||
import com.jetlumen.ordo.api.repository.ApprovalTaskRepository;
|
import com.jetlumen.ordo.api.repository.ApprovalTaskRepository;
|
||||||
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
|
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
|
||||||
|
import com.jetlumen.ordo.api.repository.ProcessHistoryRepository;
|
||||||
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
|
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
@@ -44,6 +51,7 @@ import java.util.List;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,12 +72,20 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
private final ProcessDefinitionRepository definitionRepository;
|
private final ProcessDefinitionRepository definitionRepository;
|
||||||
private final ProcessInstanceRepository instanceRepository;
|
private final ProcessInstanceRepository instanceRepository;
|
||||||
private final ApprovalTaskRepository taskRepository;
|
private final ApprovalTaskRepository taskRepository;
|
||||||
|
private final ProcessHistoryRepository historyRepository;
|
||||||
|
private final ActionExecutionRepository actionExecutionRepository;
|
||||||
|
private final List<OrdoEventListener> listeners;
|
||||||
|
/** Monotonic millis offset so same-clock events keep causal order after JDBC Timestamp round-trips. */
|
||||||
|
private final AtomicLong eventSequence = new AtomicLong();
|
||||||
|
|
||||||
public DefaultOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
|
public DefaultOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
|
||||||
ActionHandler actionHandler, TransactionExecutor transactionExecutor,
|
ActionHandler actionHandler, TransactionExecutor transactionExecutor,
|
||||||
ProcessDefinitionRepository definitionRepository,
|
ProcessDefinitionRepository definitionRepository,
|
||||||
ProcessInstanceRepository instanceRepository,
|
ProcessInstanceRepository instanceRepository,
|
||||||
ApprovalTaskRepository taskRepository) {
|
ApprovalTaskRepository taskRepository,
|
||||||
|
ProcessHistoryRepository historyRepository,
|
||||||
|
ActionExecutionRepository actionExecutionRepository,
|
||||||
|
List<OrdoEventListener> listeners) {
|
||||||
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
||||||
this.assigneeResolver = Objects.requireNonNull(assigneeResolver, "assigneeResolver must not be null");
|
this.assigneeResolver = Objects.requireNonNull(assigneeResolver, "assigneeResolver must not be null");
|
||||||
this.routingCondition = Objects.requireNonNull(routingCondition, "routingCondition must not be null");
|
this.routingCondition = Objects.requireNonNull(routingCondition, "routingCondition must not be null");
|
||||||
@@ -78,6 +94,10 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
this.definitionRepository = Objects.requireNonNull(definitionRepository, "definitionRepository must not be null");
|
this.definitionRepository = Objects.requireNonNull(definitionRepository, "definitionRepository must not be null");
|
||||||
this.instanceRepository = Objects.requireNonNull(instanceRepository, "instanceRepository must not be null");
|
this.instanceRepository = Objects.requireNonNull(instanceRepository, "instanceRepository must not be null");
|
||||||
this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null");
|
this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null");
|
||||||
|
this.historyRepository = Objects.requireNonNull(historyRepository, "historyRepository must not be null");
|
||||||
|
this.actionExecutionRepository = Objects.requireNonNull(actionExecutionRepository,
|
||||||
|
"actionExecutionRepository must not be null");
|
||||||
|
this.listeners = List.copyOf(Objects.requireNonNull(listeners, "listeners must not be null"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -108,49 +128,58 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
requireText(initiator, "initiator");
|
requireText(initiator, "initiator");
|
||||||
Objects.requireNonNull(context, "context must not be null");
|
Objects.requireNonNull(context, "context must not be null");
|
||||||
List<PendingAction> queued = new ArrayList<>();
|
List<PendingAction> queued = new ArrayList<>();
|
||||||
|
List<ProcessEvent> events = new ArrayList<>();
|
||||||
ProcessInstance instance = transactionExecutor.execute(() -> {
|
ProcessInstance instance = transactionExecutor.execute(() -> {
|
||||||
ProcessDefinition definition = requireDefinition(definitionId);
|
ProcessDefinition definition = requireDefinition(definitionId);
|
||||||
Instant now = clock.instant();
|
Instant now = clock.instant();
|
||||||
ProcessInstance started = new ProcessInstance(nextId(), definition.id(), initiator,
|
ProcessInstance started = new ProcessInstance(nextId(), definition.id(), initiator,
|
||||||
ProcessStatus.RUNNING, now, null, context);
|
ProcessStatus.RUNNING, now, null, context);
|
||||||
instanceRepository.insert(started);
|
instanceRepository.insert(started);
|
||||||
enterStep(started, definition, definition.steps().getFirst(), now, queued);
|
record(events, started.id(), null, null, ProcessEventType.INSTANCE_STARTED, initiator, null, now);
|
||||||
|
enterStep(started, definition, definition.steps().getFirst(), now, queued, events);
|
||||||
return started;
|
return started;
|
||||||
});
|
});
|
||||||
runQueuedActions(queued);
|
finishCommittedWork(queued, events);
|
||||||
return instanceRepository.findById(instance.id()).orElse(instance);
|
return instanceRepository.findById(instance.id()).orElse(instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized ApprovalTask approve(String taskId, String actor, String comment) {
|
public synchronized ApprovalTask approve(String taskId, String actor, String comment) {
|
||||||
List<PendingAction> queued = new ArrayList<>();
|
List<PendingAction> queued = new ArrayList<>();
|
||||||
|
List<ProcessEvent> events = new ArrayList<>();
|
||||||
ApprovalTask completed = transactionExecutor.execute(() -> {
|
ApprovalTask completed = transactionExecutor.execute(() -> {
|
||||||
ApprovalTask task = requirePendingTaskForActor(taskId, actor);
|
ApprovalTask task = requirePendingTaskForActor(taskId, actor);
|
||||||
Instant now = clock.instant();
|
Instant now = clock.instant();
|
||||||
ApprovalTask completedTask = completeTask(task, TaskStatus.APPROVED, new TaskAction(actor, comment, now));
|
ApprovalTask completedTask = completeTask(task, TaskStatus.APPROVED, new TaskAction(actor, comment, now),
|
||||||
advanceAfterDecision(completedTask, now, queued);
|
events);
|
||||||
|
advanceAfterDecision(completedTask, now, queued, events);
|
||||||
return completedTask;
|
return completedTask;
|
||||||
});
|
});
|
||||||
runQueuedActions(queued);
|
finishCommittedWork(queued, events);
|
||||||
return completed;
|
return completed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized ApprovalTask reject(String taskId, String actor, String comment) {
|
public synchronized ApprovalTask reject(String taskId, String actor, String comment) {
|
||||||
return transactionExecutor.execute(() -> {
|
List<ProcessEvent> events = new ArrayList<>();
|
||||||
|
ApprovalTask completed = transactionExecutor.execute(() -> {
|
||||||
ApprovalTask task = requirePendingTaskForActor(taskId, actor);
|
ApprovalTask task = requirePendingTaskForActor(taskId, actor);
|
||||||
Instant now = clock.instant();
|
Instant now = clock.instant();
|
||||||
ApprovalTask completedTask = completeTask(task, TaskStatus.REJECTED, new TaskAction(actor, comment, now));
|
ApprovalTask completedTask = completeTask(task, TaskStatus.REJECTED, new TaskAction(actor, comment, now),
|
||||||
advanceAfterDecision(completedTask, now, List.of());
|
events);
|
||||||
|
advanceAfterDecision(completedTask, now, List.of(), events);
|
||||||
return completedTask;
|
return completedTask;
|
||||||
});
|
});
|
||||||
|
dispatch(events);
|
||||||
|
return completed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized ProcessInstance withdraw(String instanceId, String actor, String comment) {
|
public synchronized ProcessInstance withdraw(String instanceId, String actor, String comment) {
|
||||||
requireText(instanceId, "instance id");
|
requireText(instanceId, "instance id");
|
||||||
requireText(actor, "actor");
|
requireText(actor, "actor");
|
||||||
return transactionExecutor.execute(() -> {
|
List<ProcessEvent> events = new ArrayList<>();
|
||||||
|
ProcessInstance withdrawn = transactionExecutor.execute(() -> {
|
||||||
ProcessInstance instance = instanceRepository.findById(instanceId)
|
ProcessInstance instance = instanceRepository.findById(instanceId)
|
||||||
.orElseThrow(() -> new InstanceNotFoundException(instanceId));
|
.orElseThrow(() -> new InstanceNotFoundException(instanceId));
|
||||||
if (!instance.initiator().equals(actor)) {
|
if (!instance.initiator().equals(actor)) {
|
||||||
@@ -160,19 +189,25 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
throw new InstanceAlreadyCompletedException(instanceId);
|
throw new InstanceAlreadyCompletedException(instanceId);
|
||||||
}
|
}
|
||||||
Instant now = clock.instant();
|
Instant now = clock.instant();
|
||||||
ProcessInstance withdrawn = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(),
|
ProcessInstance completed = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(),
|
||||||
ProcessStatus.WITHDRAWN, instance.startedAt(), now, instance.context());
|
ProcessStatus.WITHDRAWN, instance.startedAt(), now, instance.context());
|
||||||
if (!instanceRepository.completeIfRunning(withdrawn)) {
|
if (!instanceRepository.completeIfRunning(completed)) {
|
||||||
throw new InstanceAlreadyCompletedException(instanceId);
|
throw new InstanceAlreadyCompletedException(instanceId);
|
||||||
}
|
}
|
||||||
|
record(events, instanceId, null, null, ProcessEventType.INSTANCE_WITHDRAWN, actor, comment, now);
|
||||||
TaskAction action = new TaskAction(actor, comment, now);
|
TaskAction action = new TaskAction(actor, comment, now);
|
||||||
for (ApprovalTask pending : taskRepository.findPendingByInstanceId(instanceId)) {
|
for (ApprovalTask pending : taskRepository.findPendingByInstanceId(instanceId)) {
|
||||||
ApprovalTask skipped = new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(),
|
ApprovalTask skipped = new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(),
|
||||||
pending.name(), pending.assignee(), TaskStatus.SKIPPED, pending.createdAt(), now, action);
|
pending.name(), pending.assignee(), TaskStatus.SKIPPED, pending.createdAt(), now, action);
|
||||||
taskRepository.completeIfPending(skipped);
|
if (taskRepository.completeIfPending(skipped)) {
|
||||||
|
record(events, instanceId, pending.id(), pending.stepId(), ProcessEventType.TASK_SKIPPED, actor,
|
||||||
|
comment, now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return withdrawn;
|
return completed;
|
||||||
});
|
});
|
||||||
|
dispatch(events);
|
||||||
|
return withdrawn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -222,12 +257,28 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
return definitionRepository.findAll(pageRequest);
|
return definitionRepository.findAll(pageRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createStepTasks(ProcessInstance instance, ApprovalStep step, Instant now) {
|
@Override
|
||||||
|
public synchronized Page<ProcessEvent> queryHistory(String instanceId, PageRequest pageRequest) {
|
||||||
|
requireText(instanceId, "instance id");
|
||||||
|
Objects.requireNonNull(pageRequest, "pageRequest must not be null");
|
||||||
|
return historyRepository.query(instanceId, pageRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized Page<ActionExecution> queryActionExecutions(String instanceId, PageRequest pageRequest) {
|
||||||
|
requireText(instanceId, "instance id");
|
||||||
|
Objects.requireNonNull(pageRequest, "pageRequest must not be null");
|
||||||
|
return actionExecutionRepository.query(instanceId, pageRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createStepTasks(ProcessInstance instance, ApprovalStep step, Instant now, List<ProcessEvent> events) {
|
||||||
for (String candidate : step.candidates()) {
|
for (String candidate : step.candidates()) {
|
||||||
String assignee = assigneeResolver.resolve(candidate, step, instance.context());
|
String assignee = assigneeResolver.resolve(candidate, step, instance.context());
|
||||||
requireText(assignee, "resolved assignee");
|
requireText(assignee, "resolved assignee");
|
||||||
taskRepository.save(new ApprovalTask(nextId(), instance.id(), step.id(), step.name(), assignee,
|
ApprovalTask task = new ApprovalTask(nextId(), instance.id(), step.id(), step.name(), assignee,
|
||||||
TaskStatus.PENDING, now, null, null));
|
TaskStatus.PENDING, now, null, null);
|
||||||
|
taskRepository.save(task);
|
||||||
|
record(events, instance.id(), task.id(), step.id(), ProcessEventType.TASK_CREATED, assignee, null, now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,12 +295,17 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ApprovalTask completeTask(ApprovalTask task, TaskStatus status, TaskAction action) {
|
private ApprovalTask completeTask(ApprovalTask task, TaskStatus status, TaskAction action,
|
||||||
|
List<ProcessEvent> events) {
|
||||||
ApprovalTask completed = new ApprovalTask(task.id(), task.instanceId(), task.stepId(), task.name(),
|
ApprovalTask completed = new ApprovalTask(task.id(), task.instanceId(), task.stepId(), task.name(),
|
||||||
task.assignee(), status, task.createdAt(), action.operatedAt(), action);
|
task.assignee(), status, task.createdAt(), action.operatedAt(), action);
|
||||||
if (!taskRepository.completeIfPending(completed)) {
|
if (!taskRepository.completeIfPending(completed)) {
|
||||||
throw new TaskAlreadyCompletedException(task.id());
|
throw new TaskAlreadyCompletedException(task.id());
|
||||||
}
|
}
|
||||||
|
ProcessEventType type = status == TaskStatus.APPROVED
|
||||||
|
? ProcessEventType.TASK_APPROVED : ProcessEventType.TASK_REJECTED;
|
||||||
|
record(events, task.instanceId(), task.id(), task.stepId(), type, action.actor(), action.comment(),
|
||||||
|
action.operatedAt());
|
||||||
return completed;
|
return completed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,21 +313,22 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
* Decides whether the step (and the process instance) can move on after a single candidate
|
* Decides whether the step (and the process instance) can move on after a single candidate
|
||||||
* task was approved or rejected, applying the step's {@link ApprovalPolicy}.
|
* task was approved or rejected, applying the step's {@link ApprovalPolicy}.
|
||||||
*/
|
*/
|
||||||
private void advanceAfterDecision(ApprovalTask completedTask, Instant now, List<PendingAction> queued) {
|
private void advanceAfterDecision(ApprovalTask completedTask, Instant now, List<PendingAction> queued,
|
||||||
|
List<ProcessEvent> events) {
|
||||||
ProcessInstance instance = requireInstance(completedTask.instanceId());
|
ProcessInstance instance = requireInstance(completedTask.instanceId());
|
||||||
ProcessDefinition definition = requireDefinition(instance.definitionId());
|
ProcessDefinition definition = requireDefinition(instance.definitionId());
|
||||||
ApprovalStep step = requireStep(definition, completedTask.stepId());
|
ApprovalStep step = requireStep(definition, completedTask.stepId());
|
||||||
List<ApprovalTask> siblings = taskRepository.findByInstanceIdAndStepId(instance.id(), step.id());
|
List<ApprovalTask> siblings = taskRepository.findByInstanceIdAndStepId(instance.id(), step.id());
|
||||||
|
|
||||||
if (completedTask.status() == TaskStatus.APPROVED && step.policy() == ApprovalPolicy.ANY) {
|
if (completedTask.status() == TaskStatus.APPROVED && step.policy() == ApprovalPolicy.ANY) {
|
||||||
skipPendingSiblings(siblings, completedTask.id(), now);
|
skipPendingSiblings(siblings, completedTask.id(), completedTask.action().actor(), now, events);
|
||||||
advanceOrComplete(instance, definition, step, now, queued);
|
advanceOrComplete(instance, definition, step, now, queued, events);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completedTask.status() == TaskStatus.REJECTED && step.policy() == ApprovalPolicy.ALL) {
|
if (completedTask.status() == TaskStatus.REJECTED && step.policy() == ApprovalPolicy.ALL) {
|
||||||
skipPendingSiblings(siblings, completedTask.id(), now);
|
skipPendingSiblings(siblings, completedTask.id(), completedTask.action().actor(), now, events);
|
||||||
completeInstance(instance, ProcessStatus.REJECTED, now);
|
completeInstance(instance, ProcessStatus.REJECTED, now, events);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +338,7 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
.anyMatch(sibling -> !sibling.id().equals(completedTask.id())
|
.anyMatch(sibling -> !sibling.id().equals(completedTask.id())
|
||||||
&& (sibling.status() == TaskStatus.PENDING || sibling.status() == TaskStatus.APPROVED));
|
&& (sibling.status() == TaskStatus.PENDING || sibling.status() == TaskStatus.APPROVED));
|
||||||
if (!stepStillAlive) {
|
if (!stepStillAlive) {
|
||||||
completeInstance(instance, ProcessStatus.REJECTED, now);
|
completeInstance(instance, ProcessStatus.REJECTED, now, events);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -289,32 +346,37 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
// ALL policy: only advance once every candidate has approved.
|
// ALL policy: only advance once every candidate has approved.
|
||||||
boolean allApproved = siblings.stream().allMatch(sibling -> sibling.status() == TaskStatus.APPROVED);
|
boolean allApproved = siblings.stream().allMatch(sibling -> sibling.status() == TaskStatus.APPROVED);
|
||||||
if (allApproved) {
|
if (allApproved) {
|
||||||
advanceOrComplete(instance, definition, step, now, queued);
|
advanceOrComplete(instance, definition, step, now, queued, events);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void advanceOrComplete(ProcessInstance instance, ProcessDefinition definition, ApprovalStep step,
|
private void advanceOrComplete(ProcessInstance instance, ProcessDefinition definition, ApprovalStep step,
|
||||||
Instant now, List<PendingAction> queued) {
|
Instant now, List<PendingAction> queued, List<ProcessEvent> events) {
|
||||||
StepTransition matched = resolveTransition(definition, step, instance);
|
StepTransition matched = resolveTransition(definition, step, instance);
|
||||||
if (matched.toStepId() == null) {
|
if (matched.toStepId() == null) {
|
||||||
completeInstance(instance, ProcessStatus.APPROVED, now);
|
completeInstance(instance, ProcessStatus.APPROVED, now, events);
|
||||||
} else {
|
} else {
|
||||||
enterStep(instance, definition, requireStep(definition, matched.toStepId()), now, queued);
|
enterStep(instance, definition, requireStep(definition, matched.toStepId()), now, queued, events);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void enterStep(ProcessInstance instance, ProcessDefinition definition, ApprovalStep start, Instant now,
|
private void enterStep(ProcessInstance instance, ProcessDefinition definition, ApprovalStep start, Instant now,
|
||||||
List<PendingAction> queued) {
|
List<PendingAction> queued, List<ProcessEvent> events) {
|
||||||
ApprovalStep current = start;
|
ApprovalStep current = start;
|
||||||
for (int hops = 0; hops < MAX_CONSECUTIVE_ACTIONS; hops++) {
|
for (int hops = 0; hops < MAX_CONSECUTIVE_ACTIONS; hops++) {
|
||||||
if (current.kind() == StepKind.APPROVAL) {
|
if (current.kind() == StepKind.APPROVAL) {
|
||||||
createStepTasks(instance, current, now);
|
createStepTasks(instance, current, now, events);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queued.add(new PendingAction(current.actionKey(), instance.context()));
|
String executionId = nextId();
|
||||||
|
actionExecutionRepository.insert(new ActionExecution(executionId, instance.id(), current.id(),
|
||||||
|
current.actionKey(), ActionExecutionStatus.PENDING, null,
|
||||||
|
now.plusMillis(eventSequence.getAndIncrement()), null));
|
||||||
|
queued.add(new PendingAction(executionId, current.actionKey(), instance.id(), current.id(),
|
||||||
|
instance.context()));
|
||||||
StepTransition matched = resolveTransition(definition, current, instance);
|
StepTransition matched = resolveTransition(definition, current, instance);
|
||||||
if (matched.toStepId() == null) {
|
if (matched.toStepId() == null) {
|
||||||
completeInstance(instance, ProcessStatus.APPROVED, now);
|
completeInstance(instance, ProcessStatus.APPROVED, now, events);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
current = requireStep(definition, matched.toStepId());
|
current = requireStep(definition, matched.toStepId());
|
||||||
@@ -322,16 +384,49 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
throw new IllegalStateException("too many consecutive action steps in instance: " + instance.id());
|
throw new IllegalStateException("too many consecutive action steps in instance: " + instance.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void runQueuedActions(List<PendingAction> queued) {
|
private void finishCommittedWork(List<PendingAction> queued, List<ProcessEvent> events) {
|
||||||
|
runQueuedActions(queued, events);
|
||||||
|
dispatch(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runQueuedActions(List<PendingAction> queued, List<ProcessEvent> events) {
|
||||||
for (PendingAction pending : queued) {
|
for (PendingAction pending : queued) {
|
||||||
|
Instant now = clock.instant();
|
||||||
try {
|
try {
|
||||||
actionHandler.execute(pending.actionKey(), pending.context());
|
actionHandler.execute(pending.actionKey(), pending.context());
|
||||||
|
actionExecutionRepository.complete(pending.executionId(), ActionExecutionStatus.SUCCESS, null, now);
|
||||||
|
record(events, pending.instanceId(), null, pending.stepId(), ProcessEventType.ACTION_SUCCEEDED, null,
|
||||||
|
pending.actionKey(), now);
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
|
actionExecutionRepository.complete(pending.executionId(), ActionExecutionStatus.FAILED, e.getMessage(),
|
||||||
|
now);
|
||||||
|
record(events, pending.instanceId(), null, pending.stepId(), ProcessEventType.ACTION_FAILED, null,
|
||||||
|
e.getMessage(), now);
|
||||||
LOG.log(Level.WARNING, "action failed: " + pending.actionKey(), e);
|
LOG.log(Level.WARNING, "action failed: " + pending.actionKey(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void dispatch(List<ProcessEvent> events) {
|
||||||
|
for (ProcessEvent event : events) {
|
||||||
|
for (OrdoEventListener listener : listeners) {
|
||||||
|
try {
|
||||||
|
listener.onEvent(event);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.log(Level.WARNING, "listener failed for event: " + event.type(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void record(List<ProcessEvent> events, String instanceId, String taskId, String stepId,
|
||||||
|
ProcessEventType type, String actor, String detail, Instant occurredAt) {
|
||||||
|
ProcessEvent event = new ProcessEvent(nextId(), instanceId, taskId, stepId, type, actor, detail,
|
||||||
|
occurredAt.plusMillis(eventSequence.getAndIncrement()));
|
||||||
|
historyRepository.append(event);
|
||||||
|
events.add(event);
|
||||||
|
}
|
||||||
|
|
||||||
private StepTransition resolveTransition(ProcessDefinition definition, ApprovalStep step, ProcessInstance instance) {
|
private StepTransition resolveTransition(ProcessDefinition definition, ApprovalStep step, ProcessInstance instance) {
|
||||||
List<StepTransition> candidates = definition.transitions().stream()
|
List<StepTransition> candidates = definition.transitions().stream()
|
||||||
.filter(transition -> transition.fromStepId().equals(step.id()))
|
.filter(transition -> transition.fromStepId().equals(step.id()))
|
||||||
@@ -348,7 +443,8 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Marks any still-pending sibling candidate tasks for the same step as skipped. */
|
/** Marks any still-pending sibling candidate tasks for the same step as skipped. */
|
||||||
private void skipPendingSiblings(List<ApprovalTask> siblings, String decidedTaskId, Instant now) {
|
private void skipPendingSiblings(List<ApprovalTask> siblings, String decidedTaskId, String actor, Instant now,
|
||||||
|
List<ProcessEvent> events) {
|
||||||
for (ApprovalTask sibling : siblings) {
|
for (ApprovalTask sibling : siblings) {
|
||||||
if (sibling.id().equals(decidedTaskId) || sibling.status() != TaskStatus.PENDING) {
|
if (sibling.id().equals(decidedTaskId) || sibling.status() != TaskStatus.PENDING) {
|
||||||
continue;
|
continue;
|
||||||
@@ -356,16 +452,23 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
ApprovalTask skipped = new ApprovalTask(sibling.id(), sibling.instanceId(), sibling.stepId(),
|
ApprovalTask skipped = new ApprovalTask(sibling.id(), sibling.instanceId(), sibling.stepId(),
|
||||||
sibling.name(), sibling.assignee(), TaskStatus.SKIPPED, sibling.createdAt(), now, null);
|
sibling.name(), sibling.assignee(), TaskStatus.SKIPPED, sibling.createdAt(), now, null);
|
||||||
// Best-effort: if another concurrent decision already completed this sibling, leave it as-is.
|
// Best-effort: if another concurrent decision already completed this sibling, leave it as-is.
|
||||||
taskRepository.completeIfPending(skipped);
|
if (taskRepository.completeIfPending(skipped)) {
|
||||||
|
record(events, sibling.instanceId(), sibling.id(), sibling.stepId(), ProcessEventType.TASK_SKIPPED,
|
||||||
|
actor, null, now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void completeInstance(ProcessInstance instance, ProcessStatus status, Instant now) {
|
private void completeInstance(ProcessInstance instance, ProcessStatus status, Instant now,
|
||||||
|
List<ProcessEvent> events) {
|
||||||
ProcessInstance completed = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(),
|
ProcessInstance completed = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(),
|
||||||
status, instance.startedAt(), now, instance.context());
|
status, instance.startedAt(), now, instance.context());
|
||||||
if (!instanceRepository.completeIfRunning(completed)) {
|
if (!instanceRepository.completeIfRunning(completed)) {
|
||||||
throw new InstanceAlreadyCompletedException(instance.id());
|
throw new InstanceAlreadyCompletedException(instance.id());
|
||||||
}
|
}
|
||||||
|
ProcessEventType type = status == ProcessStatus.APPROVED
|
||||||
|
? ProcessEventType.INSTANCE_APPROVED : ProcessEventType.INSTANCE_REJECTED;
|
||||||
|
record(events, instance.id(), null, null, type, null, null, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ProcessDefinition requireDefinition(String definitionId) {
|
private ProcessDefinition requireDefinition(String definitionId) {
|
||||||
@@ -395,6 +498,7 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private record PendingAction(String actionKey, ProcessContext context) {
|
private record PendingAction(String executionId, String actionKey, String instanceId, String stepId,
|
||||||
|
ProcessContext context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
package com.jetlumen.ordo.core;
|
package com.jetlumen.ordo.core;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionExecution;
|
||||||
import com.jetlumen.ordo.api.ActionHandler;
|
import com.jetlumen.ordo.api.ActionHandler;
|
||||||
import com.jetlumen.ordo.api.ApprovalTask;
|
import com.jetlumen.ordo.api.ApprovalTask;
|
||||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||||
import com.jetlumen.ordo.api.OrdoEngine;
|
import com.jetlumen.ordo.api.OrdoEngine;
|
||||||
|
import com.jetlumen.ordo.api.OrdoEventListener;
|
||||||
import com.jetlumen.ordo.api.ProcessContext;
|
import com.jetlumen.ordo.api.ProcessContext;
|
||||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||||
|
import com.jetlumen.ordo.api.ProcessEvent;
|
||||||
import com.jetlumen.ordo.api.ProcessInstance;
|
import com.jetlumen.ordo.api.ProcessInstance;
|
||||||
import com.jetlumen.ordo.api.RoutingCondition;
|
import com.jetlumen.ordo.api.RoutingCondition;
|
||||||
import com.jetlumen.ordo.api.query.InstanceQuery;
|
import com.jetlumen.ordo.api.query.InstanceQuery;
|
||||||
import com.jetlumen.ordo.api.query.Page;
|
import com.jetlumen.ordo.api.query.Page;
|
||||||
import com.jetlumen.ordo.api.query.PageRequest;
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
import com.jetlumen.ordo.api.query.TaskQuery;
|
import com.jetlumen.ordo.api.query.TaskQuery;
|
||||||
|
import com.jetlumen.ordo.core.repository.InMemoryActionExecutionRepository;
|
||||||
import com.jetlumen.ordo.core.repository.InMemoryApprovalTaskRepository;
|
import com.jetlumen.ordo.core.repository.InMemoryApprovalTaskRepository;
|
||||||
import com.jetlumen.ordo.core.repository.InMemoryProcessDefinitionRepository;
|
import com.jetlumen.ordo.core.repository.InMemoryProcessDefinitionRepository;
|
||||||
|
import com.jetlumen.ordo.core.repository.InMemoryProcessHistoryRepository;
|
||||||
import com.jetlumen.ordo.core.repository.InMemoryProcessInstanceRepository;
|
import com.jetlumen.ordo.core.repository.InMemoryProcessInstanceRepository;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
@@ -54,12 +59,20 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
|
|||||||
|
|
||||||
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
|
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
|
||||||
ActionHandler actionHandler) {
|
ActionHandler actionHandler) {
|
||||||
|
this(clock, assigneeResolver, routingCondition, actionHandler, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
|
||||||
|
ActionHandler actionHandler, List<OrdoEventListener> listeners) {
|
||||||
InMemoryProcessInstanceRepository instanceRepository = new InMemoryProcessInstanceRepository();
|
InMemoryProcessInstanceRepository instanceRepository = new InMemoryProcessInstanceRepository();
|
||||||
this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, routingCondition, actionHandler,
|
this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, routingCondition, actionHandler,
|
||||||
new NoopTransactionExecutor(),
|
new NoopTransactionExecutor(),
|
||||||
new InMemoryProcessDefinitionRepository(),
|
new InMemoryProcessDefinitionRepository(),
|
||||||
instanceRepository,
|
instanceRepository,
|
||||||
new InMemoryApprovalTaskRepository(instanceRepository));
|
new InMemoryApprovalTaskRepository(instanceRepository),
|
||||||
|
new InMemoryProcessHistoryRepository(),
|
||||||
|
new InMemoryActionExecutionRepository(),
|
||||||
|
listeners);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -131,4 +144,14 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
|
|||||||
public Page<ProcessDefinition> listDefinitions(PageRequest pageRequest) {
|
public Page<ProcessDefinition> listDefinitions(PageRequest pageRequest) {
|
||||||
return delegate.listDefinitions(pageRequest);
|
return delegate.listDefinitions(pageRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<ProcessEvent> queryHistory(String instanceId, PageRequest pageRequest) {
|
||||||
|
return delegate.queryHistory(instanceId, pageRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<ActionExecution> queryActionExecutions(String instanceId, PageRequest pageRequest) {
|
||||||
|
return delegate.queryActionExecutions(instanceId, pageRequest);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package com.jetlumen.ordo.core.repository;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionExecution;
|
||||||
|
import com.jetlumen.ordo.api.ActionExecutionStatus;
|
||||||
|
import com.jetlumen.ordo.api.query.Page;
|
||||||
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
|
import com.jetlumen.ordo.api.repository.ActionExecutionRepository;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Development-only in-memory implementation of the ACTION execution port. */
|
||||||
|
public final class InMemoryActionExecutionRepository implements ActionExecutionRepository {
|
||||||
|
private final Map<String, ActionExecution> executions = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void insert(ActionExecution execution) {
|
||||||
|
Objects.requireNonNull(execution, "execution must not be null");
|
||||||
|
executions.put(execution.id(), execution);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized boolean complete(String executionId, ActionExecutionStatus status, String errorMessage,
|
||||||
|
Instant finishedAt) {
|
||||||
|
ActionExecution current = executions.get(executionId);
|
||||||
|
if (current == null || current.status() != ActionExecutionStatus.PENDING) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
executions.put(executionId, new ActionExecution(current.id(), current.instanceId(), current.stepId(),
|
||||||
|
current.actionKey(), status, errorMessage, current.startedAt(), finishedAt));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized Page<ActionExecution> query(String instanceId, PageRequest pageRequest) {
|
||||||
|
Objects.requireNonNull(instanceId, "instanceId must not be null");
|
||||||
|
Objects.requireNonNull(pageRequest, "pageRequest must not be null");
|
||||||
|
List<ActionExecution> matched = executions.values().stream()
|
||||||
|
.filter(execution -> execution.instanceId().equals(instanceId))
|
||||||
|
.sorted(Comparator.comparing(ActionExecution::startedAt).thenComparing(ActionExecution::id))
|
||||||
|
.toList();
|
||||||
|
List<ActionExecution> page = matched.stream()
|
||||||
|
.skip((long) pageRequest.offset())
|
||||||
|
.limit(pageRequest.size())
|
||||||
|
.toList();
|
||||||
|
return new Page<>(page, matched.size(), pageRequest.page(), pageRequest.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package com.jetlumen.ordo.core.repository;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ProcessEvent;
|
||||||
|
import com.jetlumen.ordo.api.query.Page;
|
||||||
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
|
import com.jetlumen.ordo.api.repository.ProcessHistoryRepository;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Development-only in-memory implementation of the process history port. */
|
||||||
|
public final class InMemoryProcessHistoryRepository implements ProcessHistoryRepository {
|
||||||
|
private final List<ProcessEvent> events = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void append(ProcessEvent event) {
|
||||||
|
Objects.requireNonNull(event, "event must not be null");
|
||||||
|
events.add(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized Page<ProcessEvent> query(String instanceId, PageRequest pageRequest) {
|
||||||
|
Objects.requireNonNull(instanceId, "instanceId must not be null");
|
||||||
|
Objects.requireNonNull(pageRequest, "pageRequest must not be null");
|
||||||
|
List<ProcessEvent> matched = events.stream()
|
||||||
|
.filter(event -> event.instanceId().equals(instanceId))
|
||||||
|
.sorted(Comparator.comparing(ProcessEvent::occurredAt).thenComparing(ProcessEvent::id))
|
||||||
|
.toList();
|
||||||
|
List<ProcessEvent> page = matched.stream()
|
||||||
|
.skip((long) pageRequest.offset())
|
||||||
|
.limit(pageRequest.size())
|
||||||
|
.toList();
|
||||||
|
return new Page<>(page, matched.size(), pageRequest.page(), pageRequest.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
package com.jetlumen.ordo.core;
|
package com.jetlumen.ordo.core;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionExecution;
|
||||||
|
import com.jetlumen.ordo.api.ActionExecutionStatus;
|
||||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||||
import com.jetlumen.ordo.api.ApprovalPolicy;
|
import com.jetlumen.ordo.api.ApprovalPolicy;
|
||||||
import com.jetlumen.ordo.api.ApprovalStep;
|
import com.jetlumen.ordo.api.ApprovalStep;
|
||||||
import com.jetlumen.ordo.api.ApprovalTask;
|
import com.jetlumen.ordo.api.ApprovalTask;
|
||||||
import com.jetlumen.ordo.api.ProcessContext;
|
import com.jetlumen.ordo.api.ProcessContext;
|
||||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||||
|
import com.jetlumen.ordo.api.ProcessEvent;
|
||||||
|
import com.jetlumen.ordo.api.ProcessEventType;
|
||||||
import com.jetlumen.ordo.api.ProcessInstance;
|
import com.jetlumen.ordo.api.ProcessInstance;
|
||||||
import com.jetlumen.ordo.api.ProcessStatus;
|
import com.jetlumen.ordo.api.ProcessStatus;
|
||||||
import com.jetlumen.ordo.api.RoutingCondition;
|
import com.jetlumen.ordo.api.RoutingCondition;
|
||||||
@@ -512,4 +516,106 @@ class InMemoryOrdoEngineTest {
|
|||||||
assertEquals(2, all.totalElements());
|
assertEquals(2, all.totalElements());
|
||||||
assertEquals(List.of("expense", "leave"), all.content().stream().map(ProcessDefinition::id).toList());
|
assertEquals(List.of("expense", "leave"), all.content().stream().map(ProcessDefinition::id).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordsHistoryForStartApproveSkipAndComplete() {
|
||||||
|
InMemoryOrdoEngine anyEngine = new InMemoryOrdoEngine();
|
||||||
|
anyEngine.register(ProcessDefinition.linear("leave-any", "Leave request", List.of(
|
||||||
|
new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ANY),
|
||||||
|
ApprovalStep.single("hr", "HR approval", "henry")
|
||||||
|
)));
|
||||||
|
|
||||||
|
var instance = anyEngine.start("leave-any", "alice");
|
||||||
|
ApprovalTask mariaTask = anyEngine.findTasks(instance.id()).stream()
|
||||||
|
.filter(task -> task.assignee().equals("maria")).findFirst().orElseThrow();
|
||||||
|
anyEngine.approve(mariaTask.id(), "maria");
|
||||||
|
anyEngine.approve(anyEngine.findPendingTasksByInstanceId(instance.id()).getFirst().id(), "henry");
|
||||||
|
|
||||||
|
List<ProcessEventType> types = anyEngine.queryHistory(instance.id(), new PageRequest(0, 50)).content()
|
||||||
|
.stream().map(ProcessEvent::type).toList();
|
||||||
|
assertEquals(List.of(
|
||||||
|
ProcessEventType.INSTANCE_STARTED,
|
||||||
|
ProcessEventType.TASK_CREATED,
|
||||||
|
ProcessEventType.TASK_CREATED,
|
||||||
|
ProcessEventType.TASK_APPROVED,
|
||||||
|
ProcessEventType.TASK_SKIPPED,
|
||||||
|
ProcessEventType.TASK_CREATED,
|
||||||
|
ProcessEventType.TASK_APPROVED,
|
||||||
|
ProcessEventType.INSTANCE_APPROVED), types);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordsHistoryForWithdraw() {
|
||||||
|
var instance = engine.start("leave", "alice");
|
||||||
|
engine.withdraw(instance.id(), "alice", "changed plans");
|
||||||
|
|
||||||
|
List<ProcessEventType> types = engine.queryHistory(instance.id(), new PageRequest(0, 20)).content()
|
||||||
|
.stream().map(ProcessEvent::type).toList();
|
||||||
|
assertEquals(List.of(
|
||||||
|
ProcessEventType.INSTANCE_STARTED,
|
||||||
|
ProcessEventType.TASK_CREATED,
|
||||||
|
ProcessEventType.INSTANCE_WITHDRAWN,
|
||||||
|
ProcessEventType.TASK_SKIPPED), types);
|
||||||
|
assertEquals("alice", engine.queryHistory(instance.id(), new PageRequest(0, 20)).content().get(2).actor());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifiesListenersAfterCommitAndIsolatesListenerFailures() {
|
||||||
|
List<ProcessEventType> received = new java.util.ArrayList<>();
|
||||||
|
InMemoryOrdoEngine listening = new InMemoryOrdoEngine(Clock.systemUTC(), AssigneeResolver.direct(),
|
||||||
|
RoutingCondition.always(), (key, context) -> {
|
||||||
|
}, List.of(event -> {
|
||||||
|
if (event.type() == ProcessEventType.TASK_APPROVED) {
|
||||||
|
throw new IllegalStateException("listener boom");
|
||||||
|
}
|
||||||
|
received.add(event.type());
|
||||||
|
}));
|
||||||
|
listening.register(ProcessDefinition.linear("leave", "Leave request", List.of(
|
||||||
|
ApprovalStep.single("manager", "Manager approval", "maria"))));
|
||||||
|
|
||||||
|
var instance = listening.start("leave", "alice");
|
||||||
|
listening.approve(listening.findPendingTasksByInstanceId(instance.id()).getFirst().id(), "maria");
|
||||||
|
|
||||||
|
assertEquals(ProcessStatus.APPROVED, listening.findInstance(instance.id()).orElseThrow().status());
|
||||||
|
assertEquals(List.of(
|
||||||
|
ProcessEventType.INSTANCE_STARTED,
|
||||||
|
ProcessEventType.TASK_CREATED,
|
||||||
|
ProcessEventType.INSTANCE_APPROVED), received);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistsSuccessfulAndFailedActionExecutionsWithoutBlockingTheFlow() {
|
||||||
|
InMemoryOrdoEngine actionEngine = new InMemoryOrdoEngine(Clock.systemUTC(), AssigneeResolver.direct(),
|
||||||
|
RoutingCondition.always(), (key, context) -> {
|
||||||
|
if (key.equals("fail-mail")) {
|
||||||
|
throw new IllegalStateException("mail failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actionEngine.register(new ProcessDefinition("leave", "Leave request", List.of(
|
||||||
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
|
ApprovalStep.action("notify", "Notify HR", "ok-mail"),
|
||||||
|
ApprovalStep.action("fail", "Fail mail", "fail-mail")),
|
||||||
|
List.of(
|
||||||
|
StepTransition.always("manager", "notify"),
|
||||||
|
StepTransition.always("notify", "fail"),
|
||||||
|
StepTransition.end("fail"))));
|
||||||
|
|
||||||
|
var instance = actionEngine.start("leave", "alice");
|
||||||
|
actionEngine.approve(actionEngine.findPendingTasksByInstanceId(instance.id()).getFirst().id(), "maria");
|
||||||
|
|
||||||
|
assertEquals(ProcessStatus.APPROVED, actionEngine.findInstance(instance.id()).orElseThrow().status());
|
||||||
|
List<ActionExecution> executions = actionEngine.queryActionExecutions(instance.id(), new PageRequest(0, 10))
|
||||||
|
.content();
|
||||||
|
assertEquals(2, executions.size());
|
||||||
|
assertEquals("ok-mail", executions.get(0).actionKey());
|
||||||
|
assertEquals(ActionExecutionStatus.SUCCESS, executions.get(0).status());
|
||||||
|
assertEquals("fail-mail", executions.get(1).actionKey());
|
||||||
|
assertEquals(ActionExecutionStatus.FAILED, executions.get(1).status());
|
||||||
|
assertEquals("mail failed", executions.get(1).errorMessage());
|
||||||
|
List<ProcessEventType> types = actionEngine.queryHistory(instance.id(), new PageRequest(0, 50)).content()
|
||||||
|
.stream().map(ProcessEvent::type).toList();
|
||||||
|
assertTrue(types.contains(ProcessEventType.ACTION_SUCCEEDED));
|
||||||
|
assertTrue(types.contains(ProcessEventType.ACTION_FAILED));
|
||||||
|
assertTrue(types.contains(ProcessEventType.INSTANCE_APPROVED));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-2
@@ -3,17 +3,23 @@ package com.jetlumen.ordo.spring;
|
|||||||
import com.jetlumen.ordo.api.ActionHandler;
|
import com.jetlumen.ordo.api.ActionHandler;
|
||||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||||
import com.jetlumen.ordo.api.OrdoEngine;
|
import com.jetlumen.ordo.api.OrdoEngine;
|
||||||
|
import com.jetlumen.ordo.api.OrdoEventListener;
|
||||||
import com.jetlumen.ordo.api.RoutingCondition;
|
import com.jetlumen.ordo.api.RoutingCondition;
|
||||||
import com.jetlumen.ordo.api.TransactionExecutor;
|
import com.jetlumen.ordo.api.TransactionExecutor;
|
||||||
|
import com.jetlumen.ordo.api.repository.ActionExecutionRepository;
|
||||||
import com.jetlumen.ordo.api.repository.ApprovalTaskRepository;
|
import com.jetlumen.ordo.api.repository.ApprovalTaskRepository;
|
||||||
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
|
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
|
||||||
|
import com.jetlumen.ordo.api.repository.ProcessHistoryRepository;
|
||||||
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
|
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
|
||||||
import com.jetlumen.ordo.core.DefaultOrdoEngine;
|
import com.jetlumen.ordo.core.DefaultOrdoEngine;
|
||||||
|
import com.jetlumen.ordo.storage.jdbc.JdbcActionExecutionRepository;
|
||||||
import com.jetlumen.ordo.storage.jdbc.JdbcApprovalTaskRepository;
|
import com.jetlumen.ordo.storage.jdbc.JdbcApprovalTaskRepository;
|
||||||
import com.jetlumen.ordo.storage.jdbc.JdbcConnectionProvider;
|
import com.jetlumen.ordo.storage.jdbc.JdbcConnectionProvider;
|
||||||
import com.jetlumen.ordo.storage.jdbc.JdbcProcessDefinitionRepository;
|
import com.jetlumen.ordo.storage.jdbc.JdbcProcessDefinitionRepository;
|
||||||
|
import com.jetlumen.ordo.storage.jdbc.JdbcProcessHistoryRepository;
|
||||||
import com.jetlumen.ordo.storage.jdbc.JdbcProcessInstanceRepository;
|
import com.jetlumen.ordo.storage.jdbc.JdbcProcessInstanceRepository;
|
||||||
import com.jetlumen.ordo.storage.jdbc.JdbcTransactionExecutor;
|
import com.jetlumen.ordo.storage.jdbc.JdbcTransactionExecutor;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
|
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
@@ -103,6 +109,18 @@ public class OrdoJdbcAutoConfiguration {
|
|||||||
return new JdbcApprovalTaskRepository(connectionProvider);
|
return new JdbcApprovalTaskRepository(connectionProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnMissingBean
|
||||||
|
public ProcessHistoryRepository ordoProcessHistoryRepository(JdbcConnectionProvider connectionProvider) {
|
||||||
|
return new JdbcProcessHistoryRepository(connectionProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnMissingBean
|
||||||
|
public ActionExecutionRepository ordoActionExecutionRepository(JdbcConnectionProvider connectionProvider) {
|
||||||
|
return new JdbcActionExecutionRepository(connectionProvider);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
public OrdoEngine ordoEngine(Clock ordoClock,
|
public OrdoEngine ordoEngine(Clock ordoClock,
|
||||||
@@ -112,10 +130,14 @@ public class OrdoJdbcAutoConfiguration {
|
|||||||
TransactionExecutor ordoTransactionExecutor,
|
TransactionExecutor ordoTransactionExecutor,
|
||||||
ProcessDefinitionRepository ordoProcessDefinitionRepository,
|
ProcessDefinitionRepository ordoProcessDefinitionRepository,
|
||||||
ProcessInstanceRepository ordoProcessInstanceRepository,
|
ProcessInstanceRepository ordoProcessInstanceRepository,
|
||||||
ApprovalTaskRepository ordoApprovalTaskRepository) {
|
ApprovalTaskRepository ordoApprovalTaskRepository,
|
||||||
|
ProcessHistoryRepository ordoProcessHistoryRepository,
|
||||||
|
ActionExecutionRepository ordoActionExecutionRepository,
|
||||||
|
ObjectProvider<OrdoEventListener> ordoEventListeners) {
|
||||||
return new DefaultOrdoEngine(ordoClock, ordoAssigneeResolver, ordoRoutingCondition, actionHandler,
|
return new DefaultOrdoEngine(ordoClock, ordoAssigneeResolver, ordoRoutingCondition, actionHandler,
|
||||||
ordoTransactionExecutor, ordoProcessDefinitionRepository, ordoProcessInstanceRepository,
|
ordoTransactionExecutor, ordoProcessDefinitionRepository, ordoProcessInstanceRepository,
|
||||||
ordoApprovalTaskRepository);
|
ordoApprovalTaskRepository, ordoProcessHistoryRepository, ordoActionExecutionRepository,
|
||||||
|
ordoEventListeners.orderedStream().toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
+42
@@ -5,9 +5,13 @@ import com.jetlumen.ordo.api.ApprovalStep;
|
|||||||
import com.jetlumen.ordo.api.ApprovalTask;
|
import com.jetlumen.ordo.api.ApprovalTask;
|
||||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||||
import com.jetlumen.ordo.api.OrdoEngine;
|
import com.jetlumen.ordo.api.OrdoEngine;
|
||||||
|
import com.jetlumen.ordo.api.OrdoEventListener;
|
||||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||||
|
import com.jetlumen.ordo.api.ProcessEvent;
|
||||||
|
import com.jetlumen.ordo.api.ProcessEventType;
|
||||||
import com.jetlumen.ordo.api.ProcessInstance;
|
import com.jetlumen.ordo.api.ProcessInstance;
|
||||||
import com.jetlumen.ordo.api.RoutingCondition;
|
import com.jetlumen.ordo.api.RoutingCondition;
|
||||||
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
|
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||||
@@ -151,6 +155,27 @@ class OrdoJdbcAutoConfigurationTest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invokesOrdoEventListenerBeans() {
|
||||||
|
withDataSourceRunner.withUserConfiguration(RecordingListenerConfig.class)
|
||||||
|
.run(context -> {
|
||||||
|
OrdoEngine engine = context.getBean(OrdoEngine.class);
|
||||||
|
engine.register(LEAVE_REQUEST);
|
||||||
|
ProcessInstance instance = engine.start("leave-request", "alice");
|
||||||
|
engine.approve(engine.findPendingTasksByAssignee("maria").getFirst().id(), "maria");
|
||||||
|
|
||||||
|
RecordingListener listener = context.getBean(RecordingListener.class);
|
||||||
|
assertThat(listener.types).contains(
|
||||||
|
ProcessEventType.INSTANCE_STARTED,
|
||||||
|
ProcessEventType.TASK_CREATED,
|
||||||
|
ProcessEventType.TASK_APPROVED,
|
||||||
|
ProcessEventType.INSTANCE_APPROVED);
|
||||||
|
assertThat(listener.types.getFirst()).isEqualTo(ProcessEventType.INSTANCE_STARTED);
|
||||||
|
assertThat(engine.queryHistory(instance.id(), new PageRequest(0, 20))
|
||||||
|
.totalElements()).isGreaterThan(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
static class CustomAssigneeResolverConfig {
|
static class CustomAssigneeResolverConfig {
|
||||||
@Bean
|
@Bean
|
||||||
@@ -176,4 +201,21 @@ class OrdoJdbcAutoConfigurationTest {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static class RecordingListener implements OrdoEventListener {
|
||||||
|
final List<ProcessEventType> types = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEvent(ProcessEvent event) {
|
||||||
|
types.add(event.type());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
static class RecordingListenerConfig {
|
||||||
|
@Bean
|
||||||
|
RecordingListener recordingListener() {
|
||||||
|
return new RecordingListener();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package com.jetlumen.ordo.storage.jdbc;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionExecution;
|
||||||
|
import com.jetlumen.ordo.api.ActionExecutionStatus;
|
||||||
|
import com.jetlumen.ordo.api.query.Page;
|
||||||
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
|
import com.jetlumen.ordo.api.repository.ActionExecutionRepository;
|
||||||
|
import com.jetlumen.ordo.storage.jdbc.mapper.ActionExecutionMapper;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** JDBC implementation of the ACTION execution port. */
|
||||||
|
public final class JdbcActionExecutionRepository implements ActionExecutionRepository {
|
||||||
|
private static final String COLUMNS =
|
||||||
|
"id, instance_id, step_id, action_key, status, error_message, started_at, finished_at";
|
||||||
|
private static final String INSERT =
|
||||||
|
"INSERT INTO ordo_action_execution (id, instance_id, step_id, action_key, status, started_at)"
|
||||||
|
+ " VALUES (?, ?, ?, ?, ?, ?)";
|
||||||
|
private static final String COMPLETE_IF_PENDING =
|
||||||
|
"UPDATE ordo_action_execution SET status = ?, error_message = ?, finished_at = ?"
|
||||||
|
+ " WHERE id = ? AND status = 'PENDING'";
|
||||||
|
|
||||||
|
private final JdbcConnectionProvider connectionProvider;
|
||||||
|
|
||||||
|
public JdbcActionExecutionRepository(JdbcConnectionProvider connectionProvider) {
|
||||||
|
this.connectionProvider = Objects.requireNonNull(connectionProvider, "connectionProvider must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void insert(ActionExecution execution) {
|
||||||
|
Objects.requireNonNull(execution, "execution must not be null");
|
||||||
|
Connection connection = connectionProvider.getConnection();
|
||||||
|
try (PreparedStatement insert = connection.prepareStatement(INSERT)) {
|
||||||
|
ActionExecutionMapper.bindInsert(insert, execution);
|
||||||
|
insert.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new JdbcStorageException("failed to insert action execution: " + execution.id(), e);
|
||||||
|
} finally {
|
||||||
|
connectionProvider.close(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean complete(String executionId, ActionExecutionStatus status, String errorMessage, Instant finishedAt) {
|
||||||
|
Objects.requireNonNull(executionId, "executionId must not be null");
|
||||||
|
Objects.requireNonNull(status, "status must not be null");
|
||||||
|
Objects.requireNonNull(finishedAt, "finishedAt must not be null");
|
||||||
|
Connection connection = connectionProvider.getConnection();
|
||||||
|
try (PreparedStatement update = connection.prepareStatement(COMPLETE_IF_PENDING)) {
|
||||||
|
ActionExecutionMapper.bindComplete(update, executionId, status, errorMessage, finishedAt);
|
||||||
|
return update.executeUpdate() == 1;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new JdbcStorageException("failed to complete action execution: " + executionId, e);
|
||||||
|
} finally {
|
||||||
|
connectionProvider.close(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<ActionExecution> query(String instanceId, PageRequest pageRequest) {
|
||||||
|
Objects.requireNonNull(instanceId, "instanceId must not be null");
|
||||||
|
Objects.requireNonNull(pageRequest, "pageRequest must not be null");
|
||||||
|
Connection connection = connectionProvider.getConnection();
|
||||||
|
try {
|
||||||
|
long total = PageSupport.count(connection,
|
||||||
|
"SELECT COUNT(*) FROM ordo_action_execution WHERE instance_id = ?", List.of(instanceId));
|
||||||
|
String sql = "SELECT " + COLUMNS + " FROM ordo_action_execution WHERE instance_id = ?"
|
||||||
|
+ " ORDER BY started_at ASC, id ASC LIMIT ? OFFSET ?";
|
||||||
|
List<ActionExecution> content = new ArrayList<>();
|
||||||
|
try (PreparedStatement select = connection.prepareStatement(sql)) {
|
||||||
|
select.setString(1, instanceId);
|
||||||
|
select.setInt(2, pageRequest.size());
|
||||||
|
select.setInt(3, pageRequest.offset());
|
||||||
|
try (ResultSet resultSet = select.executeQuery()) {
|
||||||
|
while (resultSet.next()) {
|
||||||
|
content.add(ActionExecutionMapper.read(resultSet));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Page<>(content, total, pageRequest.page(), pageRequest.size());
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new JdbcStorageException("failed to query action executions for instance: " + instanceId, e);
|
||||||
|
} finally {
|
||||||
|
connectionProvider.close(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package com.jetlumen.ordo.storage.jdbc;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ProcessEvent;
|
||||||
|
import com.jetlumen.ordo.api.query.Page;
|
||||||
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
|
import com.jetlumen.ordo.api.repository.ProcessHistoryRepository;
|
||||||
|
import com.jetlumen.ordo.storage.jdbc.mapper.ProcessEventMapper;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** JDBC implementation of the process history port. */
|
||||||
|
public final class JdbcProcessHistoryRepository implements ProcessHistoryRepository {
|
||||||
|
private static final String COLUMNS =
|
||||||
|
"id, instance_id, task_id, step_id, event_type, actor, detail, occurred_at";
|
||||||
|
private static final String INSERT =
|
||||||
|
"INSERT INTO ordo_process_event (id, instance_id, task_id, step_id, event_type, actor, detail, occurred_at)"
|
||||||
|
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
|
private final JdbcConnectionProvider connectionProvider;
|
||||||
|
|
||||||
|
public JdbcProcessHistoryRepository(JdbcConnectionProvider connectionProvider) {
|
||||||
|
this.connectionProvider = Objects.requireNonNull(connectionProvider, "connectionProvider must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void append(ProcessEvent event) {
|
||||||
|
Objects.requireNonNull(event, "event must not be null");
|
||||||
|
Connection connection = connectionProvider.getConnection();
|
||||||
|
try (PreparedStatement insert = connection.prepareStatement(INSERT)) {
|
||||||
|
ProcessEventMapper.bindInsert(insert, event);
|
||||||
|
insert.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new JdbcStorageException("failed to append process event: " + event.id(), e);
|
||||||
|
} finally {
|
||||||
|
connectionProvider.close(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<ProcessEvent> query(String instanceId, PageRequest pageRequest) {
|
||||||
|
Objects.requireNonNull(instanceId, "instanceId must not be null");
|
||||||
|
Objects.requireNonNull(pageRequest, "pageRequest must not be null");
|
||||||
|
Connection connection = connectionProvider.getConnection();
|
||||||
|
try {
|
||||||
|
long total = PageSupport.count(connection,
|
||||||
|
"SELECT COUNT(*) FROM ordo_process_event WHERE instance_id = ?", List.of(instanceId));
|
||||||
|
String sql = "SELECT " + COLUMNS + " FROM ordo_process_event WHERE instance_id = ?"
|
||||||
|
+ " ORDER BY occurred_at ASC, id ASC LIMIT ? OFFSET ?";
|
||||||
|
List<ProcessEvent> content = new ArrayList<>();
|
||||||
|
try (PreparedStatement select = connection.prepareStatement(sql)) {
|
||||||
|
select.setString(1, instanceId);
|
||||||
|
select.setInt(2, pageRequest.size());
|
||||||
|
select.setInt(3, pageRequest.offset());
|
||||||
|
try (ResultSet resultSet = select.executeQuery()) {
|
||||||
|
while (resultSet.next()) {
|
||||||
|
content.add(ProcessEventMapper.read(resultSet));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Page<>(content, total, pageRequest.page(), pageRequest.size());
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new JdbcStorageException("failed to query process events for instance: " + instanceId, e);
|
||||||
|
} finally {
|
||||||
|
connectionProvider.close(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
package com.jetlumen.ordo.storage.jdbc.mapper;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionExecution;
|
||||||
|
import com.jetlumen.ordo.api.ActionExecutionStatus;
|
||||||
|
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.sql.Types;
|
||||||
|
|
||||||
|
/** Maps rows of {@code ordo_action_execution} to {@link ActionExecution} objects and back. */
|
||||||
|
public final class ActionExecutionMapper {
|
||||||
|
private ActionExecutionMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void bindInsert(PreparedStatement statement, ActionExecution execution) throws SQLException {
|
||||||
|
statement.setString(1, execution.id());
|
||||||
|
statement.setString(2, execution.instanceId());
|
||||||
|
statement.setString(3, execution.stepId());
|
||||||
|
statement.setString(4, execution.actionKey());
|
||||||
|
statement.setString(5, execution.status().name());
|
||||||
|
statement.setTimestamp(6, Timestamp.from(execution.startedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ActionExecution read(ResultSet resultSet) throws SQLException {
|
||||||
|
Timestamp finishedAt = resultSet.getTimestamp("finished_at");
|
||||||
|
return new ActionExecution(
|
||||||
|
resultSet.getString("id"),
|
||||||
|
resultSet.getString("instance_id"),
|
||||||
|
resultSet.getString("step_id"),
|
||||||
|
resultSet.getString("action_key"),
|
||||||
|
ActionExecutionStatus.valueOf(resultSet.getString("status")),
|
||||||
|
resultSet.getString("error_message"),
|
||||||
|
resultSet.getTimestamp("started_at").toInstant(),
|
||||||
|
finishedAt == null ? null : finishedAt.toInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void bindComplete(PreparedStatement statement, String executionId, ActionExecutionStatus status,
|
||||||
|
String errorMessage, java.time.Instant finishedAt) throws SQLException {
|
||||||
|
statement.setString(1, status.name());
|
||||||
|
if (errorMessage == null || errorMessage.isBlank()) {
|
||||||
|
statement.setNull(2, Types.VARCHAR);
|
||||||
|
} else {
|
||||||
|
statement.setString(2, errorMessage);
|
||||||
|
}
|
||||||
|
statement.setTimestamp(3, Timestamp.from(finishedAt));
|
||||||
|
statement.setString(4, executionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package com.jetlumen.ordo.storage.jdbc.mapper;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ProcessEvent;
|
||||||
|
import com.jetlumen.ordo.api.ProcessEventType;
|
||||||
|
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.sql.Types;
|
||||||
|
|
||||||
|
/** Maps rows of {@code ordo_process_event} to {@link ProcessEvent} objects and back. */
|
||||||
|
public final class ProcessEventMapper {
|
||||||
|
private ProcessEventMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void bindInsert(PreparedStatement statement, ProcessEvent event) throws SQLException {
|
||||||
|
statement.setString(1, event.id());
|
||||||
|
statement.setString(2, event.instanceId());
|
||||||
|
setNullableString(statement, 3, event.taskId());
|
||||||
|
setNullableString(statement, 4, event.stepId());
|
||||||
|
statement.setString(5, event.type().name());
|
||||||
|
setNullableString(statement, 6, event.actor());
|
||||||
|
setNullableString(statement, 7, event.detail());
|
||||||
|
statement.setTimestamp(8, Timestamp.from(event.occurredAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProcessEvent read(ResultSet resultSet) throws SQLException {
|
||||||
|
return new ProcessEvent(
|
||||||
|
resultSet.getString("id"),
|
||||||
|
resultSet.getString("instance_id"),
|
||||||
|
resultSet.getString("task_id"),
|
||||||
|
resultSet.getString("step_id"),
|
||||||
|
ProcessEventType.valueOf(resultSet.getString("event_type")),
|
||||||
|
resultSet.getString("actor"),
|
||||||
|
resultSet.getString("detail"),
|
||||||
|
resultSet.getTimestamp("occurred_at").toInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setNullableString(PreparedStatement statement, int index, String value) throws SQLException {
|
||||||
|
if (value == null) {
|
||||||
|
statement.setNull(index, Types.VARCHAR);
|
||||||
|
} else {
|
||||||
|
statement.setString(index, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
-- Process history events and ACTION-step execution records.
|
||||||
|
|
||||||
|
CREATE TABLE ordo_process_event (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
instance_id VARCHAR(36) NOT NULL,
|
||||||
|
task_id VARCHAR(36),
|
||||||
|
step_id VARCHAR(64),
|
||||||
|
event_type VARCHAR(32) NOT NULL,
|
||||||
|
actor VARCHAR(255),
|
||||||
|
detail TEXT,
|
||||||
|
occurred_at TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT fk_process_event_instance FOREIGN KEY (instance_id) REFERENCES ordo_process_instance (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_process_event_instance_time ON ordo_process_event (instance_id, occurred_at, id);
|
||||||
|
|
||||||
|
CREATE TABLE ordo_action_execution (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
instance_id VARCHAR(36) NOT NULL,
|
||||||
|
step_id VARCHAR(64) NOT NULL,
|
||||||
|
action_key VARCHAR(255) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
error_message TEXT,
|
||||||
|
started_at TIMESTAMP NOT NULL,
|
||||||
|
finished_at TIMESTAMP,
|
||||||
|
CONSTRAINT fk_action_execution_instance FOREIGN KEY (instance_id) REFERENCES ordo_process_instance (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_action_execution_instance ON ordo_action_execution (instance_id);
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package com.jetlumen.ordo.storage.jdbc;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionExecution;
|
||||||
|
import com.jetlumen.ordo.api.ActionExecutionStatus;
|
||||||
|
import com.jetlumen.ordo.api.ApprovalStep;
|
||||||
|
import com.jetlumen.ordo.api.ProcessContext;
|
||||||
|
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||||
|
import com.jetlumen.ordo.api.ProcessInstance;
|
||||||
|
import com.jetlumen.ordo.api.ProcessStatus;
|
||||||
|
import com.jetlumen.ordo.api.query.Page;
|
||||||
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class JdbcActionExecutionRepositoryTest {
|
||||||
|
private static final Instant T0 = Instant.parse("2026-03-01T08:00:00Z");
|
||||||
|
|
||||||
|
private JdbcActionExecutionRepository repository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
JdbcConnectionProvider connectionProvider = new JdbcConnectionProvider(JdbcTestSupport.newDataSource());
|
||||||
|
repository = new JdbcActionExecutionRepository(connectionProvider);
|
||||||
|
new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(ProcessDefinition.linear("leave",
|
||||||
|
"Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria"))));
|
||||||
|
new JdbcProcessInstanceRepository(connectionProvider).insert(new ProcessInstance("inst-1", "leave", "alice",
|
||||||
|
ProcessStatus.RUNNING, T0, null, ProcessContext.empty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTripsPendingThenCompletedExecution() {
|
||||||
|
ActionExecution pending = new ActionExecution("ex-1", "inst-1", "notify", "leave-mail",
|
||||||
|
ActionExecutionStatus.PENDING, null, T0, null);
|
||||||
|
repository.insert(pending);
|
||||||
|
|
||||||
|
assertTrue(repository.complete("ex-1", ActionExecutionStatus.SUCCESS, null, T0.plusSeconds(2)));
|
||||||
|
Page<ActionExecution> page = repository.query("inst-1", new PageRequest(0, 10));
|
||||||
|
assertEquals(1, page.totalElements());
|
||||||
|
ActionExecution stored = page.content().getFirst();
|
||||||
|
assertEquals(ActionExecutionStatus.SUCCESS, stored.status());
|
||||||
|
assertEquals(T0.plusSeconds(2), stored.finishedAt());
|
||||||
|
assertFalse(repository.complete("ex-1", ActionExecutionStatus.FAILED, "nope", T0.plusSeconds(3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completeFailsForUnknownExecution() {
|
||||||
|
assertFalse(repository.complete("missing", ActionExecutionStatus.FAILED, "x", T0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queryOrdersOldestFirst() {
|
||||||
|
repository.insert(new ActionExecution("ex-1", "inst-1", "a", "first", ActionExecutionStatus.PENDING, null,
|
||||||
|
T0, null));
|
||||||
|
repository.insert(new ActionExecution("ex-2", "inst-1", "b", "second", ActionExecutionStatus.PENDING, null,
|
||||||
|
T0.plusSeconds(1), null));
|
||||||
|
Page<ActionExecution> page = repository.query("inst-1", new PageRequest(0, 10));
|
||||||
|
assertEquals(List.of("ex-1", "ex-2"), page.content().stream().map(ActionExecution::id).toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-2
@@ -1,5 +1,6 @@
|
|||||||
package com.jetlumen.ordo.storage.jdbc;
|
package com.jetlumen.ordo.storage.jdbc;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionExecutionStatus;
|
||||||
import com.jetlumen.ordo.api.ActionHandler;
|
import com.jetlumen.ordo.api.ActionHandler;
|
||||||
import com.jetlumen.ordo.api.ApprovalStep;
|
import com.jetlumen.ordo.api.ApprovalStep;
|
||||||
import com.jetlumen.ordo.api.ApprovalTask;
|
import com.jetlumen.ordo.api.ApprovalTask;
|
||||||
@@ -7,8 +8,11 @@ import com.jetlumen.ordo.api.AssigneeResolver;
|
|||||||
import com.jetlumen.ordo.api.OrdoEngine;
|
import com.jetlumen.ordo.api.OrdoEngine;
|
||||||
import com.jetlumen.ordo.api.ProcessContext;
|
import com.jetlumen.ordo.api.ProcessContext;
|
||||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||||
|
import com.jetlumen.ordo.api.ProcessEvent;
|
||||||
|
import com.jetlumen.ordo.api.ProcessEventType;
|
||||||
import com.jetlumen.ordo.api.ProcessInstance;
|
import com.jetlumen.ordo.api.ProcessInstance;
|
||||||
import com.jetlumen.ordo.api.ProcessStatus;
|
import com.jetlumen.ordo.api.ProcessStatus;
|
||||||
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
import com.jetlumen.ordo.api.RoutingCondition;
|
import com.jetlumen.ordo.api.RoutingCondition;
|
||||||
import com.jetlumen.ordo.api.StepTransition;
|
import com.jetlumen.ordo.api.StepTransition;
|
||||||
import com.jetlumen.ordo.api.TaskStatus;
|
import com.jetlumen.ordo.api.TaskStatus;
|
||||||
@@ -124,7 +128,10 @@ class JdbcOrdoEngineIntegrationTest {
|
|||||||
new JdbcTransactionExecutor(connectionProvider),
|
new JdbcTransactionExecutor(connectionProvider),
|
||||||
new JdbcProcessDefinitionRepository(connectionProvider),
|
new JdbcProcessDefinitionRepository(connectionProvider),
|
||||||
new JdbcProcessInstanceRepository(connectionProvider),
|
new JdbcProcessInstanceRepository(connectionProvider),
|
||||||
new JdbcApprovalTaskRepository(connectionProvider));
|
new JdbcApprovalTaskRepository(connectionProvider),
|
||||||
|
new JdbcProcessHistoryRepository(connectionProvider),
|
||||||
|
new JdbcActionExecutionRepository(connectionProvider),
|
||||||
|
List.of());
|
||||||
failingEngine.register(new ProcessDefinition("leave-noroute", "Leave request",
|
failingEngine.register(new ProcessDefinition("leave-noroute", "Leave request",
|
||||||
List.of(ApprovalStep.single("manager", "Manager approval", "maria")),
|
List.of(ApprovalStep.single("manager", "Manager approval", "maria")),
|
||||||
List.of(StepTransition.endWhen("manager", "never", 0))));
|
List.of(StepTransition.endWhen("manager", "never", 0))));
|
||||||
@@ -137,6 +144,9 @@ class JdbcOrdoEngineIntegrationTest {
|
|||||||
assertEquals(TaskStatus.PENDING, storedTask.status());
|
assertEquals(TaskStatus.PENDING, storedTask.status());
|
||||||
assertNull(storedTask.action());
|
assertNull(storedTask.action());
|
||||||
assertEquals(ProcessStatus.RUNNING, failingEngine.findInstance(instance.id()).orElseThrow().status());
|
assertEquals(ProcessStatus.RUNNING, failingEngine.findInstance(instance.id()).orElseThrow().status());
|
||||||
|
assertEquals(List.of(ProcessEventType.INSTANCE_STARTED, ProcessEventType.TASK_CREATED),
|
||||||
|
failingEngine.queryHistory(instance.id(), new PageRequest(0, 20)).content().stream()
|
||||||
|
.map(ProcessEvent::type).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -196,12 +206,55 @@ class JdbcOrdoEngineIntegrationTest {
|
|||||||
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
|
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistHistoryAndActionExecutions() {
|
||||||
|
OrdoEngine actionEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), AssigneeResolver.direct(),
|
||||||
|
RoutingCondition.always(),
|
||||||
|
(key, context) -> {
|
||||||
|
if ("boom".equals(key)) {
|
||||||
|
throw new IllegalStateException("mail failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new JdbcTransactionExecutor(connectionProvider),
|
||||||
|
new JdbcProcessDefinitionRepository(connectionProvider),
|
||||||
|
new JdbcProcessInstanceRepository(connectionProvider),
|
||||||
|
new JdbcApprovalTaskRepository(connectionProvider),
|
||||||
|
new JdbcProcessHistoryRepository(connectionProvider),
|
||||||
|
new JdbcActionExecutionRepository(connectionProvider),
|
||||||
|
List.of());
|
||||||
|
actionEngine.register(new ProcessDefinition("leave-action", "Leave request", List.of(
|
||||||
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
|
ApprovalStep.action("notify", "Notify", "ok-mail"),
|
||||||
|
ApprovalStep.action("fail", "Fail", "boom")),
|
||||||
|
List.of(
|
||||||
|
StepTransition.always("manager", "notify"),
|
||||||
|
StepTransition.always("notify", "fail"),
|
||||||
|
StepTransition.end("fail"))));
|
||||||
|
|
||||||
|
ProcessInstance instance = actionEngine.start("leave-action", "alice");
|
||||||
|
actionEngine.approve(actionEngine.findPendingTasksByInstanceId(instance.id()).getFirst().id(), "maria");
|
||||||
|
|
||||||
|
assertEquals(ProcessStatus.APPROVED, actionEngine.findInstance(instance.id()).orElseThrow().status());
|
||||||
|
assertEquals(ActionExecutionStatus.SUCCESS,
|
||||||
|
actionEngine.queryActionExecutions(instance.id(), new PageRequest(0, 10)).content().getFirst().status());
|
||||||
|
assertEquals(ActionExecutionStatus.FAILED,
|
||||||
|
actionEngine.queryActionExecutions(instance.id(), new PageRequest(0, 10)).content().get(1).status());
|
||||||
|
List<ProcessEventType> types = actionEngine.queryHistory(instance.id(), new PageRequest(0, 50)).content()
|
||||||
|
.stream().map(ProcessEvent::type).toList();
|
||||||
|
assertTrue(types.contains(ProcessEventType.ACTION_SUCCEEDED));
|
||||||
|
assertTrue(types.contains(ProcessEventType.ACTION_FAILED));
|
||||||
|
assertTrue(types.contains(ProcessEventType.INSTANCE_APPROVED));
|
||||||
|
}
|
||||||
|
|
||||||
private OrdoEngine newEngine(AssigneeResolver assigneeResolver) {
|
private OrdoEngine newEngine(AssigneeResolver assigneeResolver) {
|
||||||
return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(),
|
return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(),
|
||||||
ActionHandler.noop(),
|
ActionHandler.noop(),
|
||||||
new JdbcTransactionExecutor(connectionProvider),
|
new JdbcTransactionExecutor(connectionProvider),
|
||||||
new JdbcProcessDefinitionRepository(connectionProvider),
|
new JdbcProcessDefinitionRepository(connectionProvider),
|
||||||
new JdbcProcessInstanceRepository(connectionProvider),
|
new JdbcProcessInstanceRepository(connectionProvider),
|
||||||
new JdbcApprovalTaskRepository(connectionProvider));
|
new JdbcApprovalTaskRepository(connectionProvider),
|
||||||
|
new JdbcProcessHistoryRepository(connectionProvider),
|
||||||
|
new JdbcActionExecutionRepository(connectionProvider),
|
||||||
|
List.of());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -224,7 +224,10 @@ class JdbcPostgresIntegrationTest {
|
|||||||
new JdbcTransactionExecutor(connectionProvider),
|
new JdbcTransactionExecutor(connectionProvider),
|
||||||
new JdbcProcessDefinitionRepository(connectionProvider),
|
new JdbcProcessDefinitionRepository(connectionProvider),
|
||||||
new JdbcProcessInstanceRepository(connectionProvider),
|
new JdbcProcessInstanceRepository(connectionProvider),
|
||||||
new JdbcApprovalTaskRepository(connectionProvider));
|
new JdbcApprovalTaskRepository(connectionProvider),
|
||||||
|
new JdbcProcessHistoryRepository(connectionProvider),
|
||||||
|
new JdbcActionExecutionRepository(connectionProvider),
|
||||||
|
List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PGSimpleDataSource newDataSource(String currentSchema) {
|
private static PGSimpleDataSource newDataSource(String currentSchema) {
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
package com.jetlumen.ordo.storage.jdbc;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ApprovalStep;
|
||||||
|
import com.jetlumen.ordo.api.ProcessContext;
|
||||||
|
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||||
|
import com.jetlumen.ordo.api.ProcessEvent;
|
||||||
|
import com.jetlumen.ordo.api.ProcessEventType;
|
||||||
|
import com.jetlumen.ordo.api.ProcessInstance;
|
||||||
|
import com.jetlumen.ordo.api.ProcessStatus;
|
||||||
|
import com.jetlumen.ordo.api.query.Page;
|
||||||
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class JdbcProcessHistoryRepositoryTest {
|
||||||
|
private static final Instant T0 = Instant.parse("2026-03-01T08:00:00Z");
|
||||||
|
|
||||||
|
private JdbcProcessHistoryRepository repository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
JdbcConnectionProvider connectionProvider = new JdbcConnectionProvider(JdbcTestSupport.newDataSource());
|
||||||
|
repository = new JdbcProcessHistoryRepository(connectionProvider);
|
||||||
|
new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(ProcessDefinition.linear("leave",
|
||||||
|
"Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria"))));
|
||||||
|
new JdbcProcessInstanceRepository(connectionProvider).insert(new ProcessInstance("inst-1", "leave", "alice",
|
||||||
|
ProcessStatus.RUNNING, T0, null, ProcessContext.empty()));
|
||||||
|
new JdbcProcessInstanceRepository(connectionProvider).insert(new ProcessInstance("inst-2", "leave", "bob",
|
||||||
|
ProcessStatus.RUNNING, T0, null, ProcessContext.empty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void appendsAndQueriesOldestFirstForOneInstance() {
|
||||||
|
ProcessEvent started = event("e1", "inst-1", ProcessEventType.INSTANCE_STARTED, T0);
|
||||||
|
ProcessEvent created = event("e2", "inst-1", ProcessEventType.TASK_CREATED, T0.plusSeconds(1));
|
||||||
|
ProcessEvent other = event("e3", "inst-2", ProcessEventType.INSTANCE_STARTED, T0);
|
||||||
|
repository.append(started);
|
||||||
|
repository.append(created);
|
||||||
|
repository.append(other);
|
||||||
|
|
||||||
|
Page<ProcessEvent> page = repository.query("inst-1", new PageRequest(0, 10));
|
||||||
|
assertEquals(2, page.totalElements());
|
||||||
|
assertEquals(List.of(started, created), page.content());
|
||||||
|
|
||||||
|
Page<ProcessEvent> first = repository.query("inst-1", new PageRequest(0, 1));
|
||||||
|
assertEquals(1, first.content().size());
|
||||||
|
assertEquals("e1", first.content().getFirst().id());
|
||||||
|
assertTrue(first.hasNext());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcessEvent event(String id, String instanceId, ProcessEventType type, Instant at) {
|
||||||
|
return new ProcessEvent(id, instanceId, null, null, type, "alice", null, at);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@ final class JdbcTestSupport {
|
|||||||
"/db/migration/V1__create_ordo_tables.sql",
|
"/db/migration/V1__create_ordo_tables.sql",
|
||||||
"/db/migration/V2__add_step_candidates_and_policy.sql",
|
"/db/migration/V2__add_step_candidates_and_policy.sql",
|
||||||
"/db/migration/V3__add_step_transitions.sql",
|
"/db/migration/V3__add_step_transitions.sql",
|
||||||
"/db/migration/V4__add_step_kind_and_action_key.sql"
|
"/db/migration/V4__add_step_kind_and_action_key.sql",
|
||||||
|
"/db/migration/V5__add_process_event_and_action_execution.sql"
|
||||||
};
|
};
|
||||||
private static final String[] SCHEMA_SQL = loadSchemas();
|
private static final String[] SCHEMA_SQL = loadSchemas();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user