UNPKG

workflow

Version:

Workflow SDK - Build durable, resilient, and observable workflows

322 lines (258 loc) • 13.5 kB
--- title: Child Workflows description: Spawn child workflows from a parent and wait for completion via hook resume. type: guide summary: Orchestrate independent child workflows from a parent using start(), defineHook(), and startAndWait(). The child resumes the parent's hook when done instead of polling getRun().status. related: - /docs/api-reference/workflow-api/start --- <CopyPrompt text="Refactor this workflow to use child workflows. Keep the parent as an exported `&quot;use workflow&quot;` function. Move independent units of durable work into separate exported child workflow functions. From the parent, call `start(childWorkflow, [args])` from `workflow/api` or the documented `startAndWait`/hook pattern where completion must resume the parent. Pass only serializable state to children. For fan-out, start children in parallel with `Promise.all` or bounded batches, collect run IDs, handle partial failures with `Promise.allSettled`, and use `getRun(runId)` when status, cancellation, streams, or return values are needed. Verify child start, completion, failure, and parent resume behavior." /> Use child workflows when a single workflow needs to orchestrate many independent units of work. Each child runs as its own workflow with a separate event log, retry boundary, and failure scope. If one child fails, it doesn't take down the parent or siblings. ## When to use child workflows Child workflows are the right choice when: - **Work units are independent**: Each child can run without knowing about the others (for example, when processing individual documents or generating separate reports). - **You need isolated failure boundaries**: A failing child should not abort unrelated work. The parent decides how to handle failures. - **You want large fan-out**: Spawning 50 or 500 children is practical because each runs on its own infrastructure. The parent still pays for each spawn, so start them in chunks rather than all at once (see [Chunked spawning](#fan-out-pattern-chunked-spawning)). - **You need per-item observability**: Each child workflow has its own run ID, status, and event log for monitoring. - **One run would otherwise get too big**: A run's event log and step count are both capped, see [Vercel World limits](/worlds/vercel#per-run-limits). Replay reads the whole log, so split before the cap: a run headed for more than a few thousand events belongs in several runs. For simpler cases where steps share a single event log, use [direct await composition](/cookbook/common-patterns/workflow-composition#direct-await-flattening) instead. ## Basic pattern: spawn and wait via hook The recommended pattern has four parts: 1. A **completion hook** the parent creates and awaits, with zero compute while waiting 2. A **wrapped child export** that runs the real child in try/catch/finally and resumes the parent's hook from a step in `finally` 3. A **`start()` call** that spawns the wrapped child with the hook token (directly from the workflow in v5) 4. A **`startAndWait()` helper** that ties the hook, spawn, and typed result together ```typescript import { defineHook, getWorkflowMetadata } from "workflow"; import { start } from "workflow/api"; import { z } from "zod"; declare function fetchDocument(documentId: string): Promise<string>; // @setup declare function analyzeContent(content: string): Promise<string>; // @setup declare function generateSummary(analysis: string): Promise<string>; // @setup const childCompletionHook = defineHook({ schema: z.discriminatedUnion("status", [ z.object({ status: z.literal("completed"), value: z.unknown() }), z.object({ status: z.literal("failed"), error: z.string() }), ]), }); function completionToken(parentRunId: string, key: string) { return `child-completion:${parentRunId}:${key}`; } async function resumeParentCompletion( token: string, result: | { status: "completed"; value: unknown } | { status: "failed"; error: string } ) { "use step"; await childCompletionHook.resume(token, result); } async function withChildCompletionHook<TResult>( runChild: () => Promise<TResult>, completionTokenArg: string ) { let result: | { status: "completed"; value: TResult } | { status: "failed"; error: string } | undefined; try { const value = await runChild(); result = { status: "completed", value }; } catch (error) { result = { status: "failed", error: error instanceof Error ? error.message : String(error), }; } finally { if (result) { await resumeParentCompletion(completionTokenArg, result); } } } // Child workflow -- processes a single document export async function processDocument(documentId: string) { "use workflow"; const content = await fetchDocument(documentId); const analysis = await analyzeContent(content); const summary = await generateSummary(analysis); return { documentId, summary }; } // Spawnable wrapper -- explicit export so `start()` can register it export async function processDocumentWithCompletion( documentId: string, completionTokenArg: string ) { "use workflow"; await withChildCompletionHook( () => processDocument(documentId), completionTokenArg ); } async function startAndWait<TResult>( key: string, startChild: (completionTokenArg: string) => Promise<void> ): Promise<TResult> { const { workflowRunId } = getWorkflowMetadata(); const token = completionToken(workflowRunId, key); const hook = childCompletionHook.create({ token }); // [!code highlight] await startChild(token); const completion = await hook; // [!code highlight] if (completion.status === "failed") { throw new Error(completion.error); } return completion.value as TResult; } // Parent workflow -- orchestrates document processing export async function processDocumentBatch(documentIds: string[]) { "use workflow"; const results = await Promise.all( documentIds.map((documentId) => startAndWait<{ documentId: string; summary: string }>(documentId, (token) => start(processDocumentWithCompletion, [documentId, token]).then(() => undefined) // [!code highlight] ) ) ); return { processed: results.length, results }; } ``` ### Why hooks instead of polling? Polling with `getRun().status` in a `sleep()` loop works, but hook resume is preferable because: - **Zero compute while waiting**: The parent suspends on the hook instead of waking every poll interval. - **Immediate wake-up**: The parent resumes as soon as the child finishes, not on the next poll tick. - **Typed payloads**: The child sends `{ status, value | error }` directly, with no separate `returnValue` fetch step. - **No worker-pool pressure**: `Run#returnValue` polling inside steps can hold worker slots while waiting for children (see [Eager Processing](/docs/changelog/eager-processing)). When a parent calls a child workflow inline with `await` (flattened into the same run), the same wrapper and hook handshake still works: pass the token and `await processDocumentWithCompletion(...)` inside `startAndWait()` instead of calling `start()`. ## Fan-out pattern: chunked spawning When spawning hundreds of children, batch the `start()` calls to avoid overwhelming the system. Each child still gets its own completion hook keyed by a stable identifier (document ID, report ID, index). ```typescript import { start } from "workflow/api"; declare function startAndWait<TResult>( key: string, startChild: (completionTokenArg: string) => Promise<void> ): Promise<TResult>; // @setup const CHUNK_SIZE = 10; export async function largeReportBatch( reportConfigs: Array<{ id: string; query: string }> ) { "use workflow"; const results = []; for (let i = 0; i < reportConfigs.length; i += CHUNK_SIZE) { const chunk = reportConfigs.slice(i, i + CHUNK_SIZE); const chunkResults = await Promise.all( chunk.map((config) => startAndWait<{ reportId: string; formatted: string }>(config.id, (token) => start(generateReportWithCompletion, [ config.id, config.query, token, ]).then(() => undefined) ) ) ); results.push(...chunkResults); } return { total: results.length, results }; } async function generateReportWithCompletion( reportId: string, query: string, completionTokenArg: string ) { "use workflow"; await withChildCompletionHook( () => generateReport(reportId, query), completionTokenArg ); } async function generateReport(reportId: string, query: string) { "use workflow"; const data = await queryDatabase(reportId, query); const formatted = await formatReport(reportId, data); return { reportId, formatted }; } declare function queryDatabase(reportId: string, query: string): Promise<string>; // @setup declare function formatReport(reportId: string, data: string): Promise<string>; // @setup declare function withChildCompletionHook<TResult>( runChild: () => Promise<TResult>, completionTokenArg: string ): Promise<void>; // @setup ``` ## Error handling ### Tolerating partial failures Use `Promise.allSettled` with `startAndWait()` so one failing child doesn't abort siblings. The hook payload already carries `{ status: "failed", error }`, so no status polling is required. ```typescript import { start } from "workflow/api"; declare function startAndWait<TResult>( key: string, startChild: (completionTokenArg: string) => Promise<void> ): Promise<TResult>; // @setup declare function processDocumentWithCompletion( documentId: string, completionTokenArg: string ): Promise<void>; // @setup export async function processDocumentBatchTolerant(documentIds: string[]) { "use workflow"; const settled = await Promise.allSettled( documentIds.map((documentId) => startAndWait<{ documentId: string; summary: string }>(documentId, (token) => start(processDocumentWithCompletion, [documentId, token]).then( () => undefined ) ) ) ); const results = settled .filter( (entry): entry is PromiseFulfilledResult<{ documentId: string; summary: string }> => entry.status === "fulfilled" ) .map((entry) => entry.value); const failed = settled.filter((entry) => entry.status === "rejected").length; return { processed: results.length, failed, results }; } ``` ### Retrying failed children When a child fails, spawn a replacement with a fresh hook token. Track restart counts to prevent infinite retry loops. ```typescript declare function startAndWait<TResult>( key: string, startChild: (completionTokenArg: string) => Promise<void> ): Promise<TResult>; // @setup declare function spawnProcessDocument( documentId: string, completionTokenArg: string ): Promise<void>; // @setup async function startAndWaitWithRetries( documentId: string, maxRestarts: number ): Promise<{ documentId: string; summary: string }> { for (let attempt = 0; attempt <= maxRestarts; attempt++) { try { return await startAndWait<{ documentId: string; summary: string }>( `${documentId}:${attempt}`, (token) => spawnProcessDocument(documentId, token) ); } catch (error) { if (attempt === maxRestarts) throw error; } } throw new Error("unreachable"); } ``` ## Tips - **Call `defineHook().resume()` from a step**: The wrapped child's `finally` block calls a step that resumes the parent hook. - **Export wrapped children at module scope**: The SDK registers `"use workflow"` functions statically, so a runtime higher-order function returned from `withChildCompletionHook()` cannot be passed to `start()`. - **Use stable hook keys**: Document IDs, job IDs, or indexes prevent token collisions between parallel children in one parent run. - **Use chunked spawning for large batches**: Starting 500 children at once can create a large burst of work. Break the work into chunks of 10–50. - **Watch the parent's log too**: Each child bounds its own log, but the parent records events for spawning and for collecting every child, so the parent's log grows with the number of children. Count those events per child against the parent's own [run limits](/worlds/vercel#per-run-limits); when the parent alone would exceed them, add a layer, so each parent spawns a modest number of intermediate runs that in turn spawn the leaves. - **Account for each child's retry semantics**: Steps inside child workflows retry independently. The parent sees the final `{ status, value | error }` payload from the hook. - **Use `deploymentId: "latest"` when children should run on the most recent deployment**: See [Versioning](/docs/foundations/versioning) for the full model and the [`start()` API reference](/docs/api-reference/workflow-api/start#using-deploymentid-latest) for compatibility considerations. ## Key APIs - [`start()`](/docs/api-reference/workflow-api/start): Spawns a new workflow run and returns its run ID. - [`defineHook()`](/docs/api-reference/workflow/define-hook): Defines a typed hook for parent-child completion handshakes. - [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Resumes a waiting parent from a step called by the child wrapper. - [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata): Returns the parent run ID for deterministic hook tokens. - [`"use workflow"`](/docs/foundations/workflows-and-steps): Marks the orchestrator function. - [`"use step"`](/docs/foundations/workflows-and-steps): Marks functions with full Node.js access.