@workflow/core 5.0.0-beta.4 → 5.0.0-beta.5
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/dist/capture-stack.d.ts +16 -0
- package/dist/capture-stack.d.ts.map +1 -0
- package/dist/capture-stack.js +21 -0
- package/dist/classify-error.d.ts +7 -0
- package/dist/classify-error.d.ts.map +1 -1
- package/dist/classify-error.js +25 -4
- package/dist/context-errors.d.ts +27 -0
- package/dist/context-errors.d.ts.map +1 -0
- package/dist/context-errors.js +101 -0
- package/dist/context-violation-error.d.ts +97 -0
- package/dist/context-violation-error.d.ts.map +1 -0
- package/dist/context-violation-error.js +147 -0
- package/dist/create-hook.d.ts.map +1 -1
- package/dist/create-hook.js +4 -3
- package/dist/define-hook.d.ts.map +1 -1
- package/dist/define-hook.js +20 -5
- package/dist/describe-error.d.ts +70 -0
- package/dist/describe-error.d.ts.map +1 -0
- package/dist/describe-error.js +119 -0
- package/dist/encryption.d.ts.map +1 -1
- package/dist/encryption.js +4 -20
- package/dist/events-consumer.d.ts +7 -0
- package/dist/events-consumer.d.ts.map +1 -1
- package/dist/events-consumer.js +9 -2
- package/dist/flushable-stream.d.ts +10 -10
- package/dist/flushable-stream.d.ts.map +1 -1
- package/dist/flushable-stream.js +13 -12
- package/dist/global.d.ts +4 -0
- package/dist/global.d.ts.map +1 -1
- package/dist/global.js +8 -2
- package/dist/log-format.d.ts +25 -0
- package/dist/log-format.d.ts.map +1 -0
- package/dist/log-format.js +250 -0
- package/dist/logger.d.ts +28 -30
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +77 -31
- package/dist/private.js +3 -3
- package/dist/runtime/get-port-lazy.d.ts +10 -0
- package/dist/runtime/get-port-lazy.d.ts.map +1 -0
- package/dist/runtime/get-port-lazy.js +30 -0
- package/dist/runtime/get-world-lazy.d.ts +32 -0
- package/dist/runtime/get-world-lazy.d.ts.map +1 -0
- package/dist/runtime/get-world-lazy.js +56 -0
- package/dist/runtime/helpers.d.ts +38 -5
- package/dist/runtime/helpers.d.ts.map +1 -1
- package/dist/runtime/helpers.js +67 -18
- package/dist/runtime/resume-hook.js +4 -4
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +31 -6
- package/dist/runtime/start.js +3 -3
- package/dist/runtime/step-executor.d.ts +40 -0
- package/dist/runtime/step-executor.d.ts.map +1 -0
- package/dist/runtime/step-executor.js +480 -0
- package/dist/runtime/step-handler.d.ts.map +1 -1
- package/dist/runtime/step-handler.js +209 -111
- package/dist/runtime/suspension-handler.d.ts +22 -4
- package/dist/runtime/suspension-handler.d.ts.map +1 -1
- package/dist/runtime/suspension-handler.js +86 -73
- package/dist/runtime/world-init.d.ts +75 -0
- package/dist/runtime/world-init.d.ts.map +1 -0
- package/dist/runtime/world-init.js +75 -0
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +47 -18
- package/dist/runtime.d.ts +8 -7
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +649 -292
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +1 -1
- package/dist/serialization/client.d.ts +17 -0
- package/dist/serialization/client.d.ts.map +1 -0
- package/dist/serialization/client.js +44 -0
- package/dist/serialization/codec-devalue.d.ts +14 -0
- package/dist/serialization/codec-devalue.d.ts.map +1 -0
- package/dist/serialization/codec-devalue.js +98 -0
- package/dist/serialization/codec.d.ts +90 -0
- package/dist/serialization/codec.d.ts.map +1 -0
- package/dist/serialization/codec.js +17 -0
- package/dist/serialization/encryption.d.ts +32 -0
- package/dist/serialization/encryption.d.ts.map +1 -0
- package/dist/serialization/encryption.js +51 -0
- package/dist/serialization/errors.d.ts +21 -0
- package/dist/serialization/errors.d.ts.map +1 -0
- package/dist/serialization/errors.js +41 -0
- package/dist/serialization/format.d.ts +60 -0
- package/dist/serialization/format.d.ts.map +1 -0
- package/dist/serialization/format.js +97 -0
- package/dist/serialization/index.d.ts +18 -0
- package/dist/serialization/index.d.ts.map +1 -0
- package/dist/serialization/index.js +20 -0
- package/dist/serialization/reducers/class.d.ts +11 -0
- package/dist/serialization/reducers/class.d.ts.map +1 -0
- package/dist/serialization/reducers/class.js +68 -0
- package/dist/serialization/reducers/common.d.ts +16 -0
- package/dist/serialization/reducers/common.d.ts.map +1 -0
- package/dist/serialization/reducers/common.js +362 -0
- package/dist/serialization/reducers/step-function.d.ts +35 -0
- package/dist/serialization/reducers/step-function.d.ts.map +1 -0
- package/dist/serialization/reducers/step-function.js +88 -0
- package/dist/serialization/step.d.ts +17 -0
- package/dist/serialization/step.d.ts.map +1 -0
- package/dist/serialization/step.js +44 -0
- package/dist/serialization/types.d.ts +201 -0
- package/dist/serialization/types.d.ts.map +1 -0
- package/dist/serialization/types.js +21 -0
- package/dist/serialization/workflow.d.ts +29 -0
- package/dist/serialization/workflow.d.ts.map +1 -0
- package/dist/serialization/workflow.js +54 -0
- package/dist/serialization-format.d.ts.map +1 -1
- package/dist/serialization-format.js +77 -3
- package/dist/serialization.d.ts +202 -226
- package/dist/serialization.d.ts.map +1 -1
- package/dist/serialization.js +773 -633
- package/dist/sleep.d.ts.map +1 -1
- package/dist/sleep.js +3 -2
- package/dist/step/get-closure-vars.d.ts.map +1 -1
- package/dist/step/get-closure-vars.js +3 -2
- package/dist/step/get-step-metadata.d.ts.map +1 -1
- package/dist/step/get-step-metadata.js +3 -2
- package/dist/step/get-workflow-metadata.d.ts.map +1 -1
- package/dist/step/get-workflow-metadata.js +3 -2
- package/dist/step/writable-stream.d.ts.map +1 -1
- package/dist/step/writable-stream.js +3 -2
- package/dist/step.d.ts.map +1 -1
- package/dist/step.js +83 -16
- package/dist/symbols.d.ts +4 -0
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +5 -1
- package/dist/telemetry/semantic-conventions.d.ts +2 -2
- package/dist/telemetry/semantic-conventions.js +1 -1
- package/dist/util.d.ts +18 -3
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +28 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +2 -2
- package/dist/vm/index.d.ts.map +1 -1
- package/dist/vm/index.js +3 -2
- package/dist/workflow/abort-controller.d.ts +65 -0
- package/dist/workflow/abort-controller.d.ts.map +1 -0
- package/dist/workflow/abort-controller.js +245 -0
- package/dist/workflow/create-hook.d.ts.map +1 -1
- package/dist/workflow/create-hook.js +3 -2
- package/dist/workflow/define-hook.d.ts +1 -1
- package/dist/workflow/define-hook.d.ts.map +1 -1
- package/dist/workflow/define-hook.js +8 -4
- package/dist/workflow/get-workflow-metadata.d.ts.map +1 -1
- package/dist/workflow/get-workflow-metadata.js +14 -3
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js +4 -3
- package/dist/workflow/world-init-stub.d.ts +15 -0
- package/dist/workflow/world-init-stub.d.ts.map +1 -0
- package/dist/workflow/world-init-stub.js +15 -0
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +71 -28
- package/docs/foundations/cancellation.mdx +460 -0
- package/docs/foundations/errors-and-retries.mdx +7 -3
- package/docs/foundations/meta.json +1 -0
- package/docs/foundations/serialization.mdx +77 -41
- package/docs/how-it-works/cancellation.mdx +287 -0
- package/docs/how-it-works/meta.json +2 -1
- package/package.json +15 -6
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Cancellation
|
|
3
|
+
description: Cancel long-running steps cooperatively using AbortSignal, or cancel entire workflow runs.
|
|
4
|
+
type: conceptual
|
|
5
|
+
summary: Cancel in-flight work with AbortSignal or stop entire workflow runs.
|
|
6
|
+
prerequisites:
|
|
7
|
+
- /docs/foundations/workflows-and-steps
|
|
8
|
+
related:
|
|
9
|
+
- /docs/foundations/common-patterns
|
|
10
|
+
- /docs/foundations/hooks
|
|
11
|
+
- /docs/how-it-works/cancellation
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
Workflow DevKit supports two cancellation mechanisms: **AbortSignal** for fine-grained, cooperative cancellation of individual operations, and **run cancellation** for stopping an entire workflow. This guide covers both.
|
|
15
|
+
|
|
16
|
+
## AbortSignal
|
|
17
|
+
|
|
18
|
+
`AbortController` and `AbortSignal` work across workflow and step boundaries. Create an `AbortController` with `new AbortController()` in a workflow function, pass its signal to steps, and call `abort()` — using the standard [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) API you already know.
|
|
19
|
+
|
|
20
|
+
```typescript lineNumbers
|
|
21
|
+
import { sleep } from "workflow";
|
|
22
|
+
|
|
23
|
+
export async function cancellableWorkflow() {
|
|
24
|
+
"use workflow";
|
|
25
|
+
|
|
26
|
+
const controller = new AbortController(); // [!code highlight]
|
|
27
|
+
|
|
28
|
+
const result = await Promise.race([
|
|
29
|
+
longRunningStep(controller.signal), // [!code highlight]
|
|
30
|
+
sleep("30s").then(() => "timeout" as const),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
if (result === "timeout") {
|
|
34
|
+
controller.abort(); // [!code highlight]
|
|
35
|
+
return { status: "timed out" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { status: "completed", result };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function longRunningStep(signal: AbortSignal) {
|
|
42
|
+
"use step";
|
|
43
|
+
|
|
44
|
+
const response = await fetch("https://api.example.com/slow-operation", {
|
|
45
|
+
signal, // [!code highlight]
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return response.json();
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
No special imports, no wrapper functions — just the standard `AbortController` API.
|
|
53
|
+
|
|
54
|
+
<Callout type="info">
|
|
55
|
+
Cancellation is **cooperative**. Aborting a signal doesn't forcefully kill a step — it's up to the step's code to check `signal.aborted` or pass the signal to APIs like `fetch` that respect it. If a step ignores the signal, it runs to completion.
|
|
56
|
+
</Callout>
|
|
57
|
+
|
|
58
|
+
<Callout type="info">
|
|
59
|
+
To learn how `AbortController` works durably across workflow suspensions, replays, and step boundaries, see [How Cancellation Works](/docs/how-it-works/cancellation).
|
|
60
|
+
</Callout>
|
|
61
|
+
|
|
62
|
+
### Timeout with Cancellation
|
|
63
|
+
|
|
64
|
+
Race a step against a timeout, and cancel the step if the timeout wins:
|
|
65
|
+
|
|
66
|
+
```typescript lineNumbers
|
|
67
|
+
import { sleep } from "workflow";
|
|
68
|
+
|
|
69
|
+
export async function fetchWithTimeout(url: string) {
|
|
70
|
+
"use workflow";
|
|
71
|
+
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
|
|
74
|
+
const result = await Promise.race([
|
|
75
|
+
fetchUrl(url, controller.signal),
|
|
76
|
+
sleep("10s").then(() => null),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
if (result === null) {
|
|
80
|
+
controller.abort(); // [!code highlight]
|
|
81
|
+
throw new Error(`Request to ${url} timed out after 10s`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function fetchUrl(url: string, signal: AbortSignal) {
|
|
88
|
+
"use step";
|
|
89
|
+
const response = await fetch(url, { signal });
|
|
90
|
+
return response.json();
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Cancelling Parallel Work
|
|
95
|
+
|
|
96
|
+
When racing multiple steps, cancel the losers:
|
|
97
|
+
|
|
98
|
+
```typescript lineNumbers
|
|
99
|
+
export async function firstResponder(urls: string[]) {
|
|
100
|
+
"use workflow";
|
|
101
|
+
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
|
|
104
|
+
const result = await Promise.race( // [!code highlight]
|
|
105
|
+
urls.map((url) => fetchUrl(url, controller.signal)) // [!code highlight]
|
|
106
|
+
); // [!code highlight]
|
|
107
|
+
|
|
108
|
+
controller.abort(); // Cancel remaining fetches // [!code highlight]
|
|
109
|
+
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function fetchUrl(url: string, signal: AbortSignal) {
|
|
114
|
+
"use step";
|
|
115
|
+
const response = await fetch(url, { signal });
|
|
116
|
+
return { url, data: await response.json() };
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Passing Signal Through a Pipeline
|
|
121
|
+
|
|
122
|
+
Pass the same signal to a chain of steps. Aborting cancels whichever step is currently running:
|
|
123
|
+
|
|
124
|
+
```typescript lineNumbers
|
|
125
|
+
declare function splitIntoChunks(data: ArrayBuffer): ArrayBuffer[]; // @setup
|
|
126
|
+
declare function processChunk(chunk: ArrayBuffer): Promise<Uint8Array>; // @setup
|
|
127
|
+
|
|
128
|
+
export async function pipelineWorkflow(dataUrl: string) {
|
|
129
|
+
"use workflow";
|
|
130
|
+
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const raw = await downloadData(dataUrl, controller.signal);
|
|
135
|
+
const transformed = await transformData(raw, controller.signal);
|
|
136
|
+
const result = await uploadData(transformed, controller.signal);
|
|
137
|
+
return result;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
140
|
+
return { status: "cancelled" };
|
|
141
|
+
}
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function downloadData(url: string, signal: AbortSignal) {
|
|
147
|
+
"use step";
|
|
148
|
+
const response = await fetch(url, { signal });
|
|
149
|
+
return response.arrayBuffer();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function transformData(data: ArrayBuffer, signal: AbortSignal) {
|
|
153
|
+
"use step";
|
|
154
|
+
|
|
155
|
+
signal.throwIfAborted(); // [!code highlight]
|
|
156
|
+
|
|
157
|
+
const chunks = splitIntoChunks(data);
|
|
158
|
+
const results = [];
|
|
159
|
+
|
|
160
|
+
for (const chunk of chunks) {
|
|
161
|
+
signal.throwIfAborted(); // [!code highlight]
|
|
162
|
+
results.push(await processChunk(chunk));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return Buffer.concat(results);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function uploadData(data: ArrayBuffer, signal: AbortSignal) {
|
|
169
|
+
"use step";
|
|
170
|
+
await fetch("https://storage.example.com/upload", {
|
|
171
|
+
method: "POST",
|
|
172
|
+
body: data,
|
|
173
|
+
signal,
|
|
174
|
+
});
|
|
175
|
+
return { status: "uploaded" };
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Step-Initiated Abort
|
|
180
|
+
|
|
181
|
+
A step can receive the full `AbortController` and call `abort()` to cancel parallel work. This is useful for watchdog/monitor patterns where one step observes an external condition and cancels other in-flight steps:
|
|
182
|
+
|
|
183
|
+
```typescript lineNumbers
|
|
184
|
+
export async function processWithQuotaCheck(userId: string, dataUrl: string) {
|
|
185
|
+
"use workflow";
|
|
186
|
+
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
|
|
189
|
+
// Run the work and a quota monitor in parallel
|
|
190
|
+
const [result] = await Promise.all([ // [!code highlight]
|
|
191
|
+
processData(dataUrl, controller.signal), // [!code highlight]
|
|
192
|
+
monitorQuota(userId, controller), // [!code highlight]
|
|
193
|
+
]); // [!code highlight]
|
|
194
|
+
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function processData(url: string, signal: AbortSignal) {
|
|
199
|
+
"use step";
|
|
200
|
+
const response = await fetch(url, { signal });
|
|
201
|
+
const data = await response.arrayBuffer();
|
|
202
|
+
// ... expensive processing ...
|
|
203
|
+
return { processed: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function monitorQuota(userId: string, controller: AbortController) {
|
|
207
|
+
"use step";
|
|
208
|
+
|
|
209
|
+
// Poll quota status while the other step is running
|
|
210
|
+
while (!controller.signal.aborted) {
|
|
211
|
+
const quota = await fetch(`https://api.example.com/quota/${userId}`);
|
|
212
|
+
const { exceeded } = await quota.json();
|
|
213
|
+
|
|
214
|
+
if (exceeded) {
|
|
215
|
+
controller.abort("Quota exceeded"); // Cancels processData // [!code highlight]
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### User-Triggered Cancellation with Hooks
|
|
225
|
+
|
|
226
|
+
Combine hooks with abort controllers to let users cancel in-flight work from an external API:
|
|
227
|
+
|
|
228
|
+
```typescript lineNumbers
|
|
229
|
+
import { createHook } from "workflow";
|
|
230
|
+
|
|
231
|
+
export async function userCancellableWorkflow(jobId: string) {
|
|
232
|
+
"use workflow";
|
|
233
|
+
|
|
234
|
+
using cancelHook = createHook<{ reason: string }>({
|
|
235
|
+
token: `cancel:${jobId}`,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const controller = new AbortController();
|
|
239
|
+
const workPromise = doExpensiveWork(controller.signal);
|
|
240
|
+
|
|
241
|
+
const result = await Promise.race([ // [!code highlight]
|
|
242
|
+
workPromise.then((data) => ({ status: "completed", data })),
|
|
243
|
+
cancelHook.then((payload) => { // [!code highlight]
|
|
244
|
+
controller.abort(); // [!code highlight]
|
|
245
|
+
return { status: "cancelled", reason: payload.reason };
|
|
246
|
+
}),
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function doExpensiveWork(signal: AbortSignal) {
|
|
253
|
+
"use step";
|
|
254
|
+
const response = await fetch("https://api.example.com/expensive", { signal });
|
|
255
|
+
return response.json();
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
```typescript title="app/api/cancel/route.ts" lineNumbers
|
|
260
|
+
import { resumeHook } from "workflow/api";
|
|
261
|
+
|
|
262
|
+
export async function POST(request: Request) {
|
|
263
|
+
const { jobId, reason } = await request.json();
|
|
264
|
+
|
|
265
|
+
await resumeHook(`cancel:${jobId}`, { reason });
|
|
266
|
+
return Response.json({ cancelled: true });
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### How Steps Handle Abort
|
|
271
|
+
|
|
272
|
+
When an `AbortSignal` is aborted, the behavior depends on how the step uses it:
|
|
273
|
+
|
|
274
|
+
| Usage | Behavior on Abort |
|
|
275
|
+
|-------|-------------------|
|
|
276
|
+
| `fetch(url, { signal })` | Request is cancelled, throws `AbortError` |
|
|
277
|
+
| `signal.throwIfAborted()` | Throws the abort reason |
|
|
278
|
+
| `signal.aborted` check | Returns `true`, step can exit gracefully |
|
|
279
|
+
| `signal.addEventListener('abort', fn)` | Callback fires, step can clean up |
|
|
280
|
+
| Ignored | Step runs to completion (abort is cooperative) |
|
|
281
|
+
|
|
282
|
+
### Abort Errors Skip Retries
|
|
283
|
+
|
|
284
|
+
When a step throws due to an abort (e.g., `fetch` throws `AbortError`, or `signal.throwIfAborted()` throws), the error is automatically wrapped in a `FatalError`. This means the step **skips retries** and the error bubbles up to the workflow immediately.
|
|
285
|
+
|
|
286
|
+
This is the correct behavior because an abort is an intentional cancellation — retrying the step would just result in another abort. You don't need to manually wrap abort errors in `FatalError`.
|
|
287
|
+
|
|
288
|
+
```typescript lineNumbers
|
|
289
|
+
import { sleep } from "workflow";
|
|
290
|
+
|
|
291
|
+
export async function workflow() {
|
|
292
|
+
"use workflow";
|
|
293
|
+
const controller = new AbortController();
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const result = await Promise.race([
|
|
297
|
+
cancellableStep(controller.signal),
|
|
298
|
+
sleep("5s").then(() => null),
|
|
299
|
+
]);
|
|
300
|
+
if (result === null) controller.abort();
|
|
301
|
+
return result;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
// AbortError arrives as FatalError — no retries attempted // [!code highlight]
|
|
304
|
+
return { status: "cancelled" };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function cancellableStep(signal: AbortSignal) {
|
|
309
|
+
"use step";
|
|
310
|
+
// If this throws AbortError, it's automatically wrapped in FatalError
|
|
311
|
+
const response = await fetch("https://api.example.com/slow", { signal });
|
|
312
|
+
return response.json();
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Passing AbortSignal as Workflow Input
|
|
317
|
+
|
|
318
|
+
You can pass an `AbortSignal` from external code into a workflow via `start()`:
|
|
319
|
+
|
|
320
|
+
{/* @skip-typecheck: myWorkflow is not declared, this is a conceptual snippet */}
|
|
321
|
+
```typescript lineNumbers
|
|
322
|
+
import { start } from "workflow/api";
|
|
323
|
+
|
|
324
|
+
export async function POST(request: Request) {
|
|
325
|
+
const controller = new AbortController();
|
|
326
|
+
const run = await start(myWorkflow, [controller.signal]); // [!code highlight]
|
|
327
|
+
|
|
328
|
+
// Later, cancel from external code
|
|
329
|
+
controller.abort(); // [!code highlight]
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
When the signal is serialized at the `start()` boundary, an event listener is attached to the external signal that writes the cancellation packet to the backing stream. This means the external `abort()` propagates into the workflow — but only while the originating process is still alive (same constraint as passing a `ReadableStream` as input).
|
|
334
|
+
|
|
335
|
+
<Callout type="info">
|
|
336
|
+
For reliable external cancellation that works regardless of process lifetime, prefer the [User-Triggered Cancellation with Hooks](#user-triggered-cancellation-with-hooks) pattern. Hooks are durable and don't depend on the caller's process staying alive.
|
|
337
|
+
</Callout>
|
|
338
|
+
|
|
339
|
+
## Run Cancellation
|
|
340
|
+
|
|
341
|
+
Run cancellation stops an entire workflow at the next suspension point. Unlike `AbortSignal`, it is not cooperative — the workflow does not continue executing after cancellation.
|
|
342
|
+
|
|
343
|
+
```typescript title="app/api/cancel-run/route.ts" lineNumbers
|
|
344
|
+
import { getRun } from "workflow/api";
|
|
345
|
+
|
|
346
|
+
export async function POST(request: Request) {
|
|
347
|
+
const { runId } = await request.json();
|
|
348
|
+
|
|
349
|
+
const run = getRun(runId);
|
|
350
|
+
await run.cancel(); // [!code highlight]
|
|
351
|
+
|
|
352
|
+
return Response.json({ cancelled: true });
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
<Callout type="info">
|
|
357
|
+
Calling `run.cancel()` is the same action as clicking the **Cancel** button on a run in the observability UI — both produce identical `run_cancelled` events in the event log.
|
|
358
|
+
</Callout>
|
|
359
|
+
|
|
360
|
+
When a run is cancelled:
|
|
361
|
+
- The workflow stops at its next suspension point (step call, hook await, or sleep)
|
|
362
|
+
- A `run_cancelled` event is recorded in the [event log](/docs/how-it-works/event-sourcing)
|
|
363
|
+
- All associated hooks are disposed and their tokens released
|
|
364
|
+
- Streams are closed
|
|
365
|
+
|
|
366
|
+
<Callout type="info">
|
|
367
|
+
Run cancellation does **not** automatically abort any outstanding `AbortSignal`s. Steps that are currently executing will run to completion. If you need in-flight cancellation of specific operations, use `AbortSignal`.
|
|
368
|
+
</Callout>
|
|
369
|
+
|
|
370
|
+
## AbortSignal vs. Run Cancellation
|
|
371
|
+
|
|
372
|
+
| | AbortSignal | Run Cancellation |
|
|
373
|
+
|---|---|---|
|
|
374
|
+
| **Scope** | Individual operations within a step | Entire workflow run |
|
|
375
|
+
| **Triggered by** | Your code (`controller.abort()`) | External API (`run.cancel()`) |
|
|
376
|
+
| **Cooperative** | Yes — steps must check the signal | No — workflow stops at the next suspension point |
|
|
377
|
+
| **Granularity** | Can target specific steps or operations | All-or-nothing |
|
|
378
|
+
| **In-flight steps** | Aborted immediately if using the signal | Run to completion |
|
|
379
|
+
|
|
380
|
+
Use `AbortSignal` when you need fine-grained, in-flight cancellation of specific operations. Use run cancellation when you want to stop the entire workflow.
|
|
381
|
+
|
|
382
|
+
## Best Practices
|
|
383
|
+
|
|
384
|
+
**Use `throwIfAborted()` before expensive work.** This throws the signal's abort reason if the signal is already aborted, preventing wasted compute:
|
|
385
|
+
|
|
386
|
+
```typescript lineNumbers
|
|
387
|
+
async function expensiveStep(signal: AbortSignal) {
|
|
388
|
+
"use step";
|
|
389
|
+
signal.throwIfAborted(); // [!code highlight]
|
|
390
|
+
// ... expensive work ...
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
**Handle abort errors in the workflow.** Abort errors arrive as `FatalError` (no retries) and can be caught with a standard try/catch:
|
|
395
|
+
|
|
396
|
+
```typescript lineNumbers
|
|
397
|
+
declare function cancellableStep(signal: AbortSignal): Promise<void>; // @setup
|
|
398
|
+
import { FatalError } from "workflow";
|
|
399
|
+
|
|
400
|
+
export async function workflow() {
|
|
401
|
+
"use workflow";
|
|
402
|
+
const controller = new AbortController();
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
await cancellableStep(controller.signal);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
if (FatalError.is(err)) { // [!code highlight]
|
|
408
|
+
return { status: "cancelled" };
|
|
409
|
+
}
|
|
410
|
+
throw err;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
**Use `AbortSignal.any()` to combine signals:**
|
|
416
|
+
|
|
417
|
+
```typescript lineNumbers
|
|
418
|
+
async function stepWithMultipleSignals(
|
|
419
|
+
userSignal: AbortSignal,
|
|
420
|
+
timeoutSignal: AbortSignal
|
|
421
|
+
) {
|
|
422
|
+
"use step";
|
|
423
|
+
|
|
424
|
+
const combined = AbortSignal.any([userSignal, timeoutSignal]); // [!code highlight]
|
|
425
|
+
const response = await fetch("https://api.example.com/data", {
|
|
426
|
+
signal: combined,
|
|
427
|
+
});
|
|
428
|
+
return response.json();
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
**Abort after a race:**
|
|
433
|
+
|
|
434
|
+
```typescript lineNumbers
|
|
435
|
+
declare function stepA(signal: AbortSignal): Promise<string>; // @setup
|
|
436
|
+
declare function stepB(signal: AbortSignal): Promise<string>; // @setup
|
|
437
|
+
|
|
438
|
+
export async function workflow() {
|
|
439
|
+
"use workflow";
|
|
440
|
+
const controller = new AbortController();
|
|
441
|
+
|
|
442
|
+
const winner = await Promise.race([
|
|
443
|
+
stepA(controller.signal),
|
|
444
|
+
stepB(controller.signal),
|
|
445
|
+
]);
|
|
446
|
+
|
|
447
|
+
controller.abort(); // Clean up whichever step is still running // [!code highlight]
|
|
448
|
+
return winner;
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
This is safe even if both steps have already completed — aborting a finished operation is a no-op.
|
|
453
|
+
|
|
454
|
+
## Related Documentation
|
|
455
|
+
|
|
456
|
+
- [How Cancellation Works](/docs/how-it-works/cancellation) — Hook and stream backing, serialization internals
|
|
457
|
+
- [Serialization](/docs/foundations/serialization) — Understanding serializable types
|
|
458
|
+
- [Common Patterns](/docs/foundations/common-patterns) — Timeout and race patterns
|
|
459
|
+
- [Hooks](/docs/foundations/hooks) — Pausing workflows for external events
|
|
460
|
+
- [Errors and Retries](/docs/foundations/errors-and-retries) — Handling step failures
|
|
@@ -141,7 +141,7 @@ callApi.maxRetries = 5; // Retry up to 5 times on failure (6 total attempts)
|
|
|
141
141
|
|
|
142
142
|
## Error Codes
|
|
143
143
|
|
|
144
|
-
When a workflow run fails, the error
|
|
144
|
+
When a workflow run fails, the error includes an `errorCode` that classifies the failure, alongside the original thrown value (preserved as `cause`):
|
|
145
145
|
|
|
146
146
|
```typescript lineNumbers
|
|
147
147
|
import { WorkflowRunFailedError } from "@workflow/errors";
|
|
@@ -153,8 +153,12 @@ try {
|
|
|
153
153
|
const result = await run.returnValue;
|
|
154
154
|
} catch (err) {
|
|
155
155
|
if (WorkflowRunFailedError.is(err)) {
|
|
156
|
-
console.log(err.
|
|
157
|
-
|
|
156
|
+
console.log(err.errorCode); // "USER_ERROR", "RUNTIME_ERROR", or undefined
|
|
157
|
+
// `cause` is the original thrown value, hydrated through the workflow
|
|
158
|
+
// serialization pipeline. It can be any thrown value, so check shape.
|
|
159
|
+
if (err.cause instanceof Error) {
|
|
160
|
+
console.log(err.cause.message); // The error message
|
|
161
|
+
}
|
|
158
162
|
}
|
|
159
163
|
}
|
|
160
164
|
```
|
|
@@ -55,6 +55,50 @@ These types have special handling and are explained in detail in the sections be
|
|
|
55
55
|
- `Response`
|
|
56
56
|
- `ReadableStream<Serializable>`
|
|
57
57
|
- `WritableStream<Serializable>`
|
|
58
|
+
- `AbortController`
|
|
59
|
+
- `AbortSignal`
|
|
60
|
+
|
|
61
|
+
## Pass-by-Value Semantics
|
|
62
|
+
|
|
63
|
+
**Parameters are passed by value, not by reference.** Steps receive deserialized copies of data. Mutations inside a step won't affect the original in the workflow.
|
|
64
|
+
|
|
65
|
+
**Incorrect:**
|
|
66
|
+
|
|
67
|
+
```typescript title="workflows/incorrect-mutation.ts" lineNumbers
|
|
68
|
+
export async function updateUserWorkflow(userId: string) {
|
|
69
|
+
"use workflow";
|
|
70
|
+
|
|
71
|
+
let user = { id: userId, name: "John", email: "john@example.com" };
|
|
72
|
+
await updateUserStep(user);
|
|
73
|
+
|
|
74
|
+
// user.email is still "john@example.com" // [!code highlight]
|
|
75
|
+
console.log(user.email); // [!code highlight]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function updateUserStep(user: { id: string; name: string; email: string }) {
|
|
79
|
+
"use step";
|
|
80
|
+
user.email = "newemail@example.com"; // Changes are lost // [!code highlight]
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Correct - return the modified data:**
|
|
85
|
+
|
|
86
|
+
```typescript title="workflows/correct-mutation.ts" lineNumbers
|
|
87
|
+
export async function updateUserWorkflow(userId: string) {
|
|
88
|
+
"use workflow";
|
|
89
|
+
|
|
90
|
+
let user = { id: userId, name: "John", email: "john@example.com" };
|
|
91
|
+
user = await updateUserStep(user); // Reassign the return value // [!code highlight]
|
|
92
|
+
|
|
93
|
+
console.log(user.email); // "newemail@example.com"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function updateUserStep(user: { id: string; name: string; email: string }) {
|
|
97
|
+
"use step";
|
|
98
|
+
user.email = "newemail@example.com";
|
|
99
|
+
return user; // [!code highlight]
|
|
100
|
+
}
|
|
101
|
+
```
|
|
58
102
|
|
|
59
103
|
**Custom Classes:**
|
|
60
104
|
|
|
@@ -125,6 +169,39 @@ export async function fetch(...args: Parameters<typeof globalThis.fetch>) {
|
|
|
125
169
|
|
|
126
170
|
This allows you to make HTTP requests directly in workflow functions while maintaining deterministic replay behavior through automatic caching.
|
|
127
171
|
|
|
172
|
+
## AbortController & AbortSignal
|
|
173
|
+
|
|
174
|
+
`AbortController` and `AbortSignal` are serializable types that enable cooperative cancellation across workflow and step boundaries. Inside a workflow function, `new AbortController()` creates a durable controller that works across suspensions and step boundaries:
|
|
175
|
+
|
|
176
|
+
```typescript lineNumbers
|
|
177
|
+
import { sleep } from "workflow";
|
|
178
|
+
|
|
179
|
+
export async function cancellableWorkflow() {
|
|
180
|
+
"use workflow";
|
|
181
|
+
|
|
182
|
+
const controller = new AbortController(); // [!code highlight]
|
|
183
|
+
|
|
184
|
+
const result = await Promise.race([
|
|
185
|
+
fetchData(controller.signal), // [!code highlight]
|
|
186
|
+
sleep("10s").then(() => null),
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
if (result === null) {
|
|
190
|
+
controller.abort(); // [!code highlight]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function fetchData(signal: AbortSignal) {
|
|
197
|
+
"use step";
|
|
198
|
+
const response = await fetch("https://api.example.com/data", { signal });
|
|
199
|
+
return response.json();
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
For usage patterns including timeouts, parallel cancellation, user-triggered cancellation, and run cancellation, see the [Cancellation Guide](/docs/foundations/cancellation). For details on the hook and stream backing that makes this work, see [How Cancellation Works](/docs/how-it-works/cancellation).
|
|
204
|
+
|
|
128
205
|
## Custom Class Serialization
|
|
129
206
|
|
|
130
207
|
By default, custom class instances cannot be serialized because the serialization system doesn't know how to reconstruct them. You can make your classes serializable by implementing two static methods using special symbols from the `@workflow/serde` package.
|
|
@@ -332,44 +409,3 @@ export async function processOrderWorkflow() {
|
|
|
332
409
|
}
|
|
333
410
|
```
|
|
334
411
|
|
|
335
|
-
## Pass-by-Value Semantics
|
|
336
|
-
|
|
337
|
-
**Parameters are passed by value, not by reference.** Steps receive deserialized copies of data. Mutations inside a step won't affect the original in the workflow.
|
|
338
|
-
|
|
339
|
-
**Incorrect:**
|
|
340
|
-
|
|
341
|
-
```typescript title="workflows/incorrect-mutation.ts" lineNumbers
|
|
342
|
-
export async function updateUserWorkflow(userId: string) {
|
|
343
|
-
"use workflow";
|
|
344
|
-
|
|
345
|
-
let user = { id: userId, name: "John", email: "john@example.com" };
|
|
346
|
-
await updateUserStep(user);
|
|
347
|
-
|
|
348
|
-
// user.email is still "john@example.com" // [!code highlight]
|
|
349
|
-
console.log(user.email); // [!code highlight]
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
async function updateUserStep(user: { id: string; name: string; email: string }) {
|
|
353
|
-
"use step";
|
|
354
|
-
user.email = "newemail@example.com"; // Changes are lost // [!code highlight]
|
|
355
|
-
}
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
**Correct - return the modified data:**
|
|
359
|
-
|
|
360
|
-
```typescript title="workflows/correct-mutation.ts" lineNumbers
|
|
361
|
-
export async function updateUserWorkflow(userId: string) {
|
|
362
|
-
"use workflow";
|
|
363
|
-
|
|
364
|
-
let user = { id: userId, name: "John", email: "john@example.com" };
|
|
365
|
-
user = await updateUserStep(user); // Reassign the return value // [!code highlight]
|
|
366
|
-
|
|
367
|
-
console.log(user.email); // "newemail@example.com"
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
async function updateUserStep(user: { id: string; name: string; email: string }) {
|
|
371
|
-
"use step";
|
|
372
|
-
user.email = "newemail@example.com";
|
|
373
|
-
return user; // [!code highlight]
|
|
374
|
-
}
|
|
375
|
-
```
|