- Docs
- Background work
- Workflows
Workflows
Run durable multi-step workflows that survive restarts, with start, signal, query, and cancel.
Use workflow for multi-step runs (onboarding, payouts, provisioning) that must survive a restart partway through: a crash after step 3 resumes at step 3 instead of starting over. The interface covers run lifecycle: Start a named workflow with JSON-marshalable input, Signal a running channel, Query state into an output value, and Cancel a run. This is not a cron scheduler: use the scheduler for cron-shaped firing, queue plus job for plain background tasks, and saga for multi-step operations with rollback.
Quick start
Section titled “Quick start”WORKFLOW_ADAPTER=memoryWORKFLOW_NAMESPACE=bookingspackage main
import ( "context" "fmt"
workflowmemory "github.com/zenta-dev/zever/adapters/workflow/memory" "github.com/zenta-dev/zever/core/workflow")
func main() { wf, _ := workflowmemory.New(workflow.Options{}) defer wf.Close()
// A workflow name must have a registered step, or Start fails with an unknown-step error. wf.(workflow.StepRegistrar).RegisterStep("booking-confirm", func(_ context.Context, in any) (any, error) { return in, nil })
ctx := context.Background() id, err := wf.Start(ctx, "booking-confirm", map[string]string{"booking_id": "b-1"}, "wf-b-1") fmt.Println(id, err) var state map[string]string fmt.Println(wf.Query(ctx, id, "state", &state), state)}wf-b-1 <nil><nil> map[booking_id:b-1]Register the adapter with workflowmemory.Register() before resolving through the container (c.Workflow()). The only query name is state; any other returns an unknown-query error. On memory, Start runs the registered step inline and finishes the run, so a later Signal or Cancel on it returns a run-completed error; those calls act on runs still in flight (db). Pass an empty workflowID to generate one (run-N on memory); a duplicate ID fails with a duplicate-run error. Signal(ctx, runID, name, value) delivers a value to a running ID. Workflow() resolves lazily, caches per container, and retries after errors; Close() error takes no context. See the container.
Choose a backend
Section titled “Choose a backend”| Adapter | Package | Use when |
|---|---|---|
memory |
adapters/workflow/memory |
Default. Local dev and tests. In-process; a run holds at most 100 pending signals, then Signal returns ErrPendingFull instead of blocking. |
db |
adapters/workflow/db |
Durable DB-backed runs, lease-reclaimed steps. Set dsn to a postgres URL (empty is sqlite). |
postgres |
adapters/workflow/db |
Legacy alias for db. |
memory state dies with the process, so two replicas each see only their own runs and a restart loses all of them; anything that must survive a crash needs db. Engines with host-registered steps expose them through the separate StepRegistrar interface (RegisterStep), so engines without the concept are unaffected. Step input and output must be JSON-marshalable; the output becomes queryable state, so keep payloads small and reference-backed.
Configure the workflow
Section titled “Configure the workflow”WORKFLOW_ADAPTER=memoryWORKFLOW_NAMESPACE=bookingsWORKFLOW_HOSTPORT=127.0.0.1:7233WORKFLOW_DSN=postgres://app:secret@db:5432/appnamespace scopes runs; host_port names a workflow server address when the backend needs one. Durable keys are dsn, table (default workflow_runs), and dedicated_pool. All are optional for memory. Validate only rejects a malformed host_port (it must split as host:port when set); everything else passes to the adapter. A durable workflow shares the container pool for its DSN with every other battery on that DSN (first opener’s max_conns wins); set dedicated_pool: true with a separate workflow database under load. All keys: workflow config reference.
Production checklist
Section titled “Production checklist”- Design every step idempotent now (locks plus idempotency keys for money-moving steps) so a durable backend can replay safely. Cap retries per step and route poison inputs to a dead-letter path.
- Inputs, signals, and step results become queryable state and may cross process boundaries. Keep secrets and PII out and pass references; never log raw option maps (use
config.RedactedServices). - The container closes only resolved services and joins errors via
errors.Join; panics become*ClosePanicError.
Troubleshooting
Section titled “Troubleshooting”ErrPendingFull on signal
The memory run’s pending-signal buffer (100) is full. Drain or cancel stuck runs, cap per-step retries, and send poison inputs to a dead-letter path rather than looping.
Query fails with a marshal error
On memory, Start and Signal store values as-is and marshal lazily at Query time, so a non-marshalable input surfaces there, not at start (db marshals the input at Start). Pass only JSON-marshalable values (maps, slices, structs without channels or funcs).
Steps re-run side effects after a crash
The step was not idempotent. Gate side-effecting steps behind lock singletons and idempotency keys, and emit an observability span per step so stalled runs are locatable.