workflow 5.0.0-beta.1 → 5.0.0-beta.3
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.
- package/README.md +4 -4
- package/dist/api-workflow.d.ts +1 -1
- package/dist/api-workflow.d.ts.map +1 -1
- package/dist/api-workflow.js +2 -2
- package/dist/api.js +1 -1
- package/dist/astro.js +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/builtins.js +1 -1
- package/dist/internal/class-serialization.js +1 -1
- package/dist/internal/errors.js +1 -1
- package/dist/nest.js +1 -1
- package/dist/next.cjs +1 -1
- package/dist/nitro.js +1 -1
- package/dist/nuxt.js +1 -1
- package/dist/observability.js +1 -1
- package/dist/runtime.js +1 -1
- package/dist/stdlib.js +1 -1
- package/dist/sveltekit.js +1 -1
- package/dist/typescript-plugin.cjs +1 -1
- package/dist/vite.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/ai/resumable-streams.mdx +1 -1
- package/docs/api-reference/workflow/create-webhook.mdx +37 -18
- package/docs/api-reference/workflow/get-workflow-metadata.mdx +34 -0
- package/docs/api-reference/workflow-ai/durable-agent.mdx +0 -4
- package/docs/api-reference/workflow-ai/index.mdx +0 -5
- package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +0 -4
- package/docs/cookbook/advanced/child-workflows.mdx +372 -0
- package/docs/cookbook/advanced/distributed-abort-controller.mdx +318 -0
- package/docs/cookbook/advanced/meta.json +9 -0
- package/docs/cookbook/advanced/publishing-libraries.mdx +336 -0
- package/docs/cookbook/advanced/serializable-steps.mdx +147 -0
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +205 -0
- package/docs/cookbook/agent-patterns/durable-agent.mdx +150 -0
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +255 -0
- package/docs/cookbook/agent-patterns/meta.json +4 -0
- package/docs/cookbook/common-patterns/batching.mdx +105 -0
- package/docs/cookbook/common-patterns/idempotency.mdx +107 -0
- package/docs/cookbook/common-patterns/meta.json +15 -0
- package/docs/cookbook/common-patterns/rate-limiting.mdx +228 -0
- package/docs/cookbook/common-patterns/saga.mdx +247 -0
- package/docs/cookbook/common-patterns/scheduling.mdx +125 -0
- package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
- package/docs/cookbook/common-patterns/timeouts.mdx +99 -0
- package/docs/cookbook/common-patterns/webhooks.mdx +185 -0
- package/docs/cookbook/common-patterns/workflow-composition.mdx +118 -0
- package/docs/cookbook/index.mdx +38 -0
- package/docs/cookbook/integrations/ai-sdk.mdx +360 -0
- package/docs/cookbook/integrations/chat-sdk.mdx +303 -0
- package/docs/cookbook/integrations/meta.json +4 -0
- package/docs/cookbook/integrations/sandbox.mdx +516 -0
- package/docs/cookbook/meta.json +5 -0
- package/docs/deploying/world/local-world.mdx +1 -1
- package/docs/deploying/world/postgres-world.mdx +1 -1
- package/docs/deploying/world/vercel-world.mdx +1 -1
- package/docs/errors/start-invalid-workflow-function.mdx +1 -1
- package/docs/foundations/index.mdx +0 -3
- package/docs/foundations/meta.json +0 -1
- package/docs/foundations/serialization.mdx +1 -1
- package/docs/foundations/starting-workflows.mdx +1 -1
- package/docs/getting-started/index.mdx +8 -1
- package/docs/getting-started/meta.json +2 -1
- package/docs/getting-started/python.mdx +165 -0
- package/docs/meta.json +1 -0
- package/docs/migration-guides/index.mdx +34 -0
- package/docs/migration-guides/meta.json +9 -0
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +363 -0
- package/docs/migration-guides/migrating-from-inngest.mdx +314 -0
- package/docs/migration-guides/migrating-from-temporal.mdx +318 -0
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +337 -0
- package/package.json +13 -13
- package/docs/foundations/common-patterns.mdx +0 -265
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
---
|
|
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.
|
|
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.
|
|
6
|
+
---
|
|
7
|
+
|
|
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.
|
|
9
|
+
|
|
10
|
+
## When to use child workflows
|
|
11
|
+
|
|
12
|
+
Child workflows are the right choice when:
|
|
13
|
+
|
|
14
|
+
- **Work units are independent.** Each child can run without knowing about the others (e.g., processing individual documents, generating separate reports).
|
|
15
|
+
- **You need isolated failure boundaries.** A failing child should not abort unrelated work. The parent decides how to handle failures.
|
|
16
|
+
- **You want massive fan-out.** Spawning 50 or 500 children is practical because each runs on its own infrastructure.
|
|
17
|
+
- **You need per-item observability.** Each child workflow has its own run ID, status, and event log for monitoring.
|
|
18
|
+
|
|
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
|
+
|
|
21
|
+
## Basic pattern: spawn and poll
|
|
22
|
+
|
|
23
|
+
The core pattern has three parts:
|
|
24
|
+
|
|
25
|
+
1. A **step** that calls `start()` to spawn a child workflow and returns the run ID
|
|
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
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { sleep } from "workflow";
|
|
31
|
+
import { getRun, start } from "workflow/api";
|
|
32
|
+
|
|
33
|
+
declare function pollUntilComplete(runIds: string[]): Promise<void>; // @setup
|
|
34
|
+
declare function collectResults(runIds: string[]): Promise<Array<{ documentId: string; summary: string }>>; // @setup
|
|
35
|
+
|
|
36
|
+
// Child workflow -- processes a single document
|
|
37
|
+
export async function processDocument(documentId: string) {
|
|
38
|
+
"use workflow";
|
|
39
|
+
|
|
40
|
+
const content = await fetchDocument(documentId);
|
|
41
|
+
const analysis = await analyzeContent(content);
|
|
42
|
+
const summary = await generateSummary(analysis);
|
|
43
|
+
|
|
44
|
+
return { documentId, summary };
|
|
45
|
+
}
|
|
46
|
+
|
|
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
|
+
}
|
|
52
|
+
|
|
53
|
+
async function analyzeContent(content: string): Promise<string> {
|
|
54
|
+
"use step";
|
|
55
|
+
// Call analysis API
|
|
56
|
+
return `analysis of ${content.length} chars`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function generateSummary(analysis: string): Promise<string> {
|
|
60
|
+
"use step";
|
|
61
|
+
// Generate summary from analysis
|
|
62
|
+
return `Summary: ${analysis}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Parent workflow -- orchestrates document processing
|
|
66
|
+
export async function processDocumentBatch(documentIds: string[]) {
|
|
67
|
+
"use workflow";
|
|
68
|
+
|
|
69
|
+
// Spawn a child workflow for each document
|
|
70
|
+
const runIds = await spawnChildren(documentIds);
|
|
71
|
+
|
|
72
|
+
// Poll until all children complete
|
|
73
|
+
await pollUntilComplete(runIds);
|
|
74
|
+
|
|
75
|
+
// Collect results
|
|
76
|
+
const results = await collectResults(runIds);
|
|
77
|
+
|
|
78
|
+
return { processed: results.length, results };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function spawnChildren(
|
|
82
|
+
documentIds: string[]
|
|
83
|
+
): Promise<string[]> {
|
|
84
|
+
"use step"; // [!code highlight]
|
|
85
|
+
|
|
86
|
+
const runIds: string[] = [];
|
|
87
|
+
for (const docId of documentIds) {
|
|
88
|
+
const run = await start(processDocument, [docId]); // [!code highlight]
|
|
89
|
+
runIds.push(run.runId);
|
|
90
|
+
}
|
|
91
|
+
return runIds;
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Polling loop
|
|
96
|
+
|
|
97
|
+
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.
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { sleep } from "workflow";
|
|
101
|
+
import { getRun } from "workflow/api";
|
|
102
|
+
|
|
103
|
+
const POLL_INTERVAL = "30s";
|
|
104
|
+
const MAX_POLL_ITERATIONS = 120; // 60 minutes at 30s intervals
|
|
105
|
+
|
|
106
|
+
async function pollUntilComplete(runIds: string[]): Promise<void> {
|
|
107
|
+
let iteration = 0;
|
|
108
|
+
|
|
109
|
+
while (iteration < MAX_POLL_ITERATIONS) {
|
|
110
|
+
const status = await checkStatuses(runIds); // [!code highlight]
|
|
111
|
+
|
|
112
|
+
if (status.running === 0) {
|
|
113
|
+
if (status.failed > 0) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`${status.failed} of ${runIds.length} children failed`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return; // All completed successfully
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
iteration += 1;
|
|
122
|
+
await sleep(POLL_INTERVAL); // [!code highlight]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
throw new Error("Timed out waiting for children to complete");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function checkStatuses(
|
|
129
|
+
runIds: string[]
|
|
130
|
+
): Promise<{ running: number; completed: number; failed: number }> {
|
|
131
|
+
"use step"; // [!code highlight]
|
|
132
|
+
|
|
133
|
+
let running = 0;
|
|
134
|
+
let completed = 0;
|
|
135
|
+
let failed = 0;
|
|
136
|
+
|
|
137
|
+
for (const runId of runIds) {
|
|
138
|
+
const run = getRun(runId); // [!code highlight]
|
|
139
|
+
const status = await run.status; // [!code highlight]
|
|
140
|
+
|
|
141
|
+
if (status === "completed") completed += 1;
|
|
142
|
+
else if (status === "failed" || status === "cancelled") failed += 1;
|
|
143
|
+
else running += 1; // pending, running
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { running, completed, failed };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function collectResults(
|
|
150
|
+
runIds: string[]
|
|
151
|
+
): Promise<Array<{ documentId: string; summary: string }>> {
|
|
152
|
+
"use step";
|
|
153
|
+
|
|
154
|
+
const results = [];
|
|
155
|
+
for (const runId of runIds) {
|
|
156
|
+
const run = getRun(runId);
|
|
157
|
+
const value = await run.returnValue;
|
|
158
|
+
results.push(value as { documentId: string; summary: string });
|
|
159
|
+
}
|
|
160
|
+
return results;
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Fan-out pattern: chunked spawning
|
|
165
|
+
|
|
166
|
+
When spawning hundreds of children, batch the `start()` calls to avoid overwhelming the system. Use multiple spawn steps, each launching a chunk of children.
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
import { start } from "workflow/api";
|
|
170
|
+
|
|
171
|
+
declare function pollUntilComplete(runIds: string[]): Promise<void>; // @setup
|
|
172
|
+
|
|
173
|
+
const CHUNK_SIZE = 10;
|
|
174
|
+
|
|
175
|
+
export async function largeReportBatch(reportConfigs: Array<{ id: string; query: string }>) {
|
|
176
|
+
"use workflow";
|
|
177
|
+
|
|
178
|
+
// Spawn children in chunks
|
|
179
|
+
const allRunIds: string[] = [];
|
|
180
|
+
for (let i = 0; i < reportConfigs.length; i += CHUNK_SIZE) {
|
|
181
|
+
const chunk = reportConfigs.slice(i, i + CHUNK_SIZE);
|
|
182
|
+
const runIds = await spawnReportChunk(chunk); // [!code highlight]
|
|
183
|
+
allRunIds.push(...runIds);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Poll until all complete
|
|
187
|
+
await pollUntilComplete(allRunIds);
|
|
188
|
+
|
|
189
|
+
const results = await collectReportResults(allRunIds);
|
|
190
|
+
return { total: results.length, results };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function spawnReportChunk(
|
|
194
|
+
configs: Array<{ id: string; query: string }>
|
|
195
|
+
): Promise<string[]> {
|
|
196
|
+
"use step";
|
|
197
|
+
|
|
198
|
+
const runIds: string[] = [];
|
|
199
|
+
for (const config of configs) {
|
|
200
|
+
const run = await start(generateReport, [config.id, config.query]);
|
|
201
|
+
runIds.push(run.runId);
|
|
202
|
+
}
|
|
203
|
+
return runIds;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function generateReport(reportId: string, query: string) {
|
|
207
|
+
"use workflow";
|
|
208
|
+
|
|
209
|
+
const data = await queryDatabase(reportId, query);
|
|
210
|
+
const formatted = await formatReport(reportId, data);
|
|
211
|
+
return { reportId, formatted };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
declare function queryDatabase(reportId: string, query: string): Promise<string>; // @setup
|
|
215
|
+
declare function formatReport(reportId: string, data: string): Promise<string>; // @setup
|
|
216
|
+
|
|
217
|
+
declare function collectReportResults(
|
|
218
|
+
runIds: string[]
|
|
219
|
+
): Promise<Array<{ reportId: string; formatted: string }>>; // @setup
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Error handling
|
|
223
|
+
|
|
224
|
+
### Tolerating partial failures
|
|
225
|
+
|
|
226
|
+
Not every batch requires 100% success. Use `allowFailures` logic to let the parent continue when some children fail, while still surfacing the failures.
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
import { sleep } from "workflow";
|
|
230
|
+
import { getRun } from "workflow/api";
|
|
231
|
+
|
|
232
|
+
const POLL_INTERVAL = "30s";
|
|
233
|
+
const MAX_POLL_ITERATIONS = 120;
|
|
234
|
+
|
|
235
|
+
async function pollWithPartialFailures(
|
|
236
|
+
runIds: string[],
|
|
237
|
+
maxFailureRate: number
|
|
238
|
+
): Promise<{ completed: string[]; failed: string[] }> {
|
|
239
|
+
let iteration = 0;
|
|
240
|
+
const completedIds: string[] = [];
|
|
241
|
+
const failedIds: string[] = [];
|
|
242
|
+
|
|
243
|
+
while (iteration < MAX_POLL_ITERATIONS) {
|
|
244
|
+
const status = await checkDetailedStatuses(runIds);
|
|
245
|
+
|
|
246
|
+
completedIds.length = 0;
|
|
247
|
+
failedIds.length = 0;
|
|
248
|
+
|
|
249
|
+
for (const entry of status) {
|
|
250
|
+
if (entry.status === "completed") completedIds.push(entry.runId);
|
|
251
|
+
else if (entry.status === "failed" || entry.status === "cancelled")
|
|
252
|
+
failedIds.push(entry.runId);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const active = runIds.length - completedIds.length - failedIds.length;
|
|
256
|
+
|
|
257
|
+
// Check if failure rate exceeds threshold
|
|
258
|
+
const failureRate = failedIds.length / Math.max(1, runIds.length); // [!code highlight]
|
|
259
|
+
if (failureRate > maxFailureRate) { // [!code highlight]
|
|
260
|
+
throw new Error( // [!code highlight]
|
|
261
|
+
`Failure rate ${(failureRate * 100).toFixed(1)}% exceeds ` + // [!code highlight]
|
|
262
|
+
`threshold of ${(maxFailureRate * 100).toFixed(1)}%` // [!code highlight]
|
|
263
|
+
); // [!code highlight]
|
|
264
|
+
} // [!code highlight]
|
|
265
|
+
|
|
266
|
+
if (active === 0) {
|
|
267
|
+
return { completed: completedIds, failed: failedIds };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
iteration += 1;
|
|
271
|
+
await sleep(POLL_INTERVAL);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
throw new Error("Timed out waiting for children");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function checkDetailedStatuses(
|
|
278
|
+
runIds: string[]
|
|
279
|
+
): Promise<Array<{ runId: string; status: string }>> {
|
|
280
|
+
"use step";
|
|
281
|
+
|
|
282
|
+
const statuses = [];
|
|
283
|
+
for (const runId of runIds) {
|
|
284
|
+
const run = getRun(runId);
|
|
285
|
+
const status = await run.status;
|
|
286
|
+
statuses.push({ runId, status });
|
|
287
|
+
}
|
|
288
|
+
return statuses;
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Retrying failed children
|
|
293
|
+
|
|
294
|
+
When a child fails, the parent can spawn a replacement and continue polling. Track restart counts to prevent infinite retry loops.
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
import { sleep } from "workflow";
|
|
298
|
+
|
|
299
|
+
declare function checkDetailedStatuses(runIds: string[]): Promise<Array<{ runId: string; status: string }>>; // @setup
|
|
300
|
+
|
|
301
|
+
const POLL_INTERVAL = "30s";
|
|
302
|
+
const MAX_POLL_ITERATIONS = 120;
|
|
303
|
+
|
|
304
|
+
async function pollWithRetries(
|
|
305
|
+
initialRunIds: string[],
|
|
306
|
+
maxRestartsPerChild: number,
|
|
307
|
+
spawnReplacement: (index: number) => Promise<string>
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
const activeRuns = new Map<number, string>();
|
|
310
|
+
const restartCounts = new Map<number, number>();
|
|
311
|
+
|
|
312
|
+
initialRunIds.forEach((runId, index) => activeRuns.set(index, runId));
|
|
313
|
+
|
|
314
|
+
let iteration = 0;
|
|
315
|
+
|
|
316
|
+
while (iteration < MAX_POLL_ITERATIONS) {
|
|
317
|
+
const statuses = await checkDetailedStatuses(
|
|
318
|
+
Array.from(activeRuns.values())
|
|
319
|
+
);
|
|
320
|
+
const statusByRunId = new Map(
|
|
321
|
+
statuses.map((s) => [s.runId, s.status])
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
for (const [index, runId] of activeRuns.entries()) {
|
|
325
|
+
const status = statusByRunId.get(runId) ?? "running";
|
|
326
|
+
|
|
327
|
+
if (status === "completed") {
|
|
328
|
+
activeRuns.delete(index);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (status === "failed" || status === "cancelled") {
|
|
333
|
+
const restarts = (restartCounts.get(index) ?? 0) + 1; // [!code highlight]
|
|
334
|
+
restartCounts.set(index, restarts); // [!code highlight]
|
|
335
|
+
|
|
336
|
+
if (restarts > maxRestartsPerChild) { // [!code highlight]
|
|
337
|
+
throw new Error( // [!code highlight]
|
|
338
|
+
`Child ${index} exceeded restart limit (${maxRestartsPerChild})` // [!code highlight]
|
|
339
|
+
); // [!code highlight]
|
|
340
|
+
} // [!code highlight]
|
|
341
|
+
|
|
342
|
+
const newRunId = await spawnReplacement(index); // [!code highlight]
|
|
343
|
+
activeRuns.set(index, newRunId); // [!code highlight]
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (activeRuns.size === 0) return;
|
|
348
|
+
|
|
349
|
+
iteration += 1;
|
|
350
|
+
await sleep(POLL_INTERVAL);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
throw new Error("Timed out waiting for children");
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## Tips
|
|
358
|
+
|
|
359
|
+
- **`start()` must be called from a step**, not directly from a workflow function. Wrap it in a `"use step"` function.
|
|
360
|
+
- **`getRun()` must also be called from a step.** The polling loop lives in the workflow, but the actual status check is a step.
|
|
361
|
+
- **Set a max iteration count on polling loops** to prevent runaway workflows. Calculate the count from your expected max duration and poll interval.
|
|
362
|
+
- **Use chunked spawning for large batches.** Spawning 500 children in a single step can time out. Break it into chunks of 10-50.
|
|
363
|
+
- **Each child has its own retry semantics.** Steps inside child workflows retry independently. The parent only sees the child's final status.
|
|
364
|
+
- **Use `deploymentId: "latest"`** if children should run on the most recent deployment. See the [`start()` API reference](/docs/api-reference/workflow-api/start#using-deploymentid-latest) for compatibility considerations.
|
|
365
|
+
|
|
366
|
+
## Key APIs
|
|
367
|
+
|
|
368
|
+
- [`start()`](/docs/api-reference/workflow-api/start) -- spawn a new workflow run and get its run ID
|
|
369
|
+
- [`getRun()`](/docs/api-reference/workflow-api/get-run) -- retrieve a workflow run's status and return value
|
|
370
|
+
- [`sleep()`](/docs/api-reference/workflow/sleep) -- durably pause between polling iterations
|
|
371
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
|
|
372
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions with full Node.js access
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Distributed Abort Controller
|
|
3
|
+
description: A distributed AbortController that uses durable workflows for cross-process cancellation signaling.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Build a distributed abort controller that uses workflow streams and hooks to propagate cancellation signals across process boundaries.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Use this pattern when you need an `AbortController`-like interface that works across distributed systems. The controller uses a durable workflow to coordinate cancellation — calling `.abort()` on one machine triggers the `.signal` on any other machine.
|
|
9
|
+
|
|
10
|
+
## When to use this
|
|
11
|
+
|
|
12
|
+
- **Cross-process cancellation** — Cancel a long-running operation from a different server, worker, or edge function
|
|
13
|
+
- **Durable cancellation** — The abort signal persists even if the process that created it crashes
|
|
14
|
+
- **UI stop buttons** — Let users cancel operations running on the server from the browser
|
|
15
|
+
- **Timeout coordination** — The built-in TTL auto-expires stale controllers
|
|
16
|
+
|
|
17
|
+
## Pattern
|
|
18
|
+
|
|
19
|
+
The `DistributedAbortController` class encapsulates a workflow that:
|
|
20
|
+
1. Accepts a user-provided unique ID (like a chat ID or task ID)
|
|
21
|
+
2. Creates or reconnects to an existing workflow using that ID
|
|
22
|
+
3. Waits for a hook signal OR TTL expiration
|
|
23
|
+
4. Writes a cancellation message to the run's stream when triggered
|
|
24
|
+
|
|
25
|
+
### Core Implementation
|
|
26
|
+
|
|
27
|
+
```typescript lineNumbers
|
|
28
|
+
import { defineHook, getWritable, sleep } from "workflow";
|
|
29
|
+
import { start, getRun, getHookByToken } from "workflow/api";
|
|
30
|
+
|
|
31
|
+
// Default TTL: 24 hours
|
|
32
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
33
|
+
// Default grace period: 1 hour (keeps hook alive after abort for late subscribers)
|
|
34
|
+
const DEFAULT_GRACE_MS = 60 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
// Hook to trigger the abort signal
|
|
37
|
+
export const abortHook = defineHook<{ reason?: string }>();
|
|
38
|
+
|
|
39
|
+
// The abort message written to the stream
|
|
40
|
+
export type AbortMessage = {
|
|
41
|
+
type: "abort";
|
|
42
|
+
reason?: string;
|
|
43
|
+
expired?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Helper to create a consistent hook token from the user ID
|
|
47
|
+
function getAbortToken(id: string): string {
|
|
48
|
+
return `abort:${id}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Step function that writes the abort message to the stream
|
|
52
|
+
async function writeAbortSignal(reason?: string, expired?: boolean) {
|
|
53
|
+
"use step";
|
|
54
|
+
|
|
55
|
+
const writable = getWritable<AbortMessage>();
|
|
56
|
+
const writer = writable.getWriter();
|
|
57
|
+
try {
|
|
58
|
+
await writer.write({ type: "abort", reason, expired });
|
|
59
|
+
} finally {
|
|
60
|
+
writer.releaseLock();
|
|
61
|
+
}
|
|
62
|
+
await writable.close();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Workflow that waits for abort or TTL expiration
|
|
66
|
+
export async function abortControllerWorkflow(
|
|
67
|
+
id: string,
|
|
68
|
+
ttlMs: number,
|
|
69
|
+
graceMs: number
|
|
70
|
+
) {
|
|
71
|
+
"use workflow";
|
|
72
|
+
|
|
73
|
+
const startTime = Date.now();
|
|
74
|
+
const hook = abortHook.create({ token: getAbortToken(id) });
|
|
75
|
+
|
|
76
|
+
// Race: manual abort OR TTL expiration // [!code highlight]
|
|
77
|
+
const result = await Promise.race([
|
|
78
|
+
hook.then((payload) => ({
|
|
79
|
+
reason: payload.reason,
|
|
80
|
+
expired: false,
|
|
81
|
+
})),
|
|
82
|
+
sleep(`${ttlMs}ms`).then(() => ({
|
|
83
|
+
reason: "Controller expired",
|
|
84
|
+
expired: true,
|
|
85
|
+
})),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
await writeAbortSignal(result.reason, result.expired);
|
|
89
|
+
|
|
90
|
+
// Only sleep through grace period on TTL expiration (keeps hook alive for late subscribers). // [!code highlight]
|
|
91
|
+
// Manual aborts complete immediately.
|
|
92
|
+
if (result.expired) {
|
|
93
|
+
const elapsed = Date.now() - startTime;
|
|
94
|
+
const remainingTime = graceMs - (elapsed - ttlMs);
|
|
95
|
+
if (remainingTime > 0) {
|
|
96
|
+
await sleep(`${remainingTime}ms`); // [!code highlight]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { aborted: true, reason: result.reason, expired: result.expired };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A distributed abort controller that works across process boundaries.
|
|
105
|
+
* Uses a semantically meaningful ID (like a chat ID or task ID) to coordinate.
|
|
106
|
+
*/
|
|
107
|
+
export class DistributedAbortController {
|
|
108
|
+
private id: string;
|
|
109
|
+
readonly runId: string;
|
|
110
|
+
|
|
111
|
+
private constructor(id: string, runId: string) {
|
|
112
|
+
this.id = id;
|
|
113
|
+
this.runId = runId;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Creates or reconnects to a distributed abort controller.
|
|
118
|
+
* If a controller with this ID already exists, reconnects to it.
|
|
119
|
+
* Otherwise, starts a new workflow.
|
|
120
|
+
*
|
|
121
|
+
* @param id - A unique, semantically meaningful ID (e.g., "chat:123")
|
|
122
|
+
* @param options.ttlMs - Time-to-live in ms (default: 24 hours)
|
|
123
|
+
* @param options.graceMs - Grace period after abort (default: 1 hour)
|
|
124
|
+
*/
|
|
125
|
+
static async create( // [!code highlight]
|
|
126
|
+
id: string,
|
|
127
|
+
options: { ttlMs?: number; graceMs?: number } = {}
|
|
128
|
+
): Promise<DistributedAbortController> {
|
|
129
|
+
const { ttlMs = DEFAULT_TTL_MS, graceMs = DEFAULT_GRACE_MS } = options;
|
|
130
|
+
const token = getAbortToken(id);
|
|
131
|
+
|
|
132
|
+
// Try to find an existing run with this hook token
|
|
133
|
+
const existingHook = await getHookByToken(token).catch(() => null); // [!code highlight]
|
|
134
|
+
|
|
135
|
+
if (existingHook) {
|
|
136
|
+
// Reconnect to existing controller
|
|
137
|
+
return new DistributedAbortController(id, existingHook.runId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Create a new workflow
|
|
141
|
+
const run = await start(abortControllerWorkflow, [id, ttlMs, graceMs]); // [!code highlight]
|
|
142
|
+
return new DistributedAbortController(id, run.runId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Triggers the abort signal.
|
|
147
|
+
* Idempotent: safe to call multiple times or after the workflow has completed.
|
|
148
|
+
*/
|
|
149
|
+
async abort(reason?: string): Promise<void> { // [!code highlight]
|
|
150
|
+
try {
|
|
151
|
+
await abortHook.resume(getAbortToken(this.id), { reason });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const msg = error instanceof Error ? error.message.toLowerCase() : '';
|
|
154
|
+
if (msg.includes('not found') || msg.includes('expired')) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Returns an AbortSignal that fires when abort() is called or TTL expires.
|
|
163
|
+
* The signal fires with a reason indicating what triggered it.
|
|
164
|
+
*/
|
|
165
|
+
get signal(): AbortSignal { // [!code highlight]
|
|
166
|
+
const run = getRun<{ aborted: boolean; reason?: string; expired?: boolean }>(this.runId);
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const readable = run.getReadable<AbortMessage>();
|
|
169
|
+
|
|
170
|
+
(async () => {
|
|
171
|
+
const reader = readable.getReader();
|
|
172
|
+
try {
|
|
173
|
+
while (true) {
|
|
174
|
+
const { done, value } = await reader.read();
|
|
175
|
+
if (done) break;
|
|
176
|
+
if (value.type === "abort") {
|
|
177
|
+
const reason = value.expired
|
|
178
|
+
? `${value.reason} (expired)`
|
|
179
|
+
: value.reason;
|
|
180
|
+
controller.abort(reason);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (!controller.signal.aborted) {
|
|
186
|
+
controller.abort(
|
|
187
|
+
error instanceof Error ? error.message : "Stream read failed"
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
reader.releaseLock();
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
194
|
+
|
|
195
|
+
return controller.signal;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Usage: Single Process
|
|
201
|
+
|
|
202
|
+
```typescript lineNumbers
|
|
203
|
+
import { DistributedAbortController } from "./distributed-abort-controller";
|
|
204
|
+
|
|
205
|
+
// Create a controller with a meaningful ID
|
|
206
|
+
const controller = await DistributedAbortController.create("chat:user-123");
|
|
207
|
+
|
|
208
|
+
// Get the signal and use it with fetch
|
|
209
|
+
const signal = controller.signal;
|
|
210
|
+
const response = await fetch("https://api.example.com/long-operation", {
|
|
211
|
+
signal,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Later: abort the operation
|
|
215
|
+
await controller.abort("User cancelled");
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Usage: Cross-Process Coordination
|
|
219
|
+
|
|
220
|
+
```typescript lineNumbers
|
|
221
|
+
import { DistributedAbortController } from "./distributed-abort-controller";
|
|
222
|
+
|
|
223
|
+
// Process A: Create the controller
|
|
224
|
+
const controller = await DistributedAbortController.create("task:build-123");
|
|
225
|
+
// start long operation using controller.signal...
|
|
226
|
+
|
|
227
|
+
// Process B: Reconnect and abort (no run ID sharing needed!)
|
|
228
|
+
const sameController = await DistributedAbortController.create("task:build-123"); // [!code highlight]
|
|
229
|
+
await sameController.abort("Cancelled by admin");
|
|
230
|
+
|
|
231
|
+
// Process C: Reconnect and listen
|
|
232
|
+
const anotherRef = await DistributedAbortController.create("task:build-123");
|
|
233
|
+
anotherRef.signal.addEventListener("abort", (e) => {
|
|
234
|
+
console.log("Task was cancelled:", (e.target as AbortSignal).reason);
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Custom TTL
|
|
239
|
+
|
|
240
|
+
```typescript lineNumbers
|
|
241
|
+
import { DistributedAbortController } from "./distributed-abort-controller";
|
|
242
|
+
|
|
243
|
+
// Short-lived controller for a quick operation (5 minutes)
|
|
244
|
+
const shortLived = await DistributedAbortController.create("quick-task", {
|
|
245
|
+
ttlMs: 5 * 60 * 1000,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Long-lived controller for batch jobs (7 days)
|
|
249
|
+
const longLived = await DistributedAbortController.create("batch-job", {
|
|
250
|
+
ttlMs: 7 * 24 * 60 * 60 * 1000,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// When TTL expires, the signal fires with expired reason
|
|
254
|
+
shortLived.signal.addEventListener("abort", (e) => {
|
|
255
|
+
const reason = (e.target as AbortSignal).reason;
|
|
256
|
+
if (reason?.includes("expired")) {
|
|
257
|
+
console.log("Controller expired, cleaning up...");
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### API Route for Remote Abort
|
|
263
|
+
|
|
264
|
+
```typescript lineNumbers
|
|
265
|
+
import { DistributedAbortController } from "@/lib/distributed-abort-controller";
|
|
266
|
+
|
|
267
|
+
export async function POST(
|
|
268
|
+
request: Request,
|
|
269
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
270
|
+
) {
|
|
271
|
+
const { id } = await params;
|
|
272
|
+
const { reason } = await request.json();
|
|
273
|
+
|
|
274
|
+
const controller = await DistributedAbortController.create(id);
|
|
275
|
+
await controller.abort(reason || "Cancelled via API");
|
|
276
|
+
|
|
277
|
+
return Response.json({ success: true });
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Client Cancel Button
|
|
282
|
+
|
|
283
|
+
```tsx lineNumbers
|
|
284
|
+
"use client";
|
|
285
|
+
|
|
286
|
+
export function CancelButton({ taskId }: { taskId: string }) {
|
|
287
|
+
const handleCancel = async () => {
|
|
288
|
+
await fetch(`/api/abort/${taskId}`, {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: { "Content-Type": "application/json" },
|
|
291
|
+
body: JSON.stringify({ reason: "User clicked cancel" }),
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<button type="button" onClick={handleCancel}>
|
|
297
|
+
Cancel Operation
|
|
298
|
+
</button>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## Tips
|
|
304
|
+
|
|
305
|
+
- **Use semantic IDs** — Use meaningful IDs like `chat:123` or `task:abc` instead of random UUIDs
|
|
306
|
+
- **Create is idempotent** — Calling `create()` with the same ID reconnects to the existing controller
|
|
307
|
+
- **TTL auto-cleanup** — Workflows self-terminate after TTL expires; no manual cleanup needed
|
|
308
|
+
- **Signal is a getter** — Each access to `.signal` creates a new listener; cache it if needed
|
|
309
|
+
- **One-shot** — Once aborted or expired, the workflow completes; create a new controller for new operations
|
|
310
|
+
|
|
311
|
+
## Key APIs
|
|
312
|
+
|
|
313
|
+
- [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook for the abort trigger
|
|
314
|
+
- [`getWritable()`](/docs/api-reference/workflow/get-writable) — write abort messages to the stream
|
|
315
|
+
- [`sleep()`](/docs/api-reference/workflow/sleep) — TTL timer for auto-expiration
|
|
316
|
+
- [`start()`](/docs/api-reference/workflow-api/start) — start the abort controller workflow
|
|
317
|
+
- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) — find existing run by hook token
|
|
318
|
+
- [`getRun()`](/docs/api-reference/workflow-api/get-run) — reconnect to the workflow's readable stream
|