Francis
GitHub

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#

KindConstructorTasksNotes
Plainworkflow.Step(name, opts...)1The common case
Parallel groupworkflow.Parallel(name, steps...)One per memberMembers run concurrently
Fan-outworkflow.ForEach(name, opts...)One per item, sized at runtimeItems come from an upstream step’s output
Child workflowworkflow.Child(name, opts...)1 (a child instance)Runs another registered definition
Wait for eventworkflow.WaitForEvent(name, opts...)0Parks the instance until RaiseEvent or a deadline
Loopworkflow.Loop(name, steps...)0Repeats 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) error

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

KindOutput seen by later steps
PlainThe handler’s return value
Parallel groupAn object keyed by member name, each member’s output as its value
Fan-outAn array of the tasks’ outputs, ordered by item index - a failed item carries {"error": "…"} under TolerateFailures
Child workflowThe child instance’s output
Wait for eventThe event’s payload
SkippedAbsent, 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.

Call GetStatus to show the attempts each task has spent and why the last one failed.

ReturnWhat happens
nil errorThe output is recorded and the task completes.
An ordinary errorA failed attempt is recorded and retried per WithMaxAttempts, after the WithRetryBackoff delay.
actor.ErrJobPermanentFailureThe task fails permanently, without further attempts.
actor.ErrJobRejectedThis 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#

OptionDescription
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)
WithStepTimeout(d)How long this step may take before its outstanding attempts are failed
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:

DeclaredAfter the step failsNamed dependentsTerminal status
(default)The workflow unwindsfailed
WithSkipOnFailure("a", "b")The workflow continuesRecorded as skipped, never runfailed
WithOptional()The workflow continuescompleted
bothThe workflow continuesRecorded as skippedcompleted

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:

  • The condition is read after the body, so the body always runs at least once.
  • The whole body runs every iteration. To skip part of it on the round that ends the loop, put WithSkipIf on that step, as in WithSkipIf("check", true) on pause above.
  • 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.
  • WithMaxIterations defaults 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. WithOptional and WithSkipOnFailure decide 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.
Edit this page on GitHub