Steps and data flow
The step kinds and what a task receives
A step is a named node in the graph.
A task is one execution unit of a step: a plain step has one, a parallel group has one per member, a fan-out has one per item, and a child step’s task is a whole child instance.
An attempt is one run of a task’s handler.
Steps are identified by name. Renaming one is a change of graph, so it needs a new version .
Step kinds#
| Kind | Constructor | Tasks | Notes |
|---|---|---|---|
| Plain | workflow.Step(name, opts...) | 1 | The common case |
| Parallel group | workflow.Parallel(name, steps...) | One per member | Members run concurrently |
| Fan-out | workflow.ForEach(name, opts...) | One per item, sized at runtime | Items come from an upstream step’s output |
| Child workflow | workflow.Child(name, opts...) | 1 (a child instance) | Runs another registered definition |
| Wait for event | workflow.WaitForEvent(name, opts...) | 0 | Parks the instance until RaiseEvent or a deadline |
| Loop | workflow.Loop(name, steps...) | 0 | Repeats its body until a condition holds |
The handler contract#
// RunFunc performs one attempt of a task and returns the output recorded for it
type RunFunc func(ctx context.Context, t workflow.Task) (output any, err error)
// CompensateFunc undoes the effect of one task that had completed successfully
type CompensateFunc func(ctx context.Context, c workflow.Compensation) errorA handler is a plain function. It may call the clock, do I/O, use randomness, and start goroutines.
A handler must be idempotent. Delivery is at-least-once, so a handler could be invoked twice.
type Task interface {
// Identity, which is also what every log line and span is tagged with
InstanceID() string
Workflow() string
Step() string
// Index is the position within a parallel group or fan-out, and -1 for a plain step
Index() int
// Attempt is 1 on the first execution and increases with each retry
Attempt() int
// DecodeInput reads the workflow input, as given to Start
DecodeInput(into any) error
// DecodeItem reads this task's fan-out item, and is a no-op otherwise
DecodeItem(into any) error
// DecodeOutput reads the output of an upstream step
// It returns ErrStepSkipped when that step was skipped
DecodeOutput(step string, into any) error
}A compensation gets everything a task does, plus two more:
type Compensation interface {
workflow.Task
// DecodeResult reads the output this task produced when it succeeded
DecodeResult(into any) error
// Cause is the error that caused the workflow to unwind, or the cancellation reason
Cause() string
}What a task receives#
Each task receives only what its step declared it needs:
- The workflow input, as given to
Start - The output of the immediately preceding step
- The outputs of any steps named with
WithInputFrom("a", "b") - For a fan-out task, its item
workflow.Step("verify",
workflow.WithRun(verifyTenant),
// Read the database step's output too, not only the preceding step's
workflow.WithInputFrom("provision-database"),
)DecodeOutput returns ErrStepNotFound for any other step.
What a step outputs#
A handler returns (any, error). The value is JSON-encoded and recorded, subject to WithMaxOutputSize. What later steps see depends on the kind:
| Kind | Output seen by later steps |
|---|---|
| Plain | The handler’s return value |
| Parallel group | An object keyed by member name, each member’s output as its value |
| Fan-out | An array of the tasks’ outputs, ordered by item index - a failed item carries {"error": "…"} under TolerateFailures |
| Child workflow | The child instance’s output |
| Wait for event | The event’s payload |
| Skipped | Absent, DecodeOutput returns ErrStepSkipped |
Outputs are for control flow and small results. Keep large blobs in an object store and return a reference:
func generateThumbnail(ctx context.Context, t workflow.Task) (any, error) {
// ... encode, then write to the store ...
// Return the handle, never the bytes
return thumbnailResult{Key: key, Width: img.Width, Height: img.Height}, nil
}Every task’s output is recorded in the instance, capped in total by WithMaxJournalSize (1 MiB by default).
Keep the number of tasks multiplied by a typical output well under that cap: a few hundred tasks returning under a kilobyte each is comfortable.
For a wider fan-out, use a child workflow
per batch.
Retries and how a handler can fail#
WithMaxAttempts and WithRetryBackoff decide how many attempts a task gets and how long to wait between them. Compensations get a budget of their own.
WithAttemptTimeout bounds one handler invocation. Its clock starts inside the worker after Francis assigns capacity, so time in the durable queue and retry backoff do not consume it. Every retry receives a fresh execution budget. A fan-out applies the timeout independently to each task rather than to the fan-out as a whole.
The timeout cancels the context passed to the handler. A handler should pass that context to blocking calls and stop when it is cancelled, and an attempt that returns after its deadline is still reported as failed.
Call GetStatus to show the attempts each task has spent and why the last one failed.
| Return | What happens |
|---|---|
nil error | The output is recorded and the task completes. |
| An ordinary error | A failed attempt is recorded and retried per WithMaxAttempts, after the WithRetryBackoff delay. |
actor.ErrJobPermanentFailure | The task fails permanently, without further attempts. |
actor.ErrJobRejected | This host declines the task so another runs it, without counting an attempt. |
Deciding between the second and third is the handler’s choice.
Return an error as-is for transient failures, where retrying could succeed. Return ErrJobPermanentFailure (also wrapped/joined) when it cannot:
src, err := store.ReadOriginal(ctx, in.SourceKey)
if errors.Is(err, store.ErrNotFound) {
// This will never succeed, so do not spend the remaining attempts
return nil, errors.Join(actor.ErrJobPermanentFailure, err)
} else if err != nil {
// The store may recover, so this is worth retrying
return nil, err
}Per-step options#
| Option | Description |
|---|---|
WithRun(fn) | The handler - required for a plain step and for a fan-out that is not a child fan-out |
WithCompensate(fn) | The function that undoes a completed task of this step |
WithMaxAttempts(n) | How many attempts a task gets before it is failed (defaults to 3) |
WithRetryBackoff(initial, max) | The delay before the next attempt, and the max backoff (defaults to 2s and 1 minute) |
WithCompensateMaxAttempts(n) | Same as WithMaxAttempts, but for the compensation (defaults to 10) |
WithCompensateBackoff(initial, max) | Same as WithBackoff, but for the compensation (defaults to 10s and 10 minutes) |
WithAttemptTimeout(d) | Maximum execution time for each handler attempt, excluding queue wait and retry backoff |
WithCompensateTimeout(d) | Maximum execution time for each compensation attempt, excluding queue wait and retry backoff |
WithOptional() | This step’s failure does not fail the instance |
WithSkipOnFailure(steps...) | Steps to skip when this one fails |
WithSkipIf(step, value) | Skip this step when the named upstream step’s output equals value |
WithInputFrom(steps...) | Extra upstream outputs this step’s tasks receive |
WithRequiredCapability(cap) | Run this step’s tasks only on hosts advertising the capability |
WithCompensateOnFailure() | Compensate this step even when it failed |
WithItemsFrom(step) | (fan-out) The step whose output supplies the items |
WithMaxParallel(n) | (fan-out) How many tasks are in flight per instance |
WithFailurePolicy(p) | (group or fan-out) What a failing task costs the step. On a group, set it with .With(...) |
WithChild(wf) / WithDefinition(wf) | (fan-out / child step) The definition to run |
WithEventTimeout(d) / WithEventName(n) | (wait step) How long to wait, and for what |
Handling failures#
Two independent options say what a step’s failure means:
| Declared | After the step fails | Named dependents | Terminal status |
|---|---|---|---|
| (default) | The workflow unwinds | — | failed |
WithSkipOnFailure("a", "b") | The workflow continues | Recorded as skipped, never run | failed |
WithOptional() | The workflow continues | — | completed |
| both | The workflow continues | Recorded as skipped | completed |
WithOptional decides the terminal status, and WithSkipOnFailure decides which downstream steps are skipped.
Neither rolls the workflow back: only the default case does that.
// The run is a failure without the manifest, but there is nothing to undo
workflow.Step("manifest",
workflow.WithRun(writeManifest),
workflow.WithSkipOnFailure("notify"),
),
// Losing the notification does not fail the run
workflow.Step("notify",
workflow.WithRun(deliverNotification),
workflow.WithOptional(),
),A skipped step never enters the compensation stack.
Conditional steps#
A condition is a step that returns a value. WithSkipIf compares that step’s output against the value you name:
workflow.Step("approved", workflow.WithRun(readApproval)),
// Skipped when "approved" recorded false
workflow.Step("verify",
workflow.WithRun(verifyTenant),
workflow.WithSkipIf("approved", false),
),A step whose condition recorded nothing, because it failed or was skipped itself, is not skipped.
Loops#
workflow.Loop repeats a body until a condition holds. The condition is a body step that returns a boolean, exactly as WithSkipIf is:
workflow.Loop("poll",
workflow.Step("check", workflow.WithRun(checkReady)),
workflow.Step("pause", workflow.WithRun(waitABit)),
).With(
workflow.WithUntil("check", true),
workflow.WithMaxIterations(20),
)The body steps are ordinary steps of the workflow. They appear in a status query under their own names, and the step after the loop can read what they produced.
A few things to know:
WithUntilis checked after each iteration, so once a loop starts, its body runs at least once.WithSkipIfon the loop is checked before the loop starts. If it matches, the loop and its body steps are skipped. It must name a step that comes before the loop.- The whole body runs every iteration. To skip part of it on the round that ends the loop, put
WithSkipIfon that step, as inWithSkipIf("check", true)onpauseabove. - A loop reports the output of the step its condition named, so the step after a loop reads the loop itself rather than a body step.
WithMaxIterationsdefaults to 100. A loop whose condition has not held by the last iteration fails, which is what keeps a condition that never becomes true from running the instance to its timeout.WithOptionalandWithSkipOnFailuredecide what that costs, exactly as for any other step.- Every iteration’s work is compensated. A body step that ran five times and succeeded each time has five effects to undo, and the unwind undoes all of them.
- A body holds plain, child, and wait steps, because a loop runs one task at a time. For a parallel group or a fan-out inside a loop, put it in a child workflow the body starts.
- A body step reads the outputs of the steps before it in the graph, not from the previous iteration. Carry state across iterations through the instance input or your own store.