Yield and Re-Enter

A task can’t always finish in a single pass. It may need to wait for a human approval, run a child workflow to completion, or poll an external job that isn’t done yet. Rather than block a worker for minutes or days, the task yields: it hands control back to the Foreman, which durably parks the step and later re-executes the same task from the top once the awaited result is available.

This is one mechanic with three surface APIs — Interrupt, Subgraph, and Retry. Understanding the yield-and-re-enter shape once explains all three.

The Lifecycle

  1. The task runs and calls a yielding signal: flow.Interrupt, flow.Subgraph, or flow.Retry.
  2. The signal reports that the task should yield. The task returns immediately, without an error.
  3. The Foreman persists the parked step to SQL and frees the worker — nothing is held open. The flow survives a process crash or a mid-execution deploy in this parked state.
  4. When the awaited condition is met — resume data arrives, the child workflow finishes, or the backoff delay elapses — the Foreman dispatches the same task again, from the top.
  5. On this pass the result is available. The task recognizes the re-entry, adopts the result, and continues.

Because the flow’s state is persisted at the park point, re-entry can happen seconds or days later, on a different worker, after a redeploy. The task body is the same code both times; what changes is whether the result is ready.

The Idiom

Interrupt and Subgraph both return (yield, err) and unmarshal their result into an out-pointer you pass in. The canonical task shape branches on yield:

var out otherapi.SomeWorkflowOut
yield, err := flow.Subgraph(otherapi.SomeWorkflow.URL(), otherapi.SomeWorkflowIn{Field: value}, &out)
if yield {
    return nil // first pass: parked, awaiting the child
}
if err != nil {
    return errors.Trace(err) // re-entry with a failure: retry, route, or propagate
}
result := out.ResultField // re-entry with a result: adopt and continue
  • yield == true on the first pass — the task has just parked. Return without an error.
  • yield == false on re-entryout now holds the payload, or err carries a failure.

Interrupt

flow.Interrupt(payload, &resume) parks the flow for external input — a human approval, a third-party callback, any event that can’t be awaited synchronously. It returns (yield, err) and unmarshals whatever an external actor passes to foremanapi.Resume into resume when you resume the parked flow; err is always nil.

Subgraph

flow.Subgraph(url, in, &out) launches a child workflow from the task body — the way one workflow invokes another. It returns (yield, err) and unmarshals the child’s final state into out; err is a child-workflow failure the parent can recover from. Only the explicit in map crosses into the child and only out crosses back; nothing is auto-inherited in either direction, because the subgraph boundary is a function call rather than a shared state scope.

A subgraph invokes a whole workflow, never a bare task — a task is only ever a node in a graph, and the workflow is the independently-invocable unit. Prefer the generated typed client over the raw call: otherapi.NewSubgraph(flow).SomeWorkflow(ctx, args...) wraps flow.Subgraph with typed In/Out.

Retry

flow.Retry(initialDelay, multiplier, maxIntervalDelay, giveUpAfter) expresses the same lifecycle with a boolean instead of a tuple. It returns true while the step is within its retry horizon — the task then returns to park until the backoff elapses and re-enters from the top — and false once the horizon (giveUpAfter of wall-clock since the step was first created) is spent. It carries no condition of its own, so the task writes the retryable predicate in the surrounding if:

if status == "running" {
    // Poll on a constant 30s interval, giving up 1h after the step first ran.
    if !flow.Retry(30*time.Second, 1, 30*time.Second, time.Hour) {
        return "", errors.New("job did not complete within poll budget")
    }
    return "", nil // parked; the Foreman re-runs this task after the delay
}

Re-Entry Runs the Whole Task Again

The most important consequence of this pattern: re-entry re-executes the task body from the top. Everything before the yielding call runs on every pass. The yield guard only protects the code after the call.

func (svc *Service) ChargeAndConfirm(ctx context.Context, flow *workflow.Flow, amount int) (confirmed bool, err error) {
    svc.chargeCard(ctx, amount)                 // ⚠️ runs again on every re-entry — double charge

    var resumeData map[string]any
    yield, _ := flow.Interrupt(map[string]any{"request": "confirm"}, &resumeData)
    if yield {
        return false, nil
    }
    confirmed, _ = resumeData["confirmed"].(bool)
    return confirmed, nil
}

To keep tasks safe to re-run:

  • Keep the work above the yielding call cheap and idempotent — reading state, validating inputs, building the in map.
  • Never place an un-guarded non-idempotent side effect (charge a card, send an email, insert a row) above a yielding call; it fires once per re-entry.
  • If a task must both cause a side effect and yield, do the side effect on re-entry (guarded by !yield), or split it into its own task.

This is one reason tasks are written to read inputs from state, do their work, and write outputs back: a task built that way is naturally safe to re-run.

Sleep Parks but Does Not Re-Enter

flow.Sleep(d) also parks the flow, but it delays the next step rather than re-executing the current one — there is no re-entry and no yield return. Reach for Sleep to pace what comes after this task; reach for Retry when this task needs to run again after a delay.

One Park per Dispatch

A step parks at most once per dispatch (interrupt XOR subgraph), and a task must set at most one of Goto, Retry, Interrupt, or Subgraph. Setting two control signals fails the step. If a child workflow interrupts, the interrupt propagates up the parent chain to the root flow — each parked level re-enters in turn as the resume travels back down.

See Also