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
@@ -25,7 +25,8 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
private static final String INSERT_DEFINITION =
"INSERT INTO ordo_process_definition (id, name) VALUES (?, ?)";
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 =
"INSERT INTO ordo_step_candidate (definition_id, step_id, candidate, candidate_order) VALUES (?, ?, ?, ?)";
private static final String INSERT_TRANSITION =
@@ -41,7 +42,8 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
private static final String SELECT_DEFINITION =
"SELECT id, name FROM ordo_process_definition WHERE id = ?";
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 =
"SELECT step_id, candidate FROM ordo_step_candidate WHERE definition_id = ? ORDER BY step_id, candidate_order";
private static final String SELECT_TRANSITIONS =
@@ -76,9 +78,17 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
Objects.requireNonNull(definition, "definition must not be null");
Connection connection = connectionProvider.getConnection();
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);
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);
} catch (SQLException e) {
@@ -124,7 +134,8 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
List<ApprovalStep> steps = new ArrayList<>();
for (StepRow row : stepRows) {
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<>();
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 {
try (PreparedStatement insertDefinition = connection.prepareStatement(INSERT_DEFINITION)) {
insertDefinition.setString(1, definition.id());
@@ -191,6 +211,8 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
insertStep.setString(3, step.name());
insertStep.setString(4, step.policy().name());
insertStep.setInt(5, stepOrder++);
insertStep.setString(6, step.kind().name());
insertStep.setString(7, step.actionKey());
insertStep.executeUpdate();
}
int candidateOrder = 0;
@@ -1,6 +1,7 @@
package com.jetlumen.ordo.storage.jdbc.mapper;
import com.jetlumen.ordo.api.ApprovalPolicy;
import com.jetlumen.ordo.api.StepKind;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -16,9 +17,11 @@ public final class ApprovalStepMapper {
public static StepRow readRow(ResultSet resultSet) throws SQLException {
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;
import com.jetlumen.ordo.api.ActionHandler;
import com.jetlumen.ordo.api.ApprovalStep;
import com.jetlumen.ordo.api.ApprovalTask;
import com.jetlumen.ordo.api.AssigneeResolver;
@@ -119,7 +120,7 @@ class JdbcOrdoEngineIntegrationTest {
@Test
void rollsBackTheWholeApprovalWhenNoRouteMatches() {
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 JdbcProcessDefinitionRepository(connectionProvider),
new JdbcProcessInstanceRepository(connectionProvider),
@@ -197,6 +198,7 @@ class JdbcOrdoEngineIntegrationTest {
private OrdoEngine newEngine(AssigneeResolver assigneeResolver) {
return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(),
ActionHandler.noop(),
new JdbcTransactionExecutor(connectionProvider),
new JdbcProcessDefinitionRepository(connectionProvider),
new JdbcProcessInstanceRepository(connectionProvider),
@@ -1,5 +1,6 @@
package com.jetlumen.ordo.storage.jdbc;
import com.jetlumen.ordo.api.ActionHandler;
import com.jetlumen.ordo.api.ApprovalStep;
import com.jetlumen.ordo.api.ApprovalTask;
import com.jetlumen.ordo.api.AssigneeResolver;
@@ -219,6 +220,7 @@ class JdbcPostgresIntegrationTest {
private OrdoEngine newEngine(AssigneeResolver assigneeResolver) {
return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(),
ActionHandler.noop(),
new JdbcTransactionExecutor(connectionProvider),
new JdbcProcessDefinitionRepository(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.ProcessDefinition;
import com.jetlumen.ordo.api.StepKind;
import com.jetlumen.ordo.api.StepTransition;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -72,6 +73,21 @@ class JdbcProcessDefinitionRepositoryTest {
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) {
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 = {
"/db/migration/V1__create_ordo_tables.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();