Durable execution for existing Go code: wrap your functions, run a worker, done.
Install
·
Report Bug
·
Go Docs
Beta. anchor is pre-1.0 and under active development. Interfaces are stabilizing but may change between minor versions, and the Postgres and Redis adapters are young. Crash recovery and graceful shutdown are correct today: a cancelled worker persists in-flight outcomes and hands interrupted runs back without burning retry budget. One known gap: on Redis, a stop that lands after a claim script ran can still leave that run leased until its lease lapses (see the replay design's failure modes). Workflow bodies are single-goroutine in v1; distributed activities, forking and child runs, and durable timers are on the roadmap.
- Wrap, don't rewrite:
Activity,Step, andEventwrap the code you have. Outside a run every wrapped function is a plain Go call, so tests, scripts, and half-migrated codebases keep working unchanged. - Interfaces first, no owned schema:
pkg/ledger.Ledgerandpkg/queue.Queueare pure interfaces, the root module has zero third-party dependencies, and the in-memory implementations are complete single-process systems. - Your tables, validated, never migrated: the Postgres adapter maps onto tables you already own via a declarative
Mapping.Validateintrospects the live schema and errors with guidance;RecommendedDDLsuggests DDL and never executes it. - Postgres-only works:
pgqueue(FOR UPDATE SKIP LOCKED) runs the queue in the same database, with streaming viaLISTEN/NOTIFY. Redis is an optional throughput upgrade and is disposable: the janitor rebuilds it from the ledger. - Activities are the ABI: records are keyed
name#occurrence, so code rolls forward across releases with no version or patch APIs. Divergence fails loudly (ErrNonDeterministic); a breaking input/output change is a rename (charge-payment.v2). - Fenced attempts, budgeted failures:
(runID, attempt)fences every claim, and retry budget moves only when an execution genuinely fails, so waiting and shutdown interruptions are free. The happy path costs two run-level ledger writes plus one per record. - Waiting and streaming are native: an
Eventwith no record parks the run off the queue at zero cost, and activitiesEmitprogress deltas that checkpointed activities never re-stream. - Errors that mean it: retryable failures get replay-cheap retries,
NonRetryablememoizes the failure, panics are retryable,MaxAttemptsbounds failed executions, andIdempotencyKey(ctx)makes at-least-once effective-once downstream.
go get github.com/urmzd/anchorThe adapters are separate modules, so their dependencies never touch yours until you choose one:
go get github.com/urmzd/anchor/adapters/postgres
go get github.com/urmzd/anchor/adapters/redisYou have an order pipeline (from examples/quickstart):
func process(ctx context.Context, o Order) (Receipt, error) {
chargeID, err := charge(ctx, o)
if err != nil {
return Receipt{}, err
}
if _, err := reserve(ctx, o); err != nil {
return Receipt{}, err
}
if _, err := confirm(ctx, o); err != nil {
return Receipt{}, err
}
return Receipt{OrderID: o.ID, ChargeID: chargeID, Amount: o.Amount, IssuedAt: time.Now()}, nil
}It works, until the process dies between the charge and the email. Wrap the pieces once, at package level:
var (
chargePayment = anchor.Activity("charge-payment", charge)
reserveInventory = anchor.Activity("reserve-inventory", reserve)
sendConfirmation = anchor.Activity("send-confirmation", confirm)
processOrder = anchor.Activity("process-order", process)
)
func process(ctx context.Context, o Order) (Receipt, error) {
chargeID, err := chargePayment.Call(ctx, o)
if err != nil {
return Receipt{}, err
}
if _, err := reserveInventory.Call(ctx, o); err != nil {
return Receipt{}, err
}
if _, err := sendConfirmation.Call(ctx, o); err != nil {
return Receipt{}, err
}
// Step records inline non-determinism: the first value is the value forever.
issuedAt, err := anchor.Step(ctx, "issued-at",
func(context.Context) (time.Time, error) { return time.Now(), nil })
if err != nil {
return Receipt{}, err
}
return Receipt{OrderID: o.ID, ChargeID: chargeID, Amount: o.Amount, IssuedAt: issuedAt}, nil
}Then wire a ledger, a queue, and a worker. The worker can be your own process:
// Ledger: your Postgres, your tables. anchor only validates, never migrates.
pool, _ := pgxpool.New(ctx, "postgres://anchor:anchor@localhost:5432/anchor")
lgr, err := pgledger.New(pool, pgledger.DefaultMapping())
if err != nil {
log.Fatal(err)
}
if err := lgr.Validate(ctx); err != nil {
log.Fatal(err)
}
// Queue: disposable flow. Wiping Redis loses no runs; they live in the ledger.
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
q := redisqueue.New(rdb, "quickstart")
c := anchor.New(lgr, q)
// The worker is a pull loop, not a service: run it on a goroutine.
w := worker.New(lgr, q)
go w.Run(ctx)
receipt, err := anchor.Exec(ctx, c, processOrder,
Order{ID: "1042", Email: "ada@example.com", Amount: 2400},
anchor.WithRunID("order-1042")) // the run ID is the submit idempotency keyNow kill the worker after charge-payment records. The lease lapses, the next claim takes attempt N+1, and the workflow re-executes from the top: charge-payment returns instantly from the ledger instead of executing (no double charge), reserve-inventory likewise, and execution resumes for real at the first unrecorded call. The quickstart demos exactly this: it crashes its own worker mid-confirmation and lets a second worker finish the order.
docker compose up -d --wait # Postgres 17 + Redis 8; the init hook applies the suggested schema
make demo # or: cd examples/quickstart && go run .| Example | Description |
|---|---|
quickstart |
The hero, runnable: an order pipeline on Postgres + Redis that survives its own worker being killed mid-run |
chaos |
Cross-process kill test: SIGKILL a worker binary mid-activity and prove recorded activities never re-execute |
| Concept | What it is |
|---|---|
| ledger | Durable truth: runs and their memoized records, in tables you already own (pkg/ledger) |
| queue | Flow and clock: delivery of run IDs under fenced, time-bounded claims; disposable, rebuildable from the ledger (pkg/queue) |
| worker | A pull loop with no inbound surface: claims, replays to the frontier, writes one outcome per claim (pkg/worker) |
DefaultMapping targets the suggested anchor_runs / anchor_records tables, but a mapping can point at tables you already have, including tables shared with other rows:
lgr, err := pgledger.New(pool, pgledger.Mapping{
Runs: pgledger.RunsMap{
Table: "orders",
RunID: "order_ref",
Status: "workflow_status",
Envelope: "workflow_state",
},
Records: pgledger.RecordsMap{
Table: "order_events",
RunID: "order_ref",
Key: "event_key",
Envelope: "payload",
// Scope: this table also holds non-anchor rows; see only ours.
Scope: pgledger.Scope{Column: "source", Value: "anchor"},
// Defaults: fill your own NOT NULL columns on insert.
Defaults: func(rec run.Record) map[string]any {
return map[string]any{"tenant_id": "acme"}
},
},
})
if err != nil {
log.Fatal(err)
}
if err := lgr.Validate(ctx); err != nil {
log.Fatal(err) // reports exactly what is missing; RecommendedDDL suggests the fix
}Two validation rules worth knowing up front: mapped identifiers are lower_snake_case only, and the uniqueness the ledger depends on must be a plain unique index or unique constraint (the runs table on its run-ID column, the records table on its run-ID and key pair). Partial or expression unique indexes are rejected, because the adapter's ON CONFLICT writes cannot use them.
Waiting is the absence of a record. An Event with no record parks the run: settled off the queue, still pending, costing nothing. Anything that writes the record and enqueues the run resumes it:
// In the workflow: park until someone approves.
approval, err := anchor.Event[Approval](ctx, "approval")
// Anywhere else, any process: approve and wake the run.
err = c.Signal(ctx, "order-1042", "approval", Approval{By: "ada"})Signal is sugar for "write the event record, enqueue the run". A row inserted into your own table plus an enqueue does the same, and the janitor re-enqueues parked runs as a backstop. Parking costs nothing: a run can wait days and resume with its full retry budget intact. Call Event from the workflow body, not inside a nested activity: record keys are parent-scoped, and Signal targets top-level event keys.
// Inside an activity: emit deltas.
_ = anchor.Emit(ctx, progress{Stage: "charged"})
// Anywhere: catch-up, then live tail.
stream, _ := c.Watch(ctx, "order-1042", "")
for {
d, _, err := stream.Recv(ctx)
if err != nil {
break
}
fmt.Printf("%s\n", d.Payload)
}Deltas live on the queue side behind the optional DeltaLog capability (an append-only table plus LISTEN/NOTIFY in pgqueue.NewDeltaQueue, Redis Streams in the redis adapter), split by attempt and seal dividers so consumers can discard superseded partial output. Checkpointed activities never re-stream.
Postgres-only streaming swaps pgqueue.New for pgqueue.NewDeltaQueue and one more table (apply pgqueue.RecommendedDeltaDDL with your own migration tooling):
q, err := pgqueue.NewDeltaQueue(pool, pgqueue.DefaultMapping(), pgqueue.DefaultDeltaMapping())
if err != nil {
log.Fatal(err)
}
if err := q.Validate(ctx); err != nil {
log.Fatal(err) // checks both the queue and the delta table
}pgkit holds the two pieces every Postgres-backed component needs. Apply runs idempotent steps in one transaction under an advisory lock named by the caller, so replicas that start together migrate one at a time:
err := pgkit.Apply(ctx, pool, "orders.durable",
pgledger.RecommendedDDL(pgledger.DefaultMapping()),
pgqueue.RecommendedDDL(pgqueue.DefaultMapping()))Listener multiplexes LISTEN over one dedicated connection, held only while something is subscribed. Every subscription starts with a Resync, and gets another after a reconnect, meaning "re-read your state, notifications may have been missed":
l, err := pgkit.NewListener(pool)
notes, err := l.Listen(ctx, "orders") // closed when ctx ends
for n := range notes {
if n.Resync {
// re-read what you watch
continue
}
fmt.Println(n.Payload)
}
// Share it: one connection for the delta log and everything else.
q, err := pgqueue.NewDeltaQueue(pool, qm, dm, pgqueue.WithListener(l))anchor, a command in the Postgres adapter module (adapters/postgres/cmd/anchor), validates a ledger from the outside: it lists runs, shows one with its records, and replays one without side effects to report where a worker would resume it. It only reads, and it checks the tables with Validate before it reads anything.
cd adapters/postgres && go build -o bin/anchor ./cmd/anchor
export ANCHOR_DATABASE_URL=postgres://anchor:anchor@localhost:5432/anchor # or pass -db on each command
bin/anchor runs -status pending -limit 20 # newest first; -status pending|succeeded|failed
bin/anchor show order-1042 # the run, its records in recorded order, its status
bin/anchor replay order-1042 # where a worker would resume, or that the run is complete$ bin/anchor replay order-1042
run order-1042 (process-order), pending, 2 records
replayed from the ledger, nothing executed:
charge-payment#0 activity recorded
reserve-inventory#0 activity recorded
resume: the next call after reserve-inventory#0 executes for real, or parks the run if it awaits an event
replay has no workflow code, so it plays the recorded calls themselves into the replay.Frame a worker builds, handing the frame a ledger that refuses every write, and reports what the frame does: a complete run (a worker settles it without replaying), a resume after the last recorded call, a skipped occurrence that would execute for real (charge-payment#0 missing while charge-payment#1 is recorded), a parent whose own record is missing and whose body would re-execute around its recorded calls (the worker died inside it), or a park at an event with no record. It checks what the ledger alone can show: every key derives from its record's kind, name and occurrence; every record carries the codec content type your workers use (-codec, default application/json); activity records carry an input hash. It cannot compare input hashes with live inputs, which only your code computes, so a changed input shows up at the worker, not here.
-jsonprints machine output. Payloads that are JSON appear as JSON underinputandoutput; other bytes appear base64-encoded underinput_base64andoutput_base64.- Exit codes: 0 ok, 1 the command failed (connection, schema, unknown run), 2 usage, 3
replayfound a divergence a worker would fail the run on, or a record no call could have produced. - The URL comes from
-dborANCHOR_DATABASE_URLand is never printed, not in errors and not in-h. Connections open withdefault_transaction_read_onlyon, so the server refuses any write. - Tables:
-runs-tableand-records-tablepoint atDefaultMapping-shaped tables under other names. Other column mappings are not supported by the command; in code,pgledger.Ledger.ListRuns,GetRunandLoadread any mapping. - Build it from a checkout: the adapter module's
go.modkeeps areplacefor in-repo development, whichgo install ...@versionrefuses.
make init # install git hooks, download modules
make check # fmt, lint, test across every module
make test-integration # Postgres + Redis via docker compose
make demo # run the quickstart- Architecture overview: concepts, packages, modules, adapters, capabilities
- Replay design: the replay algorithm, the claim protocol, and the failure-mode table
- AGENTS.md: AI-facing conventions, commands, extension guide
- Contributing: development workflow and commit convention