workflow 5.0.0-beta.7 → 5.0.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  title: Child Workflows
3
- description: Spawn child workflows from a parent and poll their progress for batch processing, report generation, and other multi-workflow orchestration scenarios.
3
+ description: Spawn child workflows from a parent and wait for completion via hook resume.
4
4
  type: guide
5
- summary: Orchestrate independent child workflows from a parent workflow using start(), sleep(), and getRun() to fan out work with isolated failure boundaries.
5
+ 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.
6
6
  ---
7
7
 
8
8
  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.
@@ -18,20 +18,68 @@ Child workflows are the right choice when:
18
18
 
19
19
  For simpler cases where steps share a single event log, use [direct await composition](/cookbook/common-patterns/workflow-composition#direct-await-flattening) instead.
20
20
 
21
- ## Basic pattern: spawn and poll
21
+ ## Basic pattern: spawn and wait via hook
22
22
 
23
- The core pattern has three parts:
23
+ The recommended pattern has four parts:
24
24
 
25
- 1. A parent workflow that calls `start()` to spawn child workflows and records their run IDs
26
- 2. A **polling loop** in the parent workflow that checks child status with `getRun()`
27
- 3. A **step** that retrieves the child's return value once it completes
25
+ 1. A **completion hook** the parent creates and awaits zero compute while waiting
26
+ 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`
27
+ 3. A **`start()` call** that spawns the wrapped child with the hook token (directly from the workflow in v5)
28
+ 4. A **`startAndWait()` helper** that ties the hook, spawn, and typed result together
28
29
 
29
30
  ```typescript
30
- import { sleep } from "workflow";
31
- import { getRun, start } from "workflow/api";
31
+ import { defineHook, getWorkflowMetadata } from "workflow";
32
+ import { start } from "workflow/api";
33
+ import { z } from "zod";
34
+
35
+ declare function fetchDocument(documentId: string): Promise<string>; // @setup
36
+ declare function analyzeContent(content: string): Promise<string>; // @setup
37
+ declare function generateSummary(analysis: string): Promise<string>; // @setup
38
+
39
+ const childCompletionHook = defineHook({
40
+ schema: z.discriminatedUnion("status", [
41
+ z.object({ status: z.literal("completed"), value: z.unknown() }),
42
+ z.object({ status: z.literal("failed"), error: z.string() }),
43
+ ]),
44
+ });
45
+
46
+ function completionToken(parentRunId: string, key: string) {
47
+ return `child-completion:${parentRunId}:${key}`;
48
+ }
32
49
 
33
- declare function pollUntilComplete(runIds: string[]): Promise<void>; // @setup
34
- declare function collectResults(runIds: string[]): Promise<Array<{ documentId: string; summary: string }>>; // @setup
50
+ async function resumeParentCompletion(
51
+ token: string,
52
+ result:
53
+ | { status: "completed"; value: unknown }
54
+ | { status: "failed"; error: string }
55
+ ) {
56
+ "use step";
57
+ await childCompletionHook.resume(token, result);
58
+ }
59
+
60
+ async function withChildCompletionHook<TResult>(
61
+ runChild: () => Promise<TResult>,
62
+ completionTokenArg: string
63
+ ) {
64
+ let result:
65
+ | { status: "completed"; value: TResult }
66
+ | { status: "failed"; error: string }
67
+ | undefined;
68
+
69
+ try {
70
+ const value = await runChild();
71
+ result = { status: "completed", value };
72
+ } catch (error) {
73
+ result = {
74
+ status: "failed",
75
+ error: error instanceof Error ? error.message : String(error),
76
+ };
77
+ } finally {
78
+ if (result) {
79
+ await resumeParentCompletion(completionTokenArg, result);
80
+ }
81
+ }
82
+ }
35
83
 
36
84
  // Child workflow -- processes a single document
37
85
  export async function processDocument(documentId: string) {
@@ -44,152 +92,113 @@ export async function processDocument(documentId: string) {
44
92
  return { documentId, summary };
45
93
  }
46
94
 
47
- async function fetchDocument(documentId: string): Promise<string> {
48
- "use step";
49
- const res = await fetch(`https://docs.example.com/api/${documentId}`);
50
- return res.text();
51
- }
95
+ // Spawnable wrapper -- explicit export so `start()` can register it
96
+ export async function processDocumentWithCompletion(
97
+ documentId: string,
98
+ completionTokenArg: string
99
+ ) {
100
+ "use workflow";
52
101
 
53
- async function analyzeContent(content: string): Promise<string> {
54
- "use step";
55
- // Call analysis API
56
- return `analysis of ${content.length} chars`;
102
+ await withChildCompletionHook(
103
+ () => processDocument(documentId),
104
+ completionTokenArg
105
+ );
57
106
  }
58
107
 
59
- async function generateSummary(analysis: string): Promise<string> {
60
- "use step";
61
- // Generate summary from analysis
62
- return `Summary: ${analysis}`;
108
+ async function startAndWait<TResult>(
109
+ key: string,
110
+ startChild: (completionTokenArg: string) => Promise<void>
111
+ ): Promise<TResult> {
112
+ const { workflowRunId } = getWorkflowMetadata();
113
+ const token = completionToken(workflowRunId, key);
114
+ const hook = childCompletionHook.create({ token }); // [!code highlight]
115
+
116
+ await startChild(token);
117
+
118
+ const completion = await hook; // [!code highlight]
119
+ if (completion.status === "failed") {
120
+ throw new Error(completion.error);
121
+ }
122
+ return completion.value as TResult;
63
123
  }
64
124
 
65
125
  // Parent workflow -- orchestrates document processing
66
126
  export async function processDocumentBatch(documentIds: string[]) {
67
127
  "use workflow";
68
128
 
69
- // Spawn a child workflow for each document
70
- const runIds: string[] = [];
71
- for (const docId of documentIds) {
72
- const run = await start(processDocument, [docId]); // [!code highlight]
73
- runIds.push(run.runId);
74
- }
75
-
76
- // Poll until all children complete
77
- await pollUntilComplete(runIds);
78
-
79
- // Collect results
80
- const results = await collectResults(runIds);
129
+ const results = await Promise.all(
130
+ documentIds.map((documentId) =>
131
+ startAndWait<{ documentId: string; summary: string }>(documentId, (token) =>
132
+ start(processDocumentWithCompletion, [documentId, token]).then(() => undefined) // [!code highlight]
133
+ )
134
+ )
135
+ );
81
136
 
82
137
  return { processed: results.length, results };
83
138
  }
84
139
  ```
85
140
 
86
- ### Polling loop
87
-
88
- The parent workflow polls child statuses in a loop, sleeping between checks. This is durable -- if the parent replays, the sleep and status checks replay from the event log.
89
-
90
- ```typescript
91
- import { sleep } from "workflow";
92
- import { getRun } from "workflow/api";
93
-
94
- const POLL_INTERVAL = "30s";
95
- const MAX_POLL_ITERATIONS = 120; // 60 minutes at 30s intervals
96
-
97
- async function pollUntilComplete(runIds: string[]): Promise<void> {
98
- let iteration = 0;
99
-
100
- while (iteration < MAX_POLL_ITERATIONS) {
101
- const status = await checkStatuses(runIds); // [!code highlight]
102
-
103
- if (status.running === 0) {
104
- if (status.failed > 0) {
105
- throw new Error(
106
- `${status.failed} of ${runIds.length} children failed`
107
- );
108
- }
109
- return; // All completed successfully
110
- }
111
-
112
- iteration += 1;
113
- await sleep(POLL_INTERVAL); // [!code highlight]
114
- }
115
-
116
- throw new Error("Timed out waiting for children to complete");
117
- }
118
-
119
- async function checkStatuses(
120
- runIds: string[]
121
- ): Promise<{ running: number; completed: number; failed: number }> {
122
- "use step"; // [!code highlight]
141
+ ### Why hooks instead of polling?
123
142
 
124
- let running = 0;
125
- let completed = 0;
126
- let failed = 0;
143
+ Polling with `getRun().status` in a `sleep()` loop works, but hook resume is preferable because:
127
144
 
128
- for (const runId of runIds) {
129
- const run = getRun(runId); // [!code highlight]
130
- const status = await run.status; // [!code highlight]
145
+ - **Zero compute while waiting** — the parent suspends on the hook instead of waking every poll interval
146
+ - **Immediate wake-up** the parent resumes as soon as the child finishes, not on the next poll tick
147
+ - **Typed payloads** — the child sends `{ status, value | error }` directly; no separate `returnValue` fetch step
148
+ - **No worker-pool pressure** — `Run#returnValue` polling inside steps can hold worker slots while waiting for children (see [Eager Processing](/changelog/eager-processing))
131
149
 
132
- if (status === "completed") completed += 1;
133
- else if (status === "failed" || status === "cancelled") failed += 1;
134
- else running += 1; // pending, running
135
- }
136
-
137
- return { running, completed, failed };
138
- }
139
-
140
- async function collectResults(
141
- runIds: string[]
142
- ): Promise<Array<{ documentId: string; summary: string }>> {
143
- "use step";
144
-
145
- const results = [];
146
- for (const runId of runIds) {
147
- const run = getRun(runId);
148
- const value = await run.returnValue;
149
- results.push(value as { documentId: string; summary: string });
150
- }
151
- return results;
152
- }
153
- ```
150
+ 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()`.
154
151
 
155
152
  ## Fan-out pattern: chunked spawning
156
153
 
157
- When spawning hundreds of children, batch the `start()` calls to avoid overwhelming the system. Start one chunk at a time from the parent workflow.
154
+ 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).
158
155
 
159
156
  ```typescript
160
157
  import { start } from "workflow/api";
161
158
 
162
- declare function pollUntilComplete(runIds: string[]): Promise<void>; // @setup
159
+ declare function startAndWait<TResult>(
160
+ key: string,
161
+ startChild: (completionTokenArg: string) => Promise<void>
162
+ ): Promise<TResult>; // @setup
163
163
 
164
164
  const CHUNK_SIZE = 10;
165
165
 
166
- export async function largeReportBatch(reportConfigs: Array<{ id: string; query: string }>) {
166
+ export async function largeReportBatch(
167
+ reportConfigs: Array<{ id: string; query: string }>
168
+ ) {
167
169
  "use workflow";
168
170
 
169
- // Spawn children in chunks
170
- const allRunIds: string[] = [];
171
+ const results = [];
171
172
  for (let i = 0; i < reportConfigs.length; i += CHUNK_SIZE) {
172
173
  const chunk = reportConfigs.slice(i, i + CHUNK_SIZE);
173
- const runIds = await startReportChunk(chunk); // [!code highlight]
174
- allRunIds.push(...runIds);
174
+ const chunkResults = await Promise.all(
175
+ chunk.map((config) =>
176
+ startAndWait<{ reportId: string; formatted: string }>(config.id, (token) =>
177
+ start(generateReportWithCompletion, [
178
+ config.id,
179
+ config.query,
180
+ token,
181
+ ]).then(() => undefined)
182
+ )
183
+ )
184
+ );
185
+ results.push(...chunkResults);
175
186
  }
176
187
 
177
- // Poll until all complete
178
- await pollUntilComplete(allRunIds);
179
-
180
- const results = await collectReportResults(allRunIds);
181
188
  return { total: results.length, results };
182
189
  }
183
190
 
184
- async function startReportChunk(
185
- configs: Array<{ id: string; query: string }>
186
- ): Promise<string[]> {
187
- const runIds: string[] = [];
188
- for (const config of configs) {
189
- const run = await start(generateReport, [config.id, config.query]); // [!code highlight]
190
- runIds.push(run.runId);
191
- }
192
- return runIds;
191
+ async function generateReportWithCompletion(
192
+ reportId: string,
193
+ query: string,
194
+ completionTokenArg: string
195
+ ) {
196
+ "use workflow";
197
+
198
+ await withChildCompletionHook(
199
+ () => generateReport(reportId, query),
200
+ completionTokenArg
201
+ );
193
202
  }
194
203
 
195
204
  async function generateReport(reportId: string, query: string) {
@@ -202,160 +211,103 @@ async function generateReport(reportId: string, query: string) {
202
211
 
203
212
  declare function queryDatabase(reportId: string, query: string): Promise<string>; // @setup
204
213
  declare function formatReport(reportId: string, data: string): Promise<string>; // @setup
205
-
206
- declare function collectReportResults(
207
- runIds: string[]
208
- ): Promise<Array<{ reportId: string; formatted: string }>>; // @setup
214
+ declare function withChildCompletionHook<TResult>(
215
+ runChild: () => Promise<TResult>,
216
+ completionTokenArg: string
217
+ ): Promise<void>; // @setup
209
218
  ```
210
219
 
211
220
  ## Error handling
212
221
 
213
222
  ### Tolerating partial failures
214
223
 
215
- Not every batch requires 100% success. Use `allowFailures` logic to let the parent continue when some children fail, while still surfacing the failures.
224
+ Use `Promise.allSettled` with `startAndWait()` so one failing child doesn't abort siblings. The hook payload already carries `{ status: "failed", error }` no status polling required.
216
225
 
217
226
  ```typescript
218
- import { sleep } from "workflow";
219
- import { getRun } from "workflow/api";
220
-
221
- const POLL_INTERVAL = "30s";
222
- const MAX_POLL_ITERATIONS = 120;
223
-
224
- async function pollWithPartialFailures(
225
- runIds: string[],
226
- maxFailureRate: number
227
- ): Promise<{ completed: string[]; failed: string[] }> {
228
- let iteration = 0;
229
- const completedIds: string[] = [];
230
- const failedIds: string[] = [];
231
-
232
- while (iteration < MAX_POLL_ITERATIONS) {
233
- const status = await checkDetailedStatuses(runIds);
234
-
235
- completedIds.length = 0;
236
- failedIds.length = 0;
237
-
238
- for (const entry of status) {
239
- if (entry.status === "completed") completedIds.push(entry.runId);
240
- else if (entry.status === "failed" || entry.status === "cancelled")
241
- failedIds.push(entry.runId);
242
- }
243
-
244
- const active = runIds.length - completedIds.length - failedIds.length;
245
-
246
- // Check if failure rate exceeds threshold
247
- const failureRate = failedIds.length / Math.max(1, runIds.length); // [!code highlight]
248
- if (failureRate > maxFailureRate) { // [!code highlight]
249
- throw new Error( // [!code highlight]
250
- `Failure rate ${(failureRate * 100).toFixed(1)}% exceeds ` + // [!code highlight]
251
- `threshold of ${(maxFailureRate * 100).toFixed(1)}%` // [!code highlight]
252
- ); // [!code highlight]
253
- } // [!code highlight]
227
+ import { start } from "workflow/api";
254
228
 
255
- if (active === 0) {
256
- return { completed: completedIds, failed: failedIds };
257
- }
229
+ declare function startAndWait<TResult>(
230
+ key: string,
231
+ startChild: (completionTokenArg: string) => Promise<void>
232
+ ): Promise<TResult>; // @setup
233
+ declare function processDocumentWithCompletion(
234
+ documentId: string,
235
+ completionTokenArg: string
236
+ ): Promise<void>; // @setup
258
237
 
259
- iteration += 1;
260
- await sleep(POLL_INTERVAL);
261
- }
262
-
263
- throw new Error("Timed out waiting for children");
264
- }
265
-
266
- async function checkDetailedStatuses(
267
- runIds: string[]
268
- ): Promise<Array<{ runId: string; status: string }>> {
269
- "use step";
238
+ export async function processDocumentBatchTolerant(documentIds: string[]) {
239
+ "use workflow";
270
240
 
271
- const statuses = [];
272
- for (const runId of runIds) {
273
- const run = getRun(runId);
274
- const status = await run.status;
275
- statuses.push({ runId, status });
276
- }
277
- return statuses;
241
+ const settled = await Promise.allSettled(
242
+ documentIds.map((documentId) =>
243
+ startAndWait<{ documentId: string; summary: string }>(documentId, (token) =>
244
+ start(processDocumentWithCompletion, [documentId, token]).then(
245
+ () => undefined
246
+ )
247
+ )
248
+ )
249
+ );
250
+
251
+ const results = settled
252
+ .filter(
253
+ (entry): entry is PromiseFulfilledResult<{ documentId: string; summary: string }> =>
254
+ entry.status === "fulfilled"
255
+ )
256
+ .map((entry) => entry.value);
257
+
258
+ const failed = settled.filter((entry) => entry.status === "rejected").length;
259
+
260
+ return { processed: results.length, failed, results };
278
261
  }
279
262
  ```
280
263
 
281
264
  ### Retrying failed children
282
265
 
283
- When a child fails, the parent can spawn a replacement and continue polling. Track restart counts to prevent infinite retry loops.
266
+ When a child fails, spawn a replacement with a fresh hook token. Track restart counts to prevent infinite retry loops.
284
267
 
285
268
  ```typescript
286
- import { sleep } from "workflow";
287
-
288
- declare function checkDetailedStatuses(runIds: string[]): Promise<Array<{ runId: string; status: string }>>; // @setup
289
-
290
- const POLL_INTERVAL = "30s";
291
- const MAX_POLL_ITERATIONS = 120;
292
-
293
- async function pollWithRetries(
294
- initialRunIds: string[],
295
- maxRestartsPerChild: number,
296
- spawnReplacement: (index: number) => Promise<string>
297
- ): Promise<void> {
298
- const activeRuns = new Map<number, string>();
299
- const restartCounts = new Map<number, number>();
300
-
301
- initialRunIds.forEach((runId, index) => activeRuns.set(index, runId));
302
-
303
- let iteration = 0;
304
-
305
- while (iteration < MAX_POLL_ITERATIONS) {
306
- const statuses = await checkDetailedStatuses(
307
- Array.from(activeRuns.values())
308
- );
309
- const statusByRunId = new Map(
310
- statuses.map((s) => [s.runId, s.status])
311
- );
312
-
313
- for (const [index, runId] of activeRuns.entries()) {
314
- const status = statusByRunId.get(runId) ?? "running";
315
-
316
- if (status === "completed") {
317
- activeRuns.delete(index);
318
- continue;
319
- }
320
-
321
- if (status === "failed" || status === "cancelled") {
322
- const restarts = (restartCounts.get(index) ?? 0) + 1; // [!code highlight]
323
- restartCounts.set(index, restarts); // [!code highlight]
324
-
325
- if (restarts > maxRestartsPerChild) { // [!code highlight]
326
- throw new Error( // [!code highlight]
327
- `Child ${index} exceeded restart limit (${maxRestartsPerChild})` // [!code highlight]
328
- ); // [!code highlight]
329
- } // [!code highlight]
330
-
331
- const newRunId = await spawnReplacement(index); // [!code highlight]
332
- activeRuns.set(index, newRunId); // [!code highlight]
333
- }
269
+ declare function startAndWait<TResult>(
270
+ key: string,
271
+ startChild: (completionTokenArg: string) => Promise<void>
272
+ ): Promise<TResult>; // @setup
273
+ declare function spawnProcessDocument(
274
+ documentId: string,
275
+ completionTokenArg: string
276
+ ): Promise<void>; // @setup
277
+
278
+ async function startAndWaitWithRetries(
279
+ documentId: string,
280
+ maxRestarts: number
281
+ ): Promise<{ documentId: string; summary: string }> {
282
+ for (let attempt = 0; attempt <= maxRestarts; attempt++) {
283
+ try {
284
+ return await startAndWait<{ documentId: string; summary: string }>(
285
+ `${documentId}:${attempt}`,
286
+ (token) => spawnProcessDocument(documentId, token)
287
+ );
288
+ } catch (error) {
289
+ if (attempt === maxRestarts) throw error;
334
290
  }
335
-
336
- if (activeRuns.size === 0) return;
337
-
338
- iteration += 1;
339
- await sleep(POLL_INTERVAL);
340
291
  }
341
292
 
342
- throw new Error("Timed out waiting for children");
293
+ throw new Error("unreachable");
343
294
  }
344
295
  ```
345
296
 
346
297
  ## Tips
347
298
 
348
- - **`start()` can be called directly from a workflow function in v5.** It records a step-backed boundary in the parent event log and returns a serializable `Run`.
349
- - **`getRun()` must also be called from a step.** The polling loop lives in the workflow, but the actual status check is a step.
350
- - **Set a max iteration count on polling loops** to prevent runaway workflows. Calculate the count from your expected max duration and poll interval.
299
+ - **`defineHook().resume()` must be called from a step.** The wrapped child's `finally` block calls a step that resumes the parent hook.
300
+ - **Export wrapped children at module scope.** The SDK registers `"use workflow"` functions statically a runtime higher-order function returned from `withChildCompletionHook()` cannot be passed to `start()`.
301
+ - **Use stable hook keys** document ID, job ID, or index so parallel children inside one parent run don't collide on tokens.
351
302
  - **Use chunked spawning for large batches.** Starting 500 children at once can create a large burst of work. Break it into chunks of 10-50.
352
- - **Each child has its own retry semantics.** Steps inside child workflows retry independently. The parent only sees the child's final status.
303
+ - **Each child has its own retry semantics.** Steps inside child workflows retry independently. The parent sees the final `{ status, value | error }` payload from the hook.
353
304
  - **Use `deploymentId: "latest"`** if 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.
354
305
 
355
306
  ## Key APIs
356
307
 
357
308
  - [`start()`](/docs/api-reference/workflow-api/start) -- spawn a new workflow run and get its run ID
358
- - [`getRun()`](/docs/api-reference/workflow-api/get-run) -- retrieve a workflow run's status and return value
359
- - [`sleep()`](/docs/api-reference/workflow/sleep) -- durably pause between polling iterations
309
+ - [`defineHook()`](/docs/api-reference/workflow/define-hook) -- typed hook for parent/child completion handshakes
310
+ - [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- resume a waiting parent from a step (called by the child wrapper)
311
+ - [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) -- read the parent run ID for deterministic hook tokens
360
312
  - [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
361
313
  - [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions with full Node.js access
@@ -1,4 +1,9 @@
1
1
  {
2
2
  "title": "Advanced",
3
- "pages": ["child-workflows", "serializable-steps", "publishing-libraries"]
3
+ "pages": [
4
+ "child-workflows",
5
+ "upgrading-workflows",
6
+ "serializable-steps",
7
+ "publishing-libraries"
8
+ ]
4
9
  }