foreach runs its body once per item, and parallel runs
its branches at the same time. Both are ordinary steps otherwise — they take an id, a
condition and a retry block like anything else.
foreach
A definition carries exactly one of
step and steps. Both, or neither, is refused.
Inside the body, ${item} is the current element (${item.title} walks into it) and
${index} is its 0-based position. Both are legal anywhere in the body subtree and
illegal in items, which is evaluated before there is an iteration.
Output: an array in items order, whatever order the iterations actually finished in.
Under failure_mode: "continue" a failed slot holds {"_error": {"code": …, "message": …}}
instead of a result. An empty items array produces [] and costs nothing.
Read one iteration with ${steps.captions.output.0.…} and all of them with
${steps.captions.output.*.…}.
parallel
A parallel is a race: its branches are alternatives, they run at the same time, and
the first one to succeed is the step’s result. A branch that fails is not a problem —
surviving a failing alternative is the whole point.
Output: a single-key object, keyed by the winning branch’s first step id, whose value
is that branch’s last step’s output. For a branch of one — the common case — those are
the same step, which is why the older bare-branch spelling reads identically.
You do not need a
parallel to get concurrency. Independent top-level steps already run
at the same time, because the engine reads the dependency graph — see
overview. Reach for parallel when you want alternatives, not when
you want speed.parallel: it publishes an object, and * only
projects over arrays. Name each branch explicitly.
join — deprecated
join used to be required and had two values.
join: "all" says what the dependency graph already says. Three sibling steps that read the
same input and nothing of each other are independent, so the engine runs them together
without being told to — and as ordinary top-level steps they publish under their own ids
(${steps.wide.output.url}) instead of under a fan-out (${steps.variants.output.wide.url}),
which is the shape everything else in a definition already uses.
Definitions that already carry join: "all" keep working exactly as they do today; nothing
stored is rewritten and nothing is silently reinterpreted. New definitions should omit
join and hoist an all-branches fan-out to top-level steps.
Scope
A container body is its own scope. The rules are short and they are enforced when the definition is saved:
Three consequences worth stating outright:
- A body step publishes nothing under its own id. A
foreach’s results land understeps.<foreachId>.output.<i>and aparallel’s understeps.<parallelId>.output.<branchId>. Naming the inner step from outside is refused withbinding_out_of_scope. - A container’s own id is unreadable from inside its own body — the aggregate is published only after the whole body has finished.
- One
parallelbranch cannot read another’s steps. Every branch starts from its own copy of the caller’s scope. Refused withbinding_out_of_scope; read the fan-out instead, as${steps.<par>.output.<branchId>…}.
foreach, and
for the winning branch of a parallel (for every branch, under the deprecated
join: "all") — with credits and duration summed over the body. There is no selector for
“which body step is the result”: put the step whose output you want last.
A body runs in array order
This matters because the failure is silent and expensive. A reference to a later sibling resolves toundefined at run time with no error raised — and the step is dispatched and
billed anyway, with a missing input. It therefore has a refusal code of its own,
forward_step_ref, separate from binding_out_of_scope, because the fix is to reorder,
not to rename or re-scope.
explain binds fetch, which runs before it — legal. Swap the two and the save is refused.
The top level is the one exception, and it stays one: a top-level step may reference a
top-level step declared after it, because the runner sorts them by dependency.
Which nesting is legal
The refusal arrives as
invalid_pipeline_definition whose message begins
forbidden_nested_foreach, with the JSON path of the offending step.
Limits
What a container refuses at run time
These four are raised before any step of the body is dispatched, so they cost nothing — but they are run-time errors, not write-time ones, so a stored definition can carry them:
None of them is retryable in a useful sense — listing one in
retryable_errors buys retries
that cannot succeed.