Workflow PostgreSQL
View MarkdownStatus: Stable Package:
dev.vertique.workflow.postgresqlArtifact:vertique-workflow-postgresqlDepends on: workflow-engine, workflow-core, db-postgresql (which bringsdev.vertique:vertique-db-coreforDbSchemasandMigrationDefinition), vertx-sql-client, jackson-databind.dev.vertique:vertique-db-flywayis a declared dependency, but no type of this module references Flyway: the application supplies the migration runner. API reference: javadoc.io
vertique-workflow-postgresql is the PostgreSQL adapter for the Vertique Workflow engine. It provides PostgreSQL implementations of the engine’s internal repository and transaction-runner SPIs, owns the schema and Flyway migrations, and supplies Pg-specific query and retention services. WorkflowMigrationsModule declares those migrations as the workflow migration instance for named Flyway instances, and the optional db.schemas.workflow setting places every workflow table in one schema for the migrations and for all nine stores.
For the dialect-neutral orchestration logic — WorkflowEngine, the 6 SPI interfaces (WorkflowInstanceRepository, WorkflowHistoryRepository, WorkflowDedupRepository, BranchTokenRepository, JoinStateRepository, WorkflowTransactionRunner), RecorderRouter, WorkflowReminderComposeValidator, and the Dagger module that binds WorkflowOperations — see dev.vertique:vertique-workflow-engine.
Install this module when workflows must survive process restarts, run across more than one node, or coordinate with the transactional outbox module. WorkflowPostgresqlModule includes WorkflowEngineModule transitively, so consumer wiring is unchanged — no @Component edits are required.
What It Provides
Section titled “What It Provides”| Binding | Purpose |
|---|---|
| Pg repo impls | PgWorkflowInstanceRepository, PgWorkflowHistoryRepository, PgWorkflowDedupRepository, PgBranchTokenRepository, PgJoinStateRepository — implement the engine SPIs; each is one scoped instance built with the optional DbSchemas placement |
TaskStore<SqlClient> |
Human task persistence (PgTaskStore), one scoped instance built with the placement |
TaskReservations<SqlClient> |
The OPEN to COMPLETING reservation of a recorded completion and its reopening (PgTaskReservations), one scoped instance built with the placement; two conditional updates, each a compare-and-set on the task’s status and owner |
TimerStore<SqlClient> |
Workflow timer persistence (PgTimerStore), one scoped instance built with the placement |
WorkflowDefinitionRegistryStore |
Durable definition metadata, TEXT sources, and fenced publication (PgWorkflowDefinitionRegistryStore), one scoped instance built with the placement |
WorkflowCommandRepository<SqlClient> |
The recorded command log (PgWorkflowCommandRepository), one scoped instance built with the placement and the engine’s propagator (the Dagger module injects DurableContextPropagator): the execution probe’s bounded lists, the executor’s one-row availability-delay read, the execution lock and queue sequence, the START insert of a recorded start, the COMPLETION insert of a recorded task completion, and the executor’s peek, defer, claim, fence, completion and release; eligibility is rendered once in CommandEligibilitySql |
WorkflowTransactionRunner<SqlClient> |
Pg implementation of the engine’s transaction seam, including the deadline overload the executor’s segments run through (TransactionBuilder.deadline; TransactionDeadlineExceededException passes the workflow exception mapping unchanged) |
| Query services | PgWorkflowInstanceQueryService — read model for workflow instances and branch state |
| Retention service | PgWorkflowRetentionService — archive + purge sweep that tries each candidate’s execution lock without waiting, before it touches the instance’s row, and skips an instance whose lock is held or that has an active command (a purge also needs an ended or FAILED instance, and an instance a retry returns to work loses its archive stamp); the application drives the batch loop and cadence, there is no built-in scheduler. It archives and purges through the scoped PgWorkflowRetentionRepository, so it follows the same placement |
| Migration declaration | WorkflowMigrationsModule — contributes the workflow MigrationDefinition (classpath:db/migration/workflow) with @IntoSet; see Flyway Configuration |
| Flyway schema | Shipped V1__create_workflow_tables.sql plus the additive V002__workflow_definitions_and_scopes.sql and V003__recorded_execution.sql |
The module includes WorkflowEngineModule, which provides the WorkflowOperations binding and recorder-router wiring. It also includes WorkflowMigrationsModule and DbSchemasModule (the optional DbSchemas binding). It does not deliver downstream service calls by itself; that is a separate module. Branch failures need no recovery service: a failed segment rolls back and its command is retried.
Two similarly named modules.
WorkflowMigrationsModule(plural, indev.vertique.workflow.postgresql.engine) declares the database schema migrations of this module for Flyway.WorkflowMigrationModule(singular, indev.vertique:vertique-workflow-core) is the optional wiring for in-flight workflow definition migration, which moves running instances from one definition version to another. They are unrelated; install the second only when you use in-flight migration (see Required App Wiring).
Key Classes
Section titled “Key Classes”The nine stores and their constructors
Section titled “The nine stores and their constructors”Nine PostgreSQL classes own every workflow SQL statement: PgWorkflowInstanceRepository, PgWorkflowHistoryRepository, PgWorkflowDedupRepository, PgBranchTokenRepository, PgJoinStateRepository, PgTaskStore, PgTimerStore, PgWorkflowDefinitionRegistryStore and PgWorkflowRetentionRepository. Each has two public constructors:
// the other six stores: (Pool pool, PgDbExceptionMapper mapper)@Injectpublic PgWorkflowInstanceRepository(Pool pool, PgDbExceptionMapper mapper)
public PgWorkflowInstanceRepository(Pool pool, PgDbExceptionMapper mapper, DbSchemas schemas)
// PgTimerStore takes no Pool@Injectpublic PgTimerStore(PgDbExceptionMapper exceptionMapper)
public PgTimerStore(PgDbExceptionMapper exceptionMapper, DbSchemas schemas)
// PgWorkflowDefinitionRegistryStore also takes the optional shared document policy@Injectpublic PgWorkflowDefinitionRegistryStore( Pool pool, PgDbExceptionMapper mapper, Optional<WorkflowDefinitionsConfig> config)
public PgWorkflowDefinitionRegistryStore( Pool pool, PgDbExceptionMapper mapper, Optional<WorkflowDefinitionsConfig> config, DbSchemas schemas)- Unmapped means unchanged. The constructors without
DbSchemasleave every table unqualified, so the workflow tables resolve through the connection’s default schema. With noworkflowentry in theDbSchemaspassed to the other constructor, the SQL is the same as theirs. - Mapped means every reference is qualified. The
DbSchemasconstructors qualify every workflow table reference, in every statement, with the schema thatschemasmaps toworkflow. This covers lifecycle, history, dedup, branch and join, task, timer, definition registry, query and retention statements, including the joins and subqueries between workflow tables (the task insert’s scope subselect onworkflow_instancesand the narrowed instances projection thatPgTaskStore.findByFilterjoins for a subject filter among them) and the pin-admission and retirement-guard statements that readworkflow_definitionsandworkflow_instances. Statements are built once at construction. - Invalid mappings fail at construction. A schema that is not a single valid PostgreSQL identifier (or is reserved by PostgreSQL) throws
IllegalArgumentExceptionwhen the store is created, before any query runs (seePgTableNamesindev.vertique:vertique-db-postgresql). - Column qualifiers are never schema-qualified. Qualifiers inside the SQL, such as
workflow_dedup.workflow_idin the dedup upsert, name the table within the statement. SQL in this document is shown with unqualified table names. - Transactions are unchanged. Placement changes only where tables are named. Every method that takes a
SqlClientstill runs on the client the caller passes, so workflow rows commit or roll back with the application’s and the outbox’s own writes.
Runtime Model
Section titled “Runtime Model”Each workflow operation opens a Postgres transaction via the WorkflowTransactionRunner. The portable orchestration (owned by vertique-workflow-engine) drives the transition:
- Resolve the pinned workflow definition version.
- Lock or read the relevant workflow row.
- Apply the next plan step.
- Append workflow history.
- Persist state changes (via the Pg repo impls).
- Record side-effect intents through installed recorders.
- Commit.
The engine stores serialized workflow state as JSON. The Java state type comes from the registered workflow plan.
In-flight instances are pinned to the definition version and plan hash recorded at start. They do not silently move to a newer definition. The pin includes the definition scope, and every drive of the instance resolves the plan of the pinned definition scope, id and version. A node whose registry holds no plan for the pin does not run the instance’s commands: its executor defers them for a node that has the code, and the instance is not failed.
The WorkflowTransactionRunner in this module delegates to vertique-db-core’s TransactionBuilder. Write paths run at the connection’s default isolation (0 SET TRANSACTION statements); the query() read path uses REPEATABLE READ (1 statement, no READ ONLY).
Failures raised inside the transaction pass through a two-stage exception mapper. A DB-boundary stage owned by this module translates raw SQL/driver exceptions into DataAccessException subtypes, reusing the PostgreSQL SQL-state rules from db-postgresql; already-thrown workflow exceptions and other non-DB throwables pass through unchanged. A workflow-boundary stage owned by vertique-workflow-engine then translates those DataAccessException subtypes into the workflow exception hierarchy (for example WorkflowConflictException for a 409, WorkflowPersistenceException for a 500). See dev.vertique:vertique-workflow-engine for the full translation table.
Schema Overview
Section titled “Schema Overview”The module owns workflow persistence tables, including:
| Table | Purpose |
|---|---|
workflow_instances |
Current snapshot of each workflow instance; includes metadata JSONB for the durable context captured once at start |
workflow_history |
Append-only engine history |
workflow_dedup |
Idempotency and signal deduplication, keyed by (kind, scope_key, dedup_namespace, key) |
workflow_timers |
Durable timer state; includes metadata JSONB for durable context. This row is the authoritative source of timer status. A timer recorded by a segment links to its TIMER command by command_id and names no delayed job. The delayed_job_execution_id column stays, nullable and unwritten. |
workflow_tasks |
Human task rows |
workflow_branch_tokens |
Active branch execution state; includes metadata JSONB for durable context |
workflow_join_states |
Fan-in state for fork/join workflows |
workflow_definitions |
Durable definition metadata and DOCUMENT source text, added by V002__workflow_definitions_and_scopes.sql (see Definition Registry And Source Storage) |
workflow_definition_activations |
The current version of a definition id per scope, added by the same script (see Definition Registry And Source Storage) |
workflow_commands |
The recorded command log per instance, added by V003__recorded_execution.sql (see Recorded-execution schema) |
workflow_command_queues |
One row per instance holding the next command sequence and the open-signal counter, added by the same script |
The migration files live with the module under db/migration/workflow. The shipped V1__create_workflow_tables.sql contains the instance/runtime tables, including branch tokens and durable metadata. The additive V002__workflow_definitions_and_scopes.sql creates the definition registry and converts the released tables to scope keys:
workflow_definitionsis keyed by(scope_key, definition_id, definition_version); it carries the logicalcontract_nameandstate_type_name, thedocument_digestcolumn andkind VARCHAR(16),plan_hash VARCHAR(128)andname_binding JSONB NOT NULL DEFAULT '{}'; aCODErow must be in thedefaultscope.workflow_definition_activationsis keyed by(scope_key, definition_id), one record per scope and id, withtarget_scope_key,definition_versionandactivated_at. A check requires the target scope to be the scope itself ordefault, anddefaultto name only itself; a foreign key from(target_scope_key, definition_id, definition_version)toworkflow_definitionsisON DELETE RESTRICT, so a definition row that a record names cannot be deleted. No row means the scope follows the default scope.workflow_instancesgainsscope_key(the start scope) anddefinition_scope_key(the scope of the pinned definition version), andworkflow_tasksgainsscope_key, eachVARCHAR(255) NOT NULL DEFAULT 'default'. Existing rows readdefault; the script rewrites no row.- The business-key index
idx_workflow_instances_business_keybecomes unique per(scope_key, definition_id, business_key)where the business key is not null, andidx_workflow_instances_scope_updated_atandidx_workflow_tasks_scope_updated_atserve scope-filtered keyset queries on(scope_key, updated_at DESC, <id>). workflow_dedup.scopeis renameddedup_namespace(for start rows it still holds the definition id), ascope_keycolumn is added, and the primary key becomes(kind, scope_key, dedup_namespace, key). Stored fingerprints and namespaces are kept as they were.
Recorded-execution schema
Section titled “Recorded-execution schema”V003__recorded_execution.sql adds the schema for recorded execution. start writes the command log (a PENDING instance, its queue row and a START command), signal writes a SIGNAL command, a task completion writes a COMPLETION command and reserves its task, retry writes a RETRY command, migrate writes a MIGRATION command, and the executor claims, parks, settles and completes commands; cancellation and task reassignment do not write it yet (see Executor commands and Execution probe).
workflow_commandshas a generated identityid,workflow_id(a foreign key toworkflow_instances(id)withON DELETE CASCADE),seq,kind,state, the timer, task and target columns, the claim and lease columns, the attribution columns, the durable context columns,attempts,transient_count,deferral_count,available_at,settled_atandlast_error TEXT.(workflow_id, seq)is unique.stateadmitsPENDING,DEFERRED,CLAIMED,PARKED,DEAD,COMPLETE,REJECTED,STALE,FAILED,SUPERSEDEDandCANCELLED;kindadmitsSTART,SIGNAL,COMPLETION,RETRY,MIGRATIONandTIMER;timer_kindis set exactly whenkindisTIMER. Three partial indexes serve the active states (PENDING,DEFERRED,CLAIMED) by(workflow_id, seq)and byavailable_at, and thePARKEDandDEADstates byworkflow_id.workflow_command_queuesis keyed byworkflow_id(foreign key withON DELETE CASCADE) and holdsnext_seq BIGINT NOT NULLandopen_signals INT NOT NULL DEFAULT 0. Deleting an instance deletes its commands and queue row.open_signalscounts the instance’sSIGNALcommands that arePENDING,DEFERRED,CLAIMEDorPARKED: recording runsUPDATE ... SET open_signals = open_signals + 1 WHERE workflow_id = $1 AND open_signals < $cap(afterINSERT ... ON CONFLICT DO NOTHINGcreates the row of an instance from before the command log), and every repository write that settles aSIGNALintoCOMPLETE,REJECTED,STALE,FAILEDorDEADlowers it in the same statement. Moves between open states, including parking and the guardedPARKEDtoPENDINGre-queue, leave it unchanged. TheSIGNALcommand’starget_jsonholds{"name": ..., "forkStepId": ..., "branchId": ...}(the last two only for a branch-addressed signal) andexplicit_carrier_jsonthe sanitized explicit carrier.- An
AFTER INSERT OR UPDATE OF state, available_attrigger onworkflow_commandscallspg_notifyon the channelvertique_workflow_commandswhen the new state isPENDINGorDEFERRED. The payload isavailable_atin epoch milliseconds and names no instance; PostgreSQL delivers it at commit. workflow_instances.state_jsonandcurrent_step_idbecome nullable (statushas no check, so aPENDINGinstance needs no further change), andbaseline_logged BOOLEAN NOT NULL DEFAULT TRUEis added.workflow_historygains sixteen nullable columns (actor_kind,actor_id,actor_reason,on_behalf_of,attribution_status,executor_id,command_id,step_id,outcome,definition_scope_key,definition_id,definition_version,plan_hash,state_version_before,state_version_after,patch_json) andentry_version SMALLINT NOT NULL DEFAULT 1; existing rows keep nulls and version 1.PgWorkflowHistoryRepository.appendDecisionwrites theDECISIONandSTATE_BASELINEentries of the engine’s decision log in one statement that also assigns the sequence (MAX(sequence) + 1under the execution lock) and storesentry_version = 2.patch_jsonholds the RFC 6902 patch as a JSON array ([]for no change, SQLNULLwhen the change was too large to log as a patch); aSTATE_BASELINEholds the full state atpayload_json.state.executor_idis theclaimed_byof the entry’s command, or of the instance’s claimed command when the entry belongs to a command that is not claimed (a parked signal applied by another command’s segment). The actor columns are null on insert and set by the segment’s attribution update, which also covers these entries through theircommand_id. Storage grows with each decision’s changed fields, one full state at the first segment, at every migration and after every oversized change; a boundWorkflowHistoryRedactorkeeps the values it names out of every one of these columns.LegacyBaselineconsumesworkflow_instances.baseline_logged(UPDATE ... SET baseline_logged = TRUE WHERE id = $1 AND baseline_logged = FALSE) in the segment’s transaction, so an instance from before 0.4.0 logs one baseline at its first claim.workflow_definitionsgains nullablesignal_names_jsonandstart_schema_json. The definition store writes them in the same statement as the plan hash (code insert, code rehash, document insert) when the node binds the schema producer (WorkflowSchemaModule), leaves them unchanged for an unchanged hash, and fills a row that has both null withfillSignalSchemas, guarded bysignal_names_json IS NULLand the expected plan hash. A node without the producer writes both as null. Recording a signal readssignal_names_jsonof the instance’s pinned row in the caller’s transaction (PgWorkflowCommandRepository.declaredSignalNames); a null column admits every name. A row written before the columns existed is also filled by the engine’s plan cache after the first on-demand build of its plan on any node (see the engine module), by the same guardedfillSignalSchemasin a statement of its own on a pooled connection, so its undeclared signals are refused at record time from then on.workflow_tasks.statusadmitsCOMPLETING(the checkworkflow_tasks_status_checkis replaced), andreservation_command_id BIGINTandrequire_version_stability BOOLEANare added, both nullable; existing tasks read null. A task being completed isCOMPLETINGandreservation_command_idnames itsCOMPLETIONcommand (whosetask_idandtarget_json, holding the decision, the reviewed subject version and the completing actor, say what it completes); a new task writesrequire_version_stabilityfrom its plan and the payload schema of each decision intodecisions_snapshot_json.workflow_timers.delayed_job_execution_idbecomes nullable andcommand_id BIGINTis added.
The command log is written and read by PgWorkflowCommandRepository: the execution probe reads it (‘Execution probe’ below), a recorded start inserts its START command, and the executor claims and completes commands (‘Executor commands’ below). The same repository takes the per-instance execution lock and allocates positions in workflow_command_queues (‘Lock-Order Requirements’ below). The stores read these columns: an instance row with status = 'PENDING' and null state_json and current_step_id reads as a WorkflowInstance (it is not terminal); a COMPLETING task reads back with its reservationCommandId, reassign refuses it as if it were already completed and markCompleted completes it (it also still completes an OPEN task, for callers of the TaskStore SPI; the engine completes only a reserved task); insertOpen writes require_version_stability from TaskRecord.requireVersionStability(), and a row reads it back as null when the column is null; the decisions snapshot stores payloadSchema per decision only when present and reads it back as null when absent; PgTimerStore writes and reads command_id and no longer reads or writes delayed_job_execution_id (the column stays, nullable and unwritten), and its markCancelled also sets the linked command CANCELLED (clearing its claim) when that command is still unsettled, in the same statement. PgWorkflowInstanceRepository writes PENDING for a recorded start (and counts it as a live pin, as does the registry store’s retirement count); PgTaskReservations writes COMPLETING and its reopening, and PgWorkflowCommandRepository.recordTimer writes a timer row and its TIMER command together (‘Timer commands’ below). Every optimistic update of an instance also writes its subject columns, because the first segment of a start resolves the subject.
The script sets SET LOCAL lock_timeout = '30s' and takes ACCESS EXCLUSIVE locks for the ALTERs until the migration commits; every statement is IF [NOT] EXISTS or drops before it creates, so a failed run can be repeated. Stop every node of the previous release before the first node of this release applies it. Existing rows keep their values.
Each ALTER TABLE, the index rebuild on workflow_instances and the primary-key rebuild on workflow_dedup take an ACCESS EXCLUSIVE lock on their table until the migration commits, and each index build is a sequential scan of its table under that lock, so the blocked time grows with table size. The script bounds its wait for each lock with SET LOCAL lock_timeout = '30s': run it when no long transaction (a retention sweep, a long read) is open. While a statement waits for its lock, new statements on that table queue behind it for up to the timeout, per table, and then the migration fails and can be rerun; a manual run must be one transaction (psql --single-transaction), or the timeout has no effect. The column and index statements are IF NOT EXISTS, so an operator of a very large installation can add the scope columns on tasks and then instances and build the two scope keyset indexes with the exact statements of the migration as CREATE INDEX CONCURRENTLY IF NOT EXISTS on an autocommit connection ahead of the upgrade (set lock_timeout on that session too; a statement that times out changes nothing and can be rerun), and the migration then builds nothing for them. The business-key index is always dropped and rebuilt by the migration, and the workflow_dedup rename and primary-key change always run inside it: ALTER TABLE ... RENAME COLUMN has no IF EXISTS form, so do not run it ahead of time. Both IF NOT EXISTS forms match by name only, so use the exact statements; a failed concurrent build leaves an INVALID index that IF NOT EXISTS skips, so check pg_index.indisvalid and drop it first. V1__ is unchanged. A development database that applied an earlier snapshot of this release must be recreated, because Flyway validation fails on the changed checksum and description of version 2.
Upgrade from 0.3.0. Stop all nodes that run 0.3.0 before the first 0.4.0 node starts and applies the migration. The 0.3.0 dedup statements use ON CONFLICT (kind, scope, key); the renamed column and the new primary key (kind, scope_key, dedup_namespace, key) leave that conflict target without a matching index, so a 0.3.0 node that is still running after the migration fails every start. Keeping the old column name would not help, because the primary key change alone breaks the target. Pre-applying statements ahead of the upgrade does not lift this rule: run them only after the last 0.3.0 node has stopped. Rolling-deploy guidance applies only from 0.4.0 on, and rolling code back to 0.3.0 against a migrated database is not supported. After the upgrade the instances of 0.3.0 resume in the default scope and complete their stored task decisions.
What the migration converts. The same migration converts the running work of 0.3.0 in one step, so no 0.3.0 node and no 0.4.0 node run side by side (there is no mixed-fleet mode):
- Each
SCHEDULEDtimer becomes oneTIMERcommand at the timer’s fire time, linked from the timer bycommand_id. The command kind follows the timer’s purpose (a standalone timer fires, a signal timeout times out, a task due date and a task reminder keep their task). Timers of one instance get ascending sequences by fire time. Timers that already fired, were cancelled or failed get no command. A converted timer keeps its durable context except theidentity-snapshotnamespace, which is removed so no converted command can carry an identity to an executor. A converted signal timeout carries notarget_json, because the timer row does not record the wait’s signal names or branch; such a timeout is held back while anySIGNALorCOMPLETIONrecorded for its instance before itsfire_atisPENDING,DEFERREDorCLAIMED, so a signal already on its way applies before the timeout can end the wait. - Each branch with a
RETRY_SCHEDULEDstatus becomes one branch-addressedRETRYcommand, available at the branch’s retry time (at once when it has none), and the branch is set toRUNNING. The command re-drives that branch once; it settles as stale when the branch has been superseded or its join has already decided. Nothing writesRETRY_SCHEDULEDany more. - Each instance that received a command gets its queue row, and every existing instance is marked
baseline_logged = FALSE, so the first segment that claims it logs the full-state baseline. The mark is one unfilteredUPDATE workflow_instances, which rewrites every row of the table once while holding its locks: on a large instances table expect a one-time rewrite cost (time, WAL and table bloat until vacuum) inside the migration’s transaction, and plan the maintenance window and a vacuum afterwards accordingly. - The
ENQUEUED,PROCESSING,FAILEDandABANDONEDdelayed jobs that fired 0.3.0 timers (handlerjobs/delayed/vertique.workflow.timer.fire/execute) are deleted from the delayed-job tablejob_executionswhen that name resolves, in the schema the migration runs in (current_schema()), to a plain table. Finished jobs (SUCCEEDED,CANCELLED,DEAD_LETTER) and jobs of other handlers stay. When the job tables are absent, the migration still succeeds and deletes nothing.
Job tables under another name or schema. The migration resolves job_executions in the schema it runs in. A job table shared by several deployments or schemas is not supported by this automatic cleanup: it deletes the live timer-fire jobs of every deployment that shares the table, so migrating one schema removes the jobs of all; in that case keep the job tables out of the migrated schema and run the statement by hand once every sharing node is stopped. When the job tables live in another schema (a db.schemas.job mapping), the migration leaves them untouched, and the live timer jobs must be removed by hand before the first 0.4.0 node starts, because a 0.4.0 node does not run them and the converted commands fire the timers. Run, with <schema> replaced by the schema of the job tables (a trusted, validated identifier, never untrusted input, because the statement is not parameterized):
DELETE FROM <schema>.job_executionsWHERE handler = 'jobs/delayed/vertique.workflow.timer.fire/execute'AND state IN ('ENQUEUED', 'PROCESSING', 'FAILED', 'ABANDONED');PgWorkflowInstanceRepository.insert writes scope_key and definition_scope_key from WorkflowInstance.scopeKey() and definitionScopeKey() and no update statement changes them. PgTaskStore.insertOpen writes the task’s scope_key from the owning instance row (SELECT scope_key FROM workflow_instances WHERE id = …; the instances table is qualified like every other reference when a workflow schema is mapped) and ignores the value on the TaskRecord, so every task creation path inherits the start scope of its instance; the guarantee belongs to this store, not to the TaskStore SPI. WorkflowInstanceQuery.scopeKey() and TaskFilter.scopeKey() add an exact-match scope_key = ? condition when non-null and leave keyset order and archived handling unchanged; null matches every scope. The columns and the filters are information and a filter, not an isolation or authorization boundary: callers must not pass an unresolved scope as null expecting scoping. No operation addressed by instance or task id checks the scope either. Start dedup statements (findStart, claimOrResolveStart) and findByBusinessKey take the start scope and filter on scope_key; signal, task and dispatch dedup statements address the default scope. PgWorkflowInstanceRepository.findDefinitionContract(key, tx) reads contract_name from workflow_definitions through the caller’s transaction, so a start replay on a node without the pinned plan takes no second pool connection.
Definition Registry And Source Storage
Section titled “Definition Registry And Source Storage”The definition registry is built and tested against PostgreSQL 15 or later. workflow_definitions stores CODE metadata and DOCUMENT source text separately
from compiled plans. DOCUMENT source uses TEXT with kind/nullability constraints and no fixed database
size cap. The adapter enforces the shared WorkflowDefinitionsConfig policy at document boundaries.
PgWorkflowDefinitionRegistryStore also implements the engine-internal TransactionalDefinitionReads<SqlClient>: findDocument(key, tx) reads the record and the text of a document row on the caller’s transaction handle, with the same size and digest checks as findDocument(key), and takes no connection of its own. The plan cache reads rows through the store’s own reads, outside any transaction.
selectionRead(startScope, definitionId, tx) is the read a first start selects its version from. It is one prepared statement (UNION ALL over workflow_definitions and workflow_definition_activations) that returns the id’s rows in the start scope and the default scope (version, status, kind, contract, state type, failure mark, plan hash, and for an AVAILABLE version only, signal names and start-payload schema; no document text, provenance or name binding) and the activation records of both scopes. It runs on the caller’s transaction handle, takes no connection, lock, lock_timeout or advisory lock of its own, and writes nothing; no migration is involved. Because it is a plain statement on the caller’s connection, what it sees follows the caller’s isolation level: at READ COMMITTED (PostgreSQL’s and the framework’s default) every activation committed before the read is seen by the next start on every node, and at REPEATABLE READ or SERIALIZABLE the read sees the caller’s snapshot, so an activation change committed after that snapshot can be missed. The engine neither checks nor changes the isolation level, and no level is refused. The read is the linearization point for activation changes; pin admission (below) stays the fence for status, so a version deprecated or retired between the read and the insert is refused at every level.
Runtime document load writes a CANDIDATE. Publication uses a fresh UUID claim (publish_token, present exactly while the
status is PUBLISHING), local registration, then a fenced completion that makes the row AVAILABLE; obsolete completions fail and obsolete releases are no-ops. Every definition
mutation takes an id-derived transaction advisory lock followed by ordered locks on all rows of that
id. Claim ages use PostgreSQL wall clock after lock acquisition, with publicationClaimTimeoutSeconds
(default 60 seconds) as the stale age: a claim is stale when its age is at least that value. These locks serialize each store transaction, not the whole
cross-node compilation operation. Before every mutating lock acquisition, the store sets transaction-
local lock_timeout to 30000ms; this bounds each advisory or row-lock wait independently. A timeout
rolls back the transaction, releases its locks, and clears the local setting when the connection is
reused. SQLSTATE 55P03 is surfaced as a retryable WorkflowPersistenceException with the exact
definition id in its context and the pessimistic-locking database failure preserved as its cause. The
store does not retry inside the aborted transaction.
The status column allows exactly CANDIDATE, PUBLISHING, AVAILABLE, DEPRECATED and RETIRED. No unique index limits
the available rows of a scope and id, so any number of versions and scopes of one id are AVAILABLE at once, and complete
changes only the row of its claim. Rows for the same id and version may coexist under different scope keys in every status, and
list returns them by optional scope, id and status set without reading document bodies. A claim, a completion and a
static document or code reconciliation look only at their own row; there is no per-id holder and no conflict between scopes.
transition(key, from, to) is a conditional status change under the id lock: it checks the row and the expected current status,
and updates only a row still in that status. The store accepts AVAILABLE to DEPRECATED, DEPRECATED to AVAILABLE and
RETIRED to AVAILABLE and no other pair; an unsupported pair, a missing row or a changed status fails with
INVALID_TRANSITION. A pair outside those three is refused before any statement is issued. The last two are the
reinstatement: the store does not check that an executable source exists or that its hash matches; the lifecycle does that
before it calls the store. Like every registry mutation (see retire below), the transaction runs at READ COMMITTED whatever the
server default.
retire(key) moves a CANDIDATE row, or a DEPRECATED row, to RETIRED, however recently it was deprecated. Every registry mutation runs at READ COMMITTED, whatever the server default, so the pin count sees
every row and pin committed while the mutation waited for the id lock. Under the id lock it checks the row, the status, and for a
DEPRECATED row the exact-version count of PENDING, RUNNING, WAITING and COMPENSATING instances (archived
rows included), in that order; a CANDIDATE row is retired without the pin check because a candidate is not admitted for
pinning (a runtime registered directly for the same version is outside the guard). AVAILABLE rows cannot retire. The count scans workflow_instances by definition id and version while
retirement holds the exclusive id lock and no index covers that predicate, so on a very large live instance table a
retirement can delay new starts of that id. PgWorkflowInstanceQueryService.countNonTerminal(DefinitionKey) exposes the same
scope-aware informational count (countNonTerminal(definitionId, version) counts the default scope); it is not the
retirement guard. The count and the retirement guard include only instances pinned to the definition’s own scope.
upsertCode(record, activateDefault) and upsertDocument(record, document, activateDefault) insert a missing row and, only when
the outcome is INSERTED and activateDefault is true, write (default, id) -> (default, version) into
workflow_definition_activations in the same transaction and under the same id lock: one statement inserts the record or
replaces it only when the stored version is lower, so concurrent first boots write one record, the highest version wins in any
order, and a failure of either statement rolls back the row as well. UNCHANGED, REHASHED, conflicts and drift write no
record, so a restart never activates and never undoes an operator’s choice. Deploy a new version with activateOnDeploy false
while nodes running the previous code are still up, and activate it once every node runs the new code.
insertCandidate of a row in a scope other than the default one reads the locked rows of the id across every scope, after the advisory lock was granted, and refuses with WorkflowDefinitionContractMismatchException when the default scope holds the id, in any status, under another contract and the scope holds no row of the id; a default-scope insert is never refused for the rows of other scopes.
insertCandidate, upsertDocument when it inserts a missing row, and upsertCode when it inserts a missing row or replaces the plan hash of a row (which can change its contract and state type), also check the versions of the same scope and id from the same locked rows: a version whose contract name or state type name differs from that of another version of the scope and id in status CANDIDATE, PUBLISHING, AVAILABLE or DEPRECATED is refused with WorkflowDefinitionException naming the scope, id, version and the rule (“every version of a definition in a scope must name the same contract” or “must have the same state type”), before any row is written. RETIRED versions and the rows of other scopes take no part, and the rules hold on a node that has loaded none of the other versions. Because a retired version takes no part, transition(key, RETIRED, AVAILABLE) applies the same two rules, and the default-scope contract rule when no other version of the scope remains, to the version it reinstates under the id lock, and refuses with the same exceptions when a version of another contract or state type was loaded after the retirement.
putActivation, removeActivation and listActivations maintain workflow_definition_activations. A write runs like every registry mutation, under the id lock at READ COMMITTED: putActivation requires the target row (target_scope_key, id, version) to exist and be AVAILABLE (a refusal is INVALID_TRANSITION naming the target key), refuses with WorkflowDefinitionContractMismatchException a scope’s record that names a default-scope version of another contract than the scope’s own rows of the id, and then inserts or replaces the scope’s record; removeActivation deletes it and accepts an absent record. The default-current guard lives in transition and retire: in the same transaction and after the lock was granted, a default-scope row is refused with INVALID_TRANSITION while the default scope’s record of the id names its version, so a concurrent activation and deprecation serialize on the lock and the record never ends on a deprecated target. Rows of other scopes are not guarded. Delete a record with removeActivation (or deactivateDefault through the lifecycle) before deleting the definition rows it names, and note that tests or tools that delete definition rows directly must remove the id’s records first because of the restricting foreign key.
A node keeps no copy of the activation records and runs no propagation pass: a first start reads the records of the id with its
selection read (see selectionRead), so an activate, follow or deactivateDefault committed on one node is seen by the next
start on every node, at the isolation level of the starting transaction.
Registry refusal messages can name other scopes and plan hashes: do not forward them to end users.
migratePinAndState writes the definition scope, the definition version and the plan hash of the re-pinned instance, so a migration to another scope’s definition changes the stored definition scope. Run the cross-scope migration with vertique-workflow-schema installed when the two definitions use different state classes.
Pin admission: before insert creates an instance pin, migratePinAndState changes one, or updateOptimistic
resurrects a terminal instance (FAILED retry or FAILED to COMPENSATING), the repository takes
pg_advisory_xact_lock_shared on the definition id in its own statement, then reads the id’s definition rows in a second
statement. Starts share the lock, so they do not serialize each other, but a queued deprecate or retire waits for them and
PostgreSQL queues later admissions behind that waiting writer, so long caller transactions can delay lifecycle and startup
mutations. The caller’s pin transaction must therefore end before it awaits any store-owned lifecycle call for the same
id. At READ COMMITTED an id with no definition rows is admitted (a definition registered only by direct runtime registration after startup, or an engine built without the registry bootstrap, has none); otherwise the row of the pin’s exact definition scope, id and version must exist with the plan hash and be AVAILABLE, and a resurrection also accepts DEPRECATED; the rows of other scopes and versions of the id play no part, so a plan hash of another scope’s row is a drift against this scope’s row. The pin is the instance’s definition scope, definition id and definition version, so an instance pinned to a definition scope is admitted only against the row of that scope.
Under REPEATABLE READ or SERIALIZABLE the rows are read FOR SHARE, a changed row raises a serialization failure, and an id
with no visible row is refused with INVALID_TRANSITION (a snapshot taken before the first row cannot tell absence from a
newly managed id; this includes engines built by hand that skip the registry bootstrap). Every admission sets
SET LOCAL lock_timeout = '30000ms'; a timeout rolls back the caller’s transaction, so retry the whole caller
transaction. In a caller-owned transaction the failure is the data-access PessimisticLockingFailureException
(SQLSTATE 55P03); the retryable WorkflowPersistenceException carrying the id is what the engine’s own runners and the
store’s lifecycle operations raise. The setting overrides a stricter lock_timeout configured on the role or session and
also bounds every later lock wait of that transaction at 30 seconds until its commit or rollback. Direct callers must pass a SqlConnection with a pending Vert.x transaction; a pool, bare or completed
connection, raw BEGIN or wrapper fails with IllegalArgumentException before any SQL.
DOCUMENT bodies require server_encoding = UTF8. The adapter checks this before its first body
read/write, caches only a successful capability result, and retries a failed lookup on the next
operation. Metadata and CODE-only operations do not require that capability. Every source read checks
UTF-8 byte length and code-point count in the same SQL statement that conditionally projects the body.
Oversized rows return identity/counts with no body transfer, then fail with a sanitized key/limit
message; they are not treated as absent. Metadata listing never selects the source body.
failure_reason (VARCHAR(64), nullable, no check constraint) holds the name of the rule a failing scope row breaks,
STATE_TYPE_NOT_EXTENDED or NAME_BINDING_UNRESOLVED, and is null for every other row. Only recordFailure(key, failure)
writes it, in one UPDATE under the definition id lock that only matches a DOCUMENT row of a scope other than default (a default scope row, a CODE row or a RETIRED row is left untouched), and a startup or a reconciliation pass that finds no conflict clears it. find and
list read it into WorkflowDefinitionRecord.failure() with the rule’s fixed description as the detail; a reason this
release does not know reads as no failure. A node refuses starts from its own startup evaluation, not from this column, so
nodes that ship different code can rewrite it during a rolling deploy.
The stored plan hash covers the compiled plan structure, not callback bodies. name_binding holds, as a JSON
object, the binary name of each state and payload class the plan names, mapped to the name the hash used. The
adapter writes it in the same statement as the plan hash and never alone: when a version is first recorded (a code
row, or a document candidate) and when a changed code hash replaces the stored one. Publication, deprecation,
retirement and reinstatement leave it as it is. Every start hashes the version with the stored
binding, so annotating a class after the version was published keeps the hash of that version, and a version first
published afterwards records the logical name. A class that a non-retired version is bound to by its binary name
cannot move until those versions are retired; startup fails naming the version and the recorded name. A version that
instances of an earlier release started under is recorded by the first start of this release with the names its
classes have then, so upgrade before you annotate a class those instances use.
Annotate contracts and types before moving them. The document_digest column holds
the lowercase hex SHA-256 of the stored UTF-8 text (present exactly on
DOCUMENT rows). The adapter writes it with every document, treats an equal plan hash with a different text
digest as drift when a packaged document is reconciled, and verifies it on every guarded source read (after the
size-envelope check): altered stored text is never returned and fails as drift. This detects accidental or
concurrent change of stored text; it does not authenticate the writer, so restrict write access to the table.
Any other writer of DOCUMENT rows must supply the digest. On first boot after upgrading, every application that uses the PostgreSQL module writes a global CODE row per code-first definition even if it never touches documents; the V002__ migration must have run.
With a durable registry store bound (the PostgreSQL module binds one), the engine records every code definition, and with WorkflowDefinitionModule installed also the packaged documents, and restores available documents of every scope after
Flyway migration, and refreshes verified definition-cache entries
from committed metadata before BOOTSTRAP verticle deployment. Every node sharing live document rows must
carry their loaders and callback/type catalogs. Source locations are attribution, never remote fetch
instructions. Configure the same policy on every node; raising the input limit increases memory and
parse cost, and lowering it below required stored sources makes startup fail. The stored envelope is
floor(3L/2) UTF-8 bytes and L code points for configured raw input limit L (default 262144 bytes).
Placement. The migration script is unqualified, so the migration instance’s default schema decides where the nine tables and their indexes land. In named Flyway mode with a db.schemas.workflow entry, that schema is the default, and the stores qualify every table with it, so both agree. In single mode the migration lands where the single-run configuration says (see Flyway Configuration). V003__recorded_execution.sql also creates a trigger function and a trigger on workflow_commands, and the identity column of that table creates a sequence; they land in the same default schema as the tables. The module creates no views. The trigger notifies on the unqualified channel vertique_workflow_commands, which is database-wide, not per schema.
Flyway Configuration
Section titled “Flyway Configuration”The workflow migration is declared once, by WorkflowMigrationsModule, and can be run two ways. The declaration is metadata only: it names the workflow instance and the location classpath:db/migration/workflow, has no dependencies and needs no connection pool, so a migration-only component can include it on its own. WorkflowPostgresqlModule includes it, so an application that wires the workflow stores contributes the declaration automatically.
Named instances
Section titled “Named instances”Give the workflow tables their own schema and history table:
{ "db": { "schemas": { "workflow": "workflow_data" } }, "flyway": { "mode": "MIGRATE", "named": true, "instances": { "workflow": {} } }}"named": true turns on named mode; instances only carries per-instance overrides and may be omitted ("named": true alone runs every wired declaration with the root settings). instances without "named": true is a configuration error.
- One setting places everything.
db.schemas.workflowis the instance’s default schema and history schema, and the nine stores qualify every workflow table with it, so the migration and every statement use one schema. The tables and indexes land there, and the history table isflyway_schema_history_workflowin that schema. - Run it through the named runner. A migration component installs
DbModule,DbFlywayModuleandWorkflowMigrationsModule(plus the configuration and Vert.x modules the application already uses), and callsFlywayMigrationRunner.run(vertx);FlywayMigrationStartupStepruns the same engine at startup.dev.vertique:vertique-db-flywaydocumentsinstances(),run(Vertx), reports and failures. - Without a
db.schemas.workflowentry the migration uses the first entry of the instance’s Flywayschemaslist, or the connection’s default schema, and the stores stay unqualified. Configure the migration and runtime connections to the same default schema. - Include
DbModulein the migration component (or bindDbSchemasyourself). A named migration component without either ignoresdb.schemas, and the tables can land in a different schema than a runtime component withDbModulequeries. - The application’s own migrations are a separate instance with a disjoint location such as
classpath:db/migration/app. A broadclasspath:db/migrationlocation overlapsclasspath:db/migration/workflowand is rejected. - A dedicated DDL user is set with
flyway.userandflyway.password(both, or neither), or per instance withflyway.instances.workflow.userandflyway.instances.workflow.password. Without them the migration uses thedbpool credentials.
dev.vertique:vertique-db-flyway documents the full instance configuration, the agreement rules between db.schemas and Flyway schema lists, and the failure model.
Running the migration without the application
Section titled “Running the migration without the application”The migration does not need the application to start. A migration-only component is an ordinary Dagger component that:
- installs
DbModule,DbFlywayModule,WorkflowMigrationsModuleand the configuration module, together with the declaration modules of every other instance the deployment migrates (for example the job and inbox/outbox migration modules, and a module contributing the application’s ownMigrationDefinition); - binds the caller’s
Vertxand the application configuration, and exposesFlywayMigrationRunner; - installs no
DbPostgresqlModule, noWorkflowPostgresqlModuleand no worker or HTTP modules, so no connection pool, HTTP server or worker deployment is ever created.
A one-off deploy step or job builds that component, calls FlywayMigrationRunner.run(vertx) and reads the MigrationReport; the runtime replicas then start in VALIDATE or DISABLED mode against the migrated schemas. The runner uses the Vertx instance it is given for blocking work only and never closes it, so the caller keeps using it afterwards. dev.vertique:vertique-db-flyway documents the concurrent-start behavior of the runner and the lockRetryCount setting.
Class loader. The runner captures the calling thread’s context class loader when run or migrate is called (its own class loader when the thread has none), and resolves packaged scripts and application locations through that loader, not through the ambient loader of the Vert.x worker thread that executes the migration. Call it from a thread whose context class loader can see the migration scripts, normally the application class path. Instance locations are independent per directory prefix: classpath:db/migration/job and a sibling such as classpath:db/migration/job-v2 never read each other’s scripts.
Single Flyway configuration
Section titled “Single Flyway configuration”Without flyway.named, the default classpath:db/migration location already includes the bundled db/migration/workflow scripts, so no extra location is needed. Every bundled adapter ships its own V1 script, so one history can hold only one of them (a second would fail with a duplicate version); to run several adapters, set "named": true. Wired migration declarations are ignored in a single run, so adding another adapter module never switches the mode. The single-history example:
{ "flyway": { "mode": "MIGRATE", "user": "ddl_user", "password": "ddl_secret" }}If you override locations, list the workflow directory next to an application directory that does not contain it, for example ["classpath:db/migration/workflow", "classpath:db/app"]. Do not list both classpath:db/migration and classpath:db/migration/workflow: the first already covers the second.
In this mode the migration lands where the single-run configuration says, and nothing checks it against db.schemas. If db.schemas.workflow is also set, the stores query that schema, so make the flyway.schemas list (first entry) or the connection default match it, or the stores will look in a schema the migration never filled.
Runtime placement
Section titled “Runtime placement”- The stores read placement from
DbSchemas. A runtime component that installsDbModulegets it fromdb.schemas, with no Flyway module or Flyway configuration. A component that installs neitherDbModulenor its ownDbSchemasbinding keeps unqualified SQL. - Check the keys yourself.
db.schemaskeys are open and are not validated, so a misspelled key (workflowsforworkflow) leaves the placement unmapped and the SQL unqualified, with no error. CompareDbSchemas.mappings()with the instance names before deploying. - The runtime database role needs
USAGEon the mapped schema, besides the usual privileges on the tables. - An invalid mapping fails at construction. A mapped schema that is not a single valid PostgreSQL identifier throws
IllegalArgumentExceptionwhen a store is created, not on the first query. - Your own tables. An application can inject
DbSchemasand place its own tables with the same mapping mechanism; seedev.vertique:vertique-db-core(DbSchemas) andPgTableNamesindev.vertique:vertique-db-postgresql. - One source of
DbSchemas. A component that bindsDbSchemasitself cannot also installDbModule: Dagger reports a duplicate binding at compile time, with no silent precedence.
Required App Wiring
Section titled “Required App Wiring”A typical durable workflow application includes:
@Singleton@Component(modules = { VertxModule.class, WorkflowCoreModule.class, WorkflowPostgresqlModule.class, WorkflowServicesModule.class, TransactionalMessagingPostgresqlModule.class, TransactionalMessagingServiceModule.class, DbModule.class, // supplies DbPoolConfig and DbSchemas (from db.schemas) DbPostgresqlModule.class, DbFlywayModule.class, // only to run the migration at startup AppModule.class})public interface AppComponent { WorkflowOperations workflowOperations(); WorkflowInstanceQueryService workflowInstanceQueryService();}The runtime needs DbPostgresqlModule (the Pool and the PostgreSQL failure mapper) and DbModule (the pool configuration and the optional db.schemas placement). DbFlywayModule is needed only when the application runs the migration itself at startup; a runtime that migrates elsewhere installs no Flyway module and no Flyway configuration.
Add optional modules based on behavior:
| Behavior | Add |
|---|---|
| Timer steps, signal timeouts, task due dates and reminders | Nothing to add: they are TIMER commands that the executor runs |
| Human task service API | WorkflowTasksModule |
| External workflow event stream | WorkflowEventsModule |
| YAML/JSON definition loading | WorkflowDefinitionModule |
| In-flight definition migration | WorkflowMigrationModule (from dev.vertique:vertique-workflow-core; not the schema-migration declaration WorkflowMigrationsModule, which this module already includes) |
Executor commands
Section titled “Executor commands”PgWorkflowCommandRepository is the executor’s side of the command log; each statement composes its eligibility from CommandEligibilitySql:
- Record (
recordStart, in the caller’s transaction): allocates the next queue position and inserts aSTARTcommand in statePENDING, withrecorded_atandavailable_atread from the database clock by the insert itself, the start payload stored aspayload_json, the idempotency key asdedup_key, and a caller-supplied subject reference astarget_json. Every command insert also stores, ascontext_json, the durable context the recording caller has bound (DurableContextPropagator.mergeCapturedfor theWORKFLOWboundary, encoded for the command row’s carrier descriptor: target kindworkflow-command, the instance id as address and the queue positionseqas carrier id) without the identity-snapshot namespace, in the{"context": {...}}wrapper; the column isNULLwhen the capture is empty or the repository was built without a propagator. - Who asked: every command insert (
recordStart,recordSignal,recordCompletion,recordRetry,recordMigration) also writesactor_kind,actor_id,actor_reason,on_behalf_ofandattribution_statusfrom theCommandActorthe engine passes; the previous arities writeNONEandABSENT.recordTimerand a rearmed timer writeSYSTEM, the reasontimerandVERIFIED.fencereturns the stored actor on theSegmentCommand(nullfor a row recorded before actors were logged). The history entries the repository writes itself (deferral, dead letter, settlement at the end of an instance, supersession) copy the actor of the command they are about, withNONEandABSENTfor a command that has none;cancelAllwrites itsCOMMAND_CANCELLEDentries with the canceller it is given.attributeHistory(instance, fromSequence, actor, tx)sets the actor on the instance’s history entries from that sequence on that have none, andattributeSegmentHistorydoes the same for a segment, taking the actor of the command an entry names (itscommand_id, or thecommandIdof its payload) and the segment command’s actor for any other entry; both write only entries whoseactor_kindis null and run in the transaction that holds the execution lock. - Record a signal (
declaredSignalNames,raiseOpenSignals,recordSignal, in the caller’s transaction): reads the pinned row’ssignal_names_json, raises the instance’sopen_signalscount (creating the queue row when missing, then the conditional update), and allocates the next queue position and inserts aSIGNALcommand in statePENDINGwith the same clock reads as a start, the payload aspayload_json, the dedup key asdedup_key, the name and address astarget_jsonand the sanitized explicit carrier asexplicit_carrier_json(same{"context": {...}}wrapper); the context capture is stored as for a start. The fence and the parked-command read return bothexplicit_carrier_jsonandcontext_jsonto the segment, which binds them as described in the engine module reference. - Peek: a pooled read without locks of the claimable heads (joined with their instance’s pin), oldest availability first.
- Claim: one transaction of its own:
pg_try_advisory_xact_lockon the instance, then a locking read (FOR UPDATE) of the command that checksclaimableagain and joins the instance’s status. A busy lock or a command that is no longer claimable writes nothing. Otherwise, in this order: an instance that has ended (COMPLETED,COMPENSATED,CANCELLED,EXPIRED) settles the command as the engine’s outcome table states (REJECTEDwith aCOMMAND_REJECTEDentry;STALEwith aCOMMAND_STALEentry for a branch-addressed signal and for a timer;CANCELLEDwith aCOMMAND_CANCELLEDentry and anINSTANCE_CANCELLEDreason when the instance was cancelled, whatever the kind) with the token cleared andlast_errorset to the reason (anINSTANCE_ENDEDreason otherwise), spends no attempt and never dead-letters, and leaves the instance as it is; a completion also cancels the task it reserves; else a command withattemptsat or above the configuredmaxAttemptsbecomesDEADwith the token cleared, its attempts unchanged, anATTEMPTS_EXHAUSTEDreason and aCOMMAND_DEAD_LETTEREDhistory entry, and aSTARTalso sets its instanceFAILED(error_typethe reason code,completed_atnow) with aFAILEDhistory entry; else oneUPDATEsetsCLAIMED,attempts + 1, a newgen_random_uuid()token,lease_untilandclaimed_by.claimed_byis kept after completion. The peek reads whether the instance has ended, so that such a command is claimed without making a plan resident. - Defer (
defer): one transaction of its own for a head the node cannot run:pg_try_advisory_xact_lockon the instance, then a locking read (FOR UPDATE) of the command that checksclaimable(which includes being available now, so a command already deferred into the future fails it), and that the instance’s definition scope, id, version and plan hash equal the head’s. A busy lock or a failed check writes nothing and returns0. Otherwise oneUPDATEsetsDEFERRED,deferral_count + 1,available_atthe database clock plusmin(5 s * 2^count, 5 min)computed from the count before the increment, and aNOT_RUNNABLEreason inlast_error; attempts, token and lease are untouched. When the count goes from 0 to 1 it also appends oneCOMMAND_DEFERREDhistory entry whose payload holds the command id, sequence, kind and reason, never the command payload. It returns the new count. - Park, settle and re-queue signals (in the segment’s transaction):
parksets a claimed commandPARKEDwith the token and lease cleared and the attempt its claim spent restored, fenced on the claim;reject(..., lastError)andstalesettle it with a reason;settleParkedmoves aPARKEDcommand toCOMPLETE,REJECTEDorSTALEwith no token;requeueParkedmoves aPARKEDcommand back toPENDINGwith itsseq, id, attempts and counters andavailable_atno later than now, which wakes executors through the trigger.deadlineBlockedreads theTIMERcommands oftimer_kindTIMEOUTwhosetarget_jsonnames the wait (stepIdand, for a branch wait,branchId) and whoseworkflow_timersrow is stillSCHEDULED: a timeout that has fired has left its wait even while its own segment is open, so a wait the instance enters again is a new one.wasParkedalso answerstruefor a command that has aCOMMAND_DEAD_LETTEREDentry, so a signal re-offered by a retry logsSIGNAL_APPLIEDwhen it applies. - Timer commands:
recordTimer(spec, tx)takes the next position of the instance’s queue and inserts, in the caller’s transaction, aTIMERcommand (timer_kindFIRE,TIMEOUT,DUEorREMINDERfrom the timer’s purpose;timer_id;task_idfor a due date or reminder; statePENDING) whoserecorded_atandfire_atare read fromclock_timestamp()in the insert and whoseavailable_atequalsfire_at(a delay counts from the recording, an instant already past is taken as the recording time), and then theworkflow_timersrow withcommand_idset, the same fire time andmetadatasigned forWorkflowTimerCarriers. ATIMEOUTcommand’starget_jsonis{stepId, forkStepId?, branchId?, signalNames}.fencereturns the command’stimer_kind,timer_idandtask_idin theSegmentCommand. A claim that finds the instance ended settles aTIMERcommandSTALE(historyCOMMAND_STALE), notREJECTED. - Retry and migration commands:
recordRetryandrecordMigrationtake the next position of the instance’s queue and insert, in the caller’s transaction, aRETRYcommand (no target) or aMIGRATIONcommand (target_json{version, scope?}), statePENDING, with the recording caller’s durable context.peekreads aMIGRATIONcommand’s target version and scope into its head, so the executor can make the target plan resident or defer.hasDeadLettersis one existence read over the partial index ofPARKEDandDEADcommands. - Recovery (in the segment’s transaction, under the execution lock):
readStartreads the instance’sSTARTcommand (payload, target, key, recorded context);supersedeDeadStartsets aDEADSTARTtoSUPERSEDEDwith aCOMMAND_SUPERSEDEDentry and leaves aSTARTin any other state alone;reofferDeadLettersis one statement that sets everyDEADcommand of the instance toPENDINGwithattempts = 0, the token and lease cleared andavailable_atno later than now (so the trigger wakes executors), and in the same statement raisesworkflow_command_queues.open_signalsby the number of re-offeredSIGNALcommands without the limit check; a re-offered completion keeps its taskCOMPLETING.settleEndedlocks, in ascending id order, thePARKEDandDEADcommands of an instance whose status is ended and settles them in one statement, eachREJECTEDwith anINSTANCE_ENDEDreason (CANCELLEDwith anINSTANCE_CANCELLEDreason when the instance was cancelled), with oneCOMMAND_REJECTED(COMMAND_CANCELLED) entry each; it cancels the task a dead-lettered completion still reserves, and in the same statement lowersopen_signalsby the number ofPARKEDsignals only (the slot of aDEADsignal was released when it was dead-lettered). The engine calls it when a segment’s drive has ended the instance and after every segment that applied its command; commands that are still active are left to settle when they next execute. - Cancel (
cancelAll, in the caller’s transaction, under the execution lock): one statement locks everyPENDING,DEFERRED,CLAIMED,PARKEDandDEADcommand of the instance in ascending id order and sets itCANCELLEDwith the token and lease cleared,settled_atnow and anINSTANCE_CANCELLEDreason that names the kind and no payload value, and in the same statement lowersopen_signalsby the number of those that were open signals (DEADones lower nothing; the count never goes below zero). It then appends oneCOMMAND_CANCELLEDentry per command (a signal’s names the signal, bounded) and returns the cancelled commands with their timer and task ids so that the engine can finish the timers and the tasks that cancelled completions reserved.cancel(commandId, token, lastError, tx)is the fenced single-command form for a claimed command whose instance was found cancelled.PgTaskStore.markCancelledcancelsOPENandCOMPLETINGtasks. - Fence, complete, reject (in the segment’s transaction): the fence locks the row
FOR UPDATEand checksCLAIMED, the claim’s token andatHead; completion setsCOMPLETE, clears the token and lease and stampssettled_at, fenced on the same state and token, and returns the row count. - Release: one pooled statement that takes no lock and returns a claim to
PENDINGwith its attempt restored, only while the command is stillCLAIMEDwith the claim’s token. - Transient retry (
retryTransient): one transaction that locks only the command row (FOR UPDATE,CLAIMEDwith the claim’s token) and returns it toPENDINGwith the attempt restored,transient_count + 1,available_atthe database clock plus the backoff of the new count, the token and lease cleared and aTRANSIENT_FAILUREreason inlast_error. It takes no execution lock. - Failure disposition (
failCommand): one transaction of its own that tries the execution lock (a busy lock answersBUSYand writes nothing, so the executor retries without holding a connection), locks the command row under the same token fence (CLAIM_LOSTand nothing written when the command is no longerCLAIMEDwith it) and settles itFAILEDwith the token cleared,settled_atand the sanitized reason; the instance becomesFAILED(error_typethe reason code,error_messagethe same text,completed_atnow) with aFAILEDhistory entry (stepIdnull) unless it has already ended or failed; the repository then calls the engine’s failure listener inside the same transaction, which emitsWORKFLOW_FAILED. The dead-letter branch of the claim does the same for aSTART. - Last error: every writer stores
<REASON_CODE>: <message>built by the engine’sCommandErrors(at most 4000 characters, no exception text, class names, SQL or payload). The probe additionally cuts the column to 4000 characters when it reads.
A segment runs through PgWorkflowTransactionRunner’s deadline overload, so the work’s client is the vertique-db deadline-guarded connection. It is a SqlConnection that delegates transaction(), so the engine’s check of a pending Vert.x transaction accepts it, and it refuses every statement executed after the deadline. prepare(), begin() and close() always fail on it, so Query.stream(...) is unavailable in a segment.
Executor wake-up
Section titled “Executor wake-up”A command that becomes PENDING or DEFERRED wakes the node’s executor instances at commit, through the pg_notify trigger
on the channel vertique_workflow_commands (payload: available_at in epoch milliseconds, no instance id). The node has one
CommandNotificationListener (public, dev.vertique.workflow.postgresql.executor), shared by every workflow-executor
instance of the node, whether deployed by the deployer or started with WorkflowExecutorControl.start():
- One connection per node. The first instance to start opens a dedicated connection outside the pool with the pool’s
PgConnectOptions(TLS and credentials), issuesLISTEN vertique_workflow_commandsand nothing else, and never begins a transaction. The last instance to stop closes it. The channel is database-wide: an instance may be woken for a command another node or engine owns, peeks and finds nothing. - The payload is a hint. Only digits are read, as the command’s available time; anything else wakes the instances to peek
now. Each registered instance is woken with
runOnContexton its own verticle context, never on the connection’s context. - Scheduling is separate from reports. The executor reads
findEarliestAvailabilityDelayMs()throughPgCommandWakeupQueries, which samples the database clock once and returns the nonnegative delay to the earliest futurePENDINGorDEFERREDavailability. The executor schedules that delay on a monotonic timer; it does not compare the notification timestamp with the JVM clock. ThefindNotYetAvailableread remains a bounded diagnostic list forIdleReportand is not used to schedule the executor. - Loss degrades to polling. A connection that cannot be opened or is lost leaves the instances polling every
pollIntervalMs. The listener logs one warning for the outage, with the exception class only and no connection details, and tries again once perpollIntervalMs. There is no liveness check and no restart logic. - Queue usage. While at least one
WorkflowExecutorObserveris bound, the listener readspg_notification_queue_usage()on a pooled connection every five seconds, outside any transaction, and reports it asQueueSnapshot.notificationQueueUsage(a value from 0 to 1). The other fields of that snapshot are zero; the executor instances report the queue counts. A usage near 1 means a session that listens is not consuming notifications, and new notifications then fail at commit. - Factory-built engines construct the listener with the Vert.x instance, the pool’s connect options, the pool, the observers
and the two intervals, and pass it to
WorkflowEngineParts.Builder.commandWakeups.
Execution probe
Section titled “Execution probe”WorkflowExecutionProbe completes when an instance (or every instance) has no command in flight and none an executor could claim now. PgWorkflowCommandRepository answers it with plain pooled reads of workflow_commands: it opens no transaction on a caller’s connection and takes no lock, and it judges time with the database clock (now()). CommandEligibilitySql is the one SQL rendering of the eligibility rule, composed by every reader: a command is claimable when it is at the head of its instance’s queue (no earlier PENDING, DEFERRED or CLAIMED command blocks, where a timer before its fire_at and a held-back timer block nothing), is not held back, is available, and is PENDING, DEFERRED, or CLAIMED under an expired lease. PARKED, DEAD and settled commands block nothing. A due-date or reminder timer is held back while its task is COMPLETING, including under a dead-lettered completion. A timeout timer is held back while a SIGNAL or COMPLETION for its wait, recorded before the timeout’s fire_at, is PENDING, DEFERRED or CLAIMED: the wait is read from the timeout command’s target_json (the signal names the wait accepts, its branchId and forkStepId) and task_id, and a signal targets it when its name is among the accepted names and its branch address is the same; a parked or dead-lettered signal holds nothing back. A timeout whose target_json is null (one converted by the upgrade) is held back by any such SIGNAL or COMPLETION of its instance. A CLAIMED command under a live lease counts as in flight. The report lists DEFERRED commands that are not claimable, PARKED commands, and the other PENDING and CLAIMED commands that are not claimable, ordered by instance id then sequence; stuck lists every DEAD command and every PENDING, DEFERRED, CLAIMED or PARKED command whose first availability (fire_at for a timer, recorded_at otherwise, never the moving available_at) is older than workflow.executor.stuckAfterSeconds; a command may be in stuck and in its state list. Each list holds at most 1000 commands.
Tests that run no engine build a probe from a pool with PgExecutionProbeSupport.probe(pool, vertx) (test sources of this module).
Module Dagger Bindings
Section titled “Module Dagger Bindings”WorkflowPostgresqlModule includes WorkflowEngineModule, LoggingContextModule, WorkflowMigrationsModule (the workflow migration declaration) and DbSchemasModule (the optional DbSchemas binding), and provides:
| Binding | Scope | Description |
|---|---|---|
PgWorkflowInstanceRepository, PgWorkflowHistoryRepository, PgWorkflowDedupRepository, PgBranchTokenRepository, PgJoinStateRepository, PgTaskStore, PgTimerStore, PgWorkflowDefinitionRegistryStore, PgWorkflowRetentionRepository |
Singleton | Nine explicit providers, each built from Optional<DbSchemas>: placed in the schema mapped to workflow when the component supplies DbSchemas (a component that installs DbModule gets it from db.schemas.workflow), unqualified when it does not |
The five repository SPIs, TaskStore<SqlClient>, TimerStore<SqlClient> |
Singleton | Aliases of the concrete instances above, so a concrete lookup and an SPI lookup return the same instance |
PgTaskReservations, TaskReservations<SqlClient> |
Singleton | The task reservations, built from the Pool, the failure mapper and Optional<DbSchemas> like the stores, and bound as the engine’s TaskReservations SPI |
WorkflowTransactionRunner<SqlClient> |
Singleton | The PostgreSQL transaction seam |
CommandWakeups |
Singleton | The node’s CommandNotificationListener (see Executor wake-up), built from the optional Vertx, the optional PgConnectOptions that DbPostgresqlModule supplies, the Pool, the Set<WorkflowExecutorObserver> and the optional WorkflowExecutorConfig (its pollIntervalMs is the reconnect interval); a graph without Vertx or connect options binds a source that never wakes anyone, and its executors poll |
PgWorkflowCommandRepository, WorkflowCommandRepository<SqlClient> |
Singleton | The command repository (reads, execution lock, queue sequence), built from Optional<DbSchemas> like the stores and from the optional WorkflowRecordingConfig (default lock timeout 5000 ms) and from the DurableContextPropagator that captures the context stored with each recorded command; the engine module builds the WorkflowExecutionProbe and the executor from it |
WorkflowInstanceHistoryService |
Singleton | PgWorkflowInstanceHistoryService, built from the scoped PgWorkflowHistoryRepository (paged enriched reads, entry_version and every attribution and decision column), the Pool, the failure mapper and Optional<DbSchemas> (open commands read from workflow_commands); it reads from the pool with no lock. The instance view of query() maps only the five original columns and the stored entry_version, so the enriched columns reach no query() client |
WorkflowInstanceQueryService, WorkflowRetentionService |
Singleton | Services over the same scoped instances (as is the branch query service), so queries, archive and purge follow the placement |
Set<MigrationDefinition> |
@IntoSet |
The workflow declaration from WorkflowMigrationsModule: location classpath:db/migration/workflow, no dependencies |
Compatibility note. The nine concrete store types are now provided by explicit @Provides methods. A component that installs this module and also provides any of them itself sees a duplicate binding; remove its own provider. The @Inject constructors remain for components that do not install this module; they leave the SQL unqualified.
Idempotency And Deduplication
Section titled “Idempotency And Deduplication”Workflow operations require caller-supplied idempotency or dedup keys where duplicate delivery is possible:
| Operation | Scope | Key |
|---|---|---|
| Start | Start scope and definition id | Start idempotency key |
| Signal | Workflow instance | Signal dedup key |
| Task completion | Task id | Task completion idempotency key; a committed duplicate is looked up without a lock and returns its earlier result before any payload check |
| Task reassignment | Task id | Task reassignment idempotency key |
| Branch dispatch | Workflow and branch identity | Generated dispatch key |
Matching retries resolve as success. Reusing a key for a different logical command fails as a conflict.
Crash Safety
Section titled “Crash Safety”The engine relies on Postgres transactions, optimistic concurrency, append-only history, and transactional side-effect recording.
Important guarantees:
- A workflow transition and its side-effect records commit or roll back together.
- Waiting workflows resume from the persisted snapshot.
- Signals and task actions are deduplicated.
- Timers are commands of the executor and survive restarts with the command log.
- Outbox relay retries may redeliver messages; workflow signal and task APIs are designed for idempotent callers.
The engine does not depend on in-memory workflow state for correctness.
Lock-Order Requirements
Section titled “Lock-Order Requirements”Operations that touch multiple workflow tables follow a fixed lock order to avoid deadlocks with the timer firing executor and the signal claim path:
execution lock (instance) → workflow_timers → workflow_tasks → workflow_branch_tokens → workflow_instances → workflow_command_queuesThe execution lock is an exclusive, transaction-scoped PostgreSQL advisory lock in the two-integer form, derived from the
instance id in a namespace of its own; it never collides with the definition locks, which are single-bigint. Every
transaction that changes an instance takes it first (PgWorkflowCommandRepository.lockInstance), so one transaction at a
time changes an instance and appends to its history. The wait applies workflow.recording.lockTimeoutMs (default 5000, at
least 1) to that one acquisition and restores the caller’s own lock_timeout in the same statement, so it never leaks into
the rest of the caller’s transaction. A timeout (55P03) or a deadlock (40P01) fails the call with
WorkflowPersistenceException with retryable() true, and PostgreSQL rolls the caller’s transaction back.
tryLockInstance takes the lock without waiting. nextQueueSequence allocates from workflow_command_queues under the
lock, creating the queue row on first touch (the first value is 1); a rolled-back transaction gives its value back.
Reversing any segment of this chain deadlocks against an already-running firing executor or a concurrent signal claim. All engine code paths — fire, signal, cancel, complete, supersede — follow it.
Integrator rule: do not write custom transactions that lock workflow tables in a different order. Prefer the public workflow APIs or the provided transactional services. When extending storage behavior, preserve the established ordering across timers, tasks, branch state, and workflow instances.
Side Effects
Section titled “Side Effects”WorkflowPostgresqlModule runs installed WorkflowSideEffectRecorder<SqlClient> bindings during transitions. Recorders are responsible for durable handoff only.
Common side-effect modules:
| Side effect | Module |
|---|---|
| Service dispatch outbox entries | workflow-services |
| External workflow events | workflow-events |
Missing required recorders fail the transition. Optional event recording may be absent unless the registered workflows require reminders.
Node Roles
Section titled “Node Roles”Set workflow.executor.enabled=false for a node that only records. The adapter then contributes no workflow-executor deployment, so the node claims no command, and its startup writes no definition row and needs no workflow class or vertique-workflow-schema module; it still starts, signals, completes and reassigns tasks, cancels, migrates, queries (the contract-aware query(id, contractName) reads the contract_name of the pinned workflow_definitions row in its transaction through PgWorkflowDefinitionRegistryStore.pinnedContract) and activates, deprecates and retires versions. A node with the default true runs the commands any node recorded and requires the schema module’s producer at startup.
Queries And Management
Section titled “Queries And Management”The module exposes query services for workflow instances and branch state. These are read-model APIs for management screens and operational tools.
Management REST endpoints are intentionally separate from this persistence module. A REST management module can layer on top of the query services and public operation APIs without coupling HTTP concerns into the engine.
Durable Context Propagation Seams
Section titled “Durable Context Propagation Seams”This module implements the workflow-side durable context propagation surfaces for timers, branches, and instance-level fill on carrier rows. The portable half of the base-wins/instance-fill model — the WorkflowContextBinder binder-row seam used by signal/cancel/retry/migrate/instance-owned task and timer drives — lives in vertique-workflow-engine, documented in dev.vertique:vertique-workflow-engine.
Branch create: WorkflowEngine.handleForkNode (in vertique-workflow-engine) captures the current durable context into workflow_branch_tokens.metadata under the "WORKFLOW" boundary. Metadata is written once and never updated.
Executor branch drive: a branch-addressed signal applied by the executor goes through BranchTransitionEngine.driveExecutorBranchTransitions, which hands the document it would bind (the explicit-carrier override, else token.metadata()) to the binder’s executor entry. That entry strips the identity-snapshot namespace from it and refuses the drive, failing the segment transiently, when a SecurityContext or a deferred-execution origin is already bound. The executor never binds an identity; requester drives keep the behavior below.
Branch drive: BranchTransitionEngine.driveBranchTransitions binds token.metadata() at the start of every per-branch drive via InboundExecutionContextScope.installDurable(metadata, DispatchBoundary.WORKFLOW). The bind covers timer-step, signal-step, service-dispatch, and decision step types and closes on Future.eventually(...). The substrate helper wraps the authoritative DurableContextPropagator.bindFrom(...) — every registered durable type absent from token.metadata() is cleared for the drive’s scope so a downstream mergeCaptured(...) (e.g. the timer metadata a timer command records) cannot pick up ambient context that did not cross the workflow boundary. After the durable bind, registered InboundContextInitializers run (notably the correlation seeder), so a branch whose metadata carries no encoded CorrelationContext still gets one bound for the duration of the drive. Empty metadata still triggers the bind. Production reaches branch-drive on a duplicated context: dispatcher entry points (event bus, Kafka per-record, cron callback, service-method invoker) are all duplicates, and vertx-sql-client 5.x preserves the caller’s duplicate through pool.withTransaction(tx -> …). installDurable propagates the strict bindFrom invariant — IllegalStateException on a non-duplicated Vert.x context — so tests that call into the engine directly from a JUnit thread must first switch to a duplicate.
driveBranchTransitions accepts an additional @Nullable DurableMetadata effectiveBaseOverride parameter. When non-null — as supplied by a branch-targeted explicit signal carrier — the drive binds that already-merged effective document instead of raw token.metadata(). This is the seam every branch-owned drive routes through under the bind-once routing rule: it is never additionally wrapped by the instance-path WorkflowContextBinder, so a branch-owned drive is bound exactly once.
Timer metadata: a timer recorded by a segment stores the durable context of that drive in workflow_timers.metadata, signed for the workflow-timer carrier (WorkflowTimerCarriers in this module, the same kind and address earlier releases used) and without the identity-snapshot namespace; the timer’s command stores no context. The TIMER segment that fires the timer binds the metadata as an executor drive, which strips the namespace from metadata written by earlier releases too.
Metadata immutability: Once written at fork, workflow_branch_tokens.metadata is never overwritten. Demotion to retry-scheduled or terminal failure preserves the original value.
Invariants & Gotchas
Section titled “Invariants & Gotchas”- Branch tables have no FK to workflow_instances.
workflow_tasksandworkflow_timerscarry nullablebranch_token_id/fork_step_id/branch_idcolumns but no FK constraint, by design — adding one would invert the established lock order. - Dispatch dedup lives in the engine, not the recorder. Branch service-dispatch idempotency is enforced inside the persistence engine before the recorder runs, so the
workflow-servicesboundary stays generic. - Stranded re-enqueue is detected by row count. Timer-store mutations are guarded by
status = 'SCHEDULED'and fail whenrowCount = 0, rolling back the transaction rather than silently re-enqueuing into the wrong state. - Late callbacks are swallowed deliberately. Task / timer callbacks carrying a
branchTokenIdfrom a superseded branch are recorded asBRANCH_LATE_CALLBACK_IGNOREDhistory entries and return without raising — preventing zombie callbacks from corrupting later state. - A task is completed in two transactions.
taskCompletedwrites the dedup row, theCOMPLETIONcommand and theOPENtoCOMPLETINGreservation under the execution lock; the executor’s segment completes the task (markCompleted), or rejects the command and reopens the task. A reservation owner is never cleared except by that reopening, and aCOMPLETINGtask whose command is lost would refuse every completion and reassignment, so a command repository that settles aCOMPLETION(an ended instance, a failure disposition) settles the reservation in the same transaction. - Signal recording claims the dedup key last.
signal()looks up a committed duplicate, reads the instance and the pinned row’s declared names without the execution lock, takes the lock, raises the instance’sopen_signalscount with one conditional update and only then claims the dedup key and inserts theSIGNALcommand, so a refusal (cap, undeclared name, ended instance) leaves no dedup row.cancel()still usesfindById+claimOrResolveSignalto honor theworkflow_timers-before-workflow_instanceslock-order invariant. - Archived rows are filtered by default.
findFiltered/findByFilterexcludearchived_at IS NOT NULLrows unlessWorkflowFilter.includeArchived = true. - Retention tries the execution lock and skips busy instances. Each archive or purge batch is one transaction. It picks its candidates with
FOR UPDATE SKIP LOCKED(an unarchived instance of the status past the cutoff, or an archived one past the purge cutoff;FAILEDis a status like the others, soarchiveFailedBeforearchives failed instances, including aPENDINGinstance whose start was dead-lettered) and triespg_try_advisory_xact_lockon each candidate’s execution lock (the same two integers as the executors, derived in SQL from the instance id) without waiting. An instance whose lock is held is skipped and removed by a later run; it still counts against the batch limit. A second statement then reads eligibility again, with a snapshot taken after the locks were won: an instance that is no longer eligible, or that has anyPENDING,DEFERREDorCLAIMEDcommand, is left alone. ARETRYrecorded on an archive-eligible failed instance therefore keeps it until the retry has run. The commands, queue row and history of an archived or purged instance go with it (foreign-key cascade) under the lock. - Archive and purge do not share a clock.
archiveBeforestampsarchived_atfrom the database clock, whilepurgeArchivedBeforecompares it against the cutoff you supply. Since your application drives the sweep cadence, a purge issued immediately after an archive with a cutoff read from your own clock may skip rows it just archived, whenever the database clock is running ahead. Use a cutoff meaningfully in the past — real retention horizons of hours or days make this invisible.
Dependencies
Section titled “Dependencies”- workflow-engine - portable orchestration runtime:
WorkflowEngine, the 8 internal repository SPIs (includingTaskReservations),WorkflowTransactionRunner,RecorderRouter,WorkflowReminderComposeValidator,WorkflowEngineModule. This module includesWorkflowEngineModule. - workflow-core - public workflow APIs, plans, tasks, timers, and side-effect SPIs.
- db-postgresql - Postgres pool,
PgDbExceptionMapper,PgSqlRepositorybase, andPgTableNames, which qualifies every table reference. - db-core -
DbSchemas,DbSchemasModuleandMigrationDefinition(through db-postgresql). - db-flyway -
FlywayMigrationRunnerapplies the packaged migration. Declared as a dependency, but no type of this module references Flyway, so the application supplies and wires the runner. - vertx-sql-client - SQL connection, pool, and transaction types.
- jackson-databind - workflow state and history payload serialization.