feat: replace process definition graphs when no instance is running

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-11 16:15:17 +08:00
co-authored by Cursor
parent a6410b3dc4
commit 946c9d3c52
16 changed files with 277 additions and 48 deletions
@@ -15,6 +15,7 @@ import com.jetlumen.ordo.api.TaskAction;
import com.jetlumen.ordo.api.TaskStatus;
import com.jetlumen.ordo.api.TransactionExecutor;
import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException;
import com.jetlumen.ordo.api.exception.DefinitionInUseException;
import com.jetlumen.ordo.api.exception.DefinitionNotFoundException;
import com.jetlumen.ordo.api.exception.NoRouteFoundException;
import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
@@ -73,6 +74,18 @@ public final class DefaultOrdoEngine implements OrdoEngine {
});
}
@Override
public synchronized void replace(ProcessDefinition definition) {
Objects.requireNonNull(definition, "definition must not be null");
transactionExecutor.execute(() -> {
if (instanceRepository.existsRunning(definition.id())) {
throw new DefinitionInUseException(definition.id());
}
definitionRepository.upsert(definition);
return null;
});
}
@Override
public synchronized ProcessInstance start(String definitionId, String initiator, ProcessContext context) {
requireText(initiator, "initiator");
@@ -55,6 +55,11 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
delegate.register(definition);
}
@Override
public void replace(ProcessDefinition definition) {
delegate.replace(definition);
}
@Override
public ProcessInstance start(String definitionId, String initiator, ProcessContext context) {
return delegate.start(definitionId, initiator, context);
@@ -16,6 +16,11 @@ public final class InMemoryProcessDefinitionRepository implements ProcessDefinit
return definitions.putIfAbsent(definition.id(), definition) == null;
}
@Override
public synchronized void upsert(ProcessDefinition definition) {
definitions.put(definition.id(), definition);
}
@Override
public synchronized Optional<ProcessDefinition> findById(String definitionId) {
return Optional.ofNullable(definitions.get(definitionId));
@@ -1,6 +1,7 @@
package com.jetlumen.ordo.core.repository;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.ProcessStatus;
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
import java.util.HashMap;
@@ -25,4 +26,11 @@ public final class InMemoryProcessInstanceRepository implements ProcessInstanceR
public synchronized Optional<ProcessInstance> findById(String instanceId) {
return Optional.ofNullable(instances.get(instanceId));
}
@Override
public synchronized boolean existsRunning(String definitionId) {
return instances.values().stream()
.anyMatch(instance -> instance.definitionId().equals(definitionId)
&& instance.status() == ProcessStatus.RUNNING);
}
}
@@ -10,6 +10,7 @@ import com.jetlumen.ordo.api.RoutingCondition;
import com.jetlumen.ordo.api.StepTransition;
import com.jetlumen.ordo.api.TaskStatus;
import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException;
import com.jetlumen.ordo.api.exception.DefinitionInUseException;
import com.jetlumen.ordo.api.exception.DefinitionNotFoundException;
import com.jetlumen.ordo.api.exception.NoRouteFoundException;
import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
@@ -86,6 +87,46 @@ class InMemoryOrdoEngineTest {
"leave", "Another leave request", List.of(ApprovalStep.single("lead", "Lead approval", "lee")))));
}
@Test
void replaceInsertsWhenTheDefinitionIsMissing() {
InMemoryOrdoEngine empty = new InMemoryOrdoEngine();
empty.replace(ProcessDefinition.linear("expense", "Expense request", List.of(
ApprovalStep.single("director", "Director approval", "diana"))));
var instance = empty.start("expense", "alice");
assertEquals("diana", empty.findTasks(instance.id()).getFirst().assignee());
}
@Test
void replaceSwapsTheGraphWhenNoInstanceIsRunning() {
engine.replace(ProcessDefinition.linear("leave", "Leave request v2", List.of(
ApprovalStep.single("director", "Director approval", "diana"))));
var instance = engine.start("leave", "alice");
ApprovalTask task = engine.findTasks(instance.id()).getFirst();
assertEquals("director", task.stepId());
assertEquals("diana", task.assignee());
}
@Test
void replaceIsRejectedWhileAnInstanceIsRunning() {
engine.start("leave", "alice");
assertThrows(DefinitionInUseException.class, () -> engine.replace(ProcessDefinition.linear(
"leave", "Leave request v2", List.of(ApprovalStep.single("director", "Director approval", "diana")))));
}
@Test
void replaceSucceedsAfterInstancesReachATerminalStatus() {
var instance = engine.start("leave", "alice");
engine.reject(engine.findTasks(instance.id()).getFirst().id(), "maria");
engine.replace(ProcessDefinition.linear("leave", "Leave request v2", List.of(
ApprovalStep.single("director", "Director approval", "diana"))));
var next = engine.start("leave", "bob");
assertEquals("diana", engine.findTasks(next.id()).getFirst().assignee());
}
@Test
void rejectsBlankRuntimeArguments() {
assertThrows(IllegalArgumentException.class, () -> engine.start("leave", " "));