Fan-out & map
Spawn one child step per item of a runtime-sized list.
The DAG is static, but its width doesn’t have to be. defineMapStep computes a list at run
time and spawns one child step per item.
const resizeAll = defineMapStep({
type: 'resize-all',
workflowInputSchema,
itemOutputSchema: z.object({ url: z.string() }),
dependencies: { listImages },
items: (ctx) => ctx.deps.listImages.urls, // runtime-sized list
each: async (url, info) => ({ url: await resize(url, info.index) }),
});
// downstream reads resizeAll.items: { url: string }[]
The two halves
A map step registers two handlers: the parent (which produces the item list) and a per-item
child. each receives the item plus info:
each: async (item, info) => { … }
// info: { index: number; context: TContext; workflowId: number; partitionKey: string }
Note what info does not carry: no deps. An item handler cannot read dependency outputs —
close over what it needs in items, or fold it into the item itself.
Lifecycle
- The parent runs
items(ctx)with a normal typed context (deps validated), producing the list. - The engine inserts one child step per item, keyed
parentKey#0,parentKey#1, … with step typeparentType__item, and marks the parentmapping. - Every child is enqueued — see scale — and each runs
eachwith its own attempt budget (itemRetry), timeout (itemTimeoutMs), retryability predicate (itemIsRetryable) and gate admission. - When all children complete, the parent completes with
{ items: [...] }in index order, validated per item againstitemOutputSchema.
An empty list short-circuits: the parent completes immediately with { items: [] } and no
children are created. Downstream steps run normally with an empty array — worth handling, since
“no work to do” is a success, not a skip.
Failure
A failed item fails the whole map. The child’s terminal failure fails the parent step, still-
pending siblings are marked skipped (“sibling map item failed”), and the workflow cascades
to failed once the already-running siblings settle.
There is no partial-success mode. If you want per-item error tolerance, make it part of the item output and never throw:
itemOutputSchema: z.object({ url: z.string().nullable(), error: z.string().nullable() }),
each: async (url) => {
try { return { url: await resize(url), error: null }; }
catch (e) { return { url: null, error: String(e) }; }
},
Scale
Every child is enqueued at once. There is no built-in batching or concurrency limit on the fan-out itself — a 10,000-item list means 10,000 queue rows, immediately. What throttles actual execution is the step gate, keyed on the child’s step type:
const gate = createInMemoryStepGate({
concurrency: { 'resize-all__item': { maxConcurrent: 8 } },
});
The child’s type is the parent’s type plus __item (resize-all → resize-all__item), so cap
that type rather than the parent’s — capping the parent only limits how often the list is
computed. The same suffix is what you match on for per-item rate limits and metrics. For
lists in the tens of thousands, prefer chunking — make each item a batch of 100 — over relying
on the gate to hold back a queue that large.
Two more limits worth knowing: the aggregated { items: [...] } output is stored as a single
row value, so a very large fan-out with large per-item outputs makes a very large row; and map
fan-out still writes-then-enqueues rather than committing
transactionally, relying on job
redelivery as its repair path.
Version 0.28.0, MIT-licensed.npm