PgFlow is a native Elixir implementation of pgflow - a PostgreSQL-based workflow engine built on pgmq. It shares the same database schema and SQL functions as the TypeScript/Deno version, allowing workers from both runtimes to process the same flows side-by-side.
Design Principles
- Single Source of Truth - All workflow definitions, state, and queues live in Postgres
- DAGs Only - Directed acyclic graphs with explicit dependencies, no cycles or conditional edges
- JSON Serializable - All inputs/outputs must be JSON-compatible
- Stateless Workers - Workers poll and execute; database handles orchestration
Core Concepts
PostgreSQL
-------------------------------------------------
pgflow.* pgmq.q_* pgflow.step_tasks
(flow schema) (queues) (task state)
-------------------------------------------------
|
v
Elixir/OTP
-------------------------------------------------
PgFlow.Supervisor --> Worker.Server --> Signal.*
-------------------------------------------------Compatibility: Same pgflow.* schema, same core SQL functions, same pgmq message format. Elixir and TypeScript workers can run side-by-side processing the same flows when pinned to the same upstream SHA (PgFlow.upstream_sha/0). See UPSTREAM_COMPATIBILITY.md.
Elixir extensions: Adds helper SQL functions for OTP worker lifecycle. Backward-compatible - TypeScript workers can safely ignore them. Queue identity, four-argument claim, and startup compilation follow upstream core V02; helpers V05 reconcile recovery and pruning with persisted (queue_name, message_id) routes.
Schema verification: mix pgflow.check_schema validates installed core/helpers versions, function signatures, queue constraints, and the eight-field step_task_record before production deploys.
DSL
Flows
Multi-step DAGs with explicit dependencies:
defmodule MyApp.Flows.ProcessOrder do
use PgFlow.Flow
@flow queue: :process_order, max_attempts: 3, timeout: 60
step :validate do
fn input, _ctx -> %{valid: true} end
end
step :charge, depends_on: [:validate] do
fn deps, _ctx -> %{charged: true} end
end
map :notify_parties, depends_on: [:charge] do
fn item, _ctx -> %{notified: item} end
end
endJobs
Single-step flows with a simpler API:
defmodule MyApp.Jobs.SendEmail do
use PgFlow.Job
@job queue: :send_email, max_attempts: 3, timeout: 60
perform :deliver do
fn input, _ctx -> Mailer.send(input["to"], input["body"]) end
end
endJobs compile to a single-step PgFlow.Flow.Definition with flow_type: :job. Workers process them identically to flows.
Cron Scheduling
Both flows and jobs support cron: for pg_cron scheduling:
@flow queue: :daily_report, cron: [schedule: "0 9 * * *", input: %{"type" => "daily"}]
@job queue: :cleanup, cron: [schedule: "@hourly"]Supervision Tree
PgFlow.Supervisor (rest_for_one)
|-- Task.Supervisor (PgFlow.TaskSupervisor)
|-- Signal.Notify (only when signal_strategy: :notify)
|-- WorkerSupervisor (dynamic)
| |-- Worker.Server (flow: process_order)
| |-- Worker.Server (job: send_email)
| +-- ...
|-- StalledTaskRecovery
+-- FlowStarter (GenServer: registers flows/jobs, starts workers, retries transient failures)FlowStarter is a GenServer child started after WorkerSupervisor is alive. It registers each flow/job module via FlowRegistry, starts a Worker.Server for each, and (when using :notify strategy) enables pgmq notifications and registers workers with Signal.Notify.
Module Dependency Graph
PgFlow.Flow (DSL) PgFlow.Job (DSL)
| |
v v
FlowRegistry ----------+
| |
v v
WorkerSupervisor --> Worker.Server
|
+-------------+-------------+
v v v
Queries.* Executor Signal.*
|
v
PostgreSQL (pgflow.*, pgmq.*)Query Modules
PgFlow.Queries.Helpers — UUID parsing, RPC execution, row conversion
PgFlow.Queries.Flows — start_flow, complete_task, fail_task, read, start_tasks, etc.
PgFlow.Queries.Workers — register_worker, mark_worker_stopped
PgFlow.Queries.Pgmq — enable_notify_insert (deferred trigger, fast/slow path), disable_notify_insert, get_pgmq_versionClient API
PgFlow.Client provides the public interface for starting flows programmatically:
{:ok, run_id} = PgFlow.Client.start_flow(:process_order, %{"order_id" => 123})
{:ok, run} = PgFlow.Client.start_flow_sync(:process_order, %{"order_id" => 123}, timeout: 30_000)
{:ok, run} = PgFlow.Client.get_run(run_id)
{:ok, run} = PgFlow.Client.get_run_with_states(run_id)Resolves repo from persistent_term (set by supervisor) or application env.
Signal Strategies
Workers detect new queue messages via two strategies:
Polling (Default)
Worker PostgreSQL
| |
|---- pgmq.read() ----------->|
|<--- messages[] --------------|
| |
| (process tasks...) |
| |
|---- pgmq.read() ----------->| <-- jittered backoff: 50ms -> 5s
|<--- [] ----------------------|
| |
+-- wait (backoff) ----------->|Adaptive jittered exponential backoff:
- Active queue: polls at
min_poll_interval(default: 1s) - Idle queue: backs off to
max_poll_interval(default: 5s) - Uses decorrelated jitter to prevent thundering herd
Notify (LISTEN/NOTIFY)
More efficient than polling - workers sleep until PostgreSQL pushes a notification. pgmq 1.8.0+ includes built-in LISTEN/NOTIFY support via enable_notify_insert(), which creates a trigger that fires on queue inserts. This pairs naturally with Postgrex.Notifications for native Elixir pubsub.
Worker Signal.Notify PostgreSQL
| | |
|--> register ------->| |
| |-- LISTEN ---------->|
| | |
| | |<-- INSERT to q_*
| |<- NOTIFY -----------|
|<-- :poll_now -------| |
| | |
|---- pgmq.read() ------------------------->|pgmq 1.8.0+ features:
pgmq.enable_notify_insert(queue_name, throttle_ms)registers the queue's row in the UNLOGGEDpgmq.notify_insert_throttletable and anAFTER INSERTconstraint trigger onpgmq.q_<queue>. pgmq declares the triggerDEFERRABLEwith noINITIALLYclause, so Postgres makes itINITIALLY IMMEDIATE: it runs at each insert.- Single
Postgrex.Notificationsconnection shared across all queues with auto-reconnect
Trigger deferred to commit:
The trigger function updates the queue's throttle row on every insert. Run immediately, that row lock is held until the inserting transaction ends, so any long transaction that enqueues (for example a batch import that starts flows) blocks every other producer on the same queue until it commits. PgFlow.Queries.Pgmq.enable_notify_insert/4, which FlowStarter calls for each flow queue on every boot in :notify mode, therefore leaves the trigger, under the same name, as DEFERRABLE INITIALLY DEFERRED with the queue name as the trigger argument. This stays within pgmq's own contract: every pgmq release since 1.7.0 creates the trigger as a DEFERRABLE constraint trigger, so any caller could already defer it with SET CONSTRAINTS; pgflow makes deferred the default. As a result:
- The trigger runs at COMMIT, so a producer holds the throttle row lock only while its own commit runs, never across the body of its transaction. Other producers' inserts never wait on it; concurrent commits on one queue wait on each other only for the length of a commit.
- Each committing producer evaluates the throttle itself, so a second producer that commits while a first transaction is still open still sends its own notification. (A rolled-back transaction sends no notification either way: NOTIFY is transactional, and the rollback also undoes an immediate trigger's throttle update.)
pgmq.disable_notify_insert/1still drops it, since the name is unchanged.mix pgflow.check_schemawarns (without failing) about any notify trigger on a flow queue that is not deferred, such as one created before this change, or one thatALTER EXTENSION pgmq UPDATErebuilt, and not yet rebooted. It also warns about notify triggers whose queue has no throttle row, and about partitioned queues that cannot notify (see the caveats below). It checks only thelower(flow_slug)queues pgflow manages, not custom stepqueue_names.
How a boot installs it:
- Fast path. It first reads
pg_triggerandpgmq.notify_insert_throttle. When the queue already has the deferred trigger (deferrable, initially deferred, callingpgmq.notify_queue_listenerswith the queue name), it only upserts the throttle row: it updatesthrottle_interval_mswhen that differs fromnotify_throttle_ms, and inserts the row when it is missing. No DDL runs and no queue table lock is taken, so steady-state boots never wait on, or block, producers and readers. - Slow path. Otherwise (trigger missing, immediate, or different) it runs one transaction that sets
lock_timeouttonotify_lock_timeout_ms(default 2000), takes a per-queue advisory lock (pg_advisory_xact_lock(hashtextextended('pgflow:notify:' || queue, 0))) so installers on several nodes run one at a time, re-checks the trigger under that lock (another node may have finished), then callspgmq.enable_notify_insertand recreates the trigger deferred. pgmq's function drops and recreates the trigger, which needs ACCESS EXCLUSIVE onpgmq.q_<queue>. If an open transaction holds a conflicting lock longer than the timeout, the install fails withlock_not_available, whichFlowStartertreats as transient and retries with backoff, instead of queueing every later send and read on the queue behind the pending lock. - Degraded swap. If pgmq's function succeeds but recreating the trigger deferred fails (for example on missing privileges or a renamed pgmq object), only that swap is rolled back to a savepoint. pgmq's immediate trigger stays, so notifications still work; a warning is logged and the boot continues.
mix pgflow.check_schemathen reports the immediate trigger. - Not inside a transaction.
enable_notify_insert/4returns{:error, :in_transaction}when called inside a transaction on the repo. There, pgmq's DROP TRIGGER would hold ACCESS EXCLUSIVE until the caller commits, and dropping the deferred trigger after the caller's own send in that transaction makes Postgres skip that send's pending trigger event, so its notification is lost.FlowStarternever calls it inside a transaction. - Opt-out.
notify_trigger: :pgmq_defaultcalls onlypgmq.enable_notify_insert, which keeps pgmq's immediate trigger, and turns off the deferred-trigger check-schema warning (pass--notify-trigger pgmq_defaultto the mix task). Use one value on every node: a:pgmq_defaultnode, or a pgflow release before 0.6 in:notifymode, puts pgmq's immediate trigger back on its boot, and the next:deferredboot defers it again.
pgmq compatibility caveats:
- Same-transaction drop, purge, or ALTER. Postgres refuses to DROP, TRUNCATE, or ALTER a table that has pending trigger events. A transaction that sends to a queue (
pgmq.send,send_batch,pgflow.start_flow) and then runspgmq.drop_queue,pgmq.purge_queue, or anALTER TABLEon that queue fails withcannot DROP TABLE "q_<queue>" because it has pending trigger events(SQLSTATE 55006). The immediate trigger has no pending events, so it never hits this. RunSET CONSTRAINTS pgmq.trigger_notify_queue_insert_listeners IMMEDIATEearlier in that transaction to fire the pending events;PgFlow.Queries.Pgmq.flush_notify_events/1does that only when the constraint exists, sinceSET CONSTRAINTSon a name that matches no constraint raises (pgmq releases without notify, such as 1.5.1). pgflow's ownPgFlow.delete_flow/1(PgFlow.Queries.Flows.delete_flow/2, including flow re-registration that deletes the old definition) and the testpgflow_tests.reset_db()helper flush first. The immediate mode then lasts until that transaction ends. - REPEATABLE READ and SERIALIZABLE producers. The trigger's throttle UPDATE runs against the transaction snapshot. If another transaction notified on the same queue after that snapshot was taken, the UPDATE hits a concurrently updated row and the COMMIT fails with SQLSTATE 40001 (
could not serialize access due to concurrent update). An immediate trigger has the same failure, but only for notifications that land between the producer's insert and its commit; deferred, the window covers the whole transaction, and on a busy queue the row changes every throttle interval. Retry such producers on 40001 as usual, keep them short, or usenotify_trigger: :pgmq_default. READ COMMITTED producers are unaffected. - Extension updates.
ALTER EXTENSION pgmq UPDATEto 1.13.1 drops and recreates every notify trigger listed inpgmq.notify_insert_throttleas pgmq's immediate trigger; later upgrade scripts that rebuild triggers are likely to do the same. Notifications keep working, but producers hold the row lock again. The next:deferredboot sees the immediate trigger, takes the slow path, and defers it again;mix pgflow.check_schemawarns until then. - Errors at COMMIT and per-row memory. Any error inside the trigger, such as a deadlock between two transactions that insert into the same two queues in opposite order, a
statement_timeoutorlock_timeouthit during commit, or a droppedpgmq.notify_insert_throttletable, now fails the producer's COMMIT and rolls back the whole transaction instead of failing the INSERT that fired it. Deferred events also stay in backend memory until commit, one per inserted row, so a single transaction that inserts very many messages carries that queue and runs the throttle check once per row at commit. A transaction that runsSET CONSTRAINTS ALL IMMEDIATE(or names this trigger) moves it back to insert time and reintroduces the blocking for that transaction. - Throttle rows lost on crash or Neon restart. pgmq creates
pgmq.notify_insert_throttleUNLOGGED, so Postgres empties it during crash recovery, and Neon empties unlogged tables whenever a compute suspends or restarts. The trigger only notifies after it updates the queue's row, so a queue without its row sends no NOTIFY and workers fall back to the 30s poll. This affects pgmq's immediate trigger too. pgflow restores missing rows without DDL in two places: the boot fast path (above), andSignal.Notify, which every 30s runsPgFlow.Queries.Pgmq.restore_notify_throttle_rows/3for the queues its workers listen on. That check is one statement that writes nothing while every row is present, and it logs a warning when it restores a row.Signal.Notifyruns it rather than a reconnect hook or the worker fallback poll:Postgrex.Notificationsreconnects without telling its caller, and one statement per node covers every served queue. - Partitioned queues. On a partitioned queue table (
pgmq.create_partitioned) Postgres clones the trigger onto each partition, whereTG_TABLE_NAMEnames the partition. pgmq releases before 1.13.1 derive the queue name fromTG_TABLE_NAME, find no throttle row, and never notify, with pgmq's trigger or pgflow's. 1.13.1 reads the queue name from the trigger argument, which pgflow's trigger always passes. pgflow creates queues withpgmq.createonly, butmix pgflow.check_schemawarns when a managed flow queue is partitioned and the installed listener ignores the argument.
Throttle window:
notify_throttle_ms(default 250ms) is the minimum interval between notifications for one queue. At commit, the trigger's UPDATE succeeds, and NOTIFY is sent, only when the window since the last notification has elapsed;0notifies on every committed insert.- A commit that lands inside the window sends no notification. Its messages are picked up by whichever worker read follows: the read woken by the earlier notification if it runs after this commit, a poll-after-completion, or the fallback poll.
Fallback timer with reset:
- 30s fallback poll as safety net for missed notifications, including commits suppressed by the throttle window
- When NOTIFY arrives, fallback timer resets to 30s from now
- Reduces unnecessary DB round-trips when pubsub is working
- If NOTIFY stops (network issues, etc.), fallback fires after 30s of silence
Hybrid wake-up model:
NOTIFY and polling serve different purposes:
- NOTIFY - Wakes idle workers when new flows start externally
- Poll-after-completion - When a step with downstream dependents completes, the worker polls immediately to pick up enqueued continuation tasks (bypasses pgmq's throttle window which can suppress rapid-fire notifications)
- No poll for terminal steps - Steps without dependents skip the immediate poll
This avoids reliance on NOTIFY for task continuation while still benefiting from instant wake-ups for new work.
Worker-to-Database Mapping
| Worker State | Database Tables |
|---|---|
| worker_id | pgflow.workers.id |
| queue_name | pgmq.q_{lower(flow_slug)} |
| active_tasks | pgflow.step_tasks (status: 'started') |
Task Processing
Both signal strategies use the same two-phase protocol once triggered:
Queries.Flows.read()- Reserve messages from pgmq (makes them invisible)Queries.Flows.start_tasks()- Create step_tasks records, get task details with deps- Execute handler in
Task.Supervisor- Crash isolation per task Queries.Flows.complete_task()orQueries.Flows.fail_task()- Update state, trigger downstream
The signal strategy only determines when to poll (timer backoff vs NOTIFY wake-up).
Completion-triggered polling: When a step with downstream dependents completes, an immediate poll is scheduled. This ensures continuation tasks are picked up promptly regardless of NOTIFY throttling. Terminal steps (no dependents) skip the poll.
Skipped steps are decided in SQL (cascade_resolve_conditions, _cascade_force_skip_steps / fail_task). Workers never enqueue them. Elixir emits [:pgflow, :step, :skipped] after start_flow, complete_task, and fail_task by reading step_states.
Workers do not re-check for skips before dispatching: start_tasks returns a task only while its step is still started, and every skip path archives that step's queued/started messages in the same transaction. Delivery stays at-least-once, so a handler can still be mid-flight when the skip commits — handlers must be idempotent (see PgFlow.Worker.Server docs).
Task Timeout Enforcement
Each dispatched task gets an OTP-native timeout via Process.send_after(self(), {:task_timeout, ref}, timeout_ms).
Timeout resolution (matches DB schema's per-flow/per-step configuration):
- Step-level timeout (from DSL:
step :name, timeout: X) - Flow-level timeout (from DSL:
@flow timeout: X) - Default: 60 seconds
On timeout: Task.Supervisor.terminate_child/2 sends :shutdown, then fail_task is called.
On completion: Process.cancel_timer cancels the pending timeout.
Worker Lifecycle
States managed by PgFlow.Worker.Lifecycle:
:created -> :starting -> :running -> :stopping -> :stopped:created- Worker instantiated, not yet started:starting- Initializing (registering with DB, compiling flow):running- Actively processing messages:stopping- Draining active tasks, releasing resources:stopped- Terminal state
Stalled Task Recovery
StalledTaskRecovery GenServer sweeps every recovery_interval (default: 15s) for tasks stuck in started status beyond stale_threshold (default: 60s). Resets them to queued and makes their pgmq messages immediately visible via pgflow.set_vt_batch. After three requeues, the next sweep archives the message and stamps permanently_stalled_at. A separate query then calls pgflow.escalate_permanently_stalled(), which applies the step's when_exhausted policy through fail_task. Alert on stamped tasks whose run remains started, which indicates escalation failed.
OTP Integration
PgFlow leverages OTP primitives for fault tolerance:
- Supervision trees - Automatic restart on crashes (rest_for_one strategy)
- GenServer lifecycle - Worker states: created -> starting -> running -> stopping -> stopped
- Task.Supervisor - Crash isolation per task execution
- Process monitoring - Automatic cleanup when workers die
- Independent worker heartbeats - Persisted every 10 seconds by default, including while polling and notification workers are idle
- Process scheduling -
Process.send_afterfor adaptive poll timing, replacing Deno's blockingpgmq.read_with_poll()with non-blockingpgmq.read()— no DB connections held idle, backoff timers are cancellable by NOTIFY or completion events - Postgrex.Notifications - Native PostgreSQL LISTEN/NOTIFY support
The TypeScript implementation runs long-lived Edge Worker loops; upstream waits inside PostgreSQL with pgmq.read_with_poll, holding a connection for up to maxPollSeconds. Elixir workers are stateful GenServers where OTP itself is the scheduler, enabling adaptive backoff and instant wake-ups.
Configuration
{PgFlow.Supervisor,
repo: MyApp.Repo,
flows: [MyApp.Flows.OrderFlow],
jobs: [MyApp.Jobs.SendEmail],
signal_strategy: :notify, # :polling or :notify
heartbeat_interval: 10_000, # persisted worker liveness (max 20_000ms)
notify_throttle_ms: 250, # min ms between NOTIFYs per queue (0 = every commit)
notify_trigger: :deferred, # :deferred (runs at commit) or :pgmq_default (pgmq's immediate trigger)
notify_lock_timeout_ms: 2_000, # max wait for the queue table lock when installing the trigger
min_poll_interval: 1_000, # ms (polling strategy)
max_poll_interval: 5_000, # ms (polling strategy)
notify_fallback_interval: 30_000, # ms (safety net for notify)
max_concurrency: 10, # parallel tasks per worker (global default)
batch_size: 10, # messages per poll (global default)
worker_options: %{ # per flow/job overrides for max_concurrency and batch_size
MyApp.Flows.Import => [max_concurrency: 4]
},
recovery_interval: 15_000, # stalled task sweep (ms)
stale_threshold: 60, # seconds before task is stale
worker_name: "my-app", # optional human-readable prefix for logs
attach_default_logger: false} # attach telemetry logger (default: false)Note: Reads reserve messages for 5s; the claim then extends visibility to the step's execution timeout, derived from the flow's @flow timeout: (default 30s), not a global config.
Runtime scaling via PgFlow.scale_worker/2 updates the worker GenServer's in-memory limits on the local node only. It does not write to PostgreSQL or propagate to other nodes; supervisor restart or crash recovery restores the resolved configuration from global options, worker_options, and any start_worker/2 overrides.
Key SQL Functions
Shared with the TypeScript implementation:
| Function | Purpose |
|---|---|
pgflow.create_flow | Register flow with retry/timeout settings |
pgflow.add_step | Add step with dependencies |
pgflow.start_flow | Create run, enqueue root step tasks |
pgflow.start_tasks | Create step_tasks from reserved messages |
pgflow.complete_task | Mark done, trigger start_ready_steps |
pgflow.fail_task | Record error, retry via visibility timeout |
pgflow.start_ready_steps | Start steps whose deps are complete |
pgflow.maybe_complete_run | Complete run when all steps done |
Elixir-specific additions:
| Function | Purpose |
|---|---|
pgflow.flow_exists | Check if flow is compiled in DB |
pgflow.get_flow_input | Retrieve input for a flow run |
pgflow.get_step_output | Retrieve output of a completed step |
pgflow.recover_stalled_tasks | Reset stalled tasks to queued |
pgflow.escalate_permanently_stalled | Apply exhausted policy to stamped tasks |
pgflow.prune_data_older_than | Clean up old run data |
pgflow.analyze_and_create_flow | Explicit legacy definition compilation; destructive runtime upsert is deprecated |
pgflow.ensure_flow_compiled | Compile or verify worker definition under the upstream shape/advisory-lock contract |
pgflow.track_worker_function | Register elixir:<Module> with start mode process |
pgflow.is_valid_queue_name | Validate physical queue identity, derived from lower(flow_slug) |
pgmq functions used:
| Function | Purpose |
|---|---|
pgmq.read | Non-blocking message read |
pgmq.enable_notify_insert | Enable LISTEN/NOTIFY on queue inserts |
pgmq.disable_notify_insert | Disable LISTEN/NOTIFY on queue |
Skip Broadcast Pipelines
Skipped steps are announced via two independent pipelines:
SQL Pipeline (realtime.send)
When a step is skipped in PostgreSQL (_cascade_force_skip_steps, cascade_resolve_conditions, or fail_task),
the database optionally broadcasts via realtime.send() (if configured). This is the TypeScript/Deno
integration point for real-time dashboard updates. (Entry points: pgflow.start_flow/2, pgflow.complete_task/3, pgflow.fail_task/3.)
Elixir Telemetry Pipeline
Elixir workers independently discover skips by polling step_states after each complete_task/fail_task,
then emit [:pgflow, :step, :skipped] telemetry events. This is the Elixir runtime's mechanism.
Key distinction: These pipelines are separate. A skip committed in SQL is not automatically announced to Elixir — the worker must poll to discover it. Conversely, the Elixir worker's telemetry emissions are not sent back to SQL (the database remains the source of truth).
Delivery Contract (Telemetry)
Per-emitter exactly-once — Each Elixir worker maintains a per-run MapSet of already-emitted step slugs
(delta emission via per-worker seen-set). This guarantees that within a single worker's observation of a run,
each skip is announced exactly once.
Caveats:
- This is a per-worker guarantee, not global. Two workers processing the same run each sweep it independently,
so a skip can be announced once per worker that touches the run, and once by
Client.start_flow/2for skips decided at run start. PgFlow.LiveClienthandles this structurally by treating[:pgflow, :step, :skipped]as an idempotent state transition (not a counter), using{run_id, step_slug}as the key.
For authoritative per-emitter-exactly-once semantics, see PgFlow.Telemetry.emit_skipped_steps/4.
Task Delivery Guarantee (at-least-once)
Task delivery is at-least-once, even with skipping. See PgFlow.Worker.Server moduledoc,
"Delivery Guarantee (at-least-once)" section.
Key points:
- A handler may run for a step the database later reports as skipped, because the step can be skipped
microseconds after
start_taskshands out a task. - Handlers must be idempotent and not treat "my step completed" as guaranteed by the handler having run.
- Side effects that must not happen for a skipped step need their own guard (idempotency key, conditional write, or check inside a transaction) rather than relying on the worker.
Telemetry
PgFlow emits telemetry events for monitoring. Attach handlers via :telemetry.attach_many/4 or set attach_default_logger: true for built-in logging.
Events:
[:pgflow, :flow, :started | :completed | :failed][:pgflow, :step, :started | :completed | :failed | :skipped][:pgflow, :task, :started | :completed | :failed][:pgflow, :worker, :started | :stopped | :poll | :error]