Child workflows
Compose workflows by calling other workflows — await a child's result with Inner.start (or ctx.child), kick one off fire-and-forget with Inner.dispatch (or ctx.startChild), or fan out over a list with ctx.all. The same statics work at the top level, context-aware.
A workflow can run another workflow as a child — to break a big process into reusable pieces, to fan out work, or to hand part of the job to a workflow that runs elsewhere. Children are durable runs in their own right: their own history, retries, and dashboard entry.
There are two ways to call one, depending on whether the parent needs the result.
Inner.start / Inner.dispatch — the ergonomic form
When the child is a BaseWorkflow class you can import, its statics are
the shortest way to compose — and they are context-aware, so the same call does the right thing inside a
workflow versus outside one:
export default class CheckoutWorkflow extends BaseWorkflow {
static workflow = { name: 'checkout', version: '1' }
async run(ctx: WorkflowCtx, order: Order) {
const kyc = await KycWorkflow.start({ userId: order.userId }) // linked child — awaits the result
await AuditWorkflow.dispatch({ orderId: order.id }) // fire-and-forget child
return { verified: kyc.passed }
}
}Inner.start(input, opts?)= "I want the result." Inside a running workflow it creates a linked child (the parent suspends until the child settles, then resumes with its result) — exactlyctx.child(Inner, input).Inner.dispatch(input, opts?)= fire-and-forget. Inside a running workflow it kicks off a child and returns{ runId }while the parent keeps going — exactlyctx.startChild(Inner, input).
The same two statics also run a top-level run from a controller/service (Wf.dispatch returns { runId };
Wf.start blocks until the run settles and returns its result). One consistent rule — .start = "give me the
result", .dispatch = "fire and forget" — in both contexts:
| Static call | OUTSIDE a workflow (controller / service / script) | INSIDE a running workflow body |
|---|---|---|
Wf.start(input, opts?) | engine.start + waits for the run to settle → returns the result | linked child (ctx.child) — parent suspends until it settles, returns the child's result |
Wf.dispatch(input, opts?) | enqueues a top-level run → fire-and-forget, returns { runId } | fire-and-forget child (ctx.startChild) → returns { runId }, parent keeps going |
Why the same call is context-aware
Inside a running body it is required to route through the child path — calling the engine directly from a
workflow would break determinism. The static detects the ambient run and does this for you, so Inner.start /
Inner.dispatch are always safe to call from either place. ctx.child / ctx.startChild are the underlying
primitives and stay available.
Which to reach for. Outside a workflow, prefer the statics: a controller that only kicks off a run
doesn't need the engine for it. Inside a body, prefer ctx.child / ctx.startChild. They do the same
thing, but ctx is the visible determinism boundary: a reviewer scanning for ctx. sees every history
operation at a glance, and Inner.start() no longer looks durable. The statics earn their place inside a
body in deep helpers, a domain service a few calls below run that would otherwise thread ctx through
several signatures just to spawn a child.
Where the statics see no workflow
"Inside" means the body's own async path, read from an AsyncLocalStorage. A dispatched step handler is
not the body: the engine clears the ambient run around it, even on an in-process transport, so a static
called from a @Step method starts a top-level run, not a child. Inside a ctx.localStep body the
static resolves the ambient run and throws a NestedWorkflowCallError instead, since a step that replays
from its checkpoint would never claim that child's position again.
opts — outside vs inside
opts is { runId? } plus the engine's start options (version, priority, namespace, startAt, …).
Outside a workflow, all of them apply; runId is a random UUID when omitted and acts as an idempotency
key when you provide one. Inside a workflow only runId is used, as the child id; the other start options
are ignored. When omitted, the child id is derived deterministically from the call position (replay-stable).
Do not pass a random one, as that would break replay. Pass an explicit stable id only when you
deliberately want to join a specific child (see Fanning out by hand). For a child's
dispatch priority, call ctx.child / ctx.startChild with { priority } (see
Child call options).
The rest of this page documents ctx.child / ctx.startChild — the primitives the statics delegate to. Use
them directly when you only have the child's name string (no class to import), when the calling code is
already an engine.register(...) body, or when you need a child call option the statics
don't forward. Fanning out over a list (ctx.all / ctx.allSettled) has no static form either.
ctx.child — run a child and await its result
ctx.child(workflow, input, childId?) starts the child once and suspends the parent — zero compute — until the child reaches a terminal state, then resumes the parent with the child's output. If the child fails, the call throws in the parent (so a child failure fails the parent unless you catch it).
engine.register('checkout', '1', async (ctx, order: Order) => {
const payment = await ctx.localStep('charge', () => payments.charge(order))
// Hand shipping to its own workflow and wait for the tracking number.
const shipment = await ctx.child<{ tracking: string }>('shipping', { orderId: order.id })
return { paymentId: payment.id, tracking: shipment.tracking }
})Because the parent suspends rather than blocking, a child that takes hours (or itself sleeps, waits on a signal, or spawns its own children) costs the parent nothing while it runs.
Name or class ref
Pass the child's registered name string (the portable form). When you have a typed workflow class to import, pass the class and the call is fully typed — the input is checked against the child's run and the result is its return type:
const shipment = await ctx.child(ShippingWorkflow, { orderId: order.id })
// ^? the return type of ShippingWorkflow.run, inferred — and { orderId } is type-checkedThe name form is the only one for a cross-runtime child, such as a workflow implemented in Python. There you annotate the result yourself:
const result = await ctx.child<EnrichResult>('python-enrich', { recordId })Child call options
Both forms take an optional third argument: a childId string, or a ChildCallOptions object.
await ctx.child('python-enrich', { recordId }, { childId: `enrich:${recordId}`, priority: 10 })| Option | What it does |
|---|---|
childId | The child's run id. Defaults to <parentRunId>.child.<seq>, derived from the parent run and the call position, so it's stable across replay. |
priority | Dispatch priority for a remote child workflow (another runtime), stamped on the child run and carried onto every task that advances it. Higher wins. Ignored for an in-process (TypeScript class) child. |
parentRunIdOf(runId) (exported from @adonis-agora/durable) reads the parent back out of an engine-minted
child id (.child.<seq>, .all.<seq>.<i>, .onError.<n>). It returns undefined for a child started with
an id you chose.
1@Workflow({ name: 'onboard', version: '1' })2export default class OnboardWorkflow {3 constructor(4 private accounts: AccountSteps,5 private email: EmailSteps,6 ) {}7 8 async run(ctx: WorkflowCtx, user: User) {9 const account = await ctx.step(this.accounts.create, user)10 11 // run a child workflow and await its result:12 const kyc = await ctx.child(KycWorkflow, { userId: account.id })13 14 await ctx.step(this.email.welcome, { user, kyc })15 return { verified: kyc.passed }16 }17}ctx.startChild — fire-and-forget
ctx.startChild(workflow, input, childId?) dispatches a child and returns its run id immediately — the parent keeps running instead of suspending. Use it for side work the parent doesn't need to wait on:
engine.register('publish-post', '1', async (ctx, post: Post) => {
await ctx.localStep('publish', () => posts.publish(post))
// Kick off indexing + notifications; don't make publishing wait on them.
await ctx.startChild('reindex-search', { postId: post.id })
await ctx.startChild('notify-followers', { postId: post.id })
return { published: true }
})The dispatch is checkpointed (replay-safe — it won't re-fire on resume) and idempotent by child id.
1@Workflow({ name: 'publish-post', version: '1' })2export default class PublishPostWorkflow {3 constructor(private posts: PostSteps) {}4 5 async run(ctx: WorkflowCtx, post: Post) {6 await ctx.step(this.posts.publish, post)7 8 // fire-and-forget — don't make publishing wait on indexing:9 await ctx.startChild(ReindexSearchWorkflow, { postId: post.id })10 11 return { published: true }12 }13}⋯6 async run(ctx: WorkflowCtx, input: { postId: string }) {7 const doc = await ctx.step(this.search.reindex, input)8 await ctx.step(this.search.warmCache, doc)9 }⋯ctx.all — fan out over one workflow
Running the same child workflow across a list of inputs is common enough to have its own primitive.
ctx.all(workflow, inputs, opts?) starts one child per input, suspends the parent while they run
concurrently, and resumes with their outputs in input order:
engine.register('batch', '1', async (ctx, batch: Batch) => {
const results = await ctx.all(ProcessItemWorkflow, batch.items)
// ^? the return type of ProcessItemWorkflow.run, as an array
return { processed: results.length }
})Pass a registered name string instead of the class when you have no class to import, with the output type
as a generic: ctx.all<ItemResult>('process-item', batch.items).
The child ids are derived from the parent and the position the fan-out occupies — <parentRunId>.all.<seq>.<i>
— so they are stable across replay without you inventing them. Every sibling is also tagged with the same
parallel group, which is what lets the dashboard render one fan-out as
a single unit rather than N unrelated children. An empty inputs array returns [] immediately and starts
nothing.
A fan costs O(N), however wide it is. Settling children do not wake the parent one by one: a join barrier holds
the wake until the last child settles, so the parent replays the fan about once, not once per child. A
failFast fan is the exception, and wakes on its first failure. The barrier needs the store's
hasSignalWaiter, which the bundled stores implement. A custom store without it still runs ctx.all
correctly, but the parent then replays once per settled child, which is O(N²) across the fan. Sending the fan
is batched too: children are registered 500 at a time through the store's registerChildWaits, one
transaction per batch.
Once a fan of 100 or more children settles with every child successful, its per-child checkpoints are folded
into a single fan:<parentRunId>.all.<seq>. checkpoint holding the outputs in order. Every later turn of the
parent then loads one row for the fan instead of one per child. A fan with concurrency folds each wave as it
settles. Folding keeps the outputs, so it saves the per-row overhead and the row count, not the bytes of the
outputs themselves: have children return small results (ids, counts) and write anything large to your own
tables. A group with a failed child is not folded, so a retry can still re-run that child. A fan under 100
children keeps its per-child checkpoints, so the dashboard can show each child. Folding needs the store's
compactCheckpoints, which the bundled stores implement. Cancel cascades, deleteRun and purgeRuns still
reach every child of a folded fan.
waitAll vs failFast
opts.mode decides what a failing sibling does to the rest:
const results = await ctx.all(ProcessItemWorkflow, batch.items, { mode: 'failFast' })| Mode | Behaviour |
|---|---|
waitAll (default) | Every child runs to a terminal state. If any failed, the call then throws — nothing is cancelled, so a partial batch is fully recorded and inspectable. |
failFast | The first observed failure cancels every sibling that has not completed, then throws. |
Either way the error is a GatherError carrying which items failed:
import { GatherError } from '@adonis-agora/durable'
try {
await ctx.all(ProcessItemWorkflow, batch.items)
} catch (error) {
if (error instanceof GatherError) {
// error.failures: [{ index, id, error }, ...]
const failed = error.failures.map((f) => batch.items[f.index].id)
await ctx.step(reportPartialBatch, { batchId: batch.id, failed })
}
throw error
}Choose waitAll when each item is independent and you want the whole picture — a nightly import where three
bad rows should not hide the other nine hundred. Choose failFast when the items are facets of one operation
and finishing the rest is wasted work.
failFast cancellation is best-effort and carries no saga compensation: it is a plain cancel, and a
sibling in the middle of a step only observes it at its next checkpoint, so a fast sibling can still slip
through and complete. If a partially-run child needs to be
undone, give the child its own compensations and cancel it with
{ compensate: true } yourself instead.
When a failed item is an expected outcome — ctx.allSettled
When some items failing is normal (a line that fails validation, a record a downstream system rejects),
throwing for the whole batch is the wrong shape. ctx.allSettled is the durable Promise.allSettled: the
same fan as ctx.all in waitAll mode, but it resolves with one result per input, in input order, and leaves
the parent to decide what a failure means:
const results = await ctx.allSettled(ProcessItemWorkflow, batch.items, { concurrency: 1000 })
// results[i]: { status: 'fulfilled', value } | { status: 'rejected', id, error, code?, details? }
const rejected = results.flatMap((r, i) =>
r.status === 'rejected' ? [{ item: batch.items[i], error: r.error }] : []
)
if (rejected.length > 0) await ctx.step(recordRejectedLines, rejected)It takes the same concurrency and lays the fan out exactly as ctx.all does (same child ids and
parallelGroup). There is no mode: it always waits for every child. Cancelling the parent still cancels
the run; only the children's failures are collected. For a failure that retrying can't fix, throw
FatalError in the child's step: it skips the retries and the child settles as rejected straight away,
with the error's code and details on the item. A failed ctx.child throws a FatalError carrying them
too.
Capping how many children run at once — concurrency
By default the whole fan is sent at once. For a wide fan that floods the queue and keeps every child run and
waiter alive together, so pass concurrency to cap it:
const results = await ctx.all(ProcessItemWorkflow, batch.items, { concurrency: 1000 })Children then go out in waves of floor(concurrency / 2), two waves at a time (so an odd limit runs one child under it), and the parent sends the
next wave as soon as one settles. A slow child holds back only its own wave, not the whole window. Each settled
wave costs the parent one turn, and since settled waves are folded, that turn reads one row per earlier wave
rather than one per earlier child. concurrency must be a positive integer; a limit at or above the fan size
changes nothing.
concurrency is part of the fan's identity: the wave is in each child's id
(<parentRunId>.all.<seq>.w<k>.<i>). Changing it while a run is in flight is a non-deterministic change, the
same as changing inputs.
Fanning out over a step
When each item is just one step rather than a workflow of its own, pass the step's reference (a
defineStep ref or a @Step method) instead of a workflow. Each input becomes one durable step instead of a child run, which is far cheaper for a
large batch: a child costs a whole run (its row, a turn, a lease, a completion signal), a step costs a
checkpoint and a dispatch.
// One processRecord step per record, at most 500 in flight. The workflow wakes once per settled
// wave, not once per record, and the dashboard shows the steps as one fan.
const results = await ctx.all(processRecord, batch.records, { concurrency: 500 })Outputs come back in input order. The same mode and GatherError apply: waitAll throws once every
step has settled, with the failed indexes in .failures; failFast throws on the first failed step.
concurrency sends the steps in waves of that size, the next once the previous has settled (unlike a child
fan, which keeps two half-size waves in flight). It must be a positive integer, and unset sends every step at
once. Every step of the fan uses the @Step's retry policy, or the per-call retries / backoff you pass,
and a failed step with attempts left is retried inside the fan rather than failing it. transport,
priority and fairnessKey apply to every step too. A flow-control queue and a liveness timeoutMs (on
the call or on the step) are rejected: a step fan needs durable steps.
failFast cancels nothing. A step already dispatched still runs to the end on its worker; the steps of
waves not yet sent are never sent.
ctx.allSettled takes a step reference too, with the same options minus mode. It waits for every step
and resolves with one { status: 'fulfilled', value } | { status: 'rejected', id, error, code?, details? }
per input, where id is the step id. A step that throws FatalError settles as rejected without spending
its retries.
const results = await ctx.allSettled(validateLine, lines, { concurrency: 500 })Prefer it to Promise.all over ctx.step past a handful of steps: every result of a Promise.all
replays the workflow, which makes a fan of N steps cost O(N²) checkpoint reads. A step fan replays
once per settled wave, because a result resumes the workflow only when no step of the fan is still
pending (or when one fails). The steps share a parallelGroup of steps:<firstSeq>:<size>, with a
:failFast suffix in that mode (an allSettled fan is a waitAll one, so it never has the suffix), so any one step's checkpoint names the whole block the barrier waits on. The
barrier asks whether any step of the fan is still pending through the optional
StateStore.hasCheckpoints, which the Lucid and in-memory stores implement; a store without it is
asked countCheckpoints, and one with neither falls back to loading the run's checkpoints on every
result.
A child fan's parallelGroup is all:<firstSeq>:n<size>, with the same :failFast suffix. The size is
there so the dashboard can show how many
children are still to be started. A run that began before the size was part of the group still has
all:<firstSeq>, and the barrier reads both forms.
Fanning out by hand
ctx.all covers one workflow over N inputs, dispatched and joined in one call. When you need something it
doesn't give you — a different workflow per item, child ids you choose, or work interleaved between starting
the children and joining them — build it from startChild + child. Because startChild returns
the child id and the start is idempotent by that id, you can fan out first and join later with the same
id: the child runs exactly once and the second call just attaches to it.
engine.register('batch', '1', async (ctx, batch: Batch) => {
// 1. Start every item's workflow without waiting — they run concurrently.
const ids = await Promise.all(
batch.items.map((item) => ctx.startChild('process-item', item, `item:${item.id}`)),
)
// 2. ...do other work while they run...
// 3. Join: await each by the same id (no re-dispatch — they're already running).
const results = await Promise.all(
batch.items.map((item, i) => ctx.child('process-item', item, ids[i])),
)
return results
})ctx.all: the parent suspends (amber, zero compute) while N children — each a full durable run, labelled #i — execute concurrently and join as they settle, stamping a per-child ✓/✗ under the join. Toggle a failing child: the others still finish green, and the join resolves as a GatherError naming exactly which index failed.When to use which
ctx.child | ctx.startChild | ctx.all | ctx.allSettled | |
|---|---|---|---|---|
| Parent waits for the result | ✅ suspends until the child finishes | ❌ returns the id immediately | ✅ suspends until every child finishes | ✅ suspends until every child finishes |
| Child failure affects the parent | ✅ throws in the parent | ❌ independent (inspect/retry the child run) | ✅ throws a GatherError | ❌ returned as a rejected item |
| Typical use | one sub-task whose output you need | side work; heterogeneous fan-out | the same workflow over a list of inputs | a list where some items failing is expected |
Inner.start(input) and Inner.dispatch(input) are the ergonomic equivalents of ctx.child(Inner, input) and
ctx.startChild(Inner, input) — same behavior, they just resolve the ambient ctx for you.
Both establish the same durable parent→child relationship, so a child started either way is a normal run you can inspect, retry, or cancel from the dashboard. Read the children of a run programmatically with engine.getRunChildren(parentRunId).
Continue-as-new
A workflow that would otherwise loop forever (a long-lived entity, a poll loop) should not accumulate unbounded history. ctx.continueAsNew(input?) completes the current run and starts a fresh one — same workflow, new input, a clean history — keyed runId~N:
engine.register('counter', '1', async (ctx, input: { n: number }) => {
await ctx.localStep(`work-${input.n}`, async () => input.n)
if (input.n < 3) await ctx.continueAsNew({ n: input.n + 1 }) // → runId~1, runId~2, …
return `done at ${input.n}`
})Each generation completes cleanly, so the run never grows without bound — the durable equivalent of tail recursion.
continueAsNew always throws, so code after it never runs. Carry everything the next generation needs in its
input.
A generation that continues completes with no output, so a parent awaiting it with ctx.child (or
Wf.start) resolves with undefined as soon as the first generation hands off, not when the chain ends. Start
a looping workflow with startChild / dispatch, or have the caller wait on something the last generation
publishes (an event or a signal).
The next generation starts on the newest registered version of the workflow, so a continue-as-new is also
how a long-lived loop picks up a new version.
The handoff itself is crash-safe: the continuation run is persisted before the parent's terminal write, so a crash in the gap between "generation N completed" and "generation N+1 started" cannot lose the chain — on recovery the already-persisted continuation is simply driven forward. The continuation inherits the parent run's namespace, so a chain pinned to a worker pool stays in it across generations. Nothing else carries over: run-scoped tags, search attributes and priority from the original start are not copied to the next generation. A cancel requested while the generation runs wins over the continue-as-new: the run is cancelled and no next generation is started.
app/workflows & make:workflow
The class-based authoring convention — a BaseWorkflow subclass per file under app/workflows with a static workflow config, auto-registered at boot, scaffolded by make:workflow. The parallel to @adonisjs/queue's app/jobs and make:job.
Durable entities
Keyed virtual actors — engine.registerEntity defines a named entity whose handlers run serialized per key over durable state (exactly once). Drive it with engine.signalEntity / ctx.callEntity, read it with engine.getEntityState. The durable answer to a per-user counter or per-account balance without DB locks.