Skip to content

alob-mtc/runnerq-go

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

152 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RunnerQ

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

See it for real: examples/02-crash-and-resume charges an order, lets you Ctrl-C it, and resumes on restart without double-charging. That demo is the whole pitch in 30 seconds.

Why RunnerQ

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.

When to use it

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

Features

Durable workflows that survive crashes

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
})

Orchestration that fast-forwards on retry

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 replay

Durable timers

Sleep 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 worker

Signals — pause for the outside world

Wait 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)

Exactly-once enqueue

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)

A real queue underneath

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)

A built-in console

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

How it compares

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.

Quick start

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"
}

Examples

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

Documentation

Full reference in docs/:

Status

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.

License

MIT — see LICENSE.

About

A robust, scalable activity queue and worker system for Golang applications with pluggable storage backends.

Resources

License

Stars

6 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors