How it works
PgWorkflows turns ordinary async C# methods into durable workflows by persisting every step’s outcome in Postgres. There is no separate server and there are no event-history replay rules to learn. Your app and a handful of tables do all the work.
The mental model
Section titled “The mental model”A workflow is a plain async method. Every ctx.* call inside it is a durable step: the
step’s result is written to Postgres before the workflow moves on. If the process dies
(a crash, a deploy), another worker picks the run up and calls the method again from
the top. Steps that already completed return their stored results instantly instead of
re-running, so execution fast-forwards to where it left off and continues.
That replay is the whole trick. You write straight-line code with ifs, loops, and
try/catch; PgWorkflows makes it resumable by remembering what each step returned.
The workflow must reach its ctx.* calls in a stable order. Branches and loops are fine when they
depend on workflow input or stored activity results. Do not let current time, randomness, mutable
global state, environment changes, or direct I/O decide which durable call comes next; those values
can change between replays.
Durable steps
Section titled “Durable steps”Each ctx.Activity, ctx.WhenAll branch, ctx.Sleep, and ctx.WaitForSignal is keyed
by its position in the run. Once an outcome is persisted, every replay returns that stored
outcome instead of deliberately scheduling the operation again.
Activities have at-least-once execution semantics. If an external call succeeds but the worker dies before recording its result, the lease eventually expires and another worker can execute the activity again. Make side effects idempotent, for example with a stable business key in the activity input. The low-level delegate-registration API can also expose the stable activity job id for use as an external idempotency key. A saga compensation can undo known completed work, but it does not remove this crash window.
This is why side effects belong in activities. The workflow method itself may re-execute many times across resumes, while activities provide a durable, independently leased boundary whose recorded outcome is memoized.
Workers and leases
Section titled “Workers and leases”Workers poll Postgres for runnable work and claim it in short transactions with
FOR UPDATE SKIP LOCKED, allowing concurrent workers to claim different rows without blocking.
After the transaction, ownership is represented by a logical lease: a token plus an expiry. The
worker heartbeats the expiry forward while it works, and every state write it makes is guarded by
the token.
That guard is what makes crashes recoverable. A worker that dies stops heartbeating, its lease expires, and a peer reclaims the work and resumes from the last recorded step. If the original worker was only frozen and comes back, its token no longer matches and its database writes are rejected. The token fences PgWorkflows state; it cannot fence a stale external API call, which is another reason activities must be idempotent. Scaling out is just running more instances of your app; Postgres is the only coordinator.
Parked runs
Section titled “Parked runs”A workflow that waits on an activity, a timer, or a signal does not hold a thread, a worker slot, or any memory. The run is parked: its row is made invisible until a wake-up condition, and the lease is released. When the activity completes, the timer fires, or the signal arrives, the row becomes visible again and the next free worker resumes the run. Parked runs survive restarts and deploys, and a million sleeping workflows cost you nothing but table rows.
Parking works by throwing an internal control-flow exception that unwinds the workflow
method, so don’t wrap ctx.Sleep, ctx.WaitForSignal, ctx.Activity, or ctx.WhenAll
in a broad catch. If user code swallows the park, the run fails with a clear error
instead of recording a wrong result.
What’s in your database
Section titled “What’s in your database”Everything is plain rows you can inspect with psql:
| Table | One row per |
|---|---|
pw_workflow_runs | workflow run: status, input, result, error, attempt budget, lease |
pw_workflow_steps | durable activity step: which job backs it, its memoized result |
pw_activity_jobs | activity execution: the queue workers lease from |
pw_workflow_timers | ctx.Sleep deadline, persisted so replays don’t restart the clock |
pw_workflow_signals | delivered signal payload, consumed in FIFO order per name |
pw_workflow_signal_waits | a WaitForSignal that parked the workflow, pending or completed |
pw_workflow_failure_hooks | registered ctx.OnFailure compensation and its outcome |
pw_schema_migrations | applied schema version, so upgrades know what to run |
When something looks stuck, select * from pw_workflow_runs where status != 'succeeded'
is a useful starting point. Join the related step, job, timer, signal-wait, and failure-hook rows to
see why a run is pending or failed.
Data lifecycle and sensitive payloads
Section titled “Data lifecycle and sensitive payloads”PgWorkflows stores workflow and activity inputs, results, signal payloads, and errors in Postgres. Do not put secrets or unnecessary personal data in those payloads; database encryption and a read-only dashboard role do not replace application-level redaction and access controls.
There is currently no automatic retention job. If you implement cleanup, delete only terminal runs and use small batches to limit locks, WAL, replication lag, and vacuum pressure. Deleting a workflow run cascades to its steps, hooks, timers, signals, and signal waits, but activity-job rows do not currently cascade and require a separate cleanup policy.
Changing workflow code
Section titled “Changing workflow code”Persisted activity steps, timers, signal waits, and failure hooks are matched by separate sequence numbers within a run. Adding, removing, or reordering durable calls can make an in-flight run reach a stored sequence with different meaning. PgWorkflows does not currently provide patch markers or a versioning API and does not detect every mismatch.
For incompatible changes, register a new durable workflow name such as order-v2, route new starts
to it, and keep order-v1 and its activity contracts available until old runs finish. Compatible
code deployments and restarts are safe; arbitrary control-flow changes to in-flight workflows are
not.