feat: add ACTION steps that run after the approval transaction commits

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-11 17:26:02 +08:00
co-authored by Cursor
parent 87b449e45c
commit bdc4dd4473
19 changed files with 341 additions and 49 deletions
@@ -0,0 +1,16 @@
package com.jetlumen.ordo.api;
/**
* Executes a named action step against the running instance's context. Hosts supply a
* singleton implementation (same pattern as {@link RoutingCondition}); the database only stores
* the {@code actionKey} string.
*/
@FunctionalInterface
public interface ActionHandler {
void execute(String actionKey, ProcessContext context);
static ActionHandler noop() {
return (key, context) -> {
};
}
}
@@ -6,17 +6,29 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
/** /**
* A single, named approval step in a process definition. A step has one or more candidate * A named step in a process definition. Approval steps have one or more candidate assignees;
* assignees; when there is more than one candidate, {@link #policy()} decides whether any * action steps have an {@code actionKey} invoked by the host {@link ActionHandler}.
* single approval is enough ({@link ApprovalPolicy#ANY}) or every candidate must approve
* ({@link ApprovalPolicy#ALL}).
*/ */
public record ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy) { public record ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy, StepKind kind,
String actionKey) {
public ApprovalStep { public ApprovalStep {
requireText(id, "step id"); requireText(id, "step id");
requireText(name, "step name"); requireText(name, "step name");
kind = kind == null ? StepKind.APPROVAL : kind;
Objects.requireNonNull(policy, "policy must not be null");
Objects.requireNonNull(candidates, "candidates must not be null"); Objects.requireNonNull(candidates, "candidates must not be null");
candidates = List.copyOf(candidates); candidates = List.copyOf(candidates);
if (kind == StepKind.ACTION) {
if (!candidates.isEmpty()) {
throw new IllegalArgumentException("an action step must not have candidates");
}
requireText(actionKey, "action key");
actionKey = actionKey.strip();
} else {
if (actionKey != null && !actionKey.isBlank()) {
throw new IllegalArgumentException("an approval step must not have an action key");
}
actionKey = null;
if (candidates.isEmpty()) { if (candidates.isEmpty()) {
throw new IllegalArgumentException("a step must have at least one candidate"); throw new IllegalArgumentException("a step must have at least one candidate");
} }
@@ -27,7 +39,11 @@ public record ApprovalStep(String id, String name, List<String> candidates, Appr
throw new IllegalArgumentException("duplicate candidate in step " + id + ": " + candidate); throw new IllegalArgumentException("duplicate candidate in step " + id + ": " + candidate);
} }
} }
Objects.requireNonNull(policy, "policy must not be null"); }
}
public ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy) {
this(id, name, candidates, policy, StepKind.APPROVAL, null);
} }
/** Convenience factory for the common case of a single, fixed approver. */ /** Convenience factory for the common case of a single, fixed approver. */
@@ -35,6 +51,10 @@ public record ApprovalStep(String id, String name, List<String> candidates, Appr
return new ApprovalStep(id, name, List.of(assignee), ApprovalPolicy.ANY); return new ApprovalStep(id, name, List.of(assignee), ApprovalPolicy.ANY);
} }
public static ApprovalStep action(String id, String name, String actionKey) {
return new ApprovalStep(id, name, List.of(), ApprovalPolicy.ANY, StepKind.ACTION, actionKey);
}
static void requireText(String value, String field) { static void requireText(String value, String field) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank"); throw new IllegalArgumentException(field + " must not be blank");
@@ -14,7 +14,7 @@ public record ProcessDefinition(String id, String name, List<ApprovalStep> steps
ApprovalStep.requireText(name, "definition name"); ApprovalStep.requireText(name, "definition name");
steps = List.copyOf(steps); steps = List.copyOf(steps);
if (steps.isEmpty()) { if (steps.isEmpty()) {
throw new IllegalArgumentException("a definition must contain at least one approval step"); throw new IllegalArgumentException("a definition must contain at least one step");
} }
Set<String> ids = new HashSet<>(); Set<String> ids = new HashSet<>();
for (ApprovalStep step : steps) { for (ApprovalStep step : steps) {
@@ -37,7 +37,7 @@ public final class ProcessDefinitionParser {
private static ProcessDefinition toDefinition(DefinitionDocument document) { private static ProcessDefinition toDefinition(DefinitionDocument document) {
if (document.steps() == null || document.steps().isEmpty()) { if (document.steps() == null || document.steps().isEmpty()) {
throw new IllegalArgumentException("a definition must contain at least one approval step"); throw new IllegalArgumentException("a definition must contain at least one step");
} }
List<ApprovalStep> steps = new ArrayList<>(document.steps().size()); List<ApprovalStep> steps = new ArrayList<>(document.steps().size());
for (StepDocument step : document.steps()) { for (StepDocument step : document.steps()) {
@@ -45,7 +45,9 @@ public final class ProcessDefinitionParser {
throw new IllegalArgumentException("step must not be null"); throw new IllegalArgumentException("step must not be null");
} }
ApprovalPolicy policy = step.policy() == null ? ApprovalPolicy.ANY : step.policy(); ApprovalPolicy policy = step.policy() == null ? ApprovalPolicy.ANY : step.policy();
steps.add(new ApprovalStep(step.id(), step.name(), step.candidates(), policy)); StepKind kind = step.kind() == null ? StepKind.APPROVAL : step.kind();
List<String> candidates = step.candidates() == null ? List.of() : step.candidates();
steps.add(new ApprovalStep(step.id(), step.name(), candidates, policy, kind, step.action()));
} }
rotateStartStep(steps, document.startStep()); rotateStartStep(steps, document.startStep());
List<StepTransition> transitions = new ArrayList<>(); List<StepTransition> transitions = new ArrayList<>();
@@ -90,7 +92,13 @@ public final class ProcessDefinitionParser {
List<TransitionDocument> transitions) { List<TransitionDocument> transitions) {
} }
private record StepDocument(String id, String name, List<String> candidates, ApprovalPolicy policy) { private record StepDocument(
String id,
String name,
List<String> candidates,
ApprovalPolicy policy,
StepKind kind,
@JsonProperty("action") String action) {
} }
private record TransitionDocument( private record TransitionDocument(
@@ -0,0 +1,5 @@
package com.jetlumen.ordo.api;
public enum StepKind {
APPROVAL, ACTION
}
@@ -153,4 +153,25 @@ class ProcessDefinitionParserTest {
void rejectsMalformedJson() { void rejectsMalformedJson() {
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson("{")); assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson("{"));
} }
@Test
void parsesActionStepsAndDefaultsKindToApproval() {
String json = """
{
"id": "leave",
"name": "Leave request",
"steps": [
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] },
{ "id": "notify", "name": "Notify HR", "kind": "ACTION", "action": "leave-approved-mail" }
],
"transitions": [
{ "from": "manager", "to": "notify" },
{ "from": "notify", "to": null }
]
}
""";
ProcessDefinition definition = ProcessDefinitionParser.fromJson(json);
assertEquals(StepKind.APPROVAL, definition.steps().getFirst().kind());
assertEquals(ApprovalStep.action("notify", "Notify HR", "leave-approved-mail"), definition.steps().get(1));
}
} }
@@ -94,4 +94,28 @@ class ProcessDefinitionTest {
StepTransition.end("hr"), StepTransition.end("hr"),
StepTransition.always("manager", "hr")), definition.transitions()); StepTransition.always("manager", "hr")), definition.transitions());
} }
@Test
void rejectsActionStepsWithCandidatesOrBlankActionKey() {
assertThrows(IllegalArgumentException.class,
() -> new ApprovalStep("notify", "Notify", List.of("maria"), ApprovalPolicy.ANY, StepKind.ACTION,
"mail"));
assertThrows(IllegalArgumentException.class, () -> ApprovalStep.action("notify", "Notify", " "));
}
@Test
void rejectsApprovalStepsWithAnActionKey() {
assertThrows(IllegalArgumentException.class,
() -> new ApprovalStep("manager", "Manager", List.of("maria"), ApprovalPolicy.ANY, StepKind.APPROVAL,
"mail"));
}
@Test
void acceptsADefinitionThatIsOnlyActionSteps() {
ProcessDefinition definition = new ProcessDefinition("notify", "Notify", List.of(
ApprovalStep.action("mail", "Send mail", "leave-approved-mail")),
List.of(StepTransition.end("mail")));
assertEquals(StepKind.ACTION, definition.steps().getFirst().kind());
assertEquals("leave-approved-mail", definition.steps().getFirst().actionKey());
}
} }
@@ -1,5 +1,6 @@
package com.jetlumen.ordo.core; package com.jetlumen.ordo.core;
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;
@@ -10,6 +11,7 @@ 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.RoutingCondition;
import com.jetlumen.ordo.api.StepKind;
import com.jetlumen.ordo.api.StepTransition; 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;
@@ -30,6 +32,9 @@ 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.lang.System.Logger;
import java.lang.System.Logger.Level;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@@ -44,22 +49,27 @@ import java.util.UUID;
* when several JVMs share the same storage. * when several JVMs share the same storage.
*/ */
public final class DefaultOrdoEngine implements OrdoEngine { public final class DefaultOrdoEngine implements OrdoEngine {
private static final int MAX_CONSECUTIVE_ACTIONS = 32;
private static final Logger LOG = System.getLogger("ordo");
private final Clock clock; private final Clock clock;
private final AssigneeResolver assigneeResolver; private final AssigneeResolver assigneeResolver;
private final RoutingCondition routingCondition; private final RoutingCondition routingCondition;
private final ActionHandler actionHandler;
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, RoutingCondition routingCondition, public DefaultOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
TransactionExecutor transactionExecutor, ActionHandler actionHandler, 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.routingCondition = Objects.requireNonNull(routingCondition, "routingCondition must not be null");
this.actionHandler = Objects.requireNonNull(actionHandler, "actionHandler 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");
@@ -93,26 +103,32 @@ public final class DefaultOrdoEngine implements OrdoEngine {
public synchronized ProcessInstance start(String definitionId, String initiator, ProcessContext context) { public synchronized ProcessInstance start(String definitionId, String initiator, ProcessContext context) {
requireText(initiator, "initiator"); requireText(initiator, "initiator");
Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(context, "context must not be null");
return transactionExecutor.execute(() -> { List<PendingAction> queued = new ArrayList<>();
ProcessInstance instance = transactionExecutor.execute(() -> {
ProcessDefinition definition = requireDefinition(definitionId); ProcessDefinition definition = requireDefinition(definitionId);
Instant now = clock.instant(); Instant now = clock.instant();
ProcessInstance instance = 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(instance); instanceRepository.insert(started);
createStepTasks(instance, definition.steps().getFirst(), now); enterStep(started, definition, definition.steps().getFirst(), now, queued);
return instance; return started;
}); });
runQueuedActions(queued);
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) {
return transactionExecutor.execute(() -> { List<PendingAction> queued = 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.APPROVED, new TaskAction(actor, comment, now)); ApprovalTask completedTask = completeTask(task, TaskStatus.APPROVED, new TaskAction(actor, comment, now));
advanceAfterDecision(completedTask, now); advanceAfterDecision(completedTask, now, queued);
return completedTask; return completedTask;
}); });
runQueuedActions(queued);
return completed;
} }
@Override @Override
@@ -121,7 +137,7 @@ public final class DefaultOrdoEngine implements OrdoEngine {
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); advanceAfterDecision(completedTask, now, List.of());
return completedTask; return completedTask;
}); });
} }
@@ -217,7 +233,7 @@ 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) { private void advanceAfterDecision(ApprovalTask completedTask, Instant now, List<PendingAction> queued) {
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());
@@ -225,7 +241,7 @@ public final class DefaultOrdoEngine implements OrdoEngine {
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(), now);
advanceOrComplete(instance, definition, step, now); advanceOrComplete(instance, definition, step, now, queued);
return; return;
} }
@@ -249,16 +265,46 @@ 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); advanceOrComplete(instance, definition, step, now, queued);
} }
} }
private void advanceOrComplete(ProcessInstance instance, ProcessDefinition definition, ApprovalStep step, Instant now) { private void advanceOrComplete(ProcessInstance instance, ProcessDefinition definition, ApprovalStep step,
Instant now, List<PendingAction> queued) {
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);
} else { } else {
createStepTasks(instance, requireStep(definition, matched.toStepId()), now); enterStep(instance, definition, requireStep(definition, matched.toStepId()), now, queued);
}
}
private void enterStep(ProcessInstance instance, ProcessDefinition definition, ApprovalStep start, Instant now,
List<PendingAction> queued) {
ApprovalStep current = start;
for (int hops = 0; hops < MAX_CONSECUTIVE_ACTIONS; hops++) {
if (current.kind() == StepKind.APPROVAL) {
createStepTasks(instance, current, now);
return;
}
queued.add(new PendingAction(current.actionKey(), instance.context()));
StepTransition matched = resolveTransition(definition, current, instance);
if (matched.toStepId() == null) {
completeInstance(instance, ProcessStatus.APPROVED, now);
return;
}
current = requireStep(definition, matched.toStepId());
}
throw new IllegalStateException("too many consecutive action steps in instance: " + instance.id());
}
private void runQueuedActions(List<PendingAction> queued) {
for (PendingAction pending : queued) {
try {
actionHandler.execute(pending.actionKey(), pending.context());
} catch (RuntimeException e) {
LOG.log(Level.WARNING, "action failed: " + pending.actionKey(), e);
}
} }
} }
@@ -324,4 +370,7 @@ public final class DefaultOrdoEngine implements OrdoEngine {
throw new IllegalArgumentException(name + " must not be blank"); throw new IllegalArgumentException(name + " must not be blank");
} }
} }
private record PendingAction(String actionKey, ProcessContext context) {
}
} }
@@ -1,5 +1,6 @@
package com.jetlumen.ordo.core; package com.jetlumen.ordo.core;
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;
@@ -20,31 +21,37 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
private final DefaultOrdoEngine delegate; private final DefaultOrdoEngine delegate;
public InMemoryOrdoEngine() { public InMemoryOrdoEngine() {
this(Clock.systemUTC(), AssigneeResolver.direct(), RoutingCondition.always()); this(Clock.systemUTC(), AssigneeResolver.direct(), RoutingCondition.always(), ActionHandler.noop());
} }
public InMemoryOrdoEngine(Clock clock) { public InMemoryOrdoEngine(Clock clock) {
this(clock, AssigneeResolver.direct(), RoutingCondition.always()); this(clock, AssigneeResolver.direct(), RoutingCondition.always(), ActionHandler.noop());
} }
public InMemoryOrdoEngine(AssigneeResolver assigneeResolver) { public InMemoryOrdoEngine(AssigneeResolver assigneeResolver) {
this(Clock.systemUTC(), assigneeResolver, RoutingCondition.always()); this(Clock.systemUTC(), assigneeResolver, RoutingCondition.always(), ActionHandler.noop());
} }
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver) { public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver) {
this(clock, assigneeResolver, RoutingCondition.always()); this(clock, assigneeResolver, RoutingCondition.always(), ActionHandler.noop());
} }
public InMemoryOrdoEngine(RoutingCondition routingCondition) { public InMemoryOrdoEngine(RoutingCondition routingCondition) {
this(Clock.systemUTC(), AssigneeResolver.direct(), routingCondition); this(Clock.systemUTC(), AssigneeResolver.direct(), routingCondition, ActionHandler.noop());
} }
public InMemoryOrdoEngine(AssigneeResolver assigneeResolver, RoutingCondition routingCondition) { public InMemoryOrdoEngine(AssigneeResolver assigneeResolver, RoutingCondition routingCondition) {
this(Clock.systemUTC(), assigneeResolver, routingCondition); this(Clock.systemUTC(), assigneeResolver, routingCondition, ActionHandler.noop());
} }
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition) { public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition) {
this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, routingCondition, new NoopTransactionExecutor(), this(clock, assigneeResolver, routingCondition, ActionHandler.noop());
}
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
ActionHandler actionHandler) {
this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, routingCondition, actionHandler,
new NoopTransactionExecutor(),
new InMemoryProcessDefinitionRepository(), new InMemoryProcessDefinitionRepository(),
new InMemoryProcessInstanceRepository(), new InMemoryProcessInstanceRepository(),
new InMemoryApprovalTaskRepository()); new InMemoryApprovalTaskRepository());
@@ -1,5 +1,6 @@
package com.jetlumen.ordo.core; package com.jetlumen.ordo.core;
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;
@@ -23,6 +24,7 @@ import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -386,6 +388,65 @@ class InMemoryOrdoEngineTest {
assertTrue(routingEngine.findPendingTasksByInstanceId(low.id()).isEmpty()); assertTrue(routingEngine.findPendingTasksByInstanceId(low.id()).isEmpty());
} }
@Test
void runsActionStepsAfterApprovalThenCreatesTheNextApprovalTask() {
List<String> executed = new java.util.ArrayList<>();
InMemoryOrdoEngine actionEngine = new InMemoryOrdoEngine(Clock.systemUTC(), AssigneeResolver.direct(),
RoutingCondition.always(), (key, context) -> executed.add(key));
actionEngine.register(new ProcessDefinition("leave", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.action("notify", "Notify HR", "leave-approved-mail"),
ApprovalStep.single("hr", "HR approval", "henry")),
List.of(
StepTransition.always("manager", "notify"),
StepTransition.always("notify", "hr"),
StepTransition.end("hr"))));
var instance = actionEngine.start("leave", "alice");
actionEngine.approve(actionEngine.findPendingTasksByInstanceId(instance.id()).getFirst().id(), "maria");
assertEquals(List.of("leave-approved-mail"), executed);
ApprovalTask hrTask = actionEngine.findPendingTasksByInstanceId(instance.id()).getFirst();
assertEquals("hr", hrTask.stepId());
assertEquals(ProcessStatus.RUNNING, actionEngine.findInstance(instance.id()).orElseThrow().status());
}
@Test
void continuesWhenAnActionHandlerThrows() {
InMemoryOrdoEngine actionEngine = new InMemoryOrdoEngine(Clock.systemUTC(), AssigneeResolver.direct(),
RoutingCondition.always(), (key, context) -> {
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", "leave-approved-mail")),
List.of(
StepTransition.always("manager", "notify"),
StepTransition.end("notify"))));
var instance = actionEngine.start("leave", "alice");
actionEngine.approve(actionEngine.findPendingTasksByInstanceId(instance.id()).getFirst().id(), "maria");
assertEquals(ProcessStatus.APPROVED, actionEngine.findInstance(instance.id()).orElseThrow().status());
}
@Test
void startEntersAnActionStepThenStopsOnTheFollowingApproval() {
List<String> executed = new java.util.ArrayList<>();
InMemoryOrdoEngine actionEngine = new InMemoryOrdoEngine(Clock.systemUTC(), AssigneeResolver.direct(),
RoutingCondition.always(), (key, context) -> executed.add(key));
actionEngine.register(new ProcessDefinition("leave", "Leave request", List.of(
ApprovalStep.action("notify", "Notify manager", "leave-submitted-mail"),
ApprovalStep.single("manager", "Manager approval", "maria")),
List.of(
StepTransition.always("notify", "manager"),
StepTransition.end("manager"))));
var instance = actionEngine.start("leave", "alice");
assertEquals(List.of("leave-submitted-mail"), executed);
assertEquals("manager", actionEngine.findPendingTasksByInstanceId(instance.id()).getFirst().stepId());
assertEquals(ProcessStatus.RUNNING, instance.status());
}
@Test @Test
void throwsWhenNoTransitionMatches() { void throwsWhenNoTransitionMatches() {
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine((key, context) -> false); InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine((key, context) -> false);
@@ -1,5 +1,6 @@
package com.jetlumen.ordo.spring; package com.jetlumen.ordo.spring;
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.RoutingCondition; import com.jetlumen.ordo.api.RoutingCondition;
@@ -65,6 +66,12 @@ public class OrdoJdbcAutoConfiguration {
return RoutingCondition.always(); return RoutingCondition.always();
} }
@Bean
@ConditionalOnMissingBean
public ActionHandler ordoActionHandler() {
return ActionHandler.noop();
}
@Bean @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
@DependsOnDatabaseInitialization @DependsOnDatabaseInitialization
@@ -101,12 +108,14 @@ public class OrdoJdbcAutoConfiguration {
public OrdoEngine ordoEngine(Clock ordoClock, public OrdoEngine ordoEngine(Clock ordoClock,
AssigneeResolver ordoAssigneeResolver, AssigneeResolver ordoAssigneeResolver,
RoutingCondition ordoRoutingCondition, RoutingCondition ordoRoutingCondition,
ActionHandler actionHandler,
TransactionExecutor ordoTransactionExecutor, TransactionExecutor ordoTransactionExecutor,
ProcessDefinitionRepository ordoProcessDefinitionRepository, ProcessDefinitionRepository ordoProcessDefinitionRepository,
ProcessInstanceRepository ordoProcessInstanceRepository, ProcessInstanceRepository ordoProcessInstanceRepository,
ApprovalTaskRepository ordoApprovalTaskRepository) { ApprovalTaskRepository ordoApprovalTaskRepository) {
return new DefaultOrdoEngine(ordoClock, ordoAssigneeResolver, ordoRoutingCondition, ordoTransactionExecutor, return new DefaultOrdoEngine(ordoClock, ordoAssigneeResolver, ordoRoutingCondition, actionHandler,
ordoProcessDefinitionRepository, ordoProcessInstanceRepository, ordoApprovalTaskRepository); ordoTransactionExecutor, ordoProcessDefinitionRepository, ordoProcessInstanceRepository,
ordoApprovalTaskRepository);
} }
@Bean @Bean
@@ -1,5 +1,6 @@
package com.jetlumen.ordo.spring; package com.jetlumen.ordo.spring;
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;
import com.jetlumen.ordo.api.AssigneeResolver; import com.jetlumen.ordo.api.AssigneeResolver;
@@ -20,6 +21,7 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OrdoJdbcAutoConfigurationTest { class OrdoJdbcAutoConfigurationTest {
@@ -138,6 +140,17 @@ class OrdoJdbcAutoConfigurationTest {
}); });
} }
@Test
void honoursUserDefinedActionHandler() {
withDataSourceRunner.withUserConfiguration(CustomActionHandlerConfig.class)
.run(context -> {
assertThat(context).hasSingleBean(OrdoEngine.class);
assertThatThrownBy(() -> context.getBean(ActionHandler.class).execute("any", null))
.isInstanceOf(IllegalStateException.class)
.hasMessage("custom-action");
});
}
@Configuration @Configuration
static class CustomAssigneeResolverConfig { static class CustomAssigneeResolverConfig {
@Bean @Bean
@@ -153,4 +166,14 @@ class OrdoJdbcAutoConfigurationTest {
return (key, context) -> true; return (key, context) -> true;
} }
} }
@Configuration
static class CustomActionHandlerConfig {
@Bean
ActionHandler ordoActionHandler() {
return (key, context) -> {
throw new IllegalStateException("custom-action");
};
}
}
} }
@@ -25,7 +25,8 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
private static final String INSERT_DEFINITION = private static final String INSERT_DEFINITION =
"INSERT INTO ordo_process_definition (id, name) VALUES (?, ?)"; "INSERT INTO ordo_process_definition (id, name) VALUES (?, ?)";
private static final String INSERT_STEP = private static final String INSERT_STEP =
"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, kind, action_key)"
+ " 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 = private static final String INSERT_TRANSITION =
@@ -41,7 +42,8 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
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, kind, action_key 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 = private static final String SELECT_TRANSITIONS =
@@ -76,9 +78,17 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
Objects.requireNonNull(definition, "definition must not be null"); Objects.requireNonNull(definition, "definition must not be null");
Connection connection = connectionProvider.getConnection(); Connection connection = connectionProvider.getConnection();
try { try {
if (!insertDefinitionRow(connection, definition)) { // PostgreSQL aborts the current transaction on unique-constraint violations,
// so upsert must not probe existence via a failing INSERT.
if (definitionExists(connection, definition.id())) {
updateDefinitionName(connection, definition); updateDefinitionName(connection, definition);
deleteGraph(connection, definition.id()); deleteGraph(connection, definition.id());
} else {
try (PreparedStatement insert = connection.prepareStatement(INSERT_DEFINITION)) {
insert.setString(1, definition.id());
insert.setString(2, definition.name());
insert.executeUpdate();
}
} }
insertGraph(connection, definition); insertGraph(connection, definition);
} catch (SQLException e) { } catch (SQLException e) {
@@ -124,7 +134,8 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
List<ApprovalStep> steps = new ArrayList<>(); List<ApprovalStep> steps = new ArrayList<>();
for (StepRow row : stepRows) { for (StepRow row : stepRows) {
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(), row.kind(),
row.actionKey()));
} }
List<StepTransition> transitions = new ArrayList<>(); List<StepTransition> transitions = new ArrayList<>();
try (PreparedStatement selectTransitions = connection.prepareStatement(SELECT_TRANSITIONS)) { try (PreparedStatement selectTransitions = connection.prepareStatement(SELECT_TRANSITIONS)) {
@@ -145,6 +156,15 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
} }
} }
private static boolean definitionExists(Connection connection, String definitionId) throws SQLException {
try (PreparedStatement select = connection.prepareStatement(SELECT_DEFINITION)) {
select.setString(1, definitionId);
try (ResultSet resultSet = select.executeQuery()) {
return resultSet.next();
}
}
}
private static boolean insertDefinitionRow(Connection connection, ProcessDefinition definition) throws SQLException { private static boolean insertDefinitionRow(Connection connection, ProcessDefinition definition) throws SQLException {
try (PreparedStatement insertDefinition = connection.prepareStatement(INSERT_DEFINITION)) { try (PreparedStatement insertDefinition = connection.prepareStatement(INSERT_DEFINITION)) {
insertDefinition.setString(1, definition.id()); insertDefinition.setString(1, definition.id());
@@ -191,6 +211,8 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
insertStep.setString(3, step.name()); insertStep.setString(3, step.name());
insertStep.setString(4, step.policy().name()); insertStep.setString(4, step.policy().name());
insertStep.setInt(5, stepOrder++); insertStep.setInt(5, stepOrder++);
insertStep.setString(6, step.kind().name());
insertStep.setString(7, step.actionKey());
insertStep.executeUpdate(); insertStep.executeUpdate();
} }
int candidateOrder = 0; int candidateOrder = 0;
@@ -1,6 +1,7 @@
package com.jetlumen.ordo.storage.jdbc.mapper; package com.jetlumen.ordo.storage.jdbc.mapper;
import com.jetlumen.ordo.api.ApprovalPolicy; import com.jetlumen.ordo.api.ApprovalPolicy;
import com.jetlumen.ordo.api.StepKind;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
@@ -16,9 +17,11 @@ public final class ApprovalStepMapper {
public static StepRow readRow(ResultSet resultSet) throws SQLException { public static StepRow readRow(ResultSet resultSet) throws SQLException {
return new StepRow(resultSet.getString("step_id"), resultSet.getString("step_name"), return new StepRow(resultSet.getString("step_id"), resultSet.getString("step_name"),
ApprovalPolicy.valueOf(resultSet.getString("policy"))); ApprovalPolicy.valueOf(resultSet.getString("policy")),
StepKind.valueOf(resultSet.getString("kind")),
resultSet.getString("action_key"));
} }
public record StepRow(String stepId, String stepName, ApprovalPolicy policy) { public record StepRow(String stepId, String stepName, ApprovalPolicy policy, StepKind kind, String actionKey) {
} }
} }
@@ -0,0 +1,3 @@
-- Adds ACTION step kind and an optional action_key (host ActionHandler lookup).
ALTER TABLE ordo_approval_step ADD COLUMN kind VARCHAR(16) NOT NULL DEFAULT 'APPROVAL';
ALTER TABLE ordo_approval_step ADD COLUMN action_key VARCHAR(255);
@@ -1,5 +1,6 @@
package com.jetlumen.ordo.storage.jdbc; package com.jetlumen.ordo.storage.jdbc;
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;
import com.jetlumen.ordo.api.AssigneeResolver; import com.jetlumen.ordo.api.AssigneeResolver;
@@ -119,7 +120,7 @@ class JdbcOrdoEngineIntegrationTest {
@Test @Test
void rollsBackTheWholeApprovalWhenNoRouteMatches() { void rollsBackTheWholeApprovalWhenNoRouteMatches() {
OrdoEngine failingEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), OrdoEngine failingEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC),
AssigneeResolver.direct(), (key, context) -> false, AssigneeResolver.direct(), (key, context) -> false, ActionHandler.noop(),
new JdbcTransactionExecutor(connectionProvider), new JdbcTransactionExecutor(connectionProvider),
new JdbcProcessDefinitionRepository(connectionProvider), new JdbcProcessDefinitionRepository(connectionProvider),
new JdbcProcessInstanceRepository(connectionProvider), new JdbcProcessInstanceRepository(connectionProvider),
@@ -197,6 +198,7 @@ class JdbcOrdoEngineIntegrationTest {
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(),
new JdbcTransactionExecutor(connectionProvider), new JdbcTransactionExecutor(connectionProvider),
new JdbcProcessDefinitionRepository(connectionProvider), new JdbcProcessDefinitionRepository(connectionProvider),
new JdbcProcessInstanceRepository(connectionProvider), new JdbcProcessInstanceRepository(connectionProvider),
@@ -1,5 +1,6 @@
package com.jetlumen.ordo.storage.jdbc; package com.jetlumen.ordo.storage.jdbc;
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;
import com.jetlumen.ordo.api.AssigneeResolver; import com.jetlumen.ordo.api.AssigneeResolver;
@@ -219,6 +220,7 @@ class JdbcPostgresIntegrationTest {
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(),
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.StepKind;
import com.jetlumen.ordo.api.StepTransition; 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;
@@ -72,6 +73,21 @@ class JdbcProcessDefinitionRepositoryTest {
assertEquals(definition, repository.findById("expense").orElseThrow()); assertEquals(definition, repository.findById("expense").orElseThrow());
} }
@Test
void insertsAndReadsBackActionStepsWithoutCandidates() {
ProcessDefinition definition = new ProcessDefinition("notify", "Notify", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.action("mail", "Send mail", "leave-approved-mail")),
List.of(
StepTransition.always("manager", "mail"),
StepTransition.end("mail")));
assertTrue(repository.insertIfAbsent(definition));
assertEquals(definition, repository.findById("notify").orElseThrow());
assertEquals(StepKind.ACTION, repository.findById("notify").orElseThrow().steps().get(1).kind());
assertEquals("leave-approved-mail", repository.findById("notify").orElseThrow().steps().get(1).actionKey());
}
private static ProcessDefinition definition(String id, String name) { private static ProcessDefinition definition(String id, String name) {
return ProcessDefinition.linear(id, name, List.of(ApprovalStep.single("lead", "Lead approval", "lee"))); return ProcessDefinition.linear(id, name, List.of(ApprovalStep.single("lead", "Lead approval", "lee")));
} }
@@ -16,7 +16,8 @@ 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" "/db/migration/V3__add_step_transitions.sql",
"/db/migration/V4__add_step_kind_and_action_key.sql"
}; };
private static final String[] SCHEMA_SQL = loadSchemas(); private static final String[] SCHEMA_SQL = loadSchemas();