feat: add explicit conditional routing for approval steps

Require ProcessDefinition transitions so the engine can branch or end by conditionKey, persist them in JDBC, and pin repository text files to LF.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-10 18:56:59 +08:00
co-authored by Cursor
parent 581823290a
commit 58ec867eab
23 changed files with 594 additions and 52 deletions
+10
View File
@@ -0,0 +1,10 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
[*.java]
indent_style = space
indent_size = 4
+16
View File
@@ -0,0 +1,16 @@
* text=auto eol=lf
*.java text eol=lf
*.xml text eol=lf
*.sql text eol=lf
*.md text eol=lf
*.properties text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf
*.gitignore text eol=lf
*.gitattributes text eol=lf
*.jar binary
*.png binary
*.jpg binary
*.gif binary
*.ico binary
+177
View File
@@ -0,0 +1,177 @@
# Plan: 条件路由 (Conditional Routing / RoutingCondition)
## 已与用户确认的关键决策
1. transitions 挂在 `ProcessDefinition` 级别(独立列表),不改动 `ApprovalStep`。
2. `RoutingCondition` 采用与 `AssigneeResolver` 一致的模式:全局单例 bean,
`boolean matches(String conditionKey, ProcessContext context)`;数据库只存 `conditionKey`
字符串,不持久化任何 Java lambda/表达式(因为 JDBC 场景下引擎重启要从数据库重新装配
`ProcessDefinition`,无法反序列化任意代码逻辑)。
3. **不保留线性回退**:每个 `ProcessDefinition` 必须显式声明 transitions,覆盖每个 step 的
出边;没有 transitions 完全是非法状态(构造期校验报错),不再依赖 `steps()` 列表顺序做
执行期推进(`step_order`/列表顺序仅用于候选人/去重校验和展示,不再决定执行流)。
4. 匹配不到任何 transition 时,fail-fast 抛出 `NoRouteFoundException`(不静默回退)。
## 模型设计 (ordo-api)
### 新增 `StepTransition` record
```
record StepTransition(String fromStepId, String toStepId, String conditionKey, int priority)
```
- `toStepId == null` 表示"终止流程"(当匹配到这条边时,实例被判 APPROVED 并完成)。
- `conditionKey == null/blank` 表示无条件(总是匹配),用作 else/默认分支。
- 便捷工厂:
- `StepTransition.always(fromStepId, toStepId)` — 无条件跳转,priority=0
- `StepTransition.when(fromStepId, toStepId, conditionKey, priority)` — 条件跳转
- `StepTransition.end(fromStepId)` — 无条件终止
- `StepTransition.endWhen(fromStepId, conditionKey, priority)` — 条件终止
- 评估顺序:按 `priority` 升序,第一个匹配的生效(无条件 = 恒真)。
### 新增 `RoutingCondition` functional interface(与 AssigneeResolver 同目录/同风格)
```
@FunctionalInterface
public interface RoutingCondition {
boolean matches(String conditionKey, ProcessContext context);
static RoutingCondition always() { return (key, context) -> true; }
}
```
### `ProcessDefinition` 改为 4 个分量(破坏性变更)
```
record ProcessDefinition(String id, String name, List<ApprovalStep> steps, List<StepTransition> transitions)
```
构造期校验(在现有 id/name/steps 非空+去重校验基础上新增):
- `transitions` 非 null,`List.copyOf` 防御性拷贝。
- 每条 transition 的 `fromStepId` 必须是 `steps` 中存在的 id;`toStepId`(非 null 时)也必须存在。
- 每个 step 的 id 必须至少作为一条 transition 的 `fromStepId` 出现一次(强制显式声明出边),
否则 `IllegalArgumentException("step X has no outgoing transition")`。
- 同一 `(fromStepId, priority)` 不能重复(避免评估顺序有歧义)。
- 新增便捷工厂 `ProcessDefinition.linear(id, name, steps)`:自动生成"上一步无条件指向下一步、
最后一步无条件终止"的 transitions,等价于旧的线性行为,用于降低无需真正条件路由的测试/demo
的迁移成本(生成的仍然是显式 transitions,满足"强制显式"的约束,只是自动拼装)。
### 新增异常 `NoRouteFoundException extends OrdoException`
- message 形如 `"no matching transition for step: <stepId> in instance: <instanceId>"`。
- 放在 `com.jetlumen.ordo.api.exception` 包,命名/风格对齐现有 `DefinitionNotFoundException` 等。
## 引擎改造 (ordo-core)
### `DefaultOrdoEngine`
- 构造函数新增 `RoutingCondition routingCondition` 参数(在 `assigneeResolver` 之后插入,
与 Spring autoconfigure bean 顺序一致)。
- 删除 `indexOf(...)` 私有方法(不再需要基于下标推进)。
- `advanceOrComplete(instance, definition, step, now)` 重写为:
1. 调用新的私有 `resolveTransition(definition, step, context)`:
过滤 `definition.transitions()` 中 `fromStepId == step.id()` 的项,按 `priority` 升序排序,
依次判断 `conditionKey` 为空/blank(无条件恒真)或 `routingCondition.matches(conditionKey, context)`,
返回第一个匹配的 `StepTransition`;若无匹配,抛出 `NoRouteFoundException`。
2. 若匹配结果 `toStepId() == null` → `completeInstance(instance, ProcessStatus.APPROVED, now)`。
3. 否则 → `createStepTasks(instance, requireStep(definition, matched.toStepId()), now)`。
- `advanceAfterDecision` 中原有 ANY/ALL 策略判断逻辑不变,只是"推进到下一步"的调用点改为走上面的
transition 解析,不再用 `stepIndex == size-1` 判断是否最后一步。
- `NoRouteFoundException` 在 `transactionExecutor.execute(...)` 内抛出,天然触发整个 approve/reject
事务回滚(复用现有 `rollsBackTheWholeApprovalWhenTheNextStepCannotBeCreated` 验证过的回滚机制)。
- `start()` 的起始 step 仍取 `definition.steps().getFirst()`(未改变,属于本次范围外的假设,
如需显式声明起始 step 可后续再提出)。
### `InMemoryOrdoEngine`
- 仿照现有 `AssigneeResolver` 构造函数重载模式,新增:
- `InMemoryOrdoEngine(RoutingCondition routingCondition)`
- `InMemoryOrdoEngine(AssigneeResolver assigneeResolver, RoutingCondition routingCondition)`
- `InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition)`
- 默认无参/仅 Clock 的构造函数内部使用 `RoutingCondition.always()`。
## 持久化 (ordo-storage-jdbc)
### 新迁移 `V3__add_step_transitions.sql`
```sql
CREATE TABLE ordo_step_transition (
definition_id VARCHAR(64) NOT NULL,
from_step_id VARCHAR(64) NOT NULL,
to_step_id VARCHAR(64),
condition_key VARCHAR(255),
priority INTEGER NOT NULL,
PRIMARY KEY (definition_id, from_step_id, priority),
CONSTRAINT fk_transition_from FOREIGN KEY (definition_id, from_step_id)
REFERENCES ordo_approval_step (definition_id, step_id),
CONSTRAINT fk_transition_to FOREIGN KEY (definition_id, to_step_id)
REFERENCES ordo_approval_step (definition_id, step_id)
);
```
**重要提醒(已踩过的坑,来自 /memories/repo/any-all-multi-approval.md)**:
- 必须同步把这个新迁移文件加进 `ordo-storage-jdbc/src/test/java/.../JdbcTestSupport.java` 的
`MIGRATIONS` 数组,否则测试库缺表导致全部 JDBC 测试失败。
- 注释里不要出现分号(或依赖已有的按行剥离 `--` 注释逻辑,已经是健壮的,无需再担心)。
### 新增 `StepTransitionMapper`(仿照 `ApprovalStepMapper` 的 `StepRow` 模式)
- `readRow(ResultSet) -> TransitionRow(fromStepId, toStepId, conditionKey, priority)`。
### `JdbcProcessDefinitionRepository`
- `insertIfAbsent`:在插入 steps/candidates 之后,遍历 `definition.transitions()` 执行
`INSERT INTO ordo_step_transition (...)`。
- `findById`:新增 `SELECT_TRANSITIONS`(按 `from_step_id, priority` 排序),组装
`List<StepTransition>` 传入新的 4 参 `ProcessDefinition` 构造函数。
## Spring Boot 自动配置 (ordo-spring-boot-autoconfigure)
- `OrdoJdbcAutoConfiguration` 新增:
```java
@Bean
@ConditionalOnMissingBean
public RoutingCondition ordoRoutingCondition() {
return RoutingCondition.always();
}
```
- `ordoEngine(...)` 方法签名新增 `RoutingCondition ordoRoutingCondition` 参数,传给
`DefaultOrdoEngine` 构造函数。
- `OrdoJdbcAutoConfigurationTest`:更新 `LEAVE_REQUEST` 定义使用 `ProcessDefinition.linear(...)`;
新增一个 `honoursUserDefinedRoutingCondition` 测试,仿照现有
`honoursUserDefinedAssigneeResolver` 的写法。
## 调用点迁移(约 29 处 `new ProcessDefinition(...)`)
- 绝大多数纯线性场景(测试/demo 不需要真正的条件路由)直接把
`new ProcessDefinition(id, name, steps)` 换成 `ProcessDefinition.linear(id, name, steps)`。
- 受影响文件:
- `ordo-api/src/test/java/.../ProcessDefinitionTest.java`(同时新增 transitions 相关校验用例:
缺少出边报错、fromStepId/toStepId 引用不存在的 step 报错、重复 priority 报错)
- `ordo-core/src/test/java/.../InMemoryOrdoEngineTest.java`(8 处调用点 + 新增条件路由专项测试)
- `ordo-spring-boot-autoconfigure/src/test/java/.../OrdoJdbcAutoConfigurationTest.java`
- `ordo-storage-jdbc/src/test/java/.../Jdbc*Test.java`(约 10 处,含
`JdbcProcessDefinitionRepositoryTest` 需要新增"读写 transitions 往返"专项用例)
- `ordo-storage-jdbc/src/main/java/.../JdbcProcessDefinitionRepository.java`(`findById` 内部
组装 `ProcessDefinition` 那一处,改为传入真实加载到的 transitions)
- `ordo-example/src/main/java/.../LeaveRequestExample.java`
- `d:\Projects\Personal\java\rhizome\src\main\java\...\LeaveRequestDemoService.java`
## 新增测试(条件路由专项,主要在 `InMemoryOrdoEngineTest` 或新建
`ConditionalRoutingTest`)
1. 基础分支:manager 通过后,根据 `conditionKey`(如 `"amount-gt-1000"`)走向不同的下一 step
(高额 -> director 审批 step;低额 -> 直接 end),验证 `RoutingCondition.matches` 被正确调用
并影响推进结果。
2. 无匹配 transition 时抛出 `NoRouteFoundException`,且整个 approve 事务回滚(沿用现有
rollback 测试模式)。
3. `ProcessDefinition` 构造期校验:
- 某 step 没有任何出边 -> `IllegalArgumentException`。
- transition 引用不存在的 `fromStepId`/`toStepId` -> `IllegalArgumentException`。
- 同一 `(fromStepId, priority)` 重复 -> `IllegalArgumentException`。
4. JDBC 往返测试:`JdbcProcessDefinitionRepositoryTest` 新增用例,插入一个带条件分支的
definition,`findById` 读回后 `transitions()` 内容与写入一致(包括 `toStepId == null` 终止边)。
## 范围外 / 明确不做
- 不支持在 transition 上直接内嵌 Java lambda(会破坏 JDBC 持久化重启场景,已由用户确认)。
- 不引入显式 `startStepId` 概念,起始步骤仍是 `steps().getFirst()`。
- 不做并行分支/合并(fork-join),仅支持"单一当前 step -> 单一下一 step 或终止"的有向图推进,
与现有 ANY/ALL 多候选人机制正交(transition 发生在"某个 step 整体被判定通过/拒绝"之后)。
## 实施顺序(有依赖关系,需按序执行)
1. ordo-api:`StepTransition`、`RoutingCondition`、`ProcessDefinition`(含 `linear` 工厂)、
`NoRouteFoundException`。
2. ordo-core:`DefaultOrdoEngine` 改造、`InMemoryOrdoEngine` 构造函数重载。
3. ordo-storage-jdbc:`V3` 迁移 + `StepTransitionMapper` + `JdbcProcessDefinitionRepository` 改造
+ 同步更新 `JdbcTestSupport.MIGRATIONS`。
4. ordo-spring-boot-autoconfigure:`RoutingCondition` bean 及 `ordoEngine(...)` 装配。
5. 迁移全部 ~29 处旧调用点(`ProcessDefinition.linear(...)`),含 rhizome demo。
6. 新增条件路由专项测试(引擎行为 + 校验 + JDBC 往返)。
## 验证
- `ordo-api`/`ordo-core`/`ordo-storage-jdbc`/`ordo-spring-boot-autoconfigure` 全量单元测试通过。
- 手动确认 rhizome 端到端 curl 流程(线性场景切到 `ProcessDefinition.linear` 后)仍然可用
(非阻塞,可在实现完成后单独验证)。
@@ -1,11 +1,14 @@
package com.jetlumen.ordo.api; package com.jetlumen.ordo.api;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Set; import java.util.Set;
/** Immutable blueprint for a linear approval process. */ /** Immutable blueprint for an approval process with explicit step transitions. */
public record ProcessDefinition(String id, String name, List<ApprovalStep> steps) { public record ProcessDefinition(String id, String name, List<ApprovalStep> steps, List<StepTransition> transitions) {
public ProcessDefinition { public ProcessDefinition {
ApprovalStep.requireText(id, "definition id"); ApprovalStep.requireText(id, "definition id");
ApprovalStep.requireText(name, "definition name"); ApprovalStep.requireText(name, "definition name");
@@ -19,5 +22,45 @@ public record ProcessDefinition(String id, String name, List<ApprovalStep> steps
throw new IllegalArgumentException("duplicate step id: " + step.id()); throw new IllegalArgumentException("duplicate step id: " + step.id());
} }
} }
Objects.requireNonNull(transitions, "transitions must not be null");
Set<String> stepsWithOutgoing = new HashSet<>();
Set<String> fromPriorityKeys = new HashSet<>();
for (StepTransition transition : transitions) {
if (!ids.contains(transition.fromStepId())) {
throw new IllegalArgumentException("unknown fromStepId: " + transition.fromStepId());
}
if (transition.toStepId() != null && !ids.contains(transition.toStepId())) {
throw new IllegalArgumentException("unknown toStepId: " + transition.toStepId());
}
stepsWithOutgoing.add(transition.fromStepId());
String fromPriority = transition.fromStepId() + '\0' + transition.priority();
if (!fromPriorityKeys.add(fromPriority)) {
throw new IllegalArgumentException(
"duplicate priority " + transition.priority() + " for fromStepId: " + transition.fromStepId());
}
}
for (ApprovalStep step : steps) {
if (!stepsWithOutgoing.contains(step.id())) {
throw new IllegalArgumentException("step " + step.id() + " has no outgoing transition");
}
}
transitions = transitions.stream()
.sorted(Comparator.comparing(StepTransition::fromStepId).thenComparingInt(StepTransition::priority))
.toList();
}
/**
* Builds a definition whose transitions mirror the former linear steps order: each step
* unconditionally advances to the next, and the last step unconditionally ends.
*/
public static ProcessDefinition linear(String id, String name, List<ApprovalStep> steps) {
List<StepTransition> transitions = new ArrayList<>();
for (int i = 0; i < steps.size() - 1; i++) {
transitions.add(StepTransition.always(steps.get(i).id(), steps.get(i + 1).id()));
}
if (!steps.isEmpty()) {
transitions.add(StepTransition.end(steps.getLast().id()));
}
return new ProcessDefinition(id, name, steps, transitions);
} }
} }
@@ -0,0 +1,15 @@
package com.jetlumen.ordo.api;
/**
* Evaluates a named routing condition against the running instance's context. Hosts supply a
* singleton implementation (same pattern as {@link AssigneeResolver}); the database only stores
* the {@code conditionKey} string.
*/
@FunctionalInterface
public interface RoutingCondition {
boolean matches(String conditionKey, ProcessContext context);
static RoutingCondition always() {
return (key, context) -> true;
}
}
@@ -0,0 +1,31 @@
package com.jetlumen.ordo.api;
/**
* An outgoing edge from an approval step. When {@link #toStepId()} is {@code null}, matching this
* transition terminates the instance as approved. A blank {@link #conditionKey()} is unconditional
* (always matches) and is typically used as the else/default branch.
*/
public record StepTransition(String fromStepId, String toStepId, String conditionKey, int priority) {
public StepTransition {
ApprovalStep.requireText(fromStepId, "fromStepId");
if (toStepId != null && toStepId.isBlank()) {
throw new IllegalArgumentException("toStepId must not be blank when present");
}
}
public static StepTransition always(String fromStepId, String toStepId) {
return new StepTransition(fromStepId, toStepId, null, 0);
}
public static StepTransition when(String fromStepId, String toStepId, String conditionKey, int priority) {
return new StepTransition(fromStepId, toStepId, conditionKey, priority);
}
public static StepTransition end(String fromStepId) {
return new StepTransition(fromStepId, null, null, 0);
}
public static StepTransition endWhen(String fromStepId, String conditionKey, int priority) {
return new StepTransition(fromStepId, null, conditionKey, priority);
}
}
@@ -0,0 +1,7 @@
package com.jetlumen.ordo.api.exception;
public final class NoRouteFoundException extends OrdoException {
public NoRouteFoundException(String stepId, String instanceId) {
super("no matching transition for step: " + stepId + " in instance: " + instanceId);
}
}
@@ -28,8 +28,8 @@ class ProcessDefinitionTest {
@Test @Test
void rejectsDefinitionsWithoutStepsOrWithDuplicateStepIds() { void rejectsDefinitionsWithoutStepsOrWithDuplicateStepIds() {
assertThrows(IllegalArgumentException.class, assertThrows(IllegalArgumentException.class,
() -> new ProcessDefinition("leave", "Leave request", List.of())); () -> ProcessDefinition.linear("leave", "Leave request", List.of()));
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", List.of( assertThrows(IllegalArgumentException.class, () -> ProcessDefinition.linear("leave", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("manager", "HR approval", "henry") ApprovalStep.single("manager", "HR approval", "henry")
))); )));
@@ -39,15 +39,15 @@ class ProcessDefinitionTest {
void rejectsBlankDefinitionFields() { void rejectsBlankDefinitionFields() {
List<ApprovalStep> steps = List.of(ApprovalStep.single("manager", "Manager approval", "maria")); List<ApprovalStep> steps = List.of(ApprovalStep.single("manager", "Manager approval", "maria"));
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition(" ", "Leave request", steps)); assertThrows(IllegalArgumentException.class, () -> ProcessDefinition.linear(" ", "Leave request", steps));
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", " ", steps)); assertThrows(IllegalArgumentException.class, () -> ProcessDefinition.linear("leave", " ", steps));
} }
@Test @Test
void copiesTheSuppliedStepList() { void copiesTheSuppliedStepList() {
List<ApprovalStep> suppliedSteps = new ArrayList<>(); List<ApprovalStep> suppliedSteps = new ArrayList<>();
suppliedSteps.add(ApprovalStep.single("manager", "Manager approval", "maria")); suppliedSteps.add(ApprovalStep.single("manager", "Manager approval", "maria"));
ProcessDefinition definition = new ProcessDefinition("leave", "Leave request", suppliedSteps); ProcessDefinition definition = ProcessDefinition.linear("leave", "Leave request", suppliedSteps);
suppliedSteps.add(ApprovalStep.single("hr", "HR approval", "henry")); suppliedSteps.add(ApprovalStep.single("hr", "HR approval", "henry"));
@@ -55,4 +55,43 @@ class ProcessDefinitionTest {
assertThrows(UnsupportedOperationException.class, assertThrows(UnsupportedOperationException.class,
() -> definition.steps().add(ApprovalStep.single("lead", "Lead approval", "lee"))); () -> definition.steps().add(ApprovalStep.single("lead", "Lead approval", "lee")));
} }
@Test
void rejectsStepsWithoutOutgoingTransitions() {
List<ApprovalStep> steps = List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry"));
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps,
List.of(StepTransition.end("manager"))));
}
@Test
void rejectsTransitionsReferencingUnknownSteps() {
List<ApprovalStep> steps = List.of(ApprovalStep.single("manager", "Manager approval", "maria"));
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps,
List.of(StepTransition.end("missing"))));
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps,
List.of(StepTransition.always("manager", "missing"))));
}
@Test
void rejectsDuplicatePriorityForTheSameFromStep() {
List<ApprovalStep> steps = List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry"));
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps,
List.of(
StepTransition.when("manager", "hr", "high", 1),
StepTransition.endWhen("manager", "low", 1))));
}
@Test
void linearBuildsUnconditionalChainAndEnd() {
ProcessDefinition definition = ProcessDefinition.linear("leave", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry")));
assertEquals(List.of(
StepTransition.end("hr"),
StepTransition.always("manager", "hr")), definition.transitions());
}
} }
@@ -9,11 +9,14 @@ import com.jetlumen.ordo.api.ProcessContext;
import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessDefinition;
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.StepTransition;
import com.jetlumen.ordo.api.TaskAction; import com.jetlumen.ordo.api.TaskAction;
import com.jetlumen.ordo.api.TaskStatus; import com.jetlumen.ordo.api.TaskStatus;
import com.jetlumen.ordo.api.TransactionExecutor; import com.jetlumen.ordo.api.TransactionExecutor;
import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException; import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException;
import com.jetlumen.ordo.api.exception.DefinitionNotFoundException; import com.jetlumen.ordo.api.exception.DefinitionNotFoundException;
import com.jetlumen.ordo.api.exception.NoRouteFoundException;
import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException; import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
import com.jetlumen.ordo.api.exception.TaskNotFoundException; import com.jetlumen.ordo.api.exception.TaskNotFoundException;
import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException; import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException;
@@ -23,6 +26,7 @@ import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
import java.time.Clock; import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@@ -30,7 +34,7 @@ import java.util.UUID;
/** /**
* Repository-backed implementation of the v0.1 linear approval runtime. * Repository-backed implementation of the approval runtime with explicit step transitions.
* Multistep write operations run inside a {@link TransactionExecutor} and * Multistep write operations run inside a {@link TransactionExecutor} and
* rely on conditional repository updates, so the engine stays correct even * rely on conditional repository updates, so the engine stays correct even
* when several JVMs share the same storage. * when several JVMs share the same storage.
@@ -38,17 +42,20 @@ import java.util.UUID;
public final class DefaultOrdoEngine implements OrdoEngine { public final class DefaultOrdoEngine implements OrdoEngine {
private final Clock clock; private final Clock clock;
private final AssigneeResolver assigneeResolver; private final AssigneeResolver assigneeResolver;
private final RoutingCondition routingCondition;
private final TransactionExecutor transactionExecutor; private final TransactionExecutor transactionExecutor;
private final ProcessDefinitionRepository definitionRepository; private final ProcessDefinitionRepository definitionRepository;
private final ProcessInstanceRepository instanceRepository; private final ProcessInstanceRepository instanceRepository;
private final ApprovalTaskRepository taskRepository; private final ApprovalTaskRepository taskRepository;
public DefaultOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, TransactionExecutor transactionExecutor, public DefaultOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
TransactionExecutor transactionExecutor,
ProcessDefinitionRepository definitionRepository, ProcessDefinitionRepository definitionRepository,
ProcessInstanceRepository instanceRepository, ProcessInstanceRepository instanceRepository,
ApprovalTaskRepository taskRepository) { ApprovalTaskRepository taskRepository) {
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.transactionExecutor = Objects.requireNonNull(transactionExecutor, "transactionExecutor must not be null"); this.transactionExecutor = Objects.requireNonNull(transactionExecutor, "transactionExecutor must not be null");
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");
@@ -202,14 +209,29 @@ public final class DefaultOrdoEngine implements OrdoEngine {
} }
private void advanceOrComplete(ProcessInstance instance, ProcessDefinition definition, ApprovalStep step, Instant now) { private void advanceOrComplete(ProcessInstance instance, ProcessDefinition definition, ApprovalStep step, Instant now) {
int stepIndex = indexOf(definition, step.id()); StepTransition matched = resolveTransition(definition, step, instance);
if (stepIndex == definition.steps().size() - 1) { if (matched.toStepId() == null) {
completeInstance(instance, ProcessStatus.APPROVED, now); completeInstance(instance, ProcessStatus.APPROVED, now);
} else { } else {
createStepTasks(instance, definition.steps().get(stepIndex + 1), now); createStepTasks(instance, requireStep(definition, matched.toStepId()), now);
} }
} }
private StepTransition resolveTransition(ProcessDefinition definition, ApprovalStep step, ProcessInstance instance) {
List<StepTransition> candidates = definition.transitions().stream()
.filter(transition -> transition.fromStepId().equals(step.id()))
.sorted(Comparator.comparingInt(StepTransition::priority))
.toList();
for (StepTransition transition : candidates) {
String conditionKey = transition.conditionKey();
if (conditionKey == null || conditionKey.isBlank()
|| routingCondition.matches(conditionKey, instance.context())) {
return transition;
}
}
throw new NoRouteFoundException(step.id(), instance.id());
}
/** 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, Instant now) {
for (ApprovalTask sibling : siblings) { for (ApprovalTask sibling : siblings) {
@@ -245,15 +267,6 @@ public final class DefaultOrdoEngine implements OrdoEngine {
.orElseThrow(() -> new IllegalStateException("step not found in definition: " + stepId)); .orElseThrow(() -> new IllegalStateException("step not found in definition: " + stepId));
} }
private static int indexOf(ProcessDefinition definition, String stepId) {
for (int index = 0; index < definition.steps().size(); index++) {
if (definition.steps().get(index).id().equals(stepId)) {
return index;
}
}
throw new IllegalStateException("step not found in definition: " + stepId);
}
private static String nextId() { private static String nextId() {
return UUID.randomUUID().toString(); return UUID.randomUUID().toString();
} }
@@ -6,6 +6,7 @@ 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.ProcessInstance; import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.RoutingCondition;
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.InMemoryProcessInstanceRepository; import com.jetlumen.ordo.core.repository.InMemoryProcessInstanceRepository;
@@ -19,19 +20,31 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
private final DefaultOrdoEngine delegate; private final DefaultOrdoEngine delegate;
public InMemoryOrdoEngine() { public InMemoryOrdoEngine() {
this(Clock.systemUTC(), AssigneeResolver.direct()); this(Clock.systemUTC(), AssigneeResolver.direct(), RoutingCondition.always());
} }
public InMemoryOrdoEngine(Clock clock) { public InMemoryOrdoEngine(Clock clock) {
this(clock, AssigneeResolver.direct()); this(clock, AssigneeResolver.direct(), RoutingCondition.always());
} }
public InMemoryOrdoEngine(AssigneeResolver assigneeResolver) { public InMemoryOrdoEngine(AssigneeResolver assigneeResolver) {
this(Clock.systemUTC(), assigneeResolver); this(Clock.systemUTC(), assigneeResolver, RoutingCondition.always());
} }
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver) { public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver) {
this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, new NoopTransactionExecutor(), this(clock, assigneeResolver, RoutingCondition.always());
}
public InMemoryOrdoEngine(RoutingCondition routingCondition) {
this(Clock.systemUTC(), AssigneeResolver.direct(), routingCondition);
}
public InMemoryOrdoEngine(AssigneeResolver assigneeResolver, RoutingCondition routingCondition) {
this(Clock.systemUTC(), assigneeResolver, routingCondition);
}
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition) {
this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, routingCondition, new NoopTransactionExecutor(),
new InMemoryProcessDefinitionRepository(), new InMemoryProcessDefinitionRepository(),
new InMemoryProcessInstanceRepository(), new InMemoryProcessInstanceRepository(),
new InMemoryApprovalTaskRepository()); new InMemoryApprovalTaskRepository());
@@ -6,9 +6,12 @@ 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.ProcessStatus; import com.jetlumen.ordo.api.ProcessStatus;
import com.jetlumen.ordo.api.RoutingCondition;
import com.jetlumen.ordo.api.StepTransition;
import com.jetlumen.ordo.api.TaskStatus; import com.jetlumen.ordo.api.TaskStatus;
import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException; import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException;
import com.jetlumen.ordo.api.exception.DefinitionNotFoundException; import com.jetlumen.ordo.api.exception.DefinitionNotFoundException;
import com.jetlumen.ordo.api.exception.NoRouteFoundException;
import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException; import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
import com.jetlumen.ordo.api.exception.TaskNotFoundException; import com.jetlumen.ordo.api.exception.TaskNotFoundException;
import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException; import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException;
@@ -30,7 +33,7 @@ class InMemoryOrdoEngineTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
engine = new InMemoryOrdoEngine(); engine = new InMemoryOrdoEngine();
engine.register(new ProcessDefinition("leave", "Leave request", List.of( engine.register(ProcessDefinition.linear("leave", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry") ApprovalStep.single("hr", "HR approval", "henry")
))); )));
@@ -79,7 +82,7 @@ class InMemoryOrdoEngineTest {
void exposesSpecificExceptionsForMissingAndDuplicateResources() { void exposesSpecificExceptionsForMissingAndDuplicateResources() {
assertThrows(DefinitionNotFoundException.class, () -> engine.start("missing", "alice")); assertThrows(DefinitionNotFoundException.class, () -> engine.start("missing", "alice"));
assertThrows(TaskNotFoundException.class, () -> engine.approve("missing", "maria")); assertThrows(TaskNotFoundException.class, () -> engine.approve("missing", "maria"));
assertThrows(DefinitionAlreadyExistsException.class, () -> engine.register(new ProcessDefinition( assertThrows(DefinitionAlreadyExistsException.class, () -> engine.register(ProcessDefinition.linear(
"leave", "Another leave request", List.of(ApprovalStep.single("lead", "Lead approval", "lee"))))); "leave", "Another leave request", List.of(ApprovalStep.single("lead", "Lead approval", "lee")))));
} }
@@ -160,7 +163,7 @@ class InMemoryOrdoEngineTest {
.filter(String.class::isInstance) .filter(String.class::isInstance)
.map(String.class::cast) .map(String.class::cast)
.orElse(candidate)); .orElse(candidate));
contextAwareEngine.register(new ProcessDefinition("leave", "Leave request", List.of( contextAwareEngine.register(ProcessDefinition.linear("leave", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry") ApprovalStep.single("hr", "HR approval", "henry")
))); )));
@@ -180,7 +183,7 @@ class InMemoryOrdoEngineTest {
@Test @Test
void anyPolicyAdvancesOnFirstApprovalAndSkipsTheOtherCandidates() { void anyPolicyAdvancesOnFirstApprovalAndSkipsTheOtherCandidates() {
InMemoryOrdoEngine anyEngine = new InMemoryOrdoEngine(); InMemoryOrdoEngine anyEngine = new InMemoryOrdoEngine();
anyEngine.register(new ProcessDefinition("leave-any", "Leave request", List.of( anyEngine.register(ProcessDefinition.linear("leave-any", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ANY), new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ANY),
ApprovalStep.single("hr", "HR approval", "henry") ApprovalStep.single("hr", "HR approval", "henry")
))); )));
@@ -209,7 +212,7 @@ class InMemoryOrdoEngineTest {
@Test @Test
void anyPolicyOnlyRejectsTheStepOnceEveryCandidateHasRejected() { void anyPolicyOnlyRejectsTheStepOnceEveryCandidateHasRejected() {
InMemoryOrdoEngine anyEngine = new InMemoryOrdoEngine(); InMemoryOrdoEngine anyEngine = new InMemoryOrdoEngine();
anyEngine.register(new ProcessDefinition("leave-any-reject", "Leave request", List.of( anyEngine.register(ProcessDefinition.linear("leave-any-reject", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ANY) new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ANY)
))); )));
@@ -229,7 +232,7 @@ class InMemoryOrdoEngineTest {
@Test @Test
void allPolicyOnlyAdvancesOnceEveryCandidateHasApproved() { void allPolicyOnlyAdvancesOnceEveryCandidateHasApproved() {
InMemoryOrdoEngine allEngine = new InMemoryOrdoEngine(); InMemoryOrdoEngine allEngine = new InMemoryOrdoEngine();
allEngine.register(new ProcessDefinition("leave-all", "Leave request", List.of( allEngine.register(ProcessDefinition.linear("leave-all", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ALL), new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ALL),
ApprovalStep.single("hr", "HR approval", "henry") ApprovalStep.single("hr", "HR approval", "henry")
))); )));
@@ -254,7 +257,7 @@ class InMemoryOrdoEngineTest {
@Test @Test
void allPolicyFailsFastAndSkipsRemainingCandidatesOnASingleRejection() { void allPolicyFailsFastAndSkipsRemainingCandidatesOnASingleRejection() {
InMemoryOrdoEngine allEngine = new InMemoryOrdoEngine(); InMemoryOrdoEngine allEngine = new InMemoryOrdoEngine();
allEngine.register(new ProcessDefinition("leave-all-reject", "Leave request", List.of( allEngine.register(ProcessDefinition.linear("leave-all-reject", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ALL) new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ALL)
))); )));
@@ -269,4 +272,49 @@ class InMemoryOrdoEngineTest {
assertEquals(TaskStatus.SKIPPED, allEngine.findTask(mikeTask.id()).orElseThrow().status()); assertEquals(TaskStatus.SKIPPED, allEngine.findTask(mikeTask.id()).orElseThrow().status());
assertNull(allEngine.findTask(mikeTask.id()).orElseThrow().action()); assertNull(allEngine.findTask(mikeTask.id()).orElseThrow().action());
} }
@Test
void routesToDifferentNextStepsBasedOnConditionKey() {
RoutingCondition routingCondition = (key, context) -> {
if (!"amount-gt-1000".equals(key)) {
return false;
}
return context.value("amount")
.filter(Number.class::isInstance)
.map(Number.class::cast)
.map(amount -> amount.doubleValue() > 1000)
.orElse(false);
};
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine(routingCondition);
List<ApprovalStep> steps = List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("director", "Director approval", "diana"));
routingEngine.register(new ProcessDefinition("expense", "Expense request", steps, List.of(
StepTransition.when("manager", "director", "amount-gt-1000", 0),
new StepTransition("manager", null, null, 1),
StepTransition.end("director"))));
var high = routingEngine.start("expense", "alice", new ProcessContext(Map.of("amount", 2500)));
routingEngine.approve(routingEngine.findPendingTasksByInstanceId(high.id()).getFirst().id(), "maria");
assertEquals("director", routingEngine.findPendingTasksByInstanceId(high.id()).getFirst().stepId());
routingEngine.approve(routingEngine.findPendingTasksByInstanceId(high.id()).getFirst().id(), "diana");
assertEquals(ProcessStatus.APPROVED, routingEngine.findInstance(high.id()).orElseThrow().status());
var low = routingEngine.start("expense", "bob", new ProcessContext(Map.of("amount", 200)));
routingEngine.approve(routingEngine.findPendingTasksByInstanceId(low.id()).getFirst().id(), "maria");
assertEquals(ProcessStatus.APPROVED, routingEngine.findInstance(low.id()).orElseThrow().status());
assertTrue(routingEngine.findPendingTasksByInstanceId(low.id()).isEmpty());
}
@Test
void throwsWhenNoTransitionMatches() {
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine((key, context) -> false);
routingEngine.register(new ProcessDefinition("expense", "Expense request",
List.of(ApprovalStep.single("manager", "Manager approval", "maria")),
List.of(StepTransition.endWhen("manager", "never", 0))));
var instance = routingEngine.start("expense", "alice");
ApprovalTask task = routingEngine.findPendingTasksByInstanceId(instance.id()).getFirst();
assertThrows(NoRouteFoundException.class, () -> routingEngine.approve(task.id(), "maria"));
}
} }
@@ -15,7 +15,7 @@ public final class LeaveRequestExample {
public static void main(String[] args) { public static void main(String[] args) {
OrdoEngine ordo = new InMemoryOrdoEngine(); OrdoEngine ordo = new InMemoryOrdoEngine();
ordo.register(new ProcessDefinition("leave-request", "Leave request", List.of( ordo.register(ProcessDefinition.linear("leave-request", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry") ApprovalStep.single("hr", "HR approval", "henry")
))); )));
@@ -2,6 +2,7 @@ package com.jetlumen.ordo.spring;
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.RoutingCondition;
import com.jetlumen.ordo.api.TransactionExecutor; import com.jetlumen.ordo.api.TransactionExecutor;
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;
@@ -58,6 +59,12 @@ public class OrdoJdbcAutoConfiguration {
return AssigneeResolver.direct(); return AssigneeResolver.direct();
} }
@Bean
@ConditionalOnMissingBean
public RoutingCondition ordoRoutingCondition() {
return RoutingCondition.always();
}
@Bean @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
@DependsOnDatabaseInitialization @DependsOnDatabaseInitialization
@@ -93,11 +100,12 @@ public class OrdoJdbcAutoConfiguration {
@ConditionalOnMissingBean @ConditionalOnMissingBean
public OrdoEngine ordoEngine(Clock ordoClock, public OrdoEngine ordoEngine(Clock ordoClock,
AssigneeResolver ordoAssigneeResolver, AssigneeResolver ordoAssigneeResolver,
RoutingCondition ordoRoutingCondition,
TransactionExecutor ordoTransactionExecutor, TransactionExecutor ordoTransactionExecutor,
ProcessDefinitionRepository ordoProcessDefinitionRepository, ProcessDefinitionRepository ordoProcessDefinitionRepository,
ProcessInstanceRepository ordoProcessInstanceRepository, ProcessInstanceRepository ordoProcessInstanceRepository,
ApprovalTaskRepository ordoApprovalTaskRepository) { ApprovalTaskRepository ordoApprovalTaskRepository) {
return new DefaultOrdoEngine(ordoClock, ordoAssigneeResolver, ordoTransactionExecutor, return new DefaultOrdoEngine(ordoClock, ordoAssigneeResolver, ordoRoutingCondition, ordoTransactionExecutor,
ordoProcessDefinitionRepository, ordoProcessInstanceRepository, ordoApprovalTaskRepository); ordoProcessDefinitionRepository, ordoProcessInstanceRepository, ordoApprovalTaskRepository);
} }
} }
@@ -6,6 +6,7 @@ import com.jetlumen.ordo.api.AssigneeResolver;
import com.jetlumen.ordo.api.OrdoEngine; import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.ProcessInstance; import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.RoutingCondition;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
@@ -21,7 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class OrdoJdbcAutoConfigurationTest { class OrdoJdbcAutoConfigurationTest {
private static final ProcessDefinition LEAVE_REQUEST = new ProcessDefinition("leave-request", "Leave request", private static final ProcessDefinition LEAVE_REQUEST = ProcessDefinition.linear("leave-request", "Leave request",
List.of(ApprovalStep.single("manager", "Manager approval", "maria"))); List.of(ApprovalStep.single("manager", "Manager approval", "maria")));
private final ApplicationContextRunner withDataSourceRunner = new ApplicationContextRunner() private final ApplicationContextRunner withDataSourceRunner = new ApplicationContextRunner()
@@ -76,6 +77,15 @@ class OrdoJdbcAutoConfigurationTest {
}); });
} }
@Test
void honoursUserDefinedRoutingCondition() {
withDataSourceRunner.withUserConfiguration(CustomRoutingConditionConfig.class)
.run(context -> {
assertThat(context).hasSingleBean(OrdoEngine.class);
assertThat(context.getBean(RoutingCondition.class).matches("any", null)).isTrue();
});
}
@Configuration @Configuration
static class CustomAssigneeResolverConfig { static class CustomAssigneeResolverConfig {
@Bean @Bean
@@ -83,4 +93,12 @@ class OrdoJdbcAutoConfigurationTest {
return (candidate, step, ctx) -> "custom-assignee"; return (candidate, step, ctx) -> "custom-assignee";
} }
} }
@Configuration
static class CustomRoutingConditionConfig {
@Bean
RoutingCondition ordoRoutingCondition() {
return (key, context) -> true;
}
}
} }
@@ -2,9 +2,12 @@ package com.jetlumen.ordo.storage.jdbc;
import com.jetlumen.ordo.api.ApprovalStep; import com.jetlumen.ordo.api.ApprovalStep;
import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.StepTransition;
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository; import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
import com.jetlumen.ordo.storage.jdbc.mapper.ApprovalStepMapper; import com.jetlumen.ordo.storage.jdbc.mapper.ApprovalStepMapper;
import com.jetlumen.ordo.storage.jdbc.mapper.ApprovalStepMapper.StepRow; import com.jetlumen.ordo.storage.jdbc.mapper.ApprovalStepMapper.StepRow;
import com.jetlumen.ordo.storage.jdbc.mapper.StepTransitionMapper;
import com.jetlumen.ordo.storage.jdbc.mapper.StepTransitionMapper.TransitionRow;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
@@ -25,12 +28,17 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
"INSERT INTO ordo_approval_step (definition_id, step_id, step_name, policy, step_order) VALUES (?, ?, ?, ?, ?)"; "INSERT INTO ordo_approval_step (definition_id, step_id, step_name, policy, step_order) VALUES (?, ?, ?, ?, ?)";
private static final String INSERT_CANDIDATE = private static final String INSERT_CANDIDATE =
"INSERT INTO ordo_step_candidate (definition_id, step_id, candidate, candidate_order) VALUES (?, ?, ?, ?)"; "INSERT INTO ordo_step_candidate (definition_id, step_id, candidate, candidate_order) VALUES (?, ?, ?, ?)";
private static final String INSERT_TRANSITION =
"INSERT INTO ordo_step_transition (definition_id, from_step_id, to_step_id, condition_key, priority) VALUES (?, ?, ?, ?, ?)";
private static final String SELECT_DEFINITION = private static final String SELECT_DEFINITION =
"SELECT id, name FROM ordo_process_definition WHERE id = ?"; "SELECT id, name FROM ordo_process_definition WHERE id = ?";
private static final String SELECT_STEPS = private static final String SELECT_STEPS =
"SELECT step_id, step_name, policy FROM ordo_approval_step WHERE definition_id = ? ORDER BY step_order"; "SELECT step_id, step_name, policy FROM ordo_approval_step WHERE definition_id = ? ORDER BY step_order";
private static final String SELECT_CANDIDATES = private static final String SELECT_CANDIDATES =
"SELECT step_id, candidate FROM ordo_step_candidate WHERE definition_id = ? ORDER BY step_id, candidate_order"; "SELECT step_id, candidate FROM ordo_step_candidate WHERE definition_id = ? ORDER BY step_id, candidate_order";
private static final String SELECT_TRANSITIONS =
"SELECT from_step_id, to_step_id, condition_key, priority FROM ordo_step_transition "
+ "WHERE definition_id = ? ORDER BY from_step_id, priority";
private final JdbcConnectionProvider connectionProvider; private final JdbcConnectionProvider connectionProvider;
@@ -74,6 +82,16 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
} }
} }
} }
for (StepTransition transition : definition.transitions()) {
try (PreparedStatement insertTransition = connection.prepareStatement(INSERT_TRANSITION)) {
insertTransition.setString(1, definition.id());
insertTransition.setString(2, transition.fromStepId());
insertTransition.setString(3, transition.toStepId());
insertTransition.setString(4, transition.conditionKey());
insertTransition.setInt(5, transition.priority());
insertTransition.executeUpdate();
}
}
return true; return true;
} catch (SQLException e) { } catch (SQLException e) {
throw new JdbcStorageException("failed to insert definition: " + definition.id(), e); throw new JdbcStorageException("failed to insert definition: " + definition.id(), e);
@@ -120,7 +138,18 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
List<String> candidates = candidatesByStep.getOrDefault(row.stepId(), List.of()); List<String> candidates = candidatesByStep.getOrDefault(row.stepId(), List.of());
steps.add(new ApprovalStep(row.stepId(), row.stepName(), candidates, row.policy())); steps.add(new ApprovalStep(row.stepId(), row.stepName(), candidates, row.policy()));
} }
return Optional.of(new ProcessDefinition(definitionId, name, steps)); List<StepTransition> transitions = new ArrayList<>();
try (PreparedStatement selectTransitions = connection.prepareStatement(SELECT_TRANSITIONS)) {
selectTransitions.setString(1, definitionId);
try (ResultSet resultSet = selectTransitions.executeQuery()) {
while (resultSet.next()) {
TransitionRow row = StepTransitionMapper.readRow(resultSet);
transitions.add(new StepTransition(row.fromStepId(), row.toStepId(), row.conditionKey(),
row.priority()));
}
}
}
return Optional.of(new ProcessDefinition(definitionId, name, steps, transitions));
} catch (SQLException e) { } catch (SQLException e) {
throw new JdbcStorageException("failed to load definition: " + definitionId, e); throw new JdbcStorageException("failed to load definition: " + definitionId, e);
} finally { } finally {
@@ -0,0 +1,21 @@
package com.jetlumen.ordo.storage.jdbc.mapper;
import java.sql.ResultSet;
import java.sql.SQLException;
/** Maps rows of {@code ordo_step_transition}. */
public final class StepTransitionMapper {
private StepTransitionMapper() {
}
public static TransitionRow readRow(ResultSet resultSet) throws SQLException {
return new TransitionRow(
resultSet.getString("from_step_id"),
resultSet.getString("to_step_id"),
resultSet.getString("condition_key"),
resultSet.getInt("priority"));
}
public record TransitionRow(String fromStepId, String toStepId, String conditionKey, int priority) {
}
}
@@ -0,0 +1,12 @@
CREATE TABLE ordo_step_transition (
definition_id VARCHAR(64) NOT NULL,
from_step_id VARCHAR(64) NOT NULL,
to_step_id VARCHAR(64),
condition_key VARCHAR(255),
priority INTEGER NOT NULL,
PRIMARY KEY (definition_id, from_step_id, priority),
CONSTRAINT fk_transition_from FOREIGN KEY (definition_id, from_step_id)
REFERENCES ordo_approval_step (definition_id, step_id),
CONSTRAINT fk_transition_to FOREIGN KEY (definition_id, to_step_id)
REFERENCES ordo_approval_step (definition_id, step_id)
);
@@ -38,7 +38,7 @@ class JdbcApprovalTaskRepositoryTest {
/** Tasks reference their instance, which references its definition; both parent rows must exist. */ /** Tasks reference their instance, which references its definition; both parent rows must exist. */
private void insertFixtureData() { private void insertFixtureData() {
new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition("leave", new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(ProcessDefinition.linear("leave",
"Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria")))); "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria"))));
JdbcProcessInstanceRepository instanceRepository = new JdbcProcessInstanceRepository(connectionProvider); JdbcProcessInstanceRepository instanceRepository = new JdbcProcessInstanceRepository(connectionProvider);
instanceRepository.insert(new ProcessInstance("inst-1", "leave", "alice", ProcessStatus.RUNNING, instanceRepository.insert(new ProcessInstance("inst-1", "leave", "alice", ProcessStatus.RUNNING,
@@ -8,7 +8,10 @@ import com.jetlumen.ordo.api.ProcessContext;
import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessDefinition;
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.StepTransition;
import com.jetlumen.ordo.api.TaskStatus; import com.jetlumen.ordo.api.TaskStatus;
import com.jetlumen.ordo.api.exception.NoRouteFoundException;
import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException; import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
import com.jetlumen.ordo.core.DefaultOrdoEngine; import com.jetlumen.ordo.core.DefaultOrdoEngine;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -35,7 +38,7 @@ class JdbcOrdoEngineIntegrationTest {
void setUp() { void setUp() {
connectionProvider = new JdbcConnectionProvider(JdbcTestSupport.newDataSource()); connectionProvider = new JdbcConnectionProvider(JdbcTestSupport.newDataSource());
engine = newEngine(AssigneeResolver.direct()); engine = newEngine(AssigneeResolver.direct());
engine.register(new ProcessDefinition("leave", "Leave request", List.of( engine.register(ProcessDefinition.linear("leave", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry")))); ApprovalStep.single("hr", "HR approval", "henry"))));
} }
@@ -94,7 +97,7 @@ class JdbcOrdoEngineIntegrationTest {
} }
return candidate; return candidate;
}); });
failingEngine.register(new ProcessDefinition("leave2", "Leave request", List.of( failingEngine.register(ProcessDefinition.linear("leave2", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry")))); ApprovalStep.single("hr", "HR approval", "henry"))));
@@ -110,6 +113,28 @@ class JdbcOrdoEngineIntegrationTest {
assertEquals(ProcessStatus.RUNNING, failingEngine.findInstance(instance.id()).orElseThrow().status()); assertEquals(ProcessStatus.RUNNING, failingEngine.findInstance(instance.id()).orElseThrow().status());
} }
@Test
void rollsBackTheWholeApprovalWhenNoRouteMatches() {
OrdoEngine failingEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC),
AssigneeResolver.direct(), (key, context) -> false,
new JdbcTransactionExecutor(connectionProvider),
new JdbcProcessDefinitionRepository(connectionProvider),
new JdbcProcessInstanceRepository(connectionProvider),
new JdbcApprovalTaskRepository(connectionProvider));
failingEngine.register(new ProcessDefinition("leave-noroute", "Leave request",
List.of(ApprovalStep.single("manager", "Manager approval", "maria")),
List.of(StepTransition.endWhen("manager", "never", 0))));
ProcessInstance instance = failingEngine.start("leave-noroute", "alice");
ApprovalTask managerTask = failingEngine.findPendingTasksByInstanceId(instance.id()).getFirst();
assertThrows(NoRouteFoundException.class, () -> failingEngine.approve(managerTask.id(), "maria"));
ApprovalTask storedTask = failingEngine.findTask(managerTask.id()).orElseThrow();
assertEquals(TaskStatus.PENDING, storedTask.status());
assertNull(storedTask.action());
assertEquals(ProcessStatus.RUNNING, failingEngine.findInstance(instance.id()).orElseThrow().status());
}
@Test @Test
void dataSurvivesAcrossEngineInstancesOverTheSameDataSource() { void dataSurvivesAcrossEngineInstancesOverTheSameDataSource() {
ProcessInstance instance = engine.start("leave", "alice"); ProcessInstance instance = engine.start("leave", "alice");
@@ -124,7 +149,7 @@ class JdbcOrdoEngineIntegrationTest {
} }
private OrdoEngine newEngine(AssigneeResolver assigneeResolver) { private OrdoEngine newEngine(AssigneeResolver assigneeResolver) {
return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(),
new JdbcTransactionExecutor(connectionProvider), new JdbcTransactionExecutor(connectionProvider),
new JdbcProcessDefinitionRepository(connectionProvider), new JdbcProcessDefinitionRepository(connectionProvider),
new JdbcProcessInstanceRepository(connectionProvider), new JdbcProcessInstanceRepository(connectionProvider),
@@ -8,6 +8,7 @@ import com.jetlumen.ordo.api.ProcessContext;
import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessDefinition;
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.TaskAction; import com.jetlumen.ordo.api.TaskAction;
import com.jetlumen.ordo.api.TaskStatus; import com.jetlumen.ordo.api.TaskStatus;
import com.jetlumen.ordo.core.DefaultOrdoEngine; import com.jetlumen.ordo.core.DefaultOrdoEngine;
@@ -102,7 +103,7 @@ class JdbcPostgresIntegrationTest {
@Test @Test
void engineCompletesASequentialApprovalProcessOverPostgres() { void engineCompletesASequentialApprovalProcessOverPostgres() {
OrdoEngine engine = newEngine(AssigneeResolver.direct()); OrdoEngine engine = newEngine(AssigneeResolver.direct());
engine.register(new ProcessDefinition("leave-pg", "Leave request", List.of( engine.register(ProcessDefinition.linear("leave-pg", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry")))); ApprovalStep.single("hr", "HR approval", "henry"))));
@@ -122,18 +123,18 @@ class JdbcPostgresIntegrationTest {
@Test @Test
void rejectsDuplicateDefinitionIdsViaTheDatabaseUniqueConstraint() { void rejectsDuplicateDefinitionIdsViaTheDatabaseUniqueConstraint() {
JdbcProcessDefinitionRepository repository = new JdbcProcessDefinitionRepository(connectionProvider); JdbcProcessDefinitionRepository repository = new JdbcProcessDefinitionRepository(connectionProvider);
ProcessDefinition definition = new ProcessDefinition("leave-dup-pg", "Leave request", ProcessDefinition definition = ProcessDefinition.linear("leave-dup-pg", "Leave request",
List.of(ApprovalStep.single("manager", "Manager approval", "maria"))); List.of(ApprovalStep.single("manager", "Manager approval", "maria")));
assertTrue(repository.insertIfAbsent(definition)); assertTrue(repository.insertIfAbsent(definition));
assertFalse(repository.insertIfAbsent(new ProcessDefinition("leave-dup-pg", "Second attempt", assertFalse(repository.insertIfAbsent(ProcessDefinition.linear("leave-dup-pg", "Second attempt",
List.of(ApprovalStep.single("manager", "Manager approval", "maria"))))); List.of(ApprovalStep.single("manager", "Manager approval", "maria")))));
assertEquals("Leave request", repository.findById("leave-dup-pg").orElseThrow().name()); assertEquals("Leave request", repository.findById("leave-dup-pg").orElseThrow().name());
} }
@Test @Test
void rejectsDuplicateInstanceIdsViaTheDatabaseUniqueConstraint() { void rejectsDuplicateInstanceIdsViaTheDatabaseUniqueConstraint() {
new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition( new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(ProcessDefinition.linear(
"leave-dup-inst-pg", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria")))); "leave-dup-inst-pg", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria"))));
JdbcProcessInstanceRepository repository = new JdbcProcessInstanceRepository(connectionProvider); JdbcProcessInstanceRepository repository = new JdbcProcessInstanceRepository(connectionProvider);
ProcessInstance instance = new ProcessInstance("inst-dup-pg", "leave-dup-inst-pg", "alice", ProcessInstance instance = new ProcessInstance("inst-dup-pg", "leave-dup-inst-pg", "alice",
@@ -145,7 +146,7 @@ class JdbcPostgresIntegrationTest {
@Test @Test
void roundsTimestampsToMicrosecondPrecision() { void roundsTimestampsToMicrosecondPrecision() {
new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition( new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(ProcessDefinition.linear(
"leave-time-pg", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria")))); "leave-time-pg", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria"))));
JdbcProcessInstanceRepository repository = new JdbcProcessInstanceRepository(connectionProvider); JdbcProcessInstanceRepository repository = new JdbcProcessInstanceRepository(connectionProvider);
@@ -164,7 +165,7 @@ class JdbcPostgresIntegrationTest {
@Test @Test
void onlyOneOfTwoConcurrentCompletionsWinsOnPostgres() throws Exception { void onlyOneOfTwoConcurrentCompletionsWinsOnPostgres() throws Exception {
new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition( new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(ProcessDefinition.linear(
"leave-race-pg", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria")))); "leave-race-pg", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria"))));
JdbcProcessInstanceRepository instanceRepository = new JdbcProcessInstanceRepository(connectionProvider); JdbcProcessInstanceRepository instanceRepository = new JdbcProcessInstanceRepository(connectionProvider);
instanceRepository.insert(new ProcessInstance("inst-race-pg", "leave-race-pg", "alice", instanceRepository.insert(new ProcessInstance("inst-race-pg", "leave-race-pg", "alice",
@@ -200,7 +201,7 @@ class JdbcPostgresIntegrationTest {
} }
return candidate; return candidate;
}); });
failingEngine.register(new ProcessDefinition("leave-rollback-pg", "Leave request", List.of( failingEngine.register(ProcessDefinition.linear("leave-rollback-pg", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry")))); ApprovalStep.single("hr", "HR approval", "henry"))));
@@ -217,7 +218,7 @@ class JdbcPostgresIntegrationTest {
} }
private OrdoEngine newEngine(AssigneeResolver assigneeResolver) { private OrdoEngine newEngine(AssigneeResolver assigneeResolver) {
return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(),
new JdbcTransactionExecutor(connectionProvider), new JdbcTransactionExecutor(connectionProvider),
new JdbcProcessDefinitionRepository(connectionProvider), new JdbcProcessDefinitionRepository(connectionProvider),
new JdbcProcessInstanceRepository(connectionProvider), new JdbcProcessInstanceRepository(connectionProvider),
@@ -2,6 +2,7 @@ package com.jetlumen.ordo.storage.jdbc;
import com.jetlumen.ordo.api.ApprovalStep; import com.jetlumen.ordo.api.ApprovalStep;
import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.StepTransition;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -22,7 +23,7 @@ class JdbcProcessDefinitionRepositoryTest {
@Test @Test
void insertsAndReadsBackADefinitionWithItsStepsInOrder() { void insertsAndReadsBackADefinitionWithItsStepsInOrder() {
ProcessDefinition definition = new ProcessDefinition("leave", "Leave request", List.of( ProcessDefinition definition = ProcessDefinition.linear("leave", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry"))); ApprovalStep.single("hr", "HR approval", "henry")));
@@ -43,7 +44,21 @@ class JdbcProcessDefinitionRepositoryTest {
assertTrue(repository.findById("missing").isEmpty()); assertTrue(repository.findById("missing").isEmpty());
} }
@Test
void insertsAndReadsBackConditionalTransitionsIncludingTerminalEdges() {
ProcessDefinition definition = new ProcessDefinition("expense", "Expense request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("director", "Director approval", "diana")),
List.of(
StepTransition.when("manager", "director", "amount-gt-1000", 0),
new StepTransition("manager", null, null, 1),
StepTransition.end("director")));
assertTrue(repository.insertIfAbsent(definition));
assertEquals(definition, repository.findById("expense").orElseThrow());
}
private static ProcessDefinition definition(String id, String name) { private static ProcessDefinition definition(String id, String name) {
return new ProcessDefinition(id, name, List.of(ApprovalStep.single("lead", "Lead approval", "lee"))); return ProcessDefinition.linear(id, name, List.of(ApprovalStep.single("lead", "Lead approval", "lee")));
} }
} }
@@ -27,7 +27,7 @@ class JdbcProcessInstanceRepositoryTest {
connectionProvider = new JdbcConnectionProvider(JdbcTestSupport.newDataSource()); connectionProvider = new JdbcConnectionProvider(JdbcTestSupport.newDataSource());
repository = new JdbcProcessInstanceRepository(connectionProvider); repository = new JdbcProcessInstanceRepository(connectionProvider);
// instances reference their definition, so the parent row must exist // instances reference their definition, so the parent row must exist
new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition("leave", new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(ProcessDefinition.linear("leave",
"Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria")))); "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria"))));
} }
@@ -15,7 +15,8 @@ import java.util.UUID;
final class JdbcTestSupport { final class JdbcTestSupport {
private static final String[] MIGRATIONS = { private static final String[] MIGRATIONS = {
"/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"
}; };
private static final String[] SCHEMA_SQL = loadSchemas(); private static final String[] SCHEMA_SQL = loadSchemas();