parallel_subworkflows
Execute multiple subworkflows concurrently and collect their results.
Parameters
- flowsarrayconditional
- Static array of flow configurations to execute in parallel. Use either
flowsor dynamicflow+source_key. - flowstringconditional
- Dynamic fan-out child flow path. Required when
flowsis omitted. - source_keystringconditional
- Context key containing the runtime array to fan out over. Required when
flowsis omitted. - inputobject
- In dynamic mode, authored base input mapping applied to every child run; string values select a matching parent key or remain literals.
- item_keystringdefault "item"
- Dynamic mode child context key that receives the current source item as a literal value, never a parent-key reference.
- index_keystringdefault "index"
- Dynamic mode child context key that receives the 1-based source item index.
- child_output_keystring
- Dynamic mode namespace for each child context inside its result entry; cannot be
success,flow, orerror. - output_keystringdefault "parallel_results"
- Key for the results array in context
- on_errorstringdefault "fail_fast"
- Error handling:
"fail_fast"(fail on any error) or"ignore"(collect all results) - max_concurrentnumberdefault CPU count
- Maximum child workflows executing at the same time. Hard-capped at
1024.
Flow Entry Parameters
Each entry in the flows array:
| Parameter | Type | Required | Description |
|---|---|---|---|
flow | string | yes | Path to the .lua flow file (relative to _flow_dir) |
input | object | no | Context mapping — keys are child context keys, values are parent context keys or literals. String values are treated as a parent context key when present, otherwise kept as string literals. |
output_key | string | no | Namespace the child's output under this key in the result entry; cannot be success, flow, or error. |
Context Output
| Key | Type | Description |
|---|---|---|
{output_key} | array | Array of result objects, one per flow (in original order) |
{output_key}_count | number | Total number of flows |
{output_key}_errors | number | Number of flows that failed |
{output_key}_all_succeeded | boolean | true if all flows succeeded |
Each result entry contains:
| Key | Type | Description |
|---|---|---|
success | boolean | Whether the subworkflow succeeded |
flow | string | The loaded child flow's declared name, or its configured path when loading/execution cannot return a finalized child result |
error | string | Error message (only present on failure) |
| context keys | any | Public child context, excluding reserved metadata keys when flattened, or under per-flow output_key |
success, flow, and error are reserved at the top level of each result
entry. They describe the actual child execution, never values published or
inherited by the child. Successful entries have no top-level error field;
failed entries have the engine's failure description. Aggregate counts and
flags follow those same execution outcomes.
For finalized failed children, error includes unresolved task names and
reasons, not just the child status. Summaries use the same redaction, ordering,
and limits as subworkflow: at most eight
failures, 128 bytes per name, and 768 bytes per message, with truncation markers
and an omitted-failure count. Recovered task errors are excluded. Cancelled
children or unavailable task details retain a status-only description;
loading/execution errors keep their existing error-entry behavior.
on_error = "fail_fast" collects the children before failing the parent step;
its aggregate message includes each failed child's reason in input order,
using the same text as the corresponding error entry. It does not stop the
remaining children at the first failure. These details come from live execution,
not a context/history reload.
Without a child namespace, same-named child fields are omitted from the result.
To retain them, use a non-reserved per-flow output_key (static mode) or
child_output_key (dynamic mode), such as "child". Then result.success
remains authoritative while result.child.success, result.child.flow, and
result.child.error retain their domain values. Nested fields are not filtered
by these metadata names; the existing private-key and redaction rules still
apply.
Reserved namespace names are configuration errors, rejected at node execution
before any child loads or starts, including an empty dynamic source. They are
not suppressed by on_error = "ignore". The node-level output_key names the
results array in the parent context and is not subject to this restriction.
Existing workflows using a reserved child namespace must rename it; workflows
needing flattened domain fields with these names must opt into namespacing.
Both static and dynamic fan-out collect full, redacted live child results with
top-level _-prefixed keys removed. IRONFLOW_MAX_TASK_OUTPUT_BYTES bounds
persisted task/final inspection snapshots, not the values delivered to parent
steps. The existing on_error policy is unchanged: ignoring a failure does not
discard values committed before that failure.
This is an in-process handoff, not durable result recovery. Existing child execution limits and parent cancellation/timeouts still apply. Large results remain in memory while collected; prefer artifact references for bulky payloads.
Example
if135_child_error_details.lua
uses its bundled helper
to demonstrate tolerated child errors without external services. It asserts
that a failed result includes the task name and boom: unsupported file type,
while a successful result has no error field.
The offline parallel_result_metadata.lua
example and its metadata_child.lua
helper verify successful and failed static/dynamic children with colliding
domain fields, including namespaced preservation.
local flow = Flow.new("parallel_workers")
-- Run three flows concurrently
flow:step("run_all", nodes.parallel_subworkflows({
flows = {
{ flow = "fetch_users.lua", output_key = "users" },
{ flow = "fetch_orders.lua", output_key = "orders" },
{ flow = "fetch_metrics.lua", output_key = "metrics" }
},
max_concurrent = 3
}))
-- Use results from all three
flow:step("summarize", function(ctx)
local results = ctx.parallel_results
return {
all_ok = ctx.parallel_results_all_succeeded,
count = ctx.parallel_results_count
}
end):depends_on("run_all")
return flow
With input mapping
flow:step("process", nodes.parallel_subworkflows({
flows = {
{ flow = "worker.lua", input = { job_id = "job_1" } },
{ flow = "worker.lua", input = { job_id = "job_2" } },
{ flow = "worker.lua", input = { job_id = "job_3" } }
},
on_error = "ignore"
}))
Dynamic fan-out from context
Use flow + source_key when the parent workflow discovers work items at runtime. The child flow receives the current item under item and its 1-based position under index by default.
flow:step("run_jobs", nodes.parallel_subworkflows({
flow = "worker.lua",
source_key = "jobs",
input = {
run_id = "parent_run_id",
},
item_key = "job",
index_key = "job_index",
child_output_key = "result",
max_concurrent = 5,
output_key = "job_results",
}))
If ctx.jobs is:
[
{ "id": "a", "path": "/tmp/a.txt" },
{ "id": "b", "path": "/tmp/b.txt" }
]
then worker.lua runs twice. Each child context receives job, job_index, plus any mapped input fields. Dynamic mode allows an empty source array and returns an empty results array with {output_key}_all_succeeded = true.
Source items and authored mappings have different semantics. If ctx.jobs is
{ "customer" } and ctx.customer is { id = "C-42" }, the child's item
is the string "customer", not that object. An explicit mapping such as
input = { customer_copy = "customer" } still selects the parent object.
Items are copied without key lookup or interpolation, preserving strings,
objects, arrays, booleans, nulls, and numbers within the existing Lua conversion
limits. A JSON null reaches a Lua handler as json_null, not nil.
Mapped strings resolve once against a top-level parent key; a selected string
is not resolved again. Non-string mapping values are copied without recursively
interpreting strings inside objects or arrays. Dynamic mode does not inherit
unmapped parent fields. Static entries without input retain their existing
parent-context inheritance; input = {} selects no parent fields.
The injected item and numeric one-based index override same-named authored
input fields. Choose distinct item_key and index_key: if they are identical,
the index retains its existing precedence. Engine-owned context keys, private
output filtering, execution-overlay redaction, and resource limits still apply.
parallel_literal_inputs.lua
and its literal_item_child.lua
helper demonstrate literal dynamic items alongside explicit parent mappings.
Error handling
-- fail_fast (default): step fails if any subworkflow fails
flow:step("strict", nodes.parallel_subworkflows({
flows = { ... }
}))
-- ignore: collect all results, check errors yourself
flow:step("tolerant", nodes.parallel_subworkflows({
flows = { ... },
on_error = "ignore"
}))