feat: let initiators withdraw running process instances

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-11 16:39:17 +08:00
co-authored by Cursor
parent 946c9d3c52
commit 87b449e45c
15 changed files with 213 additions and 4 deletions
@@ -19,6 +19,8 @@ public final class JdbcProcessInstanceRepository implements ProcessInstanceRepos
+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
private static final String UPDATE_INSTANCE =
"UPDATE ordo_process_instance SET status = ?, finished_at = ? WHERE id = ?";
private static final String COMPLETE_IF_RUNNING =
"UPDATE ordo_process_instance SET status = ?, finished_at = ? WHERE id = ? AND status = 'RUNNING'";
private static final String SELECT_INSTANCE =
"SELECT id, definition_id, initiator, status, context_json, started_at, finished_at"
+ " FROM ordo_process_instance WHERE id = ?";
@@ -91,4 +93,18 @@ public final class JdbcProcessInstanceRepository implements ProcessInstanceRepos
connectionProvider.close(connection);
}
}
@Override
public boolean completeIfRunning(ProcessInstance completed) {
Objects.requireNonNull(completed, "completed must not be null");
Connection connection = connectionProvider.getConnection();
try (PreparedStatement update = connection.prepareStatement(COMPLETE_IF_RUNNING)) {
ProcessInstanceMapper.bindUpdate(update, completed);
return update.executeUpdate() == 1;
} catch (SQLException e) {
throw new JdbcStorageException("failed to complete instance: " + completed.id(), e);
} finally {
connectionProvider.close(connection);
}
}
}
@@ -12,6 +12,8 @@ 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.InstanceAlreadyCompletedException;
import com.jetlumen.ordo.api.exception.UnauthorizedInstanceOperationException;
import com.jetlumen.ordo.api.exception.NoRouteFoundException;
import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
import com.jetlumen.ordo.core.DefaultOrdoEngine;
@@ -165,6 +167,34 @@ class JdbcOrdoEngineIntegrationTest {
"leave", "Leave request v2", List.of(ApprovalStep.single("director", "Director approval", "diana")))));
}
@Test
void initiatorCanWithdrawWhileALaterStepIsPending() {
ProcessInstance instance = engine.start("leave", "alice");
engine.approve(engine.findPendingTasksByInstanceId(instance.id()).getFirst().id(), "maria");
ProcessInstance withdrawn = engine.withdraw(instance.id(), "alice", "changed plans");
assertEquals(ProcessStatus.WITHDRAWN, withdrawn.status());
assertEquals(NOW, withdrawn.finishedAt());
assertTrue(engine.findPendingTasksByInstanceId(instance.id()).isEmpty());
ApprovalTask hrTask = engine.findTasks(instance.id()).stream()
.filter(task -> task.stepId().equals("hr"))
.findFirst()
.orElseThrow();
assertEquals(TaskStatus.SKIPPED, hrTask.status());
assertEquals("alice", hrTask.action().actor());
assertEquals("changed plans", hrTask.action().comment());
}
@Test
void withdrawIsRejectedForNonInitiatorAndCompletedInstances() {
ProcessInstance instance = engine.start("leave", "alice");
assertThrows(UnauthorizedInstanceOperationException.class,
() -> engine.withdraw(instance.id(), "mallory"));
engine.reject(engine.findPendingTasksByInstanceId(instance.id()).getFirst().id(), "maria");
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
}
private OrdoEngine newEngine(AssigneeResolver assigneeResolver) {
return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(),
new JdbcTransactionExecutor(connectionProvider),
@@ -81,6 +81,25 @@ class JdbcProcessInstanceRepositoryTest {
assertFalse(repository.existsRunning("leave"));
}
@Test
void completeIfRunningOnlyUpdatesARunningInstance() {
ProcessInstance running = new ProcessInstance("inst-1", "leave", "alice", ProcessStatus.RUNNING,
STARTED_AT, null, ProcessContext.empty());
repository.insert(running);
Instant finishedAt = STARTED_AT.plusSeconds(30);
ProcessInstance withdrawn = new ProcessInstance("inst-1", "leave", "alice", ProcessStatus.WITHDRAWN,
STARTED_AT, finishedAt, ProcessContext.empty());
assertTrue(repository.completeIfRunning(withdrawn));
assertEquals(ProcessStatus.WITHDRAWN, repository.findById("inst-1").orElseThrow().status());
assertEquals(finishedAt, repository.findById("inst-1").orElseThrow().finishedAt());
assertFalse(repository.completeIfRunning(new ProcessInstance("inst-1", "leave", "alice",
ProcessStatus.APPROVED, STARTED_AT, finishedAt, ProcessContext.empty())));
assertFalse(repository.completeIfRunning(new ProcessInstance("missing", "leave", "alice",
ProcessStatus.WITHDRAWN, STARTED_AT, finishedAt, ProcessContext.empty())));
}
@Test
void updateOfAnUnknownInstanceFails() {
assertThrows(IllegalStateException.class, () -> repository.update(new ProcessInstance("missing", "leave",