Durable workflows and queues for Go, backed by Postgres.
Add crash-proof background jobs and multi-step workflows to your Go app in a few lines — no separate orchestrator to run, no new infrastructure. RunnerQ is a library: import it, point it at the Postgres you already have, and write workflows as ordinary Go functions. When a process crashes mid-workflow, it resumes from where it left off without redoing completed work.
func (h *Checkout) Handle(ctx runnerq.ActivityContext, payload json.RawMessage) (json.RawMessage, error) {
// Each step is checkpointed in Postgres. Crash after the charge and
// restart — the charge is NOT repeated; the workflow resumes at shipping.
receipt, err := ctx.Run("charge-card", func() (json.RawMessage, error) {
return chargeCard(payload) // runs exactly once
})
if err != nil {
return nil, err
}
ship, err := ctx.ActivityExecutor.Activity("ship").Step("ship").Payload(payload).Execute(ctx.Ctx)
if err != nil {
return nil, err
}
return ship.GetResult(ctx.Ctx) // parent parks here — frees the worker, survives deploys
}go get github.com/alob-mtc/runnerq-goSee it for real:
examples/02-crash-and-resumecharges an order, lets youCtrl-Cit, and resumes on restart without double-charging. That demo is the whole pitch in 30 seconds.
Background work that touches the real world — charging cards, sending email, calling APIs, orchestrating multi-step jobs — has to survive crashes, deploys, and retries without doing things twice. The usual options are a heavy workflow server (a cluster to operate) or a plain task queue (no durability — you hand-roll idempotency and recovery yourself).
RunnerQ gives you durable execution as a library:
- A workflow is just a Go function. Orchestration is normal control flow — loops, conditionals, error handling — not a DSL or a DAG.
- Steps are checkpointed. Completed work is skipped on replay, so retries and restarts are cheap and side effects don't repeat.
- Workflows pause for free. Sleep for days or wait for a webhook while holding zero workers — paused workflows are just rows in Postgres.
- Only Postgres. No orchestrator cluster, no broker, no control-plane bill. Scale by adding stateless worker processes.
- Use RunnerQ when you want durable, crash-safe workflows or queues with minimal new infrastructure, and you're already running Postgres.
- Reach for a dedicated workflow server (Temporal, Cadence) if you need many SDK languages or want workflow orchestration decoupled from your app and your database.
- A plain queue (River, Asynq, SQS) is enough if you only need fire-and-forget tasks and don't need workflows, steps, signals, or durable timers.
A handler that calls ctx.Run steps is a durable workflow. Each step's result
is checkpointed; if the process dies, the handler replays and completed steps
return their stored results instead of re-executing.
receipt, err := ctx.Run("charge-card", func() (json.RawMessage, error) {
return chargeCard(payload) // at most once, even across retries and restarts
})Spawn child activities with .Step(name). A retried parent reattaches to the
children it already launched instead of duplicating them, and parents that
await children park in the database — no goroutine, no lease, no retry
burned while they wait.
fut, _ := ctx.ActivityExecutor.Activity("reserve").Step("reserve").Payload(p).Execute(ctx.Ctx)
reserved, _ := fut.GetResult(ctx.Ctx) // memoized on replaySleep inside a workflow for seconds or weeks. The deadline is persisted — a restart resumes the remainder — and long sleeps hold no worker.
ctx.Sleep("cooling-off", 24 * time.Hour) // survives restarts; frees the workerWait for an approval, a webhook, or a payment confirmation. The workflow parks until a signal arrives, delivered from anywhere — even another process.
decision, err := ctx.WaitForSignal("approval", 48*time.Hour) // parks, holds no worker
// from an HTTP handler, a Slack action, a Kafka consumer:
engine.Signal(ctx, activityID, "approval", payload)Idempotency keys make enqueuing exactly-once — dedupe webhooks and event handlers with one option.
executor.Activity("process_event").
Payload(p).
IdempotencyKeyOption(eventID, runnerq.ReturnExisting). // duplicate deliveries collapse to one
Execute(ctx)Priorities, exponential-backoff retries, a dead-letter queue, scheduling, and worker-level type filtering so slow jobs can't starve latency-sensitive ones.
executor.Activity("send_email").
Payload(p).
Priority(runnerq.PriorityHigh).
MaxRetries(5).
Timeout(2 * time.Minute).
Delay(30 * time.Second).
Execute(ctx)An embedded web dashboard (no npm, no build step) with live queue stats, activity browsing, results, and lifecycle timelines over SSE.
mux.Handle("/console/", http.StripPrefix("/console", ui.RunnerQUI(inspector)))| RunnerQ | Temporal / Cadence | Plain task queue (River, Asynq, …) | |
|---|---|---|---|
| Durable workflows | ✅ | ✅ | ❌ |
| Deploy model | a Go library | a server cluster to operate | a library |
| Infrastructure | Postgres you already run | server + its own datastore | Postgres or Redis |
| Workflow code | plain Go, step-memoized | plain code, full replay-determinism | n/a |
| Durable timers / signals | ✅ | ✅ | ❌ |
| Scale | add stateless worker processes | scale the cluster | add workers |
The economics: RunnerQ adds one dependency you almost certainly already have — Postgres. There's no orchestrator cluster to size, secure, and pay for, and no separate control plane. Workers are stateless; scale out by running more of your own binary. The trade-off versus a dedicated server is deliberate: durable execution that fits inside your app and your database, rather than a system you operate alongside it.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/alob-mtc/runnerq-go"
"github.com/alob-mtc/runnerq-go/storage/postgres"
)
type Greeting struct{ runnerq.DefaultDeadLetterHandler }
func (h *Greeting) ActivityType() string { return "greeting" }
func (h *Greeting) Handle(ctx runnerq.ActivityContext, payload json.RawMessage) (json.RawMessage, error) {
greeting, err := ctx.Run("compose", func() (json.RawMessage, error) {
return json.Marshal("hello, " + string(payload))
})
return greeting, err
}
func main() {
ctx := context.Background()
backend, err := postgres.New(ctx, "postgres://postgres:runnerq@localhost:5432/runnerq", "my_app")
if err != nil {
log.Fatal(err)
}
defer backend.Close()
engine, err := runnerq.Builder().Backend(backend).MaxWorkers(8).Build()
if err != nil {
log.Fatal(err)
}
engine.RegisterActivity("greeting", &Greeting{})
go engine.Start(ctx)
future, _ := engine.GetActivityExecutor().
Activity("greeting").
Payload(json.RawMessage(`"world"`)).
Execute(ctx)
result, _ := future.GetResult(ctx)
fmt.Println(string(result)) // "hello, world"
}Runnable, realistic, copy-pasteable — each in examples/ with its
own README. docker compose up -d once, then go run . in any of them.
| # | Example | Shows |
|---|---|---|
| 01 | hello-workflow | a durable two-step workflow |
| 02 | crash-and-resume | kill it mid-flight; it resumes without redoing work |
| 03 | steps-and-retries | child activities memoized across a retry |
| 04 | checkpoint-side-effects | charge once, even when the handler retries |
| 05 | durable-sleep | timers that survive restarts |
| 06 | human-approval | pause for an approval, delivered over HTTP |
| 07 | fan-out | spawn many children, run them in parallel |
| 08 | exactly-once-webhook | idempotency keys collapse duplicate deliveries |
| 09 | cross-process-futures | enqueue in a web tier, await by ID anywhere |
| 10 | retries-and-dead-letter | backoff, the dead-letter queue, OnDeadLetter |
| 11 | retention | TTL cleanup of completed workflows |
| 12 | workload-isolation | many worker fleets sharing one queue |
| 13 | console | the built-in observability dashboard |
Full reference in docs/:
- Getting Started
- Workflows & Activities
- Durable Execution —
Step,Run,Sleep, signals, versioning - Retries & Dead Letter
- Configuration — tuning, scheduling, workload isolation, retention
- Observability — console, stats, metrics
- Storage Backends
RunnerQ is pre-1.0. The durable-execution core (steps, checkpoints, signals, durable sleep, crash recovery) is built and integration-tested against Postgres, but APIs may still change before 1.0. See RELEASING.md for the stability policy.
MIT — see LICENSE.