durable

package module
v0.1.5 Latest Latest
Warning

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

Go to latest
Published: Sep 19, 2026 License: Apache-2.0 Imports: 29 Imported by: 0

README

durable-go

CI Security Release Go Reference License

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. Useful for any Go app that needs reliable, resumable workflows without a heavy orchestration framework.

Releases follow Semantic Versioning; see the latest release.

Features

  • Engine APINewEngine, RegisterTask, RunTask, RunStep in a single package.
  • Async fan-outRunStep starts work and returns immediately, matching RunTask; call several before Get-ing any of them.
  • First-of-NStepRun.Done() exposes a read-only channel so you can select across 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 ErrStepPending and complete later via CompleteStep (human approval, webhooks). Suspends only that step's Get, not the whole task — sibling steps keep running.
  • CancellationCancelRun persists a durable cancel signal and cancels the run's ctx immediately if it is executing in this process; every RunStep call, including on a later resume, fails fast with ErrRunCancelled instead of re-running fn.
  • Fire-and-forget runsRunTask returns immediately; TaskRun.Get waits; RunID() is available at once.
  • Step observabilityGetStep / LoadSteps for current state, WatchSteps for 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.Func closures or structs with Exec.
  • Payload privacy — optional PayloadCodec (built-in AES-GCM); owner-only journal modes; inspect --redact.

Why durable-go

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. NewEngine takes an exclusive OS flock on <dataDir>/.lock. Do not open the same directory from two writer processes. Another process can open the same directory with NewReadOnlyEngine (shared lock).

Install

go get github.com/agenticenv/durable-go@latest

Go 1.26+. No infrastructure required. The library module depends only on flock, ulid, and protobuf — examples and benchmarks live in their own modules.

Quick Start

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 result

Full example: examples/func-task/.

Struct-based tasks

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

Pending steps

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"})
Fan-out and first-of-N

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

Cancellation

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. inside waitForSignal after ErrStepPending, or a step function that itself selects on ctx) 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 RunTask for that taskID/runID cancels ctx before Task.Exec is invoked at all — every subsequent RunStep call, including a cached/replayed one, returns ErrRunCancelled immediately without running fn.
  • Cooperative, like any Go ctx. RunStep.Get/RunTask.Get return 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 for Close and timeouts) with TaskInfo.Error equal to durable.ErrRunCancelled.Error(), so callers can tell a deliberate cancel apart from another failure.
Resume

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

Writing tasks

Follow these when you write a task. On resume, the task runs again from the top; completed steps are reused, not re-executed.

  1. 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 in durable.RunStep.
  2. Non-deterministic values in RunStep. Do not use time.Now(), UUIDs, or random values in the task body to choose a step ID or a branch. Generate them inside a RunStep so resume sees the same result.
  3. 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).
  4. Check ctx to be cancellable. CancelRun, WithStepTimeout, and engine Close only cancel ctx — they cannot forcibly stop a step function. Select on ctx.Done() in any loop or long-running step body, and pass ctx to ctx-aware calls (http.NewRequestWithContext, a database/sql *Context method, etc.). A step that never checks ctx keeps 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 (including fmt.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 new RunTask when 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. Use WithAutoPurge so 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 fn executes again under the new name as if it had never run. Treat a stepID like a database column name, not a display label.
  • Concurrent RunStep calls are safe. Start several steps before Get-ing any of them to fan out; join with Get or select on Done(). A duplicate stepID within one run still panics.
  • One stepID is reserved. RunStep panics if stepID is "\x00cancel" — it is reserved internally for CancelRun'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 in must 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 inside fn from env or a secret manager. Enabling a codec or journal MAC later is not a migrate — see Data privacy.
  • One task inputI is a single value, not variadic args. Bundle multiple fields in one struct; a task with no payload uses struct{} and struct{}{}.
  • One step inputin is a single value, not variadic args. Bundle multiple fields in one struct. No payload: struct{} and struct{}{}. Stored for inspect.
  • Bump step version on the next deploy — resume returns the cached result if stepID is unchanged, even when fn or in changed. If this step’s code or params change and in-flight runs must re-execute it, set WithStepVersion to a new string ("1""2") or rename the stepID (chargecharge-v2). Same version (or no version) = cache. Side effects on re-run are the caller’s problem (idempotent steps).
  • Same runID to resumeinput.json is reloaded; you do not need to pass the original input again.

Examples

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/

Inspect CLI

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 list

Commands, flags, lookup by name or ID, and the writer-lock behavior: see cmd/durable-inspect/README.md.

Data privacy & sensitive payloads

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

Use cases

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.

Performance

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

  1. Your disk, with vs without the engine — start here: go run ./benchmarks/benchmarks/README.md
  2. Per-op ns/op (this table)go test -run=^$ -bench=. -benchmem .journal_bench_test.go

Development

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.

License

Apache 2.0

Disclaimer

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.

Documentation

Overview

Package durable provides embeddable durable task execution for a single OS process. Register typed tasks, run memoised steps, and resume from a local journal after a crash. A step may return ErrStepPending and complete later via CompleteStep.

e, err := durable.NewEngine(ctx, "./data")
if err != nil { ... }
defer e.Close()

_ = 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
        }
        return OrderOutput{Result: charged}, nil
    },
))

run := durable.RunTask[OrderInput, OrderOutput](ctx, e, "process-order", "", input)
output, err := run.Get(ctx)

Index

Constants

View Source
const (

	// AESGCMRekeyAfter is the recommended maximum number of Encode calls
	// on one 96-bit-nonce key. Random-nonce birthday collision risk
	// becomes material around 2^32 seals; rotate sooner for high volume.
	AESGCMRekeyAfter uint64 = 1 << 32
)

Variables

View Source
var (
	// ErrTaskNotRegistered is returned by RunTask.Get when the taskID has not
	// been registered on this Engine. The registry is in-memory only and must
	// be rebuilt after every NewEngine call.
	ErrTaskNotRegistered = errors.New("durable: task not registered")

	// ErrTaskAlreadyRegistered is returned by RegisterTask when the same
	// taskID is registered twice on one Engine.
	ErrTaskAlreadyRegistered = errors.New("durable: task already registered")

	// ErrRunAlreadyFinished is returned by CompleteStep when the target step
	// is already completed or the run is in a terminal state (completed or failed).
	ErrRunAlreadyFinished = errors.New("durable: run already completed or failed")

	// ErrRunActive is returned by DeleteTaskRun and DeleteTask when the target run
	// (or any run under the task) is currently executing, including while
	// blocked in StatusWaiting.
	ErrRunActive = errors.New("durable: run is currently executing")

	// ErrInvalidRunID is returned when a runID contains path separators or
	// parent-directory references that would escape the data directory.
	ErrInvalidRunID = errors.New("durable: invalid run ID")

	// ErrEngineLocked is returned by NewEngine and NewReadOnlyEngine when
	// dataDir is already held by another engine instance (same process or
	// another OS process) and the lock cannot be acquired before the timeout.
	ErrEngineLocked = errors.New("durable: dataDir locked by another engine")

	// ErrStepPending is returned from a step function to suspend that step
	// until CompleteStep delivers a result for it. The engine writes
	// StepStatusWaiting and blocks that step's Get — not the whole task.
	// Sibling steps started before this one keep running; call Get on them
	// independently or select on their Done channels.
	ErrStepPending = errors.New("durable: step pending external completion")

	// ErrInvalidToken is returned by CompleteStep when the token cannot be
	// decoded into taskID, runID, and stepID, or when an HMAC token has a
	// missing or wrong MAC (including unsigned tokens while a secret is set).
	ErrInvalidToken = errors.New("durable: invalid step token")

	// ErrTokenExpired is returned by CompleteStep when an HMAC step token is
	// past its TTL.
	ErrTokenExpired = errors.New("durable: step token expired")

	// ErrPayloadTooLarge is returned when a step's persisted record does not
	// fit in one journal frame (32 MiB of encoded input plus result). The step
	// fails rather than writing a frame that replay and compaction could not
	// read back. Keep large blobs out of step results — store a handle and
	// fetch the payload inside fn.
	ErrPayloadTooLarge = errors.New("durable: payload too large for one journal frame")

	// ErrEngineClosed is returned by RunTask when the Engine is already
	// closing or closed, so no new run is started after the exclusive flock
	// on dataDir has been released.
	ErrEngineClosed = errors.New("durable: engine is closed")

	// ErrRunCancelled is the error stored on a run (TaskInfo.Error, as its
	// string form) and returned by RunStep/Get after CancelRun. It marks
	// the run as StatusFailed for the same reason engine Close and task/run
	// timeouts already do — but with a distinct message so callers can
	// tell a deliberate CancelRun apart from a generic ctx cancellation or
	// deadline. Because TaskInfo.Error is a plain string, matching after a
	// resume requires comparing against ErrRunCancelled.Error(), not
	// errors.Is.
	ErrRunCancelled = errors.New("durable: run was cancelled")
)

Functions

func CompleteStep added in v0.1.2

func CompleteStep[O any](ctx context.Context, e *Engine, token string, result O) error

CompleteStep delivers result to a suspended step identified by token. The payload is appended as a SignalEntry (durable) before the in-process waiter is signalled, so a crash after this call still resumes on the next RunTask. A second call with the same token is a no-op once the SignalEntry is on disk. Returns ErrRunAlreadyFinished if the step or run is already terminal, ErrInvalidToken if the token cannot be decoded or authenticated, and ErrTokenExpired if an HMAC token is past its TTL.

The run's status transition back to StatusRunning is owned by the waiting step itself (see leaveWaiting), not by CompleteStep — with several steps possibly waiting at once, only the step that actually resumes knows whether a sibling is still pending.

Concurrent CompleteStep calls for the same token are serialised on a per-(taskID,runID,stepID) lock distinct from the run-execution lock (which is held for the entire run, including while blocked waiting — locking it here would deadlock). A CompleteStep that loses a race with the run reaching a terminal state may append an orphan SignalEntry that is never read; this is harmless and does not corrupt the journal.

func RegisterTask added in v0.1.2

func RegisterTask[I, O any](e *Engine, taskID string, task Task[I, O], opts ...TaskOption) error

RegisterTask stores taskID → (closure + config) in the in-memory registry. Must be called before RunTask. I is the single task input type (one struct of fields, or struct{} if the task has no payload). Re-registering the same taskID returns ErrTaskAlreadyRegistered. The registry is not persisted; call this again after every NewEngine. taskID must not contain path separators or ':'.

Types

type AESGCMKey added in v0.1.5

type AESGCMKey struct {
	ID  byte
	Key []byte
}

AESGCMKey is one AES-GCM key plus the identifier written next to each ciphertext when using the v2 wire format. ID 0 is also how v1 blobs (no key-ID byte) are decoded.

type Engine added in v0.1.2

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

Engine is the process-level entry point for durable task execution. Create one per dataDir via NewEngine. Safe for concurrent use.

Always call Close to cancel in-flight runs, release the exclusive flock, stop the purger, and close journal file handles.

func NewEngine added in v0.1.2

func NewEngine(ctx context.Context, dataDir string, opts ...Option) (*Engine, error)

NewEngine opens or creates dataDir, acquires an exclusive flock on <dataDir>/.lock, and initialises the in-memory task registry. Fails with ErrEngineLocked if another Engine or ReadOnlyEngine holds the directory.

func (*Engine) CancelRun added in v0.1.3

func (e *Engine) CancelRun(ctx context.Context, taskID, runID string) error

CancelRun requests cancellation of a run. It persists a durable cancel signal — reusing the SignalEntry/CompleteStep journal plumbing via a reserved signal ID — so the intent survives a crash: on the next RunTask for this taskID/runID, the run's ctx is cancelled before Task.Exec is invoked, and every RunStep call fails fast with ErrRunCancelled instead of re-running fn. If the run is currently executing in this process, its ctx is also cancelled immediately.

Cancelling ctx only signals a step function; it cannot forcibly stop one. RunStep.Get and RunTask.Get both return promptly regardless — they select on ctx.Done() independently of whether the step's goroutine has exited. But the engine still waits for that goroutine to actually return before the run reaches a terminal state or Close/compactJournal can safely proceed (see Close). If the step function never checks ctx and never returns, that goroutine runs to completion in the background and the run stays non-terminal until it does — Close called afterward would block on it too. Write step functions so they select on ctx instead of running unconditionally to completion.

The run ends up StatusFailed (the same terminal status used for engine Close and task/run timeouts) with TaskInfo.Error set to ErrRunCancelled.Error(), so callers can distinguish a deliberate cancel from another failure by comparing that string.

Returns ErrRunAlreadyFinished if the run is already StatusCompleted or StatusFailed, or an error if taskID/runID is invalid or the run does not exist. Idempotent: calling it more than once on the same run is a no-op after the first call's signal is durably written.

func (*Engine) Close added in v0.1.2

func (e *Engine) Close() error

Close cancels in-flight runs, waits for them and the auto-purger to finish writing, then releases the exclusive flock and journal handles. Safe to call more than once.

Cancelling ctx only signals — it does not forcibly stop a step function. Close waits for every in-flight RunStep goroutine to actually return (see StepRunner.waitInFlight) so a step's own journal append can never race compactJournal or a second Engine opening the same dataDir. If a step function never checks ctx and never returns, Close blocks forever and the exclusive flock on dataDir is never released. Write step functions so they select on ctx (or pass it to ctx-aware calls like http.NewRequestWithContext) instead of running unconditionally to completion.

func (*Engine) DeleteTask added in v0.1.2

func (e *Engine) DeleteTask(ctx context.Context, taskID string) error

DeleteTask removes all runs under taskID. Destructive — use for a full wipe only. Returns ErrRunActive if any run under taskID is currently executing (Running or Waiting in-process).

func (*Engine) DeleteTaskRun added in v0.1.2

func (e *Engine) DeleteTaskRun(ctx context.Context, taskID, runID string) error

DeleteTaskRun removes one run directory and its journal. No-op if not found. Returns ErrRunActive if the run is currently executing, including while blocked in StatusWaiting — runLocks is held for that entire duration.

func (*Engine) GetStep added in v0.1.3

func (e *Engine) GetStep(ctx context.Context, taskID, runID, stepID string) (StepRecord, bool, error)

GetStep returns the latest StepRecord for one stepID in O(1) after one journal load — (zero, false, nil) if the run has no record for that stepID yet (including one stuck mid-execution: an unfinished STARTED step never appears here — see loadJournal).

func (*Engine) GetTask added in v0.1.2

func (e *Engine) GetTask(ctx context.Context, taskID, runID string) (TaskInfo, bool, error)

GetTask returns a single run's metadata. (zero, false, nil) if not found.

func (*Engine) ListTasks added in v0.1.2

func (e *Engine) ListTasks(ctx context.Context, statuses ...TaskStatus) ([]TaskInfo, error)

ListTasks returns TaskInfo records. Zero args returns every status. Pass explicit statuses to filter, e.g. ListTasks(ctx, StatusRunning, StatusWaiting) for recovery after a restart.

func (*Engine) ListTasksPage added in v0.1.5

func (e *Engine) ListTasksPage(ctx context.Context, offset, limit int, statuses ...TaskStatus) (TaskPage, error)

ListTasksPage is ListTasks with a 0-based offset into the sorted result. limit <= 0 means the rest of the list. Use it when dataDir holds more runs than you want to load at once (recovery, dashboards, purge).

func (*Engine) LoadInput added in v0.1.4

func (e *Engine) LoadInput(ctx context.Context, taskID, runID string) ([]byte, bool, error)

LoadInput returns the JSON-encoded task input written on first RunTask (one I value). (nil, false, nil) if input.json is missing.

func (*Engine) LoadSteps added in v0.1.2

func (e *Engine) LoadSteps(ctx context.Context, taskID, runID string) ([]StepRecord, error)

LoadSteps returns all StepRecords for a run. Used to inspect progress. Includes waiting, completed, and failed steps. Order is not sorted or otherwise guaranteed — it reflects map iteration order internally. Use WatchSteps if you need append order or history.

func (*Engine) WatchSteps added in v0.1.3

func (e *Engine) WatchSteps(ctx context.Context, taskID, runID string, fromOffset int, fromByteOffset int64) (<-chan StepEvent, error)

WatchSteps streams step lifecycle events for a run: STARTED, WAITING, COMPLETED, FAILED — every append, in journal order. fromOffset (event count) or fromByteOffset (file position) skip already-seen history; fromByteOffset takes priority when non-zero (pass 0 for fromOffset if you only have a byte offset). Already-written events after that point are sent first, then each new event as it is persisted. The channel closes when ctx is cancelled or the engine closes; cancelling the watch does not stop the run. A slow consumer may miss live events — a warning is logged and delivery continues; reconnect with the last Offset/ByteOffset seen to catch up from the journal.

type Option

type Option func(*engineConfig, *readOnlyConfig)

Option configures NewEngine and NewReadOnlyEngine. Writer-only options (WithAutoPurge, WithMaxRetries, WithTimeout, WithStepTokenKey, WithDefaultStepTokenTTL) are ignored by NewReadOnlyEngine.

func WithAutoPurge

func WithAutoPurge(age time.Duration, interval ...time.Duration) Option

WithAutoPurge starts a background goroutine that deletes Completed and Failed runs whose UpdatedAt is older than age. One pass runs at NewEngine (so a process that restarts more often than interval still purges). The optional interval controls later ticks; it defaults to one hour. Running and Waiting runs are never purged. Combine with WithAutoPurgeMaxRuns / WithAutoPurgeMaxBytes to cap disk. Ignored by NewReadOnlyEngine.

func WithAutoPurgeMaxBytes added in v0.1.5

func WithAutoPurgeMaxBytes(n int64) Option

WithAutoPurgeMaxBytes deletes the oldest Completed/Failed runs when terminal-run directories exceed n bytes on disk. Zero (default) means no size cap. Starts the purger even when WithAutoPurge is omitted. Ignored by NewReadOnlyEngine.

func WithAutoPurgeMaxRuns added in v0.1.5

func WithAutoPurgeMaxRuns(n int) Option

WithAutoPurgeMaxRuns deletes the oldest Completed/Failed runs when the number of those terminal runs exceeds n. Running and Waiting runs are never counted or removed. Zero (default) means no count cap. Starts the purger even when WithAutoPurge is omitted. Ignored by NewReadOnlyEngine.

func WithDefaultStepTokenTTL added in v0.1.5

func WithDefaultStepTokenTTL(d time.Duration) Option

WithDefaultStepTokenTTL sets how long HMAC StepToken values remain valid. Ignored when no step-token key is configured. Zero means no expiry. Omit this option to use the default of 24 hours when a key is set. Overridden per step by WithStepTokenTTL. Ignored by NewReadOnlyEngine.

func WithJournalMACKey added in v0.1.5

func WithJournalMACKey(key []byte) Option

WithJournalMACKey signs journal.log frames and the run sidecar files (input.json, output.json, meta.json) with HMAC-SHA256. Frame MACs are bound to taskID, runID, and the 1-based frame index (journal v2) so a journal.log cannot be copied between runs or have its frames reordered. Sidecar MACs are bound to taskID/runID. Omit it (or pass nil/empty) to keep CRC32 journal trailers and unsigned JSON sidecars, the default. Enabling a MAC on an existing CRC tree is not a migrate. The key is copied and never written under dataDir. Required on NewReadOnlyEngine to read a MAC-signed tree.

func WithLockTimeout added in v0.1.2

func WithLockTimeout(d time.Duration) Option

WithLockTimeout sets how long NewEngine waits for the exclusive flock and NewReadOnlyEngine waits for the shared flock. Default is 2 seconds.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the slog.Logger for NewEngine and NewReadOnlyEngine. If nil or omitted, a discard logger is used.

func WithMaxRetries

func WithMaxRetries(n int) Option

WithMaxRetries sets the engine-wide default for task-level retries (re-invoking the task closure). Default is 0 — retries are opt-in so non-idempotent work is not silently repeated. Overridden by WithTaskMaxRetries and WithRunMaxRetries. Ignored by NewReadOnlyEngine.

func WithPayloadCodec added in v0.1.5

func WithPayloadCodec(c PayloadCodec) Option

WithPayloadCodec sets the codec for task input/output, step input/result, and CompleteStep signal payloads on NewEngine and NewReadOnlyEngine. nil is identity (plaintext).

func WithStepTokenKey added in v0.1.5

func WithStepTokenKey(key []byte) Option

WithStepTokenKey sets the key used to sign StepToken values that CompleteStep accepts. Pass one when tokens must stay valid across a restart: without it NewEngine signs with a per-process random key, so tokens issued before a restart are rejected afterwards. The key is copied and never written under dataDir. Ignored by NewReadOnlyEngine.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout sets the engine-wide default task deadline. Zero (default) means no timeout. Overridden by WithTaskTimeout and WithRunTimeout. Ignored by NewReadOnlyEngine.

func WithUnsignedStepTokens added in v0.1.5

func WithUnsignedStepTokens() Option

WithUnsignedStepTokens opts out of authenticated step tokens: StepToken returns a plain taskID:runID:stepID triple with no signature and no expiry. Anyone who can reach CompleteStep can mint one for any run and step, so use this only where the CompleteStep caller is already authenticated by other means and tokens must survive a restart. WithStepTokenKey gives you both properties and takes precedence over this option. Ignored by NewReadOnlyEngine.

type PayloadCodec added in v0.1.5

type PayloadCodec interface {
	Encode(plaintext, aad []byte) ([]byte, error)
	Decode(ciphertext, aad []byte) ([]byte, error)
}

PayloadCodec transforms task and step payload bytes at the persistence boundary. Encode runs after JSON marshal and before the bytes are written; Decode runs after a read and before JSON unmarshal. Implementations must be reversible — hashing or redaction here would break resume.

aad binds the blob to its location (kind, taskID, runID, stepID) so a ciphertext cannot be copied between fields or steps. Omit WithPayloadCodec (or pass nil) to persist JSON plaintext, the default.

func NewAESGCMCodec added in v0.1.5

func NewAESGCMCodec(key []byte) (PayloadCodec, error)

NewAESGCMCodec returns a PayloadCodec that wraps payloads as version | nonce | ciphertext+tag (AES-GCM v1). key must be 16, 24, or 32 bytes (AES-128/192/256). The caller owns key storage (env / secret manager); this value is copied and never persisted by the engine.

Prefer NewAESGCMCodecWithKeys when you need a key ID on the wire and a dual-key read window for rotation.

func NewAESGCMCodecWithKeys added in v0.1.5

func NewAESGCMCodecWithKeys(current AESGCMKey, previous ...AESGCMKey) (PayloadCodec, error)

NewAESGCMCodecWithKeys encodes with current (v2: version | keyID | nonce | sealed) and can decode current, any previous key, and v1 blobs (treated as key ID 0). Pass the retiring key as previous so in-flight journals keep reading during a rotation. Rotate before AESGCMRekeyAfter seals on one key.

type ReadOnlyEngine added in v0.1.2

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

ReadOnlyEngine is a compile-time-restricted view of a dataDir. It acquires a shared flock so multiple readers can coexist. It cannot register or run tasks. Safe to open from a separate CLI process with no task registration.

func NewReadOnlyEngine added in v0.1.2

func NewReadOnlyEngine(dataDir string, opts ...Option) (*ReadOnlyEngine, error)

NewReadOnlyEngine acquires a shared flock on <dataDir>/.lock. Multiple readers coexist. Returns ErrEngineLocked after the lock timeout if a writer holds the exclusive lock.

func (*ReadOnlyEngine) Close added in v0.1.2

func (r *ReadOnlyEngine) Close() error

Close releases the shared flock. Safe to call more than once.

func (*ReadOnlyEngine) GetStep added in v0.1.3

func (r *ReadOnlyEngine) GetStep(ctx context.Context, taskID, runID, stepID string) (StepRecord, bool, error)

GetStep returns the latest StepRecord for one stepID. Same semantics as Engine.GetStep.

func (*ReadOnlyEngine) GetTask added in v0.1.2

func (r *ReadOnlyEngine) GetTask(ctx context.Context, taskID, runID string) (TaskInfo, bool, error)

GetTask returns a single run's metadata. (zero, false, nil) if not found.

func (*ReadOnlyEngine) ListTasks added in v0.1.2

func (r *ReadOnlyEngine) ListTasks(ctx context.Context, statuses ...TaskStatus) ([]TaskInfo, error)

ListTasks returns TaskInfo records with the same filter semantics as Engine.ListTasks.

func (*ReadOnlyEngine) ListTasksPage added in v0.1.5

func (r *ReadOnlyEngine) ListTasksPage(ctx context.Context, offset, limit int, statuses ...TaskStatus) (TaskPage, error)

ListTasksPage is ListTasksPage for a read-only engine.

func (*ReadOnlyEngine) LoadInput added in v0.1.4

func (r *ReadOnlyEngine) LoadInput(ctx context.Context, taskID, runID string) ([]byte, bool, error)

LoadInput returns the JSON-encoded task input written on first RunTask (one I value). Same semantics as Engine.LoadInput.

func (*ReadOnlyEngine) LoadSteps added in v0.1.2

func (r *ReadOnlyEngine) LoadSteps(ctx context.Context, taskID, runID string) ([]StepRecord, error)

LoadSteps returns all StepRecords for a run. Same semantics as Engine.LoadSteps.

type RunOption added in v0.1.2

type RunOption func(*runConfig)

RunOption configures a single RunTask call.

func WithRunMaxRetries added in v0.1.2

func WithRunMaxRetries(n int) RunOption

WithRunMaxRetries overrides the task and engine task-level retry count for this run. An explicit 0 disables a non-zero parent default.

func WithRunTimeout added in v0.1.2

func WithRunTimeout(d time.Duration) RunOption

WithRunTimeout overrides the task and engine timeout for this run. An explicit 0 disables a non-zero parent timeout.

type StepEvent added in v0.1.3

type StepEvent struct {
	StepRecord
	Offset     int
	ByteOffset int64
	// Generation is TaskInfo.JournalGeneration when this event was
	// observed. compactJournal increments it and rewrites offsets from 1,
	// so a WatchSteps cursor is only valid while Generation is unchanged.
	Generation int
}

StepEvent is one journal entry delivered by WatchSteps: STARTED (running), WAITING, COMPLETED, or FAILED — every append, in journal order (never last-write-wins). Offset is the 1-based position of this event across the run's whole journal (steps and signals share one counter); ByteOffset is the file position immediately after it. Reconnect with either value as fromOffset/fromByteOffset to resume a watch without missing or repeating events; byteOffset skips a full rescan, offset does not.

type StepOption added in v0.1.2

type StepOption func(*stepConfig)

StepOption configures a single RunStep call.

func WithStepMaxRetries added in v0.1.2

func WithStepMaxRetries(n int) StepOption

WithStepMaxRetries sets how many times this step's function is re-invoked on a non-panic, non-ErrStepPending error. Default is 0 (one attempt). Does not inherit from task or engine retry settings.

func WithStepTimeout added in v0.1.2

func WithStepTimeout(d time.Duration) StepOption

WithStepTimeout is an inner bound: the step deadline is min(task deadline, step timeout). It cannot extend past the task deadline. The deadline only cancels the step's ctx — it does not forcibly stop fn. A fn that never checks ctx keeps running past the deadline in the background, and the engine (Close, a later CancelRun's drain, etc.) still waits for it to actually return.

func WithStepTokenTTL added in v0.1.5

func WithStepTokenTTL(d time.Duration) StepOption

WithStepTokenTTL overrides the engine StepToken TTL for this step. Ignored when no step-token key is configured. Zero means no expiry for this step.

func WithStepVersion added in v0.1.4

func WithStepVersion(v string) StepOption

WithStepVersion tags this step so a later deploy can opt into re-running it. Resume still returns the cached result when the stored version equals v. If this step's code or params change and in-flight runs must re-execute it, pass a new v ("1" → "2") or rename the stepID. Empty v is a no-op (cache by stepID only). Side effects on re-run are the caller's problem.

type StepRecord

type StepRecord struct {
	StepID      string
	Version     string
	Input       []byte
	Result      []byte
	Error       string
	PanicTrace  string
	Status      StepStatus
	StartedAt   time.Time
	CompletedAt time.Time
}

StepRecord is the latest persistent checkpoint for one memoised step.

type StepRun added in v0.1.2

type StepRun[O any] struct {
	// contains filtered or unexported fields
}

StepRun is the handle returned by RunStep. RunStep starts work and returns immediately; Get waits for the result, Done reports readiness without blocking so callers can select across several handles (first-of-N).

func RunStep added in v0.1.2

func RunStep[I, O any](ctx context.Context, s *StepRunner, stepID string, in I, fn func(ctx context.Context, in I) (O, error), opts ...StepOption) *StepRun[O]

RunStep starts fn as a memoised checkpoint and returns immediately. Get waits for the result. On a completed or failed cache hit, fn is not called. On a miss, fn runs on a goroutine and the result is persisted. in is a single JSON-marshalable value (one struct of fields, or struct{} if the step has no payload); it is stored on the record for inspect. stepID must be unique within the run — a duplicate panics. Concurrent calls on the same StepRunner are safe: start several steps, then Get them in any order, or select on their Done channels for first-of-N.

Step IDs are the resume key: never rename a stepID once a run has started. A renamed step is treated as a new, unrelated step — the old result is orphaned and fn runs again under the new name. To re-run the same stepID after a code or param change, pass WithStepVersion with a new string.

func (*StepRun[O]) Done added in v0.1.3

func (r *StepRun[O]) Done() <-chan struct{}

Done reports readiness. It is closed once the step completes or fails — on a cache hit it is already closed when RunStep returns. Use it in a select across multiple StepRun handles to react to whichever finishes first, then call Get to retrieve that handle's result or error.

func (*StepRun[O]) Get added in v0.1.2

func (r *StepRun[O]) Get(ctx context.Context) (O, error)

Get blocks until the step completes or ctx is cancelled. The result is already available on a cache hit.

func (*StepRun[O]) StepID added in v0.1.2

func (r *StepRun[O]) StepID() string

StepID returns the stepID this handle corresponds to.

type StepRunner

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

StepRunner is scoped to a single run and provides RunStep, StepToken, and run-context accessors. Concurrent RunStep calls are safe: fan out work by calling RunStep multiple times before Get-ing any handle.

func (*StepRunner) Logger added in v0.1.2

func (s *StepRunner) Logger() *slog.Logger

Logger returns the engine logger pre-scoped with taskID and runID.

func (*StepRunner) RunID added in v0.1.2

func (s *StepRunner) RunID() string

RunID returns the runID of the current run.

func (*StepRunner) StepToken added in v0.1.2

func (s *StepRunner) StepToken(ctx context.Context) string

StepToken returns an opaque token encoding taskID, runID, and the current stepID. Must be called inside a RunStep function with that function's ctx (the stepID is carried on ctx, not on the StepRunner, so concurrent steps each get their own token). Pass the token to an external caller so they can later call CompleteStep. With WithStepTokenKey the token is HMAC-signed and may expire (default 24h, or WithDefaultStepTokenTTL / WithStepTokenTTL).

func (*StepRunner) TaskID added in v0.1.2

func (s *StepRunner) TaskID() string

TaskID returns the taskID of the current run.

type StepStatus

type StepStatus string

StepStatus is the lifecycle state of a single step checkpoint.

const (
	// StepStatusRunning means the step function is currently executing.
	// Watch-only: it is superseded by a terminal or waiting status once the
	// step finishes, and is never the record used to replay RunStep/GetStep —
	// an unfinished StepStatusRunning after a crash is treated as missing
	// (fn re-runs), not stuck.
	StepStatusRunning StepStatus = "running"
	// StepStatusWaiting means the step is suspended and awaiting CompleteStep.
	StepStatusWaiting StepStatus = "waiting"
	// StepStatusCompleted means the step succeeded and its result is cached.
	StepStatusCompleted StepStatus = "completed"
	// StepStatusFailed means the step returned an error or panicked.
	StepStatusFailed StepStatus = "failed"
)

type Task

type Task[I, O any] interface {
	// Exec performs the task logic. ctx is cancelled when the task timeout
	// elapses or the engine is closed. s is the StepRunner bound to this
	// run; wrap all memoised work in RunStep calls. Panics are recovered
	// by the engine, recorded in TaskInfo.PanicTrace, and surfaced to Get.
	Exec(ctx context.Context, s *StepRunner, input I) (O, error)
}

Task is the execution contract for a durable task. Implementations should treat any work with external side effects as a RunStep; non-deterministic logic outside of RunStep calls may not be replayed correctly. I is the single input type (not variadic args); O is the output type. Bundle several fields in one struct. A task with no payload uses I = struct{} and RunTask(..., struct{}{}).

type TaskFunc

type TaskFunc[I, O any] func(ctx context.Context, s *StepRunner, input I) (O, error)

TaskFunc adapts a plain function to Task[I, O].

func Func

func Func[I, O any](fn func(ctx context.Context, s *StepRunner, in I) (O, error)) TaskFunc[I, O]

Func wraps a plain function as a Task, triggering Go's generic type inference so callers do not need to specify type parameters explicitly. in I is the single task input; see Task and RunTask.

func (TaskFunc[I, O]) Exec

func (f TaskFunc[I, O]) Exec(ctx context.Context, s *StepRunner, input I) (O, error)

Exec implements Task[I, O] for TaskFunc.

type TaskInfo

type TaskInfo struct {
	TaskID      string            `json:"task_id"`
	RunID       string            `json:"run_id"`
	Name        string            `json:"name"`
	Tags        map[string]string `json:"tags"`
	Status      TaskStatus        `json:"status"`
	Error       string            `json:"error"`
	PanicTrace  string            `json:"panic_trace"`
	CreatedAt   time.Time         `json:"created_at"`
	StartedAt   time.Time         `json:"started_at"`
	CompletedAt time.Time         `json:"completed_at"`
	UpdatedAt   time.Time         `json:"updated_at"`
	// OwnerPID is the process that last wrote this meta while the run was
	// Running. After a crash it still names the dead process, so operators
	// can tell an orphaned in-flight run from one this engine started.
	OwnerPID int `json:"owner_pid,omitempty"`
	// JournalGeneration increments each time compactJournal rewrites
	// journal.log. WatchSteps Offset/ByteOffset are only valid for the
	// generation they were observed in.
	JournalGeneration int `json:"journal_generation,omitempty"`
}

TaskInfo is the persistent metadata for a single run. The task input is stored separately in input.json (see RunTask), not on this struct.

type TaskOption

type TaskOption func(*taskConfig)

TaskOption configures RegisterTask.

func WithName

func WithName(name string) TaskOption

WithName sets a human-readable label stored on TaskInfo. It does not affect uniqueness or execution.

func WithTag

func WithTag(k, v string) TaskOption

WithTag attaches an arbitrary key-value annotation to TaskInfo. Call multiple times to set multiple tags.

func WithTaskMaxRetries added in v0.1.2

func WithTaskMaxRetries(n int) TaskOption

WithTaskMaxRetries overrides the engine default for task-level retries (re-invoking the whole closure). Nil-vs-set is tracked so an explicit 0 disables a non-zero engine default.

func WithTaskTimeout added in v0.1.2

func WithTaskTimeout(d time.Duration) TaskOption

WithTaskTimeout overrides the engine default task deadline. An explicit 0 disables a non-zero engine default.

type TaskPage added in v0.1.5

type TaskPage struct {
	Tasks   []TaskInfo
	Offset  int
	Limit   int
	HasMore bool
}

TaskPage is one window of ListTasksPage.

type TaskRun added in v0.1.2

type TaskRun[O any] struct {
	// contains filtered or unexported fields
}

TaskRun is the handle returned by RunTask. RunID is available immediately; Get blocks until the run reaches a terminal state.

func RunTask added in v0.1.2

func RunTask[I, O any](ctx context.Context, e *Engine, taskID string, runID string, input I, opts ...RunOption) *TaskRun[O]

RunTask starts or resumes a run in a background goroutine and returns immediately. input is one typed value I — not variadic args. Put several fields on one struct; a task with no payload uses struct{} and struct{}{}. On first start the value is written to input.json. The same runID reloads that file and ignores the input argument. Pass an empty runID to resume the oldest active run for taskID, or to generate a new ULID if none is active. A completed or failed run returns the stored result without spawning a goroutine.

func (*TaskRun[O]) Get added in v0.1.2

func (r *TaskRun[O]) Get(ctx context.Context) (O, error)

Get blocks until the run completes or ctx is cancelled. The typed result is cached after the first successful wait so later calls do not re-decode.

func (*TaskRun[O]) RunID added in v0.1.2

func (r *TaskRun[O]) RunID() string

RunID returns the resolved runID. Empty if the taskID was not registered or the runID was invalid — check Get for the error.

func (*TaskRun[O]) Status added in v0.1.2

func (r *TaskRun[O]) Status() TaskStatus

Status returns the current run status without blocking.

type TaskStatus

type TaskStatus string

TaskStatus is the lifecycle state of a run.

const (
	// StatusRunning means the run is actively executing.
	StatusRunning TaskStatus = "running"
	// StatusWaiting means at least one step is awaiting CompleteStep.
	StatusWaiting TaskStatus = "waiting"
	// StatusCompleted means the run finished successfully.
	StatusCompleted TaskStatus = "completed"
	// StatusFailed means the run terminated with an error or recovered panic.
	StatusFailed TaskStatus = "failed"
)

Directories

Path Synopsis
cmd
durable-inspect command
Command durable-inspect is a read-only viewer for a durable-go journal.
Command durable-inspect is a read-only viewer for a durable-go journal.
Package durablepb contains the protobuf-generated wire types for journal.log entries.
Package durablepb contains the protobuf-generated wire types for journal.log entries.

Jump to

Keyboard shortcuts

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