Help us shape the future of Go! Take the 2026 Go Developer Survey

workflow

package module
v0.2.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 19, 2026 License: MIT Imports: 19 Imported by: 0

README

workflow

Durable, ordered task runs for Go, stored in PostgreSQL. PostgreSQL only: the engine claims work with SELECT ... FOR UPDATE SKIP LOCKED, and the store package is the only code that talks to the database. A run is a named workflow plus a snapshot of its tasks, stored in two tables the module owns. The process that starts a run tries to finish it right there, in the same process. If that process dies, is saturated, or is draining for a deploy, a poller in any other process claims the same row and carries on from the last recorded task. Task execution is therefore at-least-once, and a handler that cannot safely run twice is a bug in the handler, not in the engine.

A typical consumer has two binaries. The one serving requests registers its workflows, builds a Manager over a *pgkit.DB and starts runs. The worker registers the same workflows, builds its own Manager, and runs the poller and the sweeper through NewRunnable. Both talk to the same tables.

Install

go get github.com/goware/workflow

Import github.com/goware/workflow for the engine and github.com/goware/workflow/store for the migration entry point. The engine takes a *pgkit.DB from pgkit on the database that holds its tables. It needs Go 1.26 or later.

Schema and migrations

The store owns its two tables, workflow_runs and workflow_tasks, and the goose chain that creates them. The chain is embedded in the module under store/migrations/ and tracks its versions in its own table, workflows_goose_db_version, so it never collides with the consumer's own numbering.

The consumer runs the chain by calling store.Migrate(ctx, sqlDB) from its own migration command, after its own chain, with a database/sql handle on the same database. The call is idempotent. A change to these tables is a new file in store/migrations/, never a migration in the consumer's chain.

NewManager reads workflows_goose_db_version on start and refuses to run when the database is behind store.SchemaVersion, the version this build of the code expects. A missing version table counts as version 0. The error wraps store.ErrSchemaOutdated, so the consumer can match it and name its own migration command in the message:

mgr, err := workflow.NewManager(ctx, db, reg, cfg, logger)
if errors.Is(err, store.ErrSchemaOutdated) {
	return fmt.Errorf("%w (run migrate up)", err)
}

Because consumers upgrade on their own schedules, a schema change ships as expand then contract. A release only adds: nullable columns, indexes, appended enum values. A drop lands one release later, once every consumer runs a version that stopped reading the column.

Quick start

Define a workflow

Declare the workflow and each task at package scope. In is the input every task receives. Out is what that task records for the tasks after it. Use None when a task records nothing.

var Transfer = workflow.NewWorkflow[TransferInput]("Transfer.Send")

var (
    reserve = workflow.NewTask[reserveOutput]("transfer.reserve")
    send    = workflow.NewTask[workflow.None]("transfer.send")
)

func Register(reg *workflow.Registry, deps Handlers) error {
    return reg.Register(workflow.Define(Transfer,
        workflow.Handle(reserve, deps.reserve, workflow.MaxAttempts(8)),
        workflow.Handle(send, deps.send,
            workflow.Timeout(30*time.Second)).SkipIf(alreadySent),
    ))
}

Register is where validation happens. It rejects a frozen registry, a duplicate workflow name, a workflow with zero tasks, an empty or duplicate task name inside one workflow, more than math.MaxInt16 tasks, a bad backoff config, and a task name that is already registered with a different output type. That last rule exists because Output looks a task up by name alone: two workflows may share a task name only if they agree on Out.

NewManager freezes the registry, so register every workflow before building it.

Start a run
run, err := Transfer.Start(ctx, mgr, input, workflow.WithIdempotencyKey(projectID+":"+key))
status, err := run.Await(ctx)

Start inserts the run in its own transaction and then tries the same-process fast path. When the local run semaphore is full it leaves the row for the poller instead.

StartTx inserts inside the caller's transaction and does not launch anything. The run becomes visible when that transaction commits, and the first Await, or the poller, claims it. ClaimRunByID uses SKIP LOCKED, so a double kick claims at most once.

WithIdempotencyKey collapses a duplicate enqueue onto the existing run and leaves that run untouched, so a parked run stays parked. Without the option every call inserts a fresh run. After a duplicate start, read the run's own input with WorkflowDef.Input rather than answering from the input this call passed in, because the two may differ.

Await blocks until the run is terminal. If the caller's context ends first it returns StatusRunning and the run keeps going. The in-memory completion channel is a latency win for a run in this process. Correctness comes from the one-second GetRun poll, which also covers a run finished by another process.

Status is a plain database read. Failure is nil unless the run is stuck. When it is set it names the parked task, the last error text, the code the handler set with WithCode, and the attempt count. Persist PII-free error text, because callers surface Failure.Error.

Handler contract

func (h Handlers) send(ctx context.Context, run *workflow.TaskRun[TransferInput]) (workflow.None, error) {
    prior, err := workflow.Output(run, reserve)
    if err != nil {
        return workflow.None{}, err
    }
    if err := h.provider.Send(ctx, prior.ID, run.IdempotencyKey()); err != nil {
        if isRejected(err) {
            return workflow.None{}, workflow.Permanent(workflow.WithCode("rejected", err))
        }
        return workflow.None{}, err
    }
    return workflow.None{}, nil
}

A handler must respect ctx cancellation and must be idempotent, because the engine re-invokes a task after a retry, a crash, or a claim reclaim. The TaskRun it receives carries what it needs:

  • Input is the workflow input, already decoded. A decode failure is permanent: the stored input never changes, so a retry could not fix it.
  • Output decodes an earlier task's recorded output. It errors when that task recorded none, which includes a skipped task.
  • IdempotencyKey is runID:seq, stable across retries and crash resume. Hand it to an external provider so a re-run does not repeat the side effect. Provider dedup windows are finite, and a run parked for weeks can still re-emit.
  • LastAttempt is true when this failure spends the attempt budget. Use it for a give-up side effect. A permanent failure parks earlier regardless.

Failures are plain errors with two optional wrappers:

  • Permanent parks the run now. A plain error retries until the attempt budget is spent, then parks.
  • WithCode tags a failure so Failure.Code can map it to a public error. The engine stores the code and never interprets it. Permanent and WithCode compose in either order, and WithCode on a nil error returns nil.

NewTaskRun builds a TaskRun for a handler unit test with no manager and no database. It is a single-attempt run, so LastAttempt is true.

SkipIf is evaluated once, when the run is instantiated, against the plaintext input. A true predicate snapshots that task as skipped. The predicate takes the workflow input type, so a drifted predicate is a compile error rather than a runtime surprise.

Retries

A task with no options falls back to Config, and NewManager fills a zero Config with these defaults:

Field Default Meaning
DefaultTaskTimeout 60s One attempt, unless the task sets Timeout.
DefaultMaxAttempts 8 Attempts before the task parks, unless it sets MaxAttempts.
BackoffBase / BackoffMax 1s / 5m Exponential delay min(base*2^(attempt-1), max).
MaxInProcessBackoff 5s Shorter delays sleep in this process. Longer ones release the claim for the poller.
SweepMaxAge 10m A claim older than this is stale.
MaxLocalRuns 64 Fast-path concurrency. A full semaphore leaves the run for the poller.

Backoff and BackoffSchedule are mutually exclusive. A schedule uses schedule[attempt-1], and attempts past the end reuse the last entry. Register rejects an empty schedule, a negative entry, or both backoff modes on one task. The delay is computed from the persisted attempt count, so an executor that resumes after a crash waits the same amount the original would have.

NewManager refuses to start unless the widest task timeout plus MaxInProcessBackoff is strictly under SweepMaxAge. Otherwise the sweeper could reclaim a run whose executor is still inside one legitimate attempt.

How a run moves

Each binary that executes work builds a Manager. The one that should also drain orphaned runs calls NewRunnable, which runs the poller and the sweeper:

mgr.NewRunnable(workflow.RunnerConfig{
    PollInterval:  time.Second,
    BatchSize:     100,
    Concurrency:   64,
    SweepEnabled:  true,
    SweepInterval: 30 * time.Second,
})

A zero RunnerConfig defaults to a 30s poll, batch 10, concurrency 4, a 1m sweep interval, and FIFO claims. SweepInterval is how often the sweeper ticks. Config.SweepMaxAge is how old a claim has to be before the sweeper treats it as abandoned. Sweep is a guarded update, so it is safe to run from more than one replica.

A claim is exclusive, and every later write carries the claim id as a fence. A write that matches zero rows means another executor now owns the run, and this one stops without touching the run's state.

On a retryable failure inside MaxInProcessBackoff, the executor records the failure, sleeps, heartbeats, and tries again. A longer backoff releases the claim and leaves next_retry_at for the poller. Shutdown during an attempt does not spend budget. If the handler had already succeeded, that output is persisted first and the claim is released for immediate re-claim.

A lost heartbeat releases the claim instead of waiting out SweepMaxAge. SweepStuck bumps attempt_count on reclaim, because the dead process never came back to do it. The executor parks a task whose persisted count has already spent the budget, so a crash loop cannot re-invoke it forever.

The runnable sets draining as soon as its context ends, then waits for in-flight fast-path and poller executions. New fast-path launches stop, and a deploy does not abandon a mid-flight run. StatusCancelled is reserved. No path produces it yet.

Calling context and sealing

Config.Context carries the caller's context across processes. Implement Propagator[T] for your own snapshot type and wrap it with Propagate. The engine encodes the snapshot as JSON, stores it on the run at start, and decodes it again around each handler, never around the executor's own writes. A snapshot that no longer decodes parks the run without spending an attempt. With a nil propagator the handler sees no calling context on the fast path, and none at all on a poller resume.

type caller struct {
    Actor string `json:"actor"`
}

type callerPropagator struct{}

func (callerPropagator) Capture(ctx context.Context) (caller, error) {
    return caller{Actor: actorFrom(ctx)}, nil
}

func (callerPropagator) Restore(ctx context.Context, c caller) (context.Context, error) {
    return withActor(ctx, c.Actor), nil
}

cfg := workflow.Config{Context: workflow.Propagate(callerPropagator{})}

Config.Cipher seals the input and the call-context snapshot at rest. A nil cipher stores plain JSON. A document written before a cipher was configured still opens. A sealed document with no cipher configured parks the run without spending an attempt.

A propagator that captures actor or request metadata is carrying PII. Do not configure one without a cipher.

Alerts and logging

The engine logs through the *slog.Logger passed to NewManager. Every message starts with workflow: , and a failed write carries its error in an error attribute.

Four events mean a run is parked and will not move again without a person: input or call context that no longer opens, a task with no registered handler (version skew), a task whose attempt budget was already spent, and a task that failed permanently or on its last attempt. These log at workflow.LevelAlert, which is slog.Level(16), above slog.LevelError. slog prints an unnamed level relative to the nearest named one, so the default handlers show it as ERROR+8.

To page on these events, match the level in your handler. This example names the level ALERT in the output:

opts := &slog.HandlerOptions{
    ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
        if a.Key == slog.LevelKey && a.Value.Any().(slog.Level) >= workflow.LevelAlert {
            a.Value = slog.StringValue("ALERT")
        }
        return a
    },
}
logger := slog.New(slog.NewJSONHandler(os.Stderr, opts))

Testing

make test runs the whole suite. The registry, option and error tests need nothing. Tests that touch the store need a PostgreSQL server and read its URL from WORKFLOW_TEST_DATABASE_URL, which the Makefile sets from PG_URL (default postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable).

make test PG_URL='postgres://postgres:postgres@localhost:5433/postgres?sslmode=disable'

Each such test creates its own scratch database on that server, migrates it, and drops it on cleanup, so tests in different packages never see each other's rows and can run in parallel. When the variable is unset those tests skip, so a plain go test ./... passes without PostgreSQL. With CI set and the variable missing they fail instead, so a misconfigured job cannot pass by skipping. CI runs the same suite against a PostgreSQL service container.

License

MIT. See LICENSE.

Documentation

Index

Constants

View Source
const LevelAlert = slog.Level(16)

LevelAlert marks a run the engine parked and will not retry on its own. It is above slog.LevelError and equals go-libs alert.LevelAlert.

Variables

This section is empty.

Functions

func Output

func Output[In, Out any](run *TaskRun[In], def TaskDef[Out]) (Out, error)

Output decodes a prior task's recorded output; it errors when there is none.

func Permanent

func Permanent(err error) error

Permanent wraps err so the executor parks without retrying, returning nil for a nil err.

func WithCode

func WithCode(code string, err error) error

WithCode tags err with code for Failure.Code, returning nil for a nil err. It composes with Permanent in either order.

Types

type Cipher

type Cipher interface {
	Encrypt(ctx context.Context, plaintext string) (string, error)
	Decrypt(ctx context.Context, token string) (string, error)
}

Cipher seals a run's input and call context at rest.

type CodedError

type CodedError struct {
	Code string
	Err  error
}

CodedError carries a caller-defined code the engine persists but never interprets.

func (*CodedError) Error

func (e *CodedError) Error() string

func (*CodedError) Unwrap

func (e *CodedError) Unwrap() error

type Config

type Config struct {
	DefaultTaskTimeout  time.Duration // 60s
	DefaultMaxAttempts  int           // 8
	BackoffBase         time.Duration // 1s
	BackoffMax          time.Duration // 5m
	MaxInProcessBackoff time.Duration // 5s
	SweepMaxAge         time.Duration // 10m
	MaxLocalRuns        int           // 64

	Context Propagator[json.RawMessage] // built with Propagate; nil leaves a task with no calling context off the fast path
	Cipher  Cipher                      // nil stores input and call context as plain JSON
}

Config holds executor defaults; NewManager fills zero fields.

type Failure

type Failure struct {
	Task string
	// Error is persisted; handlers must keep it PII-free.
	Error string
	// Code is the WithCode tag, empty when none was set or the engine parked the run.
	Code     string
	Attempts int
}

Failure is why a run parked: the task that gave up and its last error.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager starts, executes, and observes workflow runs backed by the store.

func NewManager

func NewManager(ctx context.Context, db *pgkit.DB, reg *Registry, cfg Config, log *slog.Logger) (*Manager, error)

NewManager freezes reg and returns a Manager over db. It fails unless the widest task timeout plus MaxInProcessBackoff is below SweepMaxAge, so the sweeper never reclaims a run inside a live task attempt.

func (*Manager) Draining

func (m *Manager) Draining() bool

Draining reports whether the Manager is shutting down.

func (*Manager) NewRunnable

func (m *Manager) NewRunnable(cfg RunnerConfig) runnable.Runnable

NewRunnable returns the poll loop and, when cfg.SweepEnabled, the sweep loop. On shutdown it waits for every in-flight execution before returning.

type None

type None = struct{}

None is the output type of a task that records no output.

type PermanentError

type PermanentError struct {
	Err error
}

PermanentError marks a task failure as non-retryable.

func (*PermanentError) Error

func (e *PermanentError) Error() string

func (*PermanentError) Unwrap

func (e *PermanentError) Unwrap() error

type Propagator

type Propagator[T any] interface {
	Capture(ctx context.Context) (T, error)
	Restore(ctx context.Context, snapshot T) (context.Context, error)
}

Propagator captures the caller's context when a run starts and restores it around each handler, so a run resumed in another process sees the same values. Implement it for your own snapshot type and pass it through Propagate; the engine stores the snapshot as JSON.

func Propagate

func Propagate[T any](p Propagator[T]) Propagator[json.RawMessage]

Propagate adapts p to the JSON form Config.Context takes. A snapshot that does not decode into T parks the run.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry holds registered workflows. Register every workflow before NewManager, which freezes the registry.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty, mutable registry.

func (*Registry) Register

func (r *Registry) Register(wf Workflow) error

Register validates and indexes a workflow. It fails after NewManager.

type RunnerConfig

type RunnerConfig struct {
	PollInterval  time.Duration
	BatchSize     int
	Concurrency   int
	SweepEnabled  bool
	SweepInterval time.Duration
	ClaimStrategy store.ClaimStrategy
}

RunnerConfig configures the poller and sweeper.

type StartOption

type StartOption func(*startConfig)

StartOption configures Start and StartTx.

func WithIdempotencyKey

func WithIdempotencyKey(key string) StartOption

WithIdempotencyKey returns the existing run for a duplicate key untouched, so a parked run stays parked.

type Status

type Status = store.WorkflowStatus

Status is a workflow run's lifecycle state, aliased from the persisted values.

const (
	StatusRunning   Status = store.WorkflowStatusRunning
	StatusSucceeded Status = store.WorkflowStatusSucceeded
	StatusStuck     Status = store.WorkflowStatusStuck
	// StatusCancelled is reserved; no code path produces it.
	StatusCancelled Status = store.WorkflowStatusCancelled
)

type TaskDef

type TaskDef[Out any] struct {
	// contains filtered or unexported fields
}

TaskDef is a task's durable name paired with the type of output it records.

func NewTask

func NewTask[Out any](name string) TaskDef[Out]

NewTask declares a task whose handler returns Out; use None to record no output.

func (TaskDef[Out]) Name

func (d TaskDef[Out]) Name() string

Name returns the task's durable handler name.

type TaskOption

type TaskOption func(*taskConfig)

TaskOption configures one task's retry, timeout, or skip behavior.

func Backoff

func Backoff(base, max time.Duration) TaskOption

Backoff sets exponential backoff min(base*2^(attempt-1), max). Mutually exclusive with BackoffSchedule.

func BackoffSchedule

func BackoffSchedule(schedule []time.Duration) TaskOption

BackoffSchedule sets the delay after each failed attempt, reusing the last entry past the end. Mutually exclusive with Backoff; MaxAttempts still caps.

func MaxAttempts

func MaxAttempts(n int) TaskOption

MaxAttempts caps the number of attempts before a task parks.

func Timeout

func Timeout(d time.Duration) TaskOption

Timeout bounds a single task attempt.

type TaskRun

type TaskRun[In any] struct {
	Input In
	Log   *slog.Logger
	// contains filtered or unexported fields
}

TaskRun is the handler's view of one task run. Execution is at-least-once: a handler must respect ctx cancellation and be idempotent.

func NewTaskRun

func NewTaskRun[In any](input In, outputs map[string]any) (*TaskRun[In], error)

NewTaskRun builds a single-attempt TaskRun for unit-testing a handler without a Manager. Key outputs by TaskDef.Name.

func (*TaskRun[In]) IdempotencyKey

func (r *TaskRun[In]) IdempotencyKey() string

IdempotencyKey is stable across retries and crash-resumes of this task. Provider dedup windows are finite, so a run parked for weeks can still re-emit.

func (*TaskRun[In]) LastAttempt

func (r *TaskRun[In]) LastAttempt() bool

LastAttempt reports whether a failure now exhausts the attempt budget. A permanent failure parks earlier regardless.

type TaskSpec

type TaskSpec[In any] struct {
	// contains filtered or unexported fields
}

TaskSpec is one task, produced by Handle and consumed by Define.

func Handle

func Handle[In, Out any](def TaskDef[Out], h func(context.Context, *TaskRun[In]) (Out, error), opts ...TaskOption) TaskSpec[In]

Handle binds a typed handler to a task definition. A handler whose input or output type disagrees with the definition is a compile error.

func (TaskSpec[In]) SkipIf

func (s TaskSpec[In]) SkipIf(pred func(In) bool) TaskSpec[In]

SkipIf marks the task skipped when pred holds for the run's input, evaluated once when the run is built.

type Workflow

type Workflow struct {
	// contains filtered or unexported fields
}

Workflow is a named, ordered set of tasks built by Define.

func Define

func Define[In any](def WorkflowDef[In], tasks ...TaskSpec[In]) Workflow

Define assembles a workflow's ordered tasks. Registry.Register validates it.

func (Workflow) Name

func (w Workflow) Name() string

Name returns the workflow's name.

type WorkflowDef

type WorkflowDef[In any] struct {
	// contains filtered or unexported fields
}

WorkflowDef is a workflow's durable name paired with its input type.

func NewWorkflow

func NewWorkflow[In any](name string) WorkflowDef[In]

NewWorkflow declares a workflow whose tasks and Start all take input In.

func (WorkflowDef[In]) Input

func (d WorkflowDef[In]) Input(ctx context.Context, run *WorkflowRun) (in In, err error)

Input returns the input run was started with. After a duplicate start it is the first start's input, not the caller's.

func (WorkflowDef[In]) Name

func (d WorkflowDef[In]) Name() string

Name returns the workflow's durable name.

func (WorkflowDef[In]) Start

func (d WorkflowDef[In]) Start(ctx context.Context, m *Manager, input In, opts ...StartOption) (*WorkflowRun, error)

Start durably enqueues a run of the workflow and launches it in-process when a local slot is free; otherwise the poller drives it.

func (WorkflowDef[In]) StartTx

func (d WorkflowDef[In]) StartTx(ctx context.Context, m *Manager, tx pgx.Tx, input In, opts ...StartOption) (*WorkflowRun, error)

StartTx inserts the run inside tx, visible once the caller commits. It does not launch the run: the Manager cannot observe the commit, so the first Await does.

type WorkflowRun

type WorkflowRun struct {
	// contains filtered or unexported fields
}

WorkflowRun is a handle to one started workflow run.

func (*WorkflowRun) Await

func (r *WorkflowRun) Await(ctx context.Context) (Status, error)

Await blocks until the run is terminal, or returns StatusRunning when ctx expires first while the run keeps going in the background.

func (*WorkflowRun) Failure

func (r *WorkflowRun) Failure(ctx context.Context) (*Failure, error)

Failure returns why the run parked, or nil when the run is not stuck.

func (*WorkflowRun) ID

func (r *WorkflowRun) ID() uuid.UUID

ID returns the run's identifier.

func (*WorkflowRun) Status

func (r *WorkflowRun) Status(ctx context.Context) (Status, error)

Status returns the run's persisted lifecycle state.

Directories

Path Synopsis
internal
pgtest
Package pgtest gives the engine's tests a scratch Postgres database.
Package pgtest gives the engine's tests a scratch Postgres database.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL