Skip to content

Workflow tasks

View Markdown

Status: Stable Package: dev.vertique.workflow.tasks Artifact: vertique-workflow-tasks Depends on: workflow-core API reference: javadoc.io

vertique-workflow-tasks provides the application-facing API for human tasks created by workflows. When the engine reaches a human-task step, it creates a task row and suspends the workflow instance. This module lets application code list, read, complete, and reassign those tasks.

The module is intentionally thin. It contains no SQL implementation and no workflow mutation logic. Storage and engine callbacks come from the installed persistence module.


API Purpose
TaskService Application-facing service that opens its own transaction
TransactionalTaskService<TX> Same operations inside a caller-provided transaction
WorkflowTasksModule Dagger bindings for the task services
Compose validator Optional startup check that required task bindings exist

The engine still owns state transitions. Completing or reassigning a task delegates to engine callbacks so workflow state, history, timers, and idempotency are updated consistently. Completing a task is a two-step operation: complete validates and records the completion and reserves the task (COMPLETING), and the workflow executor applies the decision afterwards (see Recorded completion).


public interface TaskService {
Future<PagedResult<TaskRecord>> list(TaskFilter filter, PageCursor cursor);
Future<Optional<TaskRecord>> get(UUID taskId);
Future<Void> complete(TaskCompletionCommand cmd);
Future<Void> reassign(TaskReassignmentCommand cmd);
}

Use TransactionalTaskService<TX> when task work must participate in a larger transaction.

Task commands require an idempotency key. A retry with the same key and same logical payload succeeds as an idempotent retry. Reusing the same key with a different logical payload fails with an idempotency conflict.

complete(...) returns once the completion is recorded, not once it is applied:

  1. A committed duplicate (same key and same fingerprint) returns its earlier result at once, before any other check; it still succeeds after workflow.recording.maxPayloadBytes or the task’s stored schema has changed.
  2. Without taking a lock, the payload’s size and depth bounds are checked and, when the task’s decision snapshot stores a JSON Schema for the requested decision, the payload is validated against it. A violation fails with WorkflowInvalidPayloadException; nothing is written.
  3. Under the instance’s execution lock the task must still be OPEN (otherwise WorkflowTaskNotWaitingException), a supplied expectedAssignment must still match (WorkflowStaleAssignmentException), the reviewedSubjectVersion must match when the task requires version stability (WorkflowStaleSubjectVersionException), and the decision must be one the task offers.
  4. The task becomes COMPLETING, owned by the recorded COMPLETION command, and the call returns.

The executor then applies the decision in its own transaction: the task becomes COMPLETED and the instance advances. If the application of the decision fails deterministically (a throwing decision applicator, a payload that does not bind to the declared type on a task created before schemas were stored, a step-limit overrun), the command is rejected with a sanitized reason, the instance is unchanged and the task is OPEN again; complete it again with a new idempotency key. A COMPLETING task refuses a second completion and a reassignment with WorkflowTaskNotWaitingException. To observe the outcome, wait with the workflow execution probe (awaitIdle) and read the task back with get(...).


Human tasks are declared in workflow definitions through workflow-core:

wf.task("review")
.assignToRole("editors")
.requireVersionStability()
.decision("approve", ApprovalPayload.class)
.onDecision((state, payload) -> state.approve(payload.approvedBy()))
.toStep("publish")
.decision("reject", RejectionPayload.class)
.onDecision((state, payload) -> state.reject(payload.reason()))
.toStep("rejected")
.dueIn(Duration.ofDays(3))
.onDue(state -> state.markReviewOverdue())
.toStepOnDue("escalate")
.build();

Assignment can be literal or resolved from workflow state. Decisions carry typed payloads and update workflow state before moving to their next step. Use void.class as the payload type to declare a decision that carries no payload. Due dates and reminders require the delayed workflow module.

Version-stable tasks compare the reviewed subject version supplied by the caller with the version snapshotted when the task was created. This is useful for approval flows over mutable domain objects.


@Singleton
@Component(modules = {
WorkflowCoreModule.class,
WorkflowPostgresqlModule.class,
WorkflowTasksModule.class,
WorkflowServicesModule.class,
TransactionalMessagingPostgresqlModule.class,
TransactionalMessagingServiceModule.class,
DbPostgresqlModule.class,
DbFlywayModule.class,
AppModule.class
})
public interface AppComponent {
TaskService taskService();
WorkflowTasksComposeValidator workflowTasksComposeValidator();
}

Human tasks with due dates or reminders need no further module: their timers are commands that the workflow executor of vertique-workflow-postgresql fires.

Expose and call workflowTasksComposeValidator() during startup if you want fail-fast validation before serving requests.


Extension point Purpose
TaskStore<TX> Persists task rows; supplied by workflow-postgresql
TransactionalTaskCallbacks<TX> Engine mutation callbacks used by task completion and reassignment
TaskService Application API for task inboxes and task actions
TransactionalTaskService<TX> Transaction-aware task API

Application code should use TaskService or TransactionalTaskService<TX>. It should not call TransactionalTaskCallbacks<TX> directly.


  • Human task completion and reassignment are idempotent when callers reuse the same idempotency key for the same logical command.
  • A task with a due date depends on workflow timer infrastructure.
  • Transactions that touch timers, tasks, and workflow instances must follow the lock order enforced by the engine and persistence module.
  • Authorization is application-defined. The workflow engine records the actor supplied on the task command but does not decide whether that actor may act.

  • All-or-nothing due-date triplet. A task step’s optional due-date sub-chain (.dueDate(...), .onDueMutator(...), .dueNextStepId(...)) must specify all three or none. Partial triplets are rejected at construction by the HumanTaskNode compact constructor.
  • Non-empty unique decisions. Each task step must declare at least one decision, and decision names within one step must be unique.
  • TaskFilter single-assignee-kind guard. At most one of assigneeUser / assigneeRole / assigneeQueue may be set. Use TaskFilter.empty() plus the with* copies to compose filters. withScopeKey(...) adds an exact-match start-scope filter (null matches every scope; it is a filter, not an authorization boundary: see the core module reference).
  • Required actor. TaskCompletionCommand and TaskReassignmentCommand reject null / blank WorkflowActor via their compact constructors. Authorization is the application’s job; the actor recorded here is for audit and idempotency.
  • Blank reason normalization. Blank reason strings on commands are normalized to null before idempotency fingerprinting, so callers who omit a reason do not get treated as different commands.
  • Return contract. complete / reassign return Future<Void>. There is no result enum on the application-facing API: a succeeded future means the command’s effect is persisted (for complete, that the completion is recorded and the task reserved; the executor applies it afterwards), and every other outcome is a failed future carrying a typed exception — WorkflowTaskNotWaitingException, WorkflowConflictException, WorkflowIdempotencyConflictException, WorkflowTaskNotFoundException, WorkflowStaleSubjectVersionException, WorkflowStaleAssignmentException, or WorkflowInvalidPayloadException (400 semantics: unknown decision, payload missing or unexpected for the decision, or a payload that does not bind to the declared type). These operations never report a problem by returning a “nothing happened” value.
  • Replays succeed silently. A retry with the same idempotency key and the same logical payload succeeds without re-applying the effect, and is indistinguishable from the original call — the future carries no marker saying which call did the work. A replayed completion returns before any payload check. Callers that must know whether this call changed anything have to read the task back with get(...).
  • A completed call is not a completed task. After complete succeeds the task is COMPLETING; it is COMPLETED only once the executor has applied the decision, and OPEN again if the decision was rejected. Code that reads the task or the instance right after complete must wait for the executor (awaitIdle) first. A due-date or reminder timer that fires while the task is COMPLETING finds it not OPEN and settles itself as stale.
  • STALE_NOOP is not an outcome of these operations. It belongs to the executor’s due-date timer path, where a workflow has legitimately moved past the wait before the timer fires. If a custom TransactionalTaskCallbacks<TX> implementation returns it from taskCompleted / taskReassigned, the service fails the future with IllegalStateException rather than silently reporting success. Treat that as a bug in the SPI implementation, not as a case to handle.
  • Version-stable approvals. When a step calls .requireVersionStability(), completion must supply the reviewedSubjectVersion the reviewer saw. A mismatch fails with WorkflowStaleSubjectVersionException; creation without a snapshotable subject_version fails with WorkflowSubjectVersionUnavailableException.
  • Eligibility without a race. To authorize on the current assignment, read the task with get(...), decide eligibility from task.assignment(), and pass that same assignment as expectedAssignment on the complete / reassign command. If a concurrent reassignment moves the task first, the command fails with WorkflowStaleAssignmentException (409 semantics) instead of applying under a stale eligibility decision; re-read the task and decide again. expectedAssignment is not part of the idempotency fingerprint, and null skips the check. On TransactionalTaskService<TX> the caller owns the transaction and must roll it back when this exception is raised: the dedup claim and timer cancellations written before the check would otherwise be committed with the task still open. The same holds when complete is rejected with WorkflowInvalidPayloadException: roll the transaction back so the idempotency claim is released and a retry with a corrected payload can complete the task.

  • workflow-core - task records, filters, commands, service interfaces, and engine SPIs.