Skip to content

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.

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.

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 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.

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.

Everything is plain rows you can inspect with psql:

TableOne row per
pw_workflow_runsworkflow run: status, input, result, error, attempt budget, lease
pw_workflow_stepsdurable activity step: which job backs it, its memoized result
pw_activity_jobsactivity execution: the queue workers lease from
pw_workflow_timersctx.Sleep deadline, persisted so replays don’t restart the clock
pw_workflow_signalsdelivered signal payload, consumed in FIFO order per name
pw_workflow_signal_waitsa WaitForSignal that parked the workflow, pending or completed
pw_workflow_failure_hooksregistered ctx.OnFailure compensation and its outcome
pw_schema_migrationsapplied 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.

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.

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.