workflow 5.0.0-beta.2 → 5.0.0-beta.20
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/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.d.ts +5 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +14 -2
- package/dist/internal/builtins.d.ts +17 -0
- package/dist/internal/builtins.d.ts.map +1 -1
- package/dist/internal/builtins.js +65 -1
- package/dist/observability.d.ts +1 -1
- package/dist/observability.js +2 -2
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +2 -2
- package/docs/ai/index.mdx +27 -23
- package/docs/api-reference/index.mdx +24 -0
- package/docs/api-reference/meta.json +8 -0
- package/docs/api-reference/vitest/index.mdx +28 -7
- package/docs/api-reference/workflow/create-hook.mdx +38 -0
- package/docs/api-reference/workflow/create-webhook.mdx +1 -0
- package/docs/api-reference/workflow/experimental-set-attributes.mdx +65 -0
- package/docs/api-reference/workflow/fetch.mdx +5 -0
- package/docs/api-reference/workflow/index.mdx +3 -0
- package/docs/api-reference/workflow-ai/durable-agent.mdx +7 -45
- package/docs/api-reference/workflow-api/get-hook-by-token.mdx +7 -0
- package/docs/api-reference/workflow-api/get-run.mdx +6 -0
- package/docs/api-reference/workflow-api/index.mdx +6 -8
- package/docs/api-reference/workflow-api/resume-hook.mdx +57 -0
- package/docs/api-reference/workflow-api/start.mdx +13 -5
- package/docs/api-reference/workflow-astro/index.mdx +18 -0
- package/docs/api-reference/workflow-astro/meta.json +4 -0
- package/docs/api-reference/workflow-astro/workflow.mdx +45 -0
- package/docs/api-reference/workflow-errors/hook-conflict-error.mdx +60 -0
- package/docs/api-reference/workflow-errors/index.mdx +85 -0
- package/docs/api-reference/workflow-errors/meta.json +5 -0
- package/docs/api-reference/workflow-errors/run-not-supported-error.mdx +58 -0
- package/docs/api-reference/workflow-errors/workflow-error.mdx +52 -0
- package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +16 -6
- package/docs/api-reference/workflow-errors/workflow-run-not-completed-error.mdx +58 -0
- package/docs/api-reference/workflow-errors/workflow-runtime-error.mdx +58 -0
- package/docs/api-reference/workflow-nest/configure-workflow-controller.mdx +33 -0
- package/docs/api-reference/workflow-nest/index.mdx +31 -0
- package/docs/api-reference/workflow-nest/meta.json +9 -0
- package/docs/api-reference/workflow-nest/nest-local-builder.mdx +64 -0
- package/docs/api-reference/workflow-nest/workflow-controller.mdx +40 -0
- package/docs/api-reference/workflow-nest/workflow-module.mdx +74 -0
- package/docs/api-reference/workflow-next/with-workflow.mdx +34 -2
- package/docs/api-reference/workflow-nitro/index.mdx +59 -0
- package/docs/api-reference/workflow-nuxt/index.mdx +47 -0
- package/docs/api-reference/workflow-observability/hydrate-data.mdx +35 -0
- package/docs/api-reference/workflow-observability/hydrate-resource-io.mdx +62 -0
- package/docs/api-reference/workflow-observability/index.mdx +64 -0
- package/docs/api-reference/workflow-observability/meta.json +11 -0
- package/docs/api-reference/workflow-observability/observability-revivers.mdx +50 -0
- package/docs/api-reference/workflow-observability/parse-class-name.mdx +41 -0
- package/docs/api-reference/workflow-observability/parse-step-name.mdx +40 -0
- package/docs/api-reference/workflow-observability/parse-workflow-name.mdx +55 -0
- package/docs/api-reference/workflow-runtime/create-world.mdx +39 -0
- package/docs/api-reference/workflow-runtime/get-world-handlers.mdx +44 -0
- package/docs/api-reference/{workflow-api → workflow-runtime}/get-world.mdx +7 -10
- package/docs/api-reference/workflow-runtime/health-check.mdx +50 -0
- package/docs/api-reference/workflow-runtime/index.mdx +43 -0
- package/docs/api-reference/workflow-runtime/meta.json +12 -0
- package/docs/api-reference/workflow-runtime/set-world.mdx +49 -0
- package/docs/api-reference/workflow-runtime/workflow-entrypoint.mdx +42 -0
- package/docs/api-reference/{workflow-api → workflow-runtime}/world/index.mdx +5 -8
- package/docs/api-reference/workflow-runtime/world/meta.json +4 -0
- package/docs/api-reference/{workflow-api → workflow-runtime}/world/queue.mdx +2 -2
- package/docs/api-reference/{workflow-api → workflow-runtime}/world/storage.mdx +11 -4
- package/docs/api-reference/{workflow-api → workflow-runtime}/world/streams.mdx +2 -2
- package/docs/api-reference/workflow-serde/index.mdx +0 -1
- package/docs/api-reference/workflow-serde/workflow-deserialize.mdx +1 -2
- package/docs/api-reference/workflow-serde/workflow-serialize.mdx +1 -2
- package/docs/api-reference/workflow-sveltekit/index.mdx +18 -0
- package/docs/api-reference/workflow-sveltekit/meta.json +4 -0
- package/docs/api-reference/workflow-sveltekit/workflow-plugin.mdx +42 -0
- package/docs/api-reference/workflow-vite/index.mdx +18 -0
- package/docs/api-reference/workflow-vite/meta.json +4 -0
- package/docs/api-reference/workflow-vite/workflow.mdx +48 -0
- package/docs/changelog/attributes-mvp.mdx +380 -0
- package/docs/changelog/eager-processing.mdx +269 -0
- package/docs/changelog/index.mdx +2 -1
- package/docs/changelog/lazy-event-creation.md +127 -0
- package/docs/changelog/meta.json +6 -1
- package/docs/changelog/resilient-start.mdx +31 -283
- package/docs/cookbook/advanced/child-workflows.mdx +315 -0
- package/docs/cookbook/advanced/meta.json +2 -3
- package/docs/cookbook/advanced/publishing-libraries.mdx +87 -29
- package/docs/cookbook/advanced/serializable-steps.mdx +17 -5
- package/docs/cookbook/advanced/upgrading-workflows.mdx +195 -0
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +156 -0
- package/docs/cookbook/agent-patterns/durable-agent.mdx +11 -184
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +150 -173
- package/docs/cookbook/agent-patterns/meta.json +1 -7
- package/docs/cookbook/common-patterns/batching.mdx +44 -118
- package/docs/cookbook/common-patterns/idempotency.mdx +36 -52
- package/docs/cookbook/common-patterns/meta.json +4 -4
- package/docs/cookbook/common-patterns/rate-limiting.mdx +1 -1
- package/docs/cookbook/common-patterns/saga.mdx +128 -33
- package/docs/cookbook/common-patterns/scheduling.mdx +77 -193
- package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
- package/docs/cookbook/common-patterns/timeouts.mdx +100 -0
- package/docs/cookbook/common-patterns/workflow-composition.mdx +117 -0
- package/docs/cookbook/index.mdx +14 -17
- package/docs/cookbook/integrations/ai-sdk.mdx +330 -142
- package/docs/cookbook/integrations/chat-sdk.mdx +264 -151
- package/docs/cookbook/integrations/sandbox.mdx +482 -81
- package/docs/cookbook/meta.json +1 -1
- package/docs/deploying/building-a-world.mdx +1 -1
- package/docs/deploying/world/postgres-world.mdx +5 -3
- package/docs/deploying/world/vercel-world.mdx +2 -0
- package/docs/errors/abort-signal-timeout-in-workflow.mdx +80 -0
- package/docs/errors/corrupted-event-log.mdx +5 -5
- package/docs/errors/hook-conflict.mdx +56 -4
- package/docs/errors/index.mdx +9 -0
- package/docs/errors/replay-divergence.mdx +27 -0
- package/docs/errors/runtime-decryption-failed.mdx +77 -0
- package/docs/errors/step-executed-multiple-times.mdx +23 -0
- package/docs/errors/step-not-registered.mdx +1 -1
- package/docs/foundations/cancellation.mdx +459 -0
- package/docs/foundations/errors-and-retries.mdx +7 -3
- package/docs/foundations/hooks.mdx +29 -0
- package/docs/foundations/idempotency.mdx +236 -11
- package/docs/foundations/index.mdx +3 -3
- package/docs/foundations/meta.json +3 -2
- package/docs/foundations/serialization.mdx +78 -42
- package/docs/foundations/starting-workflows.mdx +6 -2
- package/docs/foundations/streaming.mdx +14 -23
- package/docs/foundations/versioning.mdx +263 -0
- package/docs/getting-started/astro.mdx +6 -0
- package/docs/getting-started/index.mdx +6 -7
- package/docs/getting-started/meta.json +1 -0
- package/docs/getting-started/nestjs.mdx +9 -0
- package/docs/getting-started/next.mdx +5 -3
- package/docs/getting-started/nitro.mdx +22 -0
- package/docs/getting-started/sveltekit.mdx +6 -0
- package/docs/getting-started/tanstack-start.mdx +241 -0
- package/docs/how-it-works/cancellation.mdx +287 -0
- package/docs/how-it-works/code-transform.mdx +2 -2
- package/docs/how-it-works/encryption.mdx +2 -2
- package/docs/how-it-works/event-sourcing.mdx +2 -2
- package/docs/how-it-works/meta.json +2 -1
- package/docs/internal/index.mdx +21 -0
- package/docs/internal/meta.json +10 -0
- package/docs/internal/nitro-native-build.mdx +38 -0
- package/docs/internal/nitro-web-ui.mdx +24 -0
- package/docs/internal/serializable-abort-controller.mdx +148 -0
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +63 -16
- package/docs/migration-guides/migrating-from-inngest.mdx +44 -22
- package/docs/migration-guides/migrating-from-temporal.mdx +43 -14
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +59 -27
- package/docs/observability/attributes.mdx +87 -0
- package/docs/observability/index.mdx +25 -1
- package/docs/observability/meta.json +1 -1
- package/docs/observability/tracing.mdx +106 -0
- package/docs/testing/index.mdx +2 -2
- package/package.json +14 -13
- package/docs/api-reference/workflow-api/world/meta.json +0 -4
- package/docs/api-reference/workflow-api/world/observability.mdx +0 -164
- package/docs/cookbook/advanced/custom-serialization.mdx +0 -168
- package/docs/cookbook/advanced/durable-objects.mdx +0 -148
- package/docs/cookbook/advanced/isomorphic-packages.mdx +0 -145
- package/docs/cookbook/agent-patterns/stop-workflow.mdx +0 -216
- package/docs/cookbook/agent-patterns/tool-orchestration.mdx +0 -255
- package/docs/cookbook/agent-patterns/tool-streaming.mdx +0 -181
- package/docs/cookbook/common-patterns/child-workflows.mdx +0 -372
- package/docs/cookbook/common-patterns/content-router.mdx +0 -207
- package/docs/cookbook/common-patterns/fan-out.mdx +0 -208
- package/docs/foundations/common-patterns.mdx +0 -265
|
@@ -9,167 +9,93 @@ Use batching when you need to process a large list of items in parallel while co
|
|
|
9
9
|
|
|
10
10
|
## When to use this
|
|
11
11
|
|
|
12
|
-
-
|
|
12
|
+
- Bulk data imports (contacts, orders, products from a CSV)
|
|
13
|
+
- Processing hundreds or thousands of items against external APIs
|
|
13
14
|
- Calling rate-limited APIs where you need to control concurrency
|
|
14
15
|
- Any fan-out where you want failure isolation between groups
|
|
15
16
|
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
1. Records are split into fixed-size batches.
|
|
20
|
+
2. Each batch runs in parallel via `Promise.allSettled` — failures in one record don't affect others.
|
|
21
|
+
3. A `sleep()` between batches paces requests to avoid overloading downstream services.
|
|
22
|
+
4. After all batches, a summary is returned with succeeded/failed counts.
|
|
23
|
+
|
|
16
24
|
## Pattern
|
|
17
25
|
|
|
18
|
-
The workflow splits
|
|
26
|
+
The workflow splits records into chunks, processes each chunk concurrently, tracks results per batch, and returns a final tally.
|
|
19
27
|
|
|
20
28
|
```typescript
|
|
21
29
|
import { sleep } from "workflow";
|
|
22
30
|
|
|
23
|
-
|
|
31
|
+
type Record = { name: string; email: string; role: string };
|
|
32
|
+
|
|
33
|
+
declare function processRecord(record: Record): Promise<string>; // @setup
|
|
24
34
|
|
|
25
|
-
export async function
|
|
35
|
+
export async function batchImport(records: Record[], batchSize: number) {
|
|
26
36
|
"use workflow";
|
|
27
37
|
|
|
28
|
-
|
|
38
|
+
let totalSucceeded = 0;
|
|
39
|
+
let totalFailed = 0;
|
|
29
40
|
|
|
30
|
-
for (let i = 0; i <
|
|
31
|
-
const batch =
|
|
41
|
+
for (let i = 0; i < records.length; i += batchSize) {
|
|
42
|
+
const batch = records.slice(i, i + batchSize);
|
|
32
43
|
|
|
33
|
-
// Run batch in parallel
|
|
44
|
+
// Run batch in parallel — failures are isolated per record
|
|
34
45
|
const outcomes = await Promise.allSettled( // [!code highlight]
|
|
35
|
-
batch.map((
|
|
46
|
+
batch.map((record) => processRecord(record))
|
|
36
47
|
);
|
|
37
48
|
|
|
38
49
|
for (let j = 0; j < outcomes.length; j++) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
);
|
|
50
|
+
if (outcomes[j].status === "fulfilled") {
|
|
51
|
+
totalSucceeded++;
|
|
52
|
+
} else {
|
|
53
|
+
totalFailed++;
|
|
54
|
+
}
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
// Pace between batches to avoid
|
|
48
|
-
if (i + batchSize <
|
|
57
|
+
// Pace between batches to avoid overloading downstream
|
|
58
|
+
if (i + batchSize < records.length) {
|
|
49
59
|
await sleep("1s"); // [!code highlight]
|
|
50
60
|
}
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
|
|
54
|
-
return { total: results.length, succeeded, failed: results.length - succeeded };
|
|
63
|
+
return { total: records.length, succeeded: totalSucceeded, failed: totalFailed };
|
|
55
64
|
}
|
|
56
65
|
```
|
|
57
66
|
|
|
58
67
|
### Step function
|
|
59
68
|
|
|
60
|
-
Each
|
|
69
|
+
Each record is processed in its own step with full Node.js access and automatic retries.
|
|
61
70
|
|
|
62
71
|
```typescript
|
|
63
|
-
|
|
72
|
+
type Record = { name: string; email: string; role: string };
|
|
73
|
+
|
|
74
|
+
async function processRecord(record: Record): Promise<string> {
|
|
64
75
|
"use step";
|
|
65
|
-
const res = await fetch(`https://api.example.com/
|
|
76
|
+
const res = await fetch(`https://api.example.com/contacts`, {
|
|
66
77
|
method: "POST",
|
|
67
|
-
body: JSON.stringify(
|
|
78
|
+
body: JSON.stringify(record),
|
|
68
79
|
});
|
|
69
|
-
if (!res.ok) throw new Error(`Failed to
|
|
70
|
-
|
|
80
|
+
if (!res.ok) throw new Error(`Failed to import ${record.email}`);
|
|
81
|
+
const { id } = await res.json();
|
|
82
|
+
return id;
|
|
71
83
|
}
|
|
72
84
|
```
|
|
73
85
|
|
|
74
|
-
##
|
|
75
|
-
|
|
76
|
-
### Scatter-gather
|
|
77
|
-
|
|
78
|
-
When you need results from multiple independent sources before continuing, fan out in parallel and collect all results:
|
|
79
|
-
|
|
80
|
-
```typescript
|
|
81
|
-
export async function scatterGather(query: string) {
|
|
82
|
-
"use workflow";
|
|
83
|
-
|
|
84
|
-
const [web, database, cache] = await Promise.allSettled([ // [!code highlight]
|
|
85
|
-
searchWeb(query),
|
|
86
|
-
searchDatabase(query),
|
|
87
|
-
searchCache(query),
|
|
88
|
-
]);
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
web: web.status === "fulfilled" ? web.value : null,
|
|
92
|
-
database: database.status === "fulfilled" ? database.value : null,
|
|
93
|
-
cache: cache.status === "fulfilled" ? cache.value : null,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function searchWeb(query: string): Promise<string[]> {
|
|
98
|
-
"use step";
|
|
99
|
-
// Full Node.js access -- call external APIs
|
|
100
|
-
const res = await fetch(`https://search.example.com?q=${query}`);
|
|
101
|
-
return res.json();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async function searchDatabase(query: string): Promise<string[]> {
|
|
105
|
-
"use step";
|
|
106
|
-
// Query your database
|
|
107
|
-
return [`db-result-for-${query}`];
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function searchCache(query: string): Promise<string[]> {
|
|
111
|
-
"use step";
|
|
112
|
-
return [`cached-result-for-${query}`];
|
|
113
|
-
}
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
## In-step concurrency control
|
|
117
|
-
|
|
118
|
-
When you need to process many items against a rate-limited API but want the entire operation to be a single atomic step, batch the work inside the step itself. This keeps the event log clean (one step instead of hundreds) while still controlling concurrency.
|
|
119
|
-
|
|
120
|
-
```typescript
|
|
121
|
-
async function processConcurrently<T>(
|
|
122
|
-
items: string[],
|
|
123
|
-
processor: (item: string) => Promise<T>,
|
|
124
|
-
maxConcurrent: number = 5,
|
|
125
|
-
): Promise<T[]> {
|
|
126
|
-
"use step";
|
|
127
|
-
const results: T[] = [];
|
|
128
|
-
|
|
129
|
-
for (let i = 0; i < items.length; i += maxConcurrent) {
|
|
130
|
-
const batch = items.slice(i, i + maxConcurrent);
|
|
131
|
-
const batchResults = await Promise.all(batch.map(processor)); // [!code highlight]
|
|
132
|
-
results.push(...batchResults);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return results;
|
|
136
|
-
}
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
Usage in a workflow:
|
|
140
|
-
|
|
141
|
-
```typescript
|
|
142
|
-
declare function processConcurrently<T>(items: string[], processor: (item: string) => Promise<T>, maxConcurrent?: number): Promise<T[]>; // @setup
|
|
143
|
-
|
|
144
|
-
export async function moderateImages(imageUrls: string[]) {
|
|
145
|
-
"use workflow";
|
|
146
|
-
|
|
147
|
-
const results = await processConcurrently(
|
|
148
|
-
imageUrls,
|
|
149
|
-
async (url) => {
|
|
150
|
-
const res = await fetch("https://api.example.com/moderate", {
|
|
151
|
-
method: "POST",
|
|
152
|
-
body: JSON.stringify({ url }),
|
|
153
|
-
});
|
|
154
|
-
return res.json();
|
|
155
|
-
},
|
|
156
|
-
3, // max 3 concurrent API calls
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
return { total: results.length, results };
|
|
160
|
-
}
|
|
161
|
-
```
|
|
86
|
+
## Adapting to your use case
|
|
162
87
|
|
|
163
|
-
|
|
164
|
-
-
|
|
165
|
-
-
|
|
88
|
+
- Replace the `Record` type with your actual data shape (orders, images, products, etc.).
|
|
89
|
+
- Replace `processRecord()` with your real import logic — DB upserts, API calls, file processing.
|
|
90
|
+
- Tune `batchSize` and the `sleep()` duration to match your downstream rate limits.
|
|
91
|
+
- Add or remove tracking as needed — the pattern works with any item type.
|
|
166
92
|
|
|
167
93
|
## Tips
|
|
168
94
|
|
|
169
95
|
- **Use `Promise.allSettled` over `Promise.all`** when you want to continue even if some items fail. `Promise.all` rejects on the first failure; `allSettled` waits for everything and tells you what failed.
|
|
170
96
|
- **Tune batch size to your downstream API limits.** If the API allows 10 concurrent requests, use `batchSize: 10`.
|
|
171
|
-
- **Add pacing with `sleep()`** between batches to respect rate limits. The sleep is durable
|
|
172
|
-
- **Each `
|
|
97
|
+
- **Add pacing with `sleep()`** between batches to respect rate limits. The sleep is durable — it survives cold starts.
|
|
98
|
+
- **Each `processRecord` call is an independent step.** If one fails, it retries up to 3 times without affecting other items in the batch.
|
|
173
99
|
|
|
174
100
|
## Key APIs
|
|
175
101
|
|
|
@@ -1,45 +1,25 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Idempotency
|
|
3
|
-
description:
|
|
3
|
+
description: Make step retries safe and coordinate duplicate workflow starts with hook tokens.
|
|
4
4
|
type: guide
|
|
5
|
-
summary: Use step IDs
|
|
5
|
+
summary: Use step IDs for retry-safe external calls, and use deterministic hook tokens when duplicate requests must route to one active workflow.
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Use idempotency when a retry or duplicate request should not repeat the underlying work. In Workflow, there are two common patterns: use the step ID for retry-safe external calls, and use hook tokens to coordinate duplicate workflow starts.
|
|
9
9
|
|
|
10
10
|
## When to use this
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
- Creating records in external systems where duplicates are harmful
|
|
15
|
-
- Any step that has side effects in systems you don't control
|
|
12
|
+
- A step charges a payment, sends an email, enqueues work, or creates an external record.
|
|
13
|
+
- A route may receive duplicate requests that should map to one active workflow run.
|
|
16
14
|
|
|
17
|
-
##
|
|
15
|
+
## Step idempotency
|
|
18
16
|
|
|
19
17
|
Every step has a unique, deterministic `stepId` available via `getStepMetadata()`. Pass this as the idempotency key to external APIs:
|
|
20
18
|
|
|
21
19
|
```typescript
|
|
22
20
|
import { getStepMetadata } from "workflow";
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
declare function sendReceipt(customerId: string, chargeId: string): Promise<void>; // @setup
|
|
26
|
-
|
|
27
|
-
export async function chargeCustomer(customerId: string, amount: number) {
|
|
28
|
-
"use workflow";
|
|
29
|
-
|
|
30
|
-
const charge = await createCharge(customerId, amount);
|
|
31
|
-
await sendReceipt(customerId, charge.id);
|
|
32
|
-
|
|
33
|
-
return { customerId, chargeId: charge.id, status: "completed" };
|
|
34
|
-
}
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
### Step function with idempotency key
|
|
38
|
-
|
|
39
|
-
```typescript
|
|
40
|
-
import { getStepMetadata } from "workflow";
|
|
41
|
-
|
|
42
|
-
async function createCharge(
|
|
22
|
+
export async function createCharge(
|
|
43
23
|
customerId: string,
|
|
44
24
|
amount: number
|
|
45
25
|
): Promise<{ id: string }> {
|
|
@@ -69,39 +49,43 @@ async function createCharge(
|
|
|
69
49
|
|
|
70
50
|
return charge.json();
|
|
71
51
|
}
|
|
72
|
-
|
|
73
|
-
async function sendReceipt(customerId: string, chargeId: string): Promise<void> {
|
|
74
|
-
"use step";
|
|
75
|
-
|
|
76
|
-
const { stepId } = getStepMetadata();
|
|
77
|
-
|
|
78
|
-
await fetch("https://api.example.com/receipts", {
|
|
79
|
-
method: "POST",
|
|
80
|
-
headers: { "Idempotency-Key": stepId },
|
|
81
|
-
body: JSON.stringify({ customerId, chargeId }),
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
52
|
```
|
|
85
53
|
|
|
86
|
-
|
|
54
|
+
See [Step Idempotency](/docs/foundations/idempotency#step-idempotency) for why `stepId` is stable across retries and how to think about external API conflicts.
|
|
87
55
|
|
|
88
|
-
|
|
56
|
+
## Run idempotency
|
|
89
57
|
|
|
90
|
-
-
|
|
91
|
-
- **Don't use check-then-act patterns** like "read a flag, then write if not set" -- another run could read the same flag between your read and write.
|
|
58
|
+
For duplicate workflow-start requests, derive a hook token from your domain key. You can avoid obvious duplicate starts by checking whether an active hook already owns that token before calling `start()`:
|
|
92
59
|
|
|
93
|
-
|
|
60
|
+
```typescript
|
|
61
|
+
import { getHookByToken, start } from "workflow/api";
|
|
62
|
+
import { HookNotFoundError } from "workflow/errors";
|
|
63
|
+
import { processOrder } from "./workflows/process-order";
|
|
64
|
+
|
|
65
|
+
export async function POST(request: Request) {
|
|
66
|
+
const { orderId } = await request.json();
|
|
67
|
+
const token = `order:${orderId}`;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const hook = await getHookByToken(token); // [!code highlight]
|
|
71
|
+
return Response.json({ runId: hook.runId, reused: true });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (!HookNotFoundError.is(error)) throw error;
|
|
74
|
+
}
|
|
94
75
|
|
|
95
|
-
|
|
76
|
+
const run = await start(processOrder, [orderId]); // [!code highlight]
|
|
77
|
+
return Response.json({ runId: run.runId, reused: false });
|
|
78
|
+
}
|
|
79
|
+
```
|
|
96
80
|
|
|
97
|
-
-
|
|
98
|
-
- **Always provide idempotency keys for non-idempotent external calls.** Even if you think a step won't be retried, cold-start replay will re-execute it.
|
|
99
|
-
- **Handle 409/conflict as success.** If an external API returns "already processed," treat that as a successful result, not an error.
|
|
100
|
-
- **Make your own APIs idempotent** where possible. Accept an idempotency key and return the cached result on duplicate requests.
|
|
81
|
+
The workflow should create the deterministic hook and check `await hook.getConflict()` before duplicate-sensitive work — awaiting `getConflict()` suspends the workflow to commit the hook registration and resolves with the conflicting run when another active run already owns the token (or `null` once the hook is registered). See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how to steer an active run with `resumeHook()` and how to handle the current race between `start()` and hook registration.
|
|
101
82
|
|
|
102
83
|
## Key APIs
|
|
103
84
|
|
|
104
|
-
- [`"use workflow"`](/docs/
|
|
105
|
-
- [`"use step"`](/docs/
|
|
106
|
-
- [`getStepMetadata()`](/docs/api-reference/
|
|
85
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function
|
|
86
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access
|
|
87
|
+
- [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the deterministic `stepId` for idempotency keys
|
|
88
|
+
- [`createHook()`](/docs/api-reference/workflow/create-hook) -- creates a hook with an optional deterministic token
|
|
89
|
+
- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) -- finds the active hook for a token
|
|
90
|
+
- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- resumes the active hook when the duplicate request carries data
|
|
107
91
|
- [`start()`](/docs/api-reference/workflow-api/start) -- starts a new workflow run
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"title": "Common Patterns",
|
|
3
3
|
"defaultOpen": true,
|
|
4
4
|
"pages": [
|
|
5
|
+
"sequential-and-parallel",
|
|
6
|
+
"workflow-composition",
|
|
5
7
|
"saga",
|
|
6
8
|
"batching",
|
|
7
9
|
"rate-limiting",
|
|
8
|
-
"fan-out",
|
|
9
10
|
"scheduling",
|
|
11
|
+
"timeouts",
|
|
10
12
|
"idempotency",
|
|
11
|
-
"webhooks"
|
|
12
|
-
"content-router",
|
|
13
|
-
"child-workflows"
|
|
13
|
+
"webhooks"
|
|
14
14
|
]
|
|
15
15
|
}
|
|
@@ -224,5 +224,5 @@ export async function downloadWithRetry(url: string) {
|
|
|
224
224
|
- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
|
|
225
225
|
- [`RetryableError`](/docs/api-reference/workflow/retryable-error) -- signals the runtime to retry after a delay
|
|
226
226
|
- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- signals a permanent failure, skipping retries
|
|
227
|
-
- [`getStepMetadata()`](/docs/api-reference/
|
|
227
|
+
- [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the current attempt number and step ID
|
|
228
228
|
- [`sleep()`](/docs/api-reference/workflow/sleep) -- durable pause for circuit breaker cooldowns
|
|
@@ -13,6 +13,12 @@ Use the saga pattern when a business transaction spans multiple services and you
|
|
|
13
13
|
- Any sequence where partial completion leaves the system in an inconsistent state
|
|
14
14
|
- Operations that need "all or nothing" semantics across external APIs
|
|
15
15
|
|
|
16
|
+
## How it works
|
|
17
|
+
|
|
18
|
+
1. Each forward step does work and registers a compensation function.
|
|
19
|
+
2. If any step throws `FatalError`, the catch block runs compensations in reverse (LIFO) order to restore consistency.
|
|
20
|
+
3. Regular errors are retried automatically (up to 3x by default). Use `FatalError` only for permanent failures where retrying won't help.
|
|
21
|
+
|
|
16
22
|
## Pattern
|
|
17
23
|
|
|
18
24
|
Each step returns a result and pushes a compensation handler onto a stack. If a later step throws a `FatalError`, the workflow catches it and executes compensations in LIFO order.
|
|
@@ -34,23 +40,21 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number)
|
|
|
34
40
|
const compensations: Array<() => Promise<void>> = [];
|
|
35
41
|
|
|
36
42
|
try {
|
|
37
|
-
// Step 1: Reserve seats
|
|
38
43
|
const reservationId = await reserveSeats(accountId, seats);
|
|
39
44
|
compensations.push(() => releaseSeats(accountId, reservationId)); // [!code highlight]
|
|
40
45
|
|
|
41
|
-
// Step 2: Capture payment
|
|
42
46
|
const invoiceId = await captureInvoice(accountId, seats);
|
|
43
47
|
compensations.push(() => refundInvoice(accountId, invoiceId)); // [!code highlight]
|
|
44
48
|
|
|
45
|
-
// Step 3: Provision access
|
|
46
49
|
const entitlementId = await provisionSeats(accountId, seats);
|
|
47
50
|
compensations.push(() => deprovisionSeats(accountId, entitlementId)); // [!code highlight]
|
|
48
51
|
|
|
49
|
-
//
|
|
52
|
+
// No compensation — notifications are fire-and-forget
|
|
50
53
|
await sendConfirmation(accountId, invoiceId, entitlementId);
|
|
54
|
+
|
|
51
55
|
return { status: "completed" };
|
|
52
56
|
} catch (error) {
|
|
53
|
-
// Unwind compensations in reverse order
|
|
57
|
+
// Unwind compensations in reverse (LIFO) order
|
|
54
58
|
for (const compensate of compensations.reverse()) { // [!code highlight]
|
|
55
59
|
await compensate(); // [!code highlight]
|
|
56
60
|
}
|
|
@@ -62,11 +66,13 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number)
|
|
|
62
66
|
|
|
63
67
|
### Step functions
|
|
64
68
|
|
|
65
|
-
Each step is a `"use step"` function with full Node.js access. Forward steps do the work; compensation steps undo it.
|
|
69
|
+
Each step is a `"use step"` function with full Node.js access (fetch, fs, npm packages). Forward steps do the work and throw `FatalError` on permanent failure; compensation steps undo it and must be idempotent — safe to call multiple times if the workflow restarts mid-rollback.
|
|
66
70
|
|
|
67
71
|
```typescript
|
|
68
72
|
import { FatalError } from "workflow";
|
|
69
73
|
|
|
74
|
+
// Forward steps
|
|
75
|
+
|
|
70
76
|
async function reserveSeats(accountId: string, seats: number): Promise<string> {
|
|
71
77
|
"use step";
|
|
72
78
|
const res = await fetch(`https://api.example.com/seats/reserve`, {
|
|
@@ -78,15 +84,6 @@ async function reserveSeats(accountId: string, seats: number): Promise<string> {
|
|
|
78
84
|
return reservationId;
|
|
79
85
|
}
|
|
80
86
|
|
|
81
|
-
async function releaseSeats(accountId: string, reservationId: string): Promise<void> {
|
|
82
|
-
"use step";
|
|
83
|
-
// Compensations should be idempotent — safe to call twice
|
|
84
|
-
await fetch(`https://api.example.com/seats/release`, {
|
|
85
|
-
method: "POST",
|
|
86
|
-
body: JSON.stringify({ accountId, reservationId }),
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
|
|
90
87
|
async function captureInvoice(accountId: string, seats: number): Promise<string> {
|
|
91
88
|
"use step";
|
|
92
89
|
const res = await fetch(`https://api.example.com/invoices`, {
|
|
@@ -98,14 +95,6 @@ async function captureInvoice(accountId: string, seats: number): Promise<string>
|
|
|
98
95
|
return invoiceId;
|
|
99
96
|
}
|
|
100
97
|
|
|
101
|
-
async function refundInvoice(accountId: string, invoiceId: string): Promise<void> {
|
|
102
|
-
"use step";
|
|
103
|
-
await fetch(`https://api.example.com/invoices/${invoiceId}/refund`, {
|
|
104
|
-
method: "POST",
|
|
105
|
-
body: JSON.stringify({ accountId }),
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
98
|
async function provisionSeats(accountId: string, seats: number): Promise<string> {
|
|
110
99
|
"use step";
|
|
111
100
|
const res = await fetch(`https://api.example.com/entitlements`, {
|
|
@@ -117,14 +106,6 @@ async function provisionSeats(accountId: string, seats: number): Promise<string>
|
|
|
117
106
|
return entitlementId;
|
|
118
107
|
}
|
|
119
108
|
|
|
120
|
-
async function deprovisionSeats(accountId: string, entitlementId: string): Promise<void> {
|
|
121
|
-
"use step";
|
|
122
|
-
await fetch(`https://api.example.com/entitlements/${entitlementId}`, {
|
|
123
|
-
method: "DELETE",
|
|
124
|
-
body: JSON.stringify({ accountId }),
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
109
|
async function sendConfirmation(
|
|
129
110
|
accountId: string,
|
|
130
111
|
invoiceId: string,
|
|
@@ -136,17 +117,131 @@ async function sendConfirmation(
|
|
|
136
117
|
body: JSON.stringify({ accountId, invoiceId, entitlementId, template: "upgrade-complete" }),
|
|
137
118
|
});
|
|
138
119
|
}
|
|
120
|
+
|
|
121
|
+
// Compensation steps — must be idempotent
|
|
122
|
+
|
|
123
|
+
async function releaseSeats(accountId: string, reservationId: string): Promise<void> {
|
|
124
|
+
"use step";
|
|
125
|
+
await fetch(`https://api.example.com/seats/release`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
body: JSON.stringify({ accountId, reservationId }),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function refundInvoice(accountId: string, invoiceId: string): Promise<void> {
|
|
132
|
+
"use step";
|
|
133
|
+
await fetch(`https://api.example.com/invoices/${invoiceId}/refund`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
body: JSON.stringify({ accountId }),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function deprovisionSeats(accountId: string, entitlementId: string): Promise<void> {
|
|
140
|
+
"use step";
|
|
141
|
+
await fetch(`https://api.example.com/entitlements/${entitlementId}`, {
|
|
142
|
+
method: "DELETE",
|
|
143
|
+
body: JSON.stringify({ accountId }),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Streaming step progress (optional)
|
|
149
|
+
|
|
150
|
+
Use `getWritable()` to stream progress events to a UI so users can see each step execute in real time.
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
import { FatalError } from "workflow";
|
|
154
|
+
import { getWritable } from "workflow";
|
|
155
|
+
|
|
156
|
+
type SagaEvent =
|
|
157
|
+
| { type: "step_start"; step: string }
|
|
158
|
+
| { type: "step_done"; step: string; detail: string }
|
|
159
|
+
| { type: "step_failed"; step: string; error: string }
|
|
160
|
+
| { type: "compensating"; step: string }
|
|
161
|
+
| { type: "compensated"; step: string }
|
|
162
|
+
| { type: "result"; status: "completed" | "rolled_back" };
|
|
163
|
+
|
|
164
|
+
async function emit(event: SagaEvent) {
|
|
165
|
+
"use step";
|
|
166
|
+
const writer = getWritable<SagaEvent>().getWriter();
|
|
167
|
+
try {
|
|
168
|
+
await writer.write(event);
|
|
169
|
+
} finally {
|
|
170
|
+
writer.releaseLock();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
declare function reserveSeats(accountId: string, seats: number): Promise<string>; // @setup
|
|
175
|
+
declare function releaseSeats(accountId: string, reservationId: string): Promise<void>; // @setup
|
|
176
|
+
declare function captureInvoice(accountId: string, seats: number): Promise<string>; // @setup
|
|
177
|
+
declare function refundInvoice(accountId: string, invoiceId: string): Promise<void>; // @setup
|
|
178
|
+
declare function provisionSeats(accountId: string, seats: number): Promise<string>; // @setup
|
|
179
|
+
declare function deprovisionSeats(accountId: string, entitlementId: string): Promise<void>; // @setup
|
|
180
|
+
declare function sendConfirmation(accountId: string, invoiceId: string, entitlementId: string): Promise<void>; // @setup
|
|
181
|
+
|
|
182
|
+
export async function subscriptionUpgradeSaga(accountId: string, seats: number) {
|
|
183
|
+
"use workflow";
|
|
184
|
+
|
|
185
|
+
const compensations: Array<{ name: string; execute: () => Promise<void> }> = [];
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await emit({ type: "step_start", step: "Reserve Seats" });
|
|
189
|
+
const reservationId = await reserveSeats(accountId, seats);
|
|
190
|
+
compensations.push({ name: "Release Seats", execute: () => releaseSeats(accountId, reservationId) });
|
|
191
|
+
await emit({ type: "step_done", step: "Reserve Seats", detail: reservationId });
|
|
192
|
+
|
|
193
|
+
await emit({ type: "step_start", step: "Capture Invoice" });
|
|
194
|
+
const invoiceId = await captureInvoice(accountId, seats);
|
|
195
|
+
compensations.push({ name: "Refund Invoice", execute: () => refundInvoice(accountId, invoiceId) });
|
|
196
|
+
await emit({ type: "step_done", step: "Capture Invoice", detail: invoiceId });
|
|
197
|
+
|
|
198
|
+
await emit({ type: "step_start", step: "Provision Seats" });
|
|
199
|
+
const entitlementId = await provisionSeats(accountId, seats);
|
|
200
|
+
compensations.push({ name: "Deprovision Seats", execute: () => deprovisionSeats(accountId, entitlementId) });
|
|
201
|
+
await emit({ type: "step_done", step: "Provision Seats", detail: entitlementId });
|
|
202
|
+
|
|
203
|
+
// No compensation — notifications are fire-and-forget
|
|
204
|
+
await emit({ type: "step_start", step: "Send Confirmation" });
|
|
205
|
+
await sendConfirmation(accountId, invoiceId, entitlementId);
|
|
206
|
+
await emit({ type: "step_done", step: "Send Confirmation", detail: "sent" });
|
|
207
|
+
|
|
208
|
+
await emit({ type: "result", status: "completed" });
|
|
209
|
+
return { status: "completed" };
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
212
|
+
await emit({ type: "step_failed", step: "failed", error: errorMessage });
|
|
213
|
+
|
|
214
|
+
// Unwind compensations in reverse (LIFO) order
|
|
215
|
+
for (const comp of compensations.reverse()) {
|
|
216
|
+
await emit({ type: "compensating", step: comp.name });
|
|
217
|
+
await comp.execute();
|
|
218
|
+
await emit({ type: "compensated", step: comp.name });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await emit({ type: "result", status: "rolled_back" });
|
|
222
|
+
return { status: "rolled_back" };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
139
225
|
```
|
|
140
226
|
|
|
227
|
+
## Adapting to your use case
|
|
228
|
+
|
|
229
|
+
- Replace the step functions with real API calls. Each `"use step"` function has full Node.js access.
|
|
230
|
+
- Add or remove steps as needed — the pattern scales to any number of steps.
|
|
231
|
+
- Make compensations idempotent — they may be retried if the workflow restarts mid-rollback.
|
|
232
|
+
- The `emit()` calls and `SagaEvent` type are optional — remove them if you don't need real-time UI progress.
|
|
233
|
+
|
|
141
234
|
## Tips
|
|
142
235
|
|
|
143
236
|
- **Use `FatalError` for permanent failures.** Regular errors trigger automatic retries (up to 3 by default). Throw `FatalError` when retrying won't help (e.g., insufficient funds, invalid input).
|
|
144
237
|
- **Make compensations idempotent.** If a compensation step is retried, it should produce the same result. Check whether the resource was already released before releasing it again.
|
|
145
238
|
- **Compensation steps are also `"use step"` functions.** This makes them durable — if the workflow restarts mid-rollback, it resumes where it left off.
|
|
146
239
|
- **Capture values in closures carefully.** Use block-scoped variables or copy values before pushing compensations to avoid referencing stale state.
|
|
240
|
+
- **Notifications don't need compensations.** Fire-and-forget steps like sending emails or Slack messages typically don't register a compensation.
|
|
147
241
|
|
|
148
242
|
## Key APIs
|
|
149
243
|
|
|
150
|
-
- [`"use workflow"`](/docs/
|
|
151
|
-
- [`"use step"`](/docs/
|
|
244
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function
|
|
245
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access
|
|
152
246
|
- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- non-retryable error that triggers compensation
|
|
247
|
+
- [`getWritable()`](/docs/api-reference/workflow/get-writable) -- streams data from workflows for real-time UI updates
|