From 946c9d3c52d85bd926fa05c4717e5b41e58f3994 Mon Sep 17 00:00:00 2001 From: 0264408 Date: Fri, 11 Sep 2026 16:14:45 +0800 Subject: [PATCH] feat: replace process definition graphs when no instance is running Co-authored-by: Cursor --- .../com/jetlumen/ordo/api/OrdoEngine.java | 6 + .../exception/DefinitionInUseException.java | 7 + .../ProcessDefinitionRepository.java | 8 +- .../repository/ProcessInstanceRepository.java | 2 + .../jetlumen/ordo/core/DefaultOrdoEngine.java | 13 ++ .../ordo/core/InMemoryOrdoEngine.java | 5 + .../InMemoryProcessDefinitionRepository.java | 5 + .../InMemoryProcessInstanceRepository.java | 8 + .../ordo/core/InMemoryOrdoEngineTest.java | 41 ++++++ .../ordo/spring/OrdoDefinitionLoader.java | 9 +- .../spring/OrdoJdbcAutoConfigurationTest.java | 17 +++ .../jdbc/JdbcProcessDefinitionRepository.java | 139 +++++++++++++----- .../jdbc/JdbcProcessInstanceRepository.java | 19 +++ .../jdbc/JdbcOrdoEngineIntegrationTest.java | 17 +++ .../JdbcProcessDefinitionRepositoryTest.java | 14 ++ .../JdbcProcessInstanceRepositoryTest.java | 15 ++ 16 files changed, 277 insertions(+), 48 deletions(-) create mode 100644 ordo-api/src/main/java/com/jetlumen/ordo/api/exception/DefinitionInUseException.java diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoEngine.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoEngine.java index ba19f56..041d68f 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoEngine.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoEngine.java @@ -6,6 +6,12 @@ import java.util.Optional; /** Public entry point for definition registration and approval operations. */ public interface OrdoEngine { void register(ProcessDefinition definition); + + /** + * Inserts the definition, or replaces its entire graph when the id already exists. + * Refuses replacement while any instance of this definition is {@code RUNNING}. + */ + void replace(ProcessDefinition definition); default ProcessInstance start(String definitionId, String initiator) { return start(definitionId, initiator, ProcessContext.empty()); } diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/DefinitionInUseException.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/DefinitionInUseException.java new file mode 100644 index 0000000..817f456 --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/DefinitionInUseException.java @@ -0,0 +1,7 @@ +package com.jetlumen.ordo.api.exception; + +public final class DefinitionInUseException extends OrdoException { + public DefinitionInUseException(String definitionId) { + super("definition in use: " + definitionId); + } +} diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ProcessDefinitionRepository.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ProcessDefinitionRepository.java index 7e1ec4b..dc0528b 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ProcessDefinitionRepository.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ProcessDefinitionRepository.java @@ -4,7 +4,7 @@ import com.jetlumen.ordo.api.ProcessDefinition; import java.util.Optional; -/** Storage port for immutable process definitions. */ +/** Storage port for process definitions. */ public interface ProcessDefinitionRepository { /** * Inserts the definition if no definition with the same id exists. @@ -13,5 +13,11 @@ public interface ProcessDefinitionRepository { */ boolean insertIfAbsent(ProcessDefinition definition); + /** + * Inserts the definition, or replaces its name, steps, candidates and transitions + * when the id already exists. + */ + void upsert(ProcessDefinition definition); + Optional findById(String definitionId); } diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ProcessInstanceRepository.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ProcessInstanceRepository.java index 7f2db0a..094e956 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ProcessInstanceRepository.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ProcessInstanceRepository.java @@ -13,4 +13,6 @@ public interface ProcessInstanceRepository { void update(ProcessInstance instance); Optional findById(String instanceId); + + boolean existsRunning(String definitionId); } diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java index 2e8ff2a..f079dc3 100644 --- a/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java @@ -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"); diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java index 0a89e22..08a76a6 100644 --- a/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java @@ -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); diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryProcessDefinitionRepository.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryProcessDefinitionRepository.java index 25e71f9..f4bf8c0 100644 --- a/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryProcessDefinitionRepository.java +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryProcessDefinitionRepository.java @@ -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 findById(String definitionId) { return Optional.ofNullable(definitions.get(definitionId)); diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryProcessInstanceRepository.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryProcessInstanceRepository.java index a746d2e..30ade3e 100644 --- a/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryProcessInstanceRepository.java +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryProcessInstanceRepository.java @@ -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 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); + } } diff --git a/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java b/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java index 0009361..58b187c 100644 --- a/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java +++ b/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java @@ -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", " ")); diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java index 11a3fda..fb3673f 100644 --- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java @@ -3,7 +3,6 @@ package com.jetlumen.ordo.spring; import com.jetlumen.ordo.api.OrdoEngine; import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessDefinitionParser; -import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.core.io.Resource; @@ -13,7 +12,7 @@ import org.springframework.core.io.support.ResourcePatternResolver; import java.io.InputStream; import java.util.Objects; -/** Loads structural JSON process definitions and registers them if absent. */ +/** Loads structural JSON process definitions and upserts them when no instance is running. */ public final class OrdoDefinitionLoader implements ApplicationRunner { private final OrdoEngine engine; private final String location; @@ -49,11 +48,7 @@ public final class OrdoDefinitionLoader implements ApplicationRunner { } catch (RuntimeException e) { throw new IllegalStateException("failed to parse ordo definition from " + describe(resource), e); } - try { - engine.register(definition); - } catch (DefinitionAlreadyExistsException ignored) { - // classpath JSON is an import source; existing ids stay as stored - } + engine.replace(definition); } } diff --git a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java index 307b78a..05099f9 100644 --- a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java +++ b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java @@ -94,6 +94,23 @@ class OrdoJdbcAutoConfigurationTest { }); } + @Test + void reloadsClasspathJsonDefinitionsWhenNoInstanceIsRunning() { + withDataSourceRunner.run(context -> { + OrdoEngine engine = context.getBean(OrdoEngine.class); + engine.replace(ProcessDefinition.linear("leave-request-routed", "stale", List.of( + ApprovalStep.single("lead", "Lead approval", "lee")))); + + context.getBean(OrdoDefinitionLoader.class).load(); + + ProcessDefinition definition = context.getBean(ProcessDefinitionRepository.class) + .findById("leave-request-routed") + .orElseThrow(); + assertThat(definition.name()).isEqualTo("Leave request"); + assertThat(definition.steps()).hasSize(2); + }); + } + @Test void honoursUserDefinedRoutingCondition() { withDataSourceRunner.withUserConfiguration(CustomRoutingConditionConfig.class) diff --git a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepository.java b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepository.java index af44c07..bf28257 100644 --- a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepository.java +++ b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepository.java @@ -30,6 +30,14 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR "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 UPDATE_DEFINITION_NAME = + "UPDATE ordo_process_definition SET name = ? WHERE id = ?"; + private static final String DELETE_TRANSITIONS = + "DELETE FROM ordo_step_transition WHERE definition_id = ?"; + private static final String DELETE_CANDIDATES = + "DELETE FROM ordo_step_candidate WHERE definition_id = ?"; + private static final String DELETE_STEPS = + "DELETE FROM ordo_approval_step WHERE definition_id = ?"; private static final String SELECT_DEFINITION = "SELECT id, name FROM ordo_process_definition WHERE id = ?"; private static final String SELECT_STEPS = @@ -51,47 +59,10 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR Objects.requireNonNull(definition, "definition must not be null"); Connection connection = connectionProvider.getConnection(); try { - try (PreparedStatement insertDefinition = connection.prepareStatement(INSERT_DEFINITION)) { - insertDefinition.setString(1, definition.id()); - insertDefinition.setString(2, definition.name()); - insertDefinition.executeUpdate(); - } catch (SQLException e) { - if (isDuplicateKey(e)) { - return false; - } - throw new JdbcStorageException("failed to insert definition: " + definition.id(), e); - } - int stepOrder = 0; - for (ApprovalStep step : definition.steps()) { - try (PreparedStatement insertStep = connection.prepareStatement(INSERT_STEP)) { - insertStep.setString(1, definition.id()); - insertStep.setString(2, step.id()); - insertStep.setString(3, step.name()); - insertStep.setString(4, step.policy().name()); - insertStep.setInt(5, stepOrder++); - insertStep.executeUpdate(); - } - int candidateOrder = 0; - for (String candidate : step.candidates()) { - try (PreparedStatement insertCandidate = connection.prepareStatement(INSERT_CANDIDATE)) { - insertCandidate.setString(1, definition.id()); - insertCandidate.setString(2, step.id()); - insertCandidate.setString(3, candidate); - insertCandidate.setInt(4, candidateOrder++); - insertCandidate.executeUpdate(); - } - } - } - 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(); - } + if (!insertDefinitionRow(connection, definition)) { + return false; } + insertGraph(connection, definition); return true; } catch (SQLException e) { throw new JdbcStorageException("failed to insert definition: " + definition.id(), e); @@ -100,6 +71,23 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR } } + @Override + public void upsert(ProcessDefinition definition) { + Objects.requireNonNull(definition, "definition must not be null"); + Connection connection = connectionProvider.getConnection(); + try { + if (!insertDefinitionRow(connection, definition)) { + updateDefinitionName(connection, definition); + deleteGraph(connection, definition.id()); + } + insertGraph(connection, definition); + } catch (SQLException e) { + throw new JdbcStorageException("failed to upsert definition: " + definition.id(), e); + } finally { + connectionProvider.close(connection); + } + } + @Override public Optional findById(String definitionId) { Connection connection = connectionProvider.getConnection(); @@ -157,6 +145,77 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR } } + private static boolean insertDefinitionRow(Connection connection, ProcessDefinition definition) throws SQLException { + try (PreparedStatement insertDefinition = connection.prepareStatement(INSERT_DEFINITION)) { + insertDefinition.setString(1, definition.id()); + insertDefinition.setString(2, definition.name()); + insertDefinition.executeUpdate(); + return true; + } catch (SQLException e) { + if (isDuplicateKey(e)) { + return false; + } + throw e; + } + } + + private static void updateDefinitionName(Connection connection, ProcessDefinition definition) throws SQLException { + try (PreparedStatement update = connection.prepareStatement(UPDATE_DEFINITION_NAME)) { + update.setString(1, definition.name()); + update.setString(2, definition.id()); + update.executeUpdate(); + } + } + + private static void deleteGraph(Connection connection, String definitionId) throws SQLException { + try (PreparedStatement deleteTransitions = connection.prepareStatement(DELETE_TRANSITIONS)) { + deleteTransitions.setString(1, definitionId); + deleteTransitions.executeUpdate(); + } + try (PreparedStatement deleteCandidates = connection.prepareStatement(DELETE_CANDIDATES)) { + deleteCandidates.setString(1, definitionId); + deleteCandidates.executeUpdate(); + } + try (PreparedStatement deleteSteps = connection.prepareStatement(DELETE_STEPS)) { + deleteSteps.setString(1, definitionId); + deleteSteps.executeUpdate(); + } + } + + private static void insertGraph(Connection connection, ProcessDefinition definition) throws SQLException { + int stepOrder = 0; + for (ApprovalStep step : definition.steps()) { + try (PreparedStatement insertStep = connection.prepareStatement(INSERT_STEP)) { + insertStep.setString(1, definition.id()); + insertStep.setString(2, step.id()); + insertStep.setString(3, step.name()); + insertStep.setString(4, step.policy().name()); + insertStep.setInt(5, stepOrder++); + insertStep.executeUpdate(); + } + int candidateOrder = 0; + for (String candidate : step.candidates()) { + try (PreparedStatement insertCandidate = connection.prepareStatement(INSERT_CANDIDATE)) { + insertCandidate.setString(1, definition.id()); + insertCandidate.setString(2, step.id()); + insertCandidate.setString(3, candidate); + insertCandidate.setInt(4, candidateOrder++); + insertCandidate.executeUpdate(); + } + } + } + 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(); + } + } + } + private static boolean isDuplicateKey(SQLException e) { return "23505".equals(e.getSQLState()); } diff --git a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepository.java b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepository.java index 91c3ddf..c285c03 100644 --- a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepository.java +++ b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepository.java @@ -1,6 +1,7 @@ package com.jetlumen.ordo.storage.jdbc; import com.jetlumen.ordo.api.ProcessInstance; +import com.jetlumen.ordo.api.ProcessStatus; import com.jetlumen.ordo.api.repository.ProcessInstanceRepository; import com.jetlumen.ordo.storage.jdbc.mapper.ProcessInstanceMapper; @@ -21,6 +22,8 @@ public final class JdbcProcessInstanceRepository implements ProcessInstanceRepos private static final String SELECT_INSTANCE = "SELECT id, definition_id, initiator, status, context_json, started_at, finished_at" + " FROM ordo_process_instance WHERE id = ?"; + private static final String EXISTS_RUNNING = + "SELECT 1 FROM ordo_process_instance WHERE definition_id = ? AND status = ? LIMIT 1"; private final JdbcConnectionProvider connectionProvider; @@ -72,4 +75,20 @@ public final class JdbcProcessInstanceRepository implements ProcessInstanceRepos connectionProvider.close(connection); } } + + @Override + public boolean existsRunning(String definitionId) { + Connection connection = connectionProvider.getConnection(); + try (PreparedStatement select = connection.prepareStatement(EXISTS_RUNNING)) { + select.setString(1, definitionId); + select.setString(2, ProcessStatus.RUNNING.name()); + try (ResultSet resultSet = select.executeQuery()) { + return resultSet.next(); + } + } catch (SQLException e) { + throw new JdbcStorageException("failed to check running instances for definition: " + definitionId, e); + } finally { + connectionProvider.close(connection); + } + } } diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java index ca61bdb..7fb964f 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java @@ -11,6 +11,7 @@ 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.exception.DefinitionInUseException; import com.jetlumen.ordo.api.exception.NoRouteFoundException; import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException; import com.jetlumen.ordo.core.DefaultOrdoEngine; @@ -148,6 +149,22 @@ class JdbcOrdoEngineIntegrationTest { assertEquals("henry", engine.findPendingTasksByInstanceId(instance.id()).getFirst().assignee()); } + @Test + void replaceSwapsTheGraphWhenNoInstanceIsRunning() { + engine.replace(ProcessDefinition.linear("leave", "Leave request v2", List.of( + ApprovalStep.single("director", "Director approval", "diana")))); + + ProcessInstance instance = engine.start("leave", "alice"); + assertEquals("diana", engine.findPendingTasksByInstanceId(instance.id()).getFirst().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"))))); + } + private OrdoEngine newEngine(AssigneeResolver assigneeResolver) { return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(), new JdbcTransactionExecutor(connectionProvider), diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepositoryTest.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepositoryTest.java index 5cc8145..cc048a4 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepositoryTest.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepositoryTest.java @@ -39,6 +39,20 @@ class JdbcProcessDefinitionRepositoryTest { assertEquals("Leave request v1", repository.findById("leave").orElseThrow().name()); } + @Test + void upsertReplacesNameStepsCandidatesAndTransitions() { + repository.insertIfAbsent(ProcessDefinition.linear("leave", "Leave request v1", List.of( + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("hr", "HR approval", "henry")))); + + ProcessDefinition replacement = new ProcessDefinition("leave", "Leave request v2", List.of( + ApprovalStep.single("director", "Director approval", "diana")), + List.of(StepTransition.end("director"))); + repository.upsert(replacement); + + assertEquals(replacement, repository.findById("leave").orElseThrow()); + } + @Test void returnsEmptyForAnUnknownDefinition() { assertTrue(repository.findById("missing").isEmpty()); diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepositoryTest.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepositoryTest.java index 96f1f33..82f80ee 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepositoryTest.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepositoryTest.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -66,6 +67,20 @@ class JdbcProcessInstanceRepositoryTest { assertTrue(repository.findById("missing").isEmpty()); } + @Test + void existsRunningOnlyCountsRunningInstancesOfThatDefinition() { + assertFalse(repository.existsRunning("leave")); + + repository.insert(new ProcessInstance("inst-1", "leave", "alice", ProcessStatus.RUNNING, + STARTED_AT, null, ProcessContext.empty())); + assertTrue(repository.existsRunning("leave")); + assertFalse(repository.existsRunning("other")); + + repository.update(new ProcessInstance("inst-1", "leave", "alice", ProcessStatus.APPROVED, + STARTED_AT, STARTED_AT.plusSeconds(60), ProcessContext.empty())); + assertFalse(repository.existsRunning("leave")); + } + @Test void updateOfAnUnknownInstanceFails() { assertThrows(IllegalStateException.class, () -> repository.update(new ProcessInstance("missing", "leave",