Lightweight, embeddable durable task execution for Go.
durable-go lets you define typed tasks, run memoized steps, and persist progress so work can resume safely after failures or restarts within a single process. Useful for any Go app that needs reliable, resumable tasks without a heavy orchestration framework or external infrastructure.
Releases follow Semantic Versioning; see the latest release.
- Engine API —
NewEngine,RegisterTask,RunTask,RunStepin a single package. - Async fan-out —
RunStepstarts work and returns immediately, matchingRunTask; call several beforeGet-ing any of them. - First-of-N —
StepRun.Done()exposes a read-only channel so you canselectacross handles and react to whichever finishes first. - Memoized steps — completed steps replay from the journal; they are not run again. Failed steps replay their stored error instead of re-running
fn. - Pending steps — return
ErrStepPendingand complete later viaCompleteStep(human approval, webhooks). Suspends only that step'sGet, not the whole task — sibling steps keep running. - Cancellation —
CancelRunpersists a durable cancel signal and cancels the run'sctximmediately if it is executing in this process; everyRunStepcall, including on a later resume, fails fast withErrRunCancelledinstead of re-runningfn. - Fire-and-forget runs —
RunTaskreturns immediately;TaskRun.Getwaits;RunID()is available at once. - Step observability —
GetStep/LoadStepsfor current state,WatchStepsfor a live/historical event stream (including STARTED). - Timeouts and retries — engine / task / run / step options. Retries default to 0 (opt-in).
- Panic recovery — task and step panics are recorded and returned as errors.
- Auto-purge — optional background cleanup of old completed and failed runs (age, max-runs, and max-bytes caps; one pass at startup).
- Flexible execution — tasks as
durable.Funcclosures or structs withExec. - Payload privacy — optional
PayloadCodec(built-in AES-GCM); owner-only journal modes; inspect--redact.
Most durable-execution frameworks require external infrastructure—such as a dedicated workflow server or a Postgres database—and enforce strict code execution models like replay determinism.
durable-go takes a zero-infra, in-process approach: a single Go library with a filesystem journal running inside your application process. Instead of replaying entire function call graphs from an external orchestrator, durable-go memoizes individual step results. On resume the task runs again from the top; completed steps return the cached result. There is no replay-determinism sandbox. The journal is files, not SQLite: no schema migrations when the library changes, and no connection/busy-lock handling for a single-process writer. See Use cases for more places where durable-go is a perfect fit.
One writer per dataDir.
NewEnginetakes an exclusive OS flock on<dataDir>/.lock. Do not open the same directory from two writer processes. Another process can open the same directory withNewReadOnlyEngine(shared lock).
go get github.com/agenticenv/durable-go@latestGo 1.26+. No infrastructure required. The library module depends only on flock, ulid, and protobuf — examples and benchmarks live in their own modules.
e, err := durable.NewEngine(ctx, "./data", durable.WithLogger(logger))
if err != nil { ... }
defer e.Close()
err = durable.RegisterTask(e, "process-order", durable.Func(
func(ctx context.Context, s *durable.StepRunner, in OrderInput) (OrderOutput, error) {
charged, err := durable.RunStep(ctx, s, "charge", in, func(ctx context.Context, in OrderInput) (string, error) {
return chargeCard(in)
}).Get(ctx)
if err != nil {
return OrderOutput{}, err
}
shipped, err := durable.RunStep(ctx, s, "ship", charged, func(ctx context.Context, charged string) (string, error) {
return scheduleShip(charged)
}).Get(ctx)
if err != nil {
return OrderOutput{}, err
}
return OrderOutput{Result: shipped}, nil
},
))
run := durable.RunTask[OrderInput, OrderOutput](ctx, e, "process-order", "", input)
storeRunID(run.RunID()) // available immediately before Get
output, err := run.Get(ctx) // block for resultFull example: examples/func-task/.
For services with injected dependencies, implement Exec on a struct and pass it to RegisterTask:
type Job struct {
DB *Database
Mail Mailer
}
func (j *Job) Exec(ctx context.Context, s *durable.StepRunner, id string) (string, error) {
return durable.RunStep(ctx, s, "notify", id, func(ctx context.Context, id string) (string, error) {
return j.Mail.Send(ctx, id)
}).Get(ctx)
}
durable.RegisterTask(e, "notify", &Job{DB: db, Mail: mailer})
run := durable.RunTask[string, string](ctx, e, "notify", "", "42")
out, err := run.Get(ctx)Full example: examples/struct-task/.
A step suspends itself by returning ErrStepPending. This blocks only that step's Get — sibling steps started before it keep running. An external caller completes it with the token from StepToken(ctx):
approval, err := durable.RunStep(ctx, s, "approve", struct{}{}, func(ctx context.Context, _ struct{}) (Approval, error) {
token := s.StepToken(ctx)
sendEmail("manager@co.com", token)
return Approval{}, durable.ErrStepPending
}).Get(ctx)
// webhook / CLI / another goroutine:
durable.CompleteStep(ctx, e, token, Approval{By: "manager@co.com"})RunStep starts work and returns immediately — the same shape as RunTask. Start several steps before calling Get on any of them to run them concurrently, then join:
charge := durable.RunStep(ctx, s, "charge", order, func(ctx context.Context, order Order) (string, error) {
return chargeCard(order)
})
notify := durable.RunStep(ctx, s, "notify", order, func(ctx context.Context, order Order) (string, error) {
return sendReceipt(order)
})
chargeResult, err := charge.Get(ctx)
if err != nil {
return Output{}, err
}
notifyResult, err := notify.Get(ctx)To react to whichever of several steps finishes first, select on Done() instead of blocking on Get:
a := durable.RunStep(ctx, s, "provider-a", req, callProviderA)
b := durable.RunStep(ctx, s, "provider-b", req, callProviderB)
select {
case <-a.Done():
result, err := a.Get(ctx)
case <-b.Done():
result, err := b.Get(ctx)
}Full example: examples/fanout/.
CancelRun persists a durable cancel signal (reusing the same journal plumbing as CompleteStep) and, if the run is executing in this process, cancels the ctx delivered to that run's Task.Exec and every in-flight RunStep call:
run := durable.RunTask[OrderInput, OrderOutput](ctx, e, "process-order", "", input)
// ... later, from another goroutine or request:
if err := e.CancelRun(ctx, "process-order", run.RunID()); err != nil {
// ErrRunAlreadyFinished if the run already completed or failed.
}
_, err := run.Get(ctx) // errors.Is(err, durable.ErrRunCancelled)- Immediate for a running process. A step already blocked on
ctx.Done()(e.g. insidewaitForSignalafterErrStepPending, or a step function that itself selects onctx) unblocks right away. - Durable across a crash. The cancel signal is written to the journal before this call returns. If the process crashes before the in-process cancellation above takes effect, the next
RunTaskfor that taskID/runID cancelsctxbeforeTask.Execis invoked at all — every subsequentRunStepcall, including a cached/replayed one, returnsErrRunCancelledimmediately without runningfn. - Cooperative, like any Go
ctx.RunStep.Get/RunTask.Getreturn promptly regardless of whether the step's goroutine has exited, but the engine's drain (Close, and any run reaching a terminal state) waits for it to actually return — see rule 4 in Writing tasks. - The run ends up
StatusFailed(the same terminal status used forCloseand timeouts) withTaskInfo.Errorequal todurable.ErrRunCancelled.Error(), so callers can tell a deliberate cancel apart from another failure.
Register tasks after every NewEngine, then resume active runs. Pass the saved runID (or "" to resume the oldest Running/Waiting run for that taskID). Completed steps replay from the journal.
durable.RegisterTask(e, "process-order", ...)
pending, _ := e.ListTasks(ctx, durable.StatusRunning, durable.StatusWaiting)
for _, t := range pending {
run := durable.RunTask[OrderInput, OrderOutput](ctx, e, t.TaskID, t.RunID, OrderInput{})
go func() { _, _ = run.Get(ctx) }()
}RunTask writes input.json on first start. The same runID reloads it; the input argument is ignored.
GetStep and LoadSteps return current state — the latest record per step, in-memory-map order (not sorted). WatchSteps returns history and live updates instead: every STARTED, WAITING, COMPLETED, and FAILED event in the order it was written, either from the beginning (fromOffset 0) or resuming past a previously-seen Offset / ByteOffset. Cancelling the watch does not stop the run. A watch that falls behind may have events dropped (logged as a warning) rather than blocking the run — reconnect with the last Offset/ByteOffset you saw to catch up. Those offsets are valid only while StepEvent.Generation is unchanged: compactJournal (run at a terminal state) increments TaskInfo.JournalGeneration and rewrites the file from index 1.
A StatusFailed run is permanently terminal. RunTask with the same runID replays the stored error and does not re-execute. Recover by DeleteTaskRun (or a new runID). ListTasksPage pages the same sorted list as ListTasks when dataDir holds many runs.
Full example: examples/resume/.
Follow these when you write a task. On resume, the task runs again from the top; completed steps are reused, not re-executed.
- Side effects in
RunStep. Do not call an API, write to a database, or publish to a queue in the task body. Wrap that work indurable.RunStep. - Non-deterministic values in
RunStep. Do not usetime.Now(), UUIDs, or random values in the task body to choose a step ID or a branch. Generate them inside aRunStepso resume sees the same result. - Idempotent steps. A crash can re-run a step after the side effect already happened. Charging a card or sending mail must be safe to do twice (or no-op).
- Check
ctxto be cancellable.CancelRun,WithStepTimeout, and engineCloseonly cancelctx— they cannot forcibly stop a step function. Select onctx.Done()in any loop or long-running step body, and passctxto ctx-aware calls (http.NewRequestWithContext, adatabase/sql*Contextmethod, etc.). A step that never checksctxkeeps running in the background past cancellation/timeout/Close, and the engine still waits for it to actually return before the run reaches a terminal state.
Also:
- Unique step IDs — one stable string per step (literals or
fmt.Sprintf("step-%d", i)). Reusing an ID panics. - Bound the steps in one run. Resume scans that run’s
journal.log. A fixed list of steps (includingfmt.Sprintf("step-%d", i)with a known N) is fine. Do not put an unbounded loop of new step IDs in one run. Start a newRunTaskwhen the work is a new unit (new agent session, next batch). Large LLM/tool payloads in every step also grow the file — keep stored results small when you can. UseWithAutoPurgeso finished runs do not pile up. - Step IDs are the resume key — never rename one. The journal matches records by stepID string only. Renaming a step between deploys orphans the old result: on the next run
fnexecutes again under the new name as if it had never run. Treat a stepID like a database column name, not a display label. - Concurrent
RunStepcalls are safe. Start several steps beforeGet-ing any of them to fan out; join withGetorselectonDone(). A duplicate stepID within one run still panics. - One stepID is reserved.
RunSteppanics ifstepIDis"\x00cancel"— it is reserved internally forCancelRun's durable signal. Any human-readable stepID you would actually choose is unaffected. - JSON results — step and task outputs must be JSON-marshalable. Step input
inmust be too. - No secrets or PII in I/O or errors. Task input, step
in, and results are persisted. err.Error() and panic values are stored in the journal (plaintext even with WithPayloadCodec) and may be logged. Pass IDs; load credentials and personal data insidefnfrom env or a secret manager. Enabling a codec or journal MAC later is not a migrate — see Data privacy. - One task input —
Iis a single value, not variadic args. Bundle multiple fields in one struct; a task with no payload usesstruct{}andstruct{}{}. - One step input —
inis a single value, not variadic args. Bundle multiple fields in one struct. No payload:struct{}andstruct{}{}. Stored for inspect. - Bump step version on the next deploy — resume returns the cached result if
stepIDis unchanged, even whenfnorinchanged. If this step’s code or params change and in-flight runs must re-execute it, setWithStepVersionto a new string ("1"→"2") or rename the stepID (charge→charge-v2). Same version (or no version) = cache. Side effects on re-run are the caller’s problem (idempotent steps). - Same runID to resume —
input.jsonis reloaded; you do not need to pass the original input again.
Runnable examples in examples/ — see examples/README.md for setup and run instructions.
| Example | What it shows |
|---|---|
examples/resume/ |
One go run: crash after step 2, resume from cache |
examples/func-task/ |
Closure-style durable.Func |
examples/struct-task/ |
Struct task with injected deps, retries, timeout |
examples/fanout/ |
Concurrent RunStep, Get-all join, ErrStepPending |
examples/yaml-task/ |
YAML file as one task; each YAML step is a RunStep |
examples/payload-codec/ |
Plaintext vs AES-GCM vs custom codec, HMAC tokens, journal MAC, inspect flags |
cd examples
go run ./resume/
go run ./func-task/
go run ./struct-task/
go run ./fanout/
go run ./yaml-task/
go run ./payload-codec/durable-inspect is a read-only viewer for a journal (task list / task get / step list / step get). --dir / -d wins over DURABLE_DIR. Set DURABLE_PAYLOAD_KEY and DURABLE_JOURNAL_MAC_KEY in the environment (do not pass keys on the command line). Hex is tried first for both. --redact hides INPUT and RESULT.
go install github.com/agenticenv/durable-go/cmd/durable-inspect@latest
durable-inspect -d ./data task listCommands, flags, lookup by name or ID, and the writer-lock behavior: see cmd/durable-inspect/README.md.
The journal is files on disk. Default persist is plaintext JSON (input.json, output.json, step Input/Result, CompleteStep payloads). Do not treat dataDir as a secret store.
Keep secrets out of I/O. Pass order IDs, user IDs, or blob handles. Fetch credentials and PII inside the step fn from the environment or a secret manager.
Optional at-rest codec. WithPayloadCodec wraps those blobs after JSON marshal. Built-in AES-GCM (16/24/32-byte key; never written under dataDir):
key, err := hex.DecodeString(os.Getenv("DURABLE_PAYLOAD_KEY"))
codec, err := durable.NewAESGCMCodec(key)
e, err := durable.NewEngine(ctx, "./data", durable.WithPayloadCodec(codec))Open the same journal with WithPayloadCodec or durable-inspect --payload-key. Resume without the matching codec+key fails closed. Inspect without a key prints stored ciphertext; a wrong --payload-key fails closed. Any reversible Encode/Decode works; AAD binds each blob to kind/task/run/step so ciphertext cannot be copied between fields. --redact is inspect-only — it is not a codec.
type kmsCodec struct{ client KMS }
func (c kmsCodec) Encode(plaintext, aad []byte) ([]byte, error) {
return c.client.Encrypt(plaintext, aad)
}
func (c kmsCodec) Decode(ciphertext, aad []byte) ([]byte, error) {
return c.client.Decrypt(ciphertext, aad)
}
e, err := durable.NewEngine(ctx, "./data", durable.WithPayloadCodec(kmsCodec{client: kms}))File modes. Unix directories are 0700 and files 0600. After the exclusive lock, NewEngine chmods dataDir / tasks/ / .lock and walks existing files once (until .perms_ok is written). It warns if group/world bits remain. Windows chmod is best-effort; encryption still helps there.
Step tokens. CompleteStep tokens are HMAC-signed by default, with a process-ephemeral key and a 24h TTL (WithDefaultStepTokenTTL, per-step WithStepTokenTTL). Tokens issued before a restart are rejected unless you persist a key with WithStepTokenKey. WithUnsignedStepTokens opts out — anyone who can call CompleteStep can then mint a token for any run. The key is not stored in dataDir.
Journal MAC. Default frames end in CRC32 (torn-write detection only). WithJournalMACKey replaces that trailer with HMAC-SHA256 bound to taskID, runID, and the 1-based frame index (journal v2), and also appends a 32-byte HMAC to input.json, output.json, and meta.json (bound to task/run). A copied or reordered journal.log fails closed. The key is not stored in dataDir. Inspect reads DURABLE_JOURNAL_MAC_KEY (hex first, else raw). Deleting files is still possible.
AES-GCM rotation. NewAESGCMCodec writes the v1 envelope (version | nonce | sealed). NewAESGCMCodecWithKeys writes v2 (version | keyID | nonce | sealed) and can decode current, any previous key, and v1 blobs (treated as key ID 0). Rotate before AESGCMRekeyAfter (~2^32) random-nonce seals on one key. A new key ID is not a migrate of an existing plaintext tree — open a second dataDir or keep the old key in the read window.
Same dataDir, same options. One directory, one codec, one journal MAC key (or none). Those options apply to every run in that tree. Do not turn them on later against an existing plaintext/CRC directory — resume and inspect fail closed.
To add AES and/or a journal MAC and keep the old journal: open a second NewEngine on a new dataDir and send new work there. Finish or cancel in-flight runs on the old engine. Wipe the old tree only if you do not need it.
plain, err := durable.NewEngine(ctx, "./data-plain")
secure, err := durable.NewEngine(ctx, "./data-secure",
durable.WithPayloadCodec(codec),
durable.WithJournalMACKey(macKey),
)Inspect: one -d per directory; set DURABLE_PAYLOAD_KEY / DURABLE_JOURNAL_MAC_KEY in the environment (not flags). Walkthrough: examples/payload-codec/. Turning on WithStepTokenKey (or the default ephemeral key) rejects already-issued unsigned tokens.
Inspect. Set DURABLE_PAYLOAD_KEY / DURABLE_JOURNAL_MAC_KEY (not flags). --redact prints [redacted] for INPUT/RESULT even after decrypt. Status, IDs, ERROR, and PANIC stay visible. Details: cmd/durable-inspect/README.md.
Limits. Encryption is at rest versus other local users of the machine — not versus this process or root. Step IDs, status, timestamps, Error, and PanicTrace stay plaintext. Compact copies ciphertext as-is.
Runnable walkthrough: examples/payload-codec/.
Match this table to your app. If your work is one process plus a local journal, durable-go is a fit.
| Use case | Why this library |
|---|---|
| Single-process agent (in-process loop, agent CLI) | Memoize each LLM / tool step so a crash resumes the same run; gate tools with ErrStepPending; inspect progress with WatchSteps / NewReadOnlyEngine. This is how agent-sdk-go uses it by default. |
| Ops CLI (migrate, import, backup, deploy) | Re-run the same command after a crash — completed steps skip; a second process can inspect the journal read-only while the job runs. |
| Daemon / sidecar on one box | On boot, ListTasks + RunTask(runID) resumes in-flight work; webhooks call CompleteStep; CancelRun stops a live run and survives a crash. |
| Cron / batch / ETL on one machine | Skip already-fetched extracts; fan-out parallel source/transform steps and join with Get, or take the first success with select on Done(). |
| Provisioning / install scripts | Sequence create/configure/verify as steps so a failed run does not recreate what already succeeded. |
| Single-process service (orders, payments, reports) | Charge, ship, notify as memoized steps; wait on manager approval or a webhook without blocking sibling work; race multiple providers with first-of-N. |
Persistence is a local journal.log append plus fsync — no extra server. On a MacBook Pro (M2 Pro, Apple NVMe SSD) that is ~4 ms per append, well under 1% of a typical LLM call (~1 s). Replay is a file read; fn does not run again.
These figures are the default persist path: plaintext JSON (no PayloadCodec / AES-GCM) and CRC32 journal frames (no WithJournalMACKey). They do not include HMAC step tokens (the default; CompleteStep only) or task-body re-execution.
| Operation | What is timed | Latency | Memory / op | Allocations |
|---|---|---|---|---|
| Journal append+sync | one appendStep + fsync |
4.1 ms/op |
328 B/op |
7 allocs/op |
loadJournal (100 steps) |
replay of journal.log only — no meta/input load, no fn |
272 µs/op |
269 KB/op |
919 allocs/op |
Completed-run Get |
re-read of output.json for an already-finished run |
33 µs/op |
3.6 KB/op |
31 allocs/op |
Same machine and ops with AES-GCM + journal MAC (NewAESGCMCodec AES-256 and WithJournalMACKey). Append stays fsync-bound; loadJournal and Get pay decrypt/MAC CPU and extra allocations.
| Operation | What is timed | Latency | Memory / op | Allocations |
|---|---|---|---|---|
| Journal append+sync | one appendStep + fsync |
4.1 ms/op |
1140 B/op |
19 allocs/op |
loadJournal (100 steps) |
replay of journal.log only |
337 µs/op |
450 KB/op |
1919 allocs/op |
Completed-run Get |
re-read of output.json |
33 µs/op |
4.9 KB/op |
57 allocs/op |
HDD/NFS will differ. Two ways to measure (not the same command):
- Your disk, with vs without the engine — start here:
go run ./benchmarks/—benchmarks/README.md - Per-op
ns/op(this table) —go test -run=^$ -bench=. -benchmem .—journal_bench_test.go
See CONTRIBUTING.md for setup, workflow, and guidelines. Project policies: SECURITY.md · CODE_OF_CONDUCT.md
Quick commands (requires Task): task check | task test | task lint | task fmt | task tidy | task test-coverage | task bench | task bench-test
Coverage reports (PR and default branch) are on Codecov. Run task test-coverage locally to produce coverage.out and coverage.html.
This project is provided "as is" under the Apache License 2.0. You are responsible for how you persist and handle task data, including secrets and personally identifiable information in step outputs. See Data privacy. For security issues, follow SECURITY.md.