feat: escalate overdue approval tasks via processDue

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-16 09:23:30 +08:00
co-authored by Cursor
parent 850583f326
commit 30047f3612
32 changed files with 736 additions and 42 deletions
@@ -13,6 +13,7 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -21,10 +22,10 @@ import java.util.Optional;
/** JDBC implementation of the task storage port and its pending-task indexes. */
public final class JdbcApprovalTaskRepository implements ApprovalTaskRepository {
private static final String TASK_COLUMNS =
"id, instance_id, step_id, task_name, assignee, status, created_at, completed_at, action_actor, action_comment, action_at";
"id, instance_id, step_id, task_name, assignee, status, created_at, completed_at, action_actor, action_comment, action_at, due_at";
private static final String INSERT_TASK =
"INSERT INTO ordo_approval_task (id, instance_id, step_id, task_name, assignee, status, created_at)"
+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
"INSERT INTO ordo_approval_task (id, instance_id, step_id, task_name, assignee, status, created_at, due_at)"
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
private static final String COMPLETE_IF_PENDING =
"UPDATE ordo_approval_task SET status = ?, completed_at = ?, action_actor = ?, action_comment = ?, action_at = ?"
+ " WHERE id = ? AND status = 'PENDING' AND assignee = ?";
@@ -43,9 +44,15 @@ public final class JdbcApprovalTaskRepository implements ApprovalTaskRepository
private static final String SELECT_PENDING_BY_INSTANCE =
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE status = 'PENDING' AND instance_id = ?"
+ " ORDER BY created_at, id";
private static final String CLAIM_IF_DUE =
"UPDATE ordo_approval_task SET due_at = NULL WHERE id = ? AND status = 'PENDING' AND assignee = ?"
+ " AND due_at IS NOT NULL AND due_at <= ?";
private static final String SELECT_DUE_PENDING =
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE status = 'PENDING' AND due_at IS NOT NULL"
+ " AND due_at <= ? ORDER BY due_at, id LIMIT ?";
private static final String TASK_COLUMNS_QUALIFIED =
"t.id, t.instance_id, t.step_id, t.task_name, t.assignee, t.status, t.created_at, t.completed_at,"
+ " t.action_actor, t.action_comment, t.action_at";
+ " t.action_actor, t.action_comment, t.action_at, t.due_at";
private final JdbcConnectionProvider connectionProvider;
@@ -139,6 +146,48 @@ public final class JdbcApprovalTaskRepository implements ApprovalTaskRepository
}
}
@Override
public List<ApprovalTask> findDuePending(Instant now, int limit) {
Objects.requireNonNull(now, "now must not be null");
if (limit <= 0) {
throw new IllegalArgumentException("limit must be positive");
}
Connection connection = connectionProvider.getConnection();
try (PreparedStatement select = connection.prepareStatement(SELECT_DUE_PENDING)) {
select.setTimestamp(1, Timestamp.from(now));
select.setInt(2, limit);
try (ResultSet resultSet = select.executeQuery()) {
List<ApprovalTask> tasks = new ArrayList<>();
while (resultSet.next()) {
tasks.add(ApprovalTaskMapper.read(resultSet));
}
return tasks;
}
} catch (SQLException e) {
throw new JdbcStorageException("failed to query due tasks", e);
} finally {
connectionProvider.close(connection);
}
}
@Override
public boolean claimIfDue(String taskId, String expectedAssignee, Instant now) {
Objects.requireNonNull(taskId, "taskId must not be null");
Objects.requireNonNull(expectedAssignee, "expectedAssignee must not be null");
Objects.requireNonNull(now, "now must not be null");
Connection connection = connectionProvider.getConnection();
try (PreparedStatement update = connection.prepareStatement(CLAIM_IF_DUE)) {
update.setString(1, taskId);
update.setString(2, expectedAssignee);
update.setTimestamp(3, Timestamp.from(now));
return update.executeUpdate() == 1;
} catch (SQLException e) {
throw new JdbcStorageException("failed to claim due task: " + taskId, e);
} finally {
connectionProvider.close(connection);
}
}
@Override
public Page<ApprovalTask> query(TaskQuery query, PageRequest pageRequest) {
Objects.requireNonNull(query, "query must not be null");
@@ -27,8 +27,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, kind, action_key)"
+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
"INSERT INTO ordo_approval_step (definition_id, step_id, step_name, policy, step_order, kind, action_key,"
+ " due_after, due_then, due_to, due_action) 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 =
@@ -44,7 +44,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, kind, action_key FROM ordo_approval_step"
"SELECT step_id, step_name, policy, kind, action_key, due_after, due_then, due_to, due_action"
+ " 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";
@@ -176,7 +177,7 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
for (StepRow row : stepRows) {
List<String> candidates = candidatesByStep.getOrDefault(row.stepId(), List.of());
steps.add(new ApprovalStep(row.stepId(), row.stepName(), candidates, row.policy(), row.kind(),
row.actionKey()));
row.actionKey(), ApprovalStepMapper.toDue(row)));
}
List<StepTransition> transitions = new ArrayList<>();
try (PreparedStatement selectTransitions = connection.prepareStatement(SELECT_TRANSITIONS)) {
@@ -249,6 +250,17 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
insertStep.setInt(5, stepOrder++);
insertStep.setString(6, step.kind().name());
insertStep.setString(7, step.actionKey());
if (step.due() == null) {
insertStep.setString(8, null);
insertStep.setString(9, null);
insertStep.setString(10, null);
insertStep.setString(11, null);
} else {
insertStep.setString(8, step.due().after().toString());
insertStep.setString(9, step.due().then().name());
insertStep.setString(10, step.due().to());
insertStep.setString(11, step.due().action());
}
insertStep.executeUpdate();
}
int candidateOrder = 0;
@@ -1,10 +1,13 @@
package com.jetlumen.ordo.storage.jdbc.mapper;
import com.jetlumen.ordo.api.ApprovalPolicy;
import com.jetlumen.ordo.api.DueThen;
import com.jetlumen.ordo.api.StepDue;
import com.jetlumen.ordo.api.StepKind;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Duration;
/**
* Maps rows of {@code ordo_approval_step}. A step's candidate list lives in the separate
@@ -19,9 +22,22 @@ public final class ApprovalStepMapper {
return new StepRow(resultSet.getString("step_id"), resultSet.getString("step_name"),
ApprovalPolicy.valueOf(resultSet.getString("policy")),
StepKind.valueOf(resultSet.getString("kind")),
resultSet.getString("action_key"));
resultSet.getString("action_key"),
resultSet.getString("due_after"),
resultSet.getString("due_then"),
resultSet.getString("due_to"),
resultSet.getString("due_action"));
}
public record StepRow(String stepId, String stepName, ApprovalPolicy policy, StepKind kind, String actionKey) {
public static StepDue toDue(StepRow row) {
if (row.dueAfter() == null || row.dueAfter().isBlank()) {
return null;
}
return new StepDue(Duration.parse(row.dueAfter()), DueThen.valueOf(row.dueThen()), row.dueTo(),
row.dueAction());
}
public record StepRow(String stepId, String stepName, ApprovalPolicy policy, StepKind kind, String actionKey,
String dueAfter, String dueThen, String dueTo, String dueAction) {
}
}
@@ -23,6 +23,11 @@ public final class ApprovalTaskMapper {
statement.setString(5, task.assignee());
statement.setString(6, task.status().name());
statement.setTimestamp(7, Timestamp.from(task.createdAt()));
if (task.dueAt() == null) {
statement.setNull(8, Types.TIMESTAMP);
} else {
statement.setTimestamp(8, Timestamp.from(task.dueAt()));
}
}
public static void bindComplete(PreparedStatement statement, ApprovalTask completedTask) throws SQLException {
@@ -49,6 +54,7 @@ public final class ApprovalTaskMapper {
TaskAction action = actionActor == null ? null
: new TaskAction(actionActor, resultSet.getString("action_comment"), actionAt.toInstant());
Timestamp completedAt = resultSet.getTimestamp("completed_at");
Timestamp dueAt = resultSet.getTimestamp("due_at");
return new ApprovalTask(
resultSet.getString("id"),
resultSet.getString("instance_id"),
@@ -58,6 +64,7 @@ public final class ApprovalTaskMapper {
TaskStatus.valueOf(resultSet.getString("status")),
resultSet.getTimestamp("created_at").toInstant(),
completedAt == null ? null : completedAt.toInstant(),
action);
action,
dueAt == null ? null : dueAt.toInstant());
}
}
@@ -0,0 +1,8 @@
-- SLA / due escalation: task due_at plus step due policy columns.
ALTER TABLE ordo_approval_task ADD COLUMN due_at TIMESTAMP;
CREATE INDEX idx_approval_task_status_due ON ordo_approval_task (status, due_at);
ALTER TABLE ordo_approval_step ADD COLUMN due_after VARCHAR(32);
ALTER TABLE ordo_approval_step ADD COLUMN due_then VARCHAR(16);
ALTER TABLE ordo_approval_step ADD COLUMN due_to VARCHAR(255);
ALTER TABLE ordo_approval_step ADD COLUMN due_action VARCHAR(255);
@@ -23,6 +23,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JdbcApprovalTaskRepositoryTest {
@@ -218,6 +219,22 @@ class JdbcApprovalTaskRepositoryTest {
}
}
@Test
void claimIfDueClearsDueAtAndIgnoresTasksThatAreNotDue() {
Instant dueAt = CREATED_AT.plusSeconds(60);
ApprovalTask pending = new ApprovalTask("task-due", "inst-1", "manager", "manager approval", "maria",
TaskStatus.PENDING, CREATED_AT, null, null, dueAt);
repository.save(pending);
assertTrue(repository.findDuePending(CREATED_AT.plusSeconds(30), 10).isEmpty());
List<ApprovalTask> due = repository.findDuePending(dueAt, 10);
assertEquals(1, due.size());
assertEquals("task-due", due.get(0).id());
assertTrue(repository.claimIfDue("task-due", "maria", dueAt));
assertNull(repository.findById("task-due").orElseThrow().dueAt());
assertFalse(repository.claimIfDue("task-due", "maria", dueAt));
}
private void attempt(CountDownLatch start, CountDownLatch done, AtomicInteger wins, ApprovalTask completed) {
try {
start.await();
@@ -234,12 +251,12 @@ class JdbcApprovalTaskRepositoryTest {
private static ApprovalTask pendingTask(String id, String instanceId, String stepId, String assignee,
Instant createdAt) {
return new ApprovalTask(id, instanceId, stepId, stepId + " approval", assignee,
TaskStatus.PENDING, createdAt, null, null);
TaskStatus.PENDING, createdAt, null, null, null);
}
private static ApprovalTask completedTask(ApprovalTask pending, String comment) {
return new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(), pending.name(),
pending.assignee(), TaskStatus.APPROVED, pending.createdAt(), COMPLETED_AT,
new TaskAction(pending.assignee(), comment, COMPLETED_AT));
new TaskAction(pending.assignee(), comment, COMPLETED_AT), pending.dueAt());
}
}
@@ -2,6 +2,7 @@ package com.jetlumen.ordo.storage.jdbc;
import com.jetlumen.ordo.api.ActionExecutionStatus;
import com.jetlumen.ordo.api.ActionHandler;
import com.jetlumen.ordo.api.ApprovalPolicy;
import com.jetlumen.ordo.api.ApprovalStep;
import com.jetlumen.ordo.api.ApprovalTask;
import com.jetlumen.ordo.api.AssigneeResolver;
@@ -14,6 +15,8 @@ import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.ProcessStatus;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.RoutingCondition;
import com.jetlumen.ordo.api.StepKind;
import com.jetlumen.ordo.api.StepDue;
import com.jetlumen.ordo.api.StepTransition;
import com.jetlumen.ordo.api.TaskStatus;
import com.jetlumen.ordo.api.exception.DefinitionInUseException;
@@ -118,6 +121,42 @@ class JdbcOrdoEngineIntegrationTest {
assertEquals(task.id(), reassignedEvent.taskId());
}
@Test
void processDueReassignsAnOverdueTask() {
ProcessDefinition dueDefinition = ProcessDefinition.linear("leave-due", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", List.of("maria"), ApprovalPolicy.ANY,
StepKind.APPROVAL, null,
StepDue.reassign(java.time.Duration.ofHours(1), "diana"))));
OrdoEngine startEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), AssigneeResolver.direct(),
RoutingCondition.always(), ActionHandler.noop(),
new JdbcTransactionExecutor(connectionProvider),
new JdbcProcessDefinitionRepository(connectionProvider),
new JdbcProcessInstanceRepository(connectionProvider),
new JdbcApprovalTaskRepository(connectionProvider),
new JdbcProcessHistoryRepository(connectionProvider),
new JdbcActionExecutionRepository(connectionProvider),
List.of());
startEngine.register(dueDefinition);
ProcessInstance instance = startEngine.start("leave-due", "alice");
assertEquals(0, startEngine.processDue(10));
OrdoEngine laterEngine = new DefaultOrdoEngine(Clock.fixed(NOW.plusSeconds(3601), ZoneOffset.UTC),
AssigneeResolver.direct(), RoutingCondition.always(), ActionHandler.noop(),
new JdbcTransactionExecutor(connectionProvider),
new JdbcProcessDefinitionRepository(connectionProvider),
new JdbcProcessInstanceRepository(connectionProvider),
new JdbcApprovalTaskRepository(connectionProvider),
new JdbcProcessHistoryRepository(connectionProvider),
new JdbcActionExecutionRepository(connectionProvider),
List.of());
assertEquals(1, laterEngine.processDue(10));
ApprovalTask escalated = laterEngine.findPendingTasksByInstanceId(instance.id()).get(0);
assertEquals("diana", escalated.assignee());
assertNull(escalated.dueAt());
assertTrue(laterEngine.queryHistory(instance.id(), new PageRequest(0, 20)).content().stream()
.anyMatch(event -> event.type() == ProcessEventType.TASK_ESCALATED));
}
@Test
void rollsBackTheWholeApprovalWhenTheNextStepCannotBeCreated() {
OrdoEngine failingEngine = newEngine((candidate, step, context) -> {
@@ -174,7 +174,7 @@ class JdbcPostgresIntegrationTest {
JdbcApprovalTaskRepository taskRepository = new JdbcApprovalTaskRepository(connectionProvider);
ApprovalTask pending = new ApprovalTask("task-race-pg", "inst-race-pg", "manager", "Manager approval",
"maria", TaskStatus.PENDING, NOW, null, null);
"maria", TaskStatus.PENDING, NOW, null, null, null);
taskRepository.save(pending);
Instant completedAt = NOW.plusSeconds(30);
@@ -272,6 +272,6 @@ class JdbcPostgresIntegrationTest {
private static ApprovalTask completedTask(ApprovalTask pending, String comment, Instant completedAt) {
return new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(), pending.name(),
pending.assignee(), TaskStatus.APPROVED, pending.createdAt(), completedAt,
new TaskAction(pending.assignee(), comment, completedAt));
new TaskAction(pending.assignee(), comment, completedAt), pending.dueAt());
}
}
@@ -1,7 +1,9 @@
package com.jetlumen.ordo.storage.jdbc;
import com.jetlumen.ordo.api.ApprovalPolicy;
import com.jetlumen.ordo.api.ApprovalStep;
import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.StepDue;
import com.jetlumen.ordo.api.StepKind;
import com.jetlumen.ordo.api.StepTransition;
import com.jetlumen.ordo.api.query.Page;
@@ -110,4 +112,13 @@ class JdbcProcessDefinitionRepositoryTest {
assertEquals(List.of("c-def"), pageTwo.content().stream().map(ProcessDefinition::id).toList());
assertFalse(pageTwo.hasNext());
}
@Test
void insertsAndReadsBackStepDue() {
ProcessDefinition definition = ProcessDefinition.linear("leave-due", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", List.of("maria"), ApprovalPolicy.ANY,
StepKind.APPROVAL, null, StepDue.reassign(java.time.Duration.parse("PT48H"), "director"))));
assertTrue(repository.insertIfAbsent(definition));
assertEquals(definition, repository.findById("leave-due").orElseThrow());
}
}
@@ -18,7 +18,8 @@ final class JdbcTestSupport {
"/db/migration/V2__add_step_candidates_and_policy.sql",
"/db/migration/V3__add_step_transitions.sql",
"/db/migration/V4__add_step_kind_and_action_key.sql",
"/db/migration/V5__add_process_event_and_action_execution.sql"
"/db/migration/V5__add_process_event_and_action_execution.sql",
"/db/migration/V6__add_step_due.sql"
};
private static final String[] SCHEMA_SQL = loadSchemas();