workflow 5.0.0-beta.15 → 5.0.0-beta.17
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/docs/api-reference/vitest/index.mdx +1 -1
- package/docs/api-reference/workflow/create-hook.mdx +7 -1
- package/docs/api-reference/workflow/fetch.mdx +5 -0
- 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/resume-hook.mdx +57 -0
- package/docs/api-reference/workflow-api/start.mdx +4 -1
- package/docs/api-reference/workflow-errors/index.mdx +85 -0
- package/docs/api-reference/workflow-next/with-workflow.mdx +2 -2
- package/docs/api-reference/workflow-runtime/world/storage.mdx +6 -0
- package/docs/changelog/index.mdx +1 -1
- package/docs/cookbook/advanced/child-workflows.mdx +3 -1
- package/docs/cookbook/advanced/publishing-libraries.mdx +13 -12
- package/docs/cookbook/advanced/serializable-steps.mdx +3 -3
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +2 -2
- package/docs/cookbook/common-patterns/idempotency.mdx +36 -52
- package/docs/cookbook/common-patterns/rate-limiting.mdx +1 -1
- package/docs/cookbook/common-patterns/saga.mdx +2 -2
- package/docs/cookbook/common-patterns/scheduling.mdx +4 -0
- package/docs/cookbook/common-patterns/timeouts.mdx +2 -1
- package/docs/cookbook/common-patterns/workflow-composition.mdx +6 -0
- package/docs/cookbook/index.mdx +1 -1
- package/docs/cookbook/integrations/ai-sdk.mdx +10 -1
- package/docs/cookbook/integrations/chat-sdk.mdx +9 -0
- package/docs/cookbook/integrations/sandbox.mdx +9 -0
- package/docs/errors/step-not-registered.mdx +1 -1
- package/docs/foundations/cancellation.mdx +1 -2
- package/docs/foundations/hooks.mdx +1 -1
- package/docs/foundations/idempotency.mdx +236 -11
- package/docs/migration-guides/migrating-from-temporal.mdx +1 -1
- package/docs/observability/attributes.mdx +14 -0
- package/docs/observability/index.mdx +3 -0
- package/docs/observability/meta.json +4 -1
- package/docs/observability/tracing.mdx +106 -0
- package/docs/testing/index.mdx +2 -2
- package/package.json +12 -12
|
@@ -5,6 +5,7 @@ type: guide
|
|
|
5
5
|
summary: Compose workflows two ways — direct await flattens the child into the parent's event log, while background spawn via start() runs the child as an independent run.
|
|
6
6
|
related:
|
|
7
7
|
- /cookbook/advanced/child-workflows
|
|
8
|
+
- /cookbook/common-patterns/idempotency
|
|
8
9
|
- /docs/api-reference/workflow-api/start
|
|
9
10
|
- /docs/api-reference/workflow-api/get-run
|
|
10
11
|
---
|
|
@@ -77,6 +78,10 @@ export async function processOrder(orderId: string) {
|
|
|
77
78
|
|
|
78
79
|
The parent continues immediately after `start()` returns. The child runs independently and can be monitored separately using the returned `runId` (e.g., via [`getRun()`](/docs/api-reference/workflow-api/get-run)).
|
|
79
80
|
|
|
81
|
+
<Callout type="info">
|
|
82
|
+
Each background spawn creates a separate run. If duplicate requests must route to one active child workflow, have the child create a deterministic hook token from the business key and use that hook as the idempotency point. If concurrent starts race, the losing child can detect the conflict early with `await hook.getConflict()`, which resolves with the active owner so the child can point callers at it. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
83
|
+
</Callout>
|
|
84
|
+
|
|
80
85
|
<Callout type="info">
|
|
81
86
|
If you want the child workflow to run on the latest deployment rather than the current one, pass [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) in the `start()` options. See [Versioning](/docs/foundations/versioning) for the full model. This is currently a Vercel-specific feature, and other Worlds may map the concept to their own deployment runtimes. Be aware that the child workflow's function name, file path, argument types, and return type must remain compatible across deployments — renaming the function or changing its location will change the workflow ID, and modifying expected inputs or outputs can cause serialization failures.
|
|
82
87
|
</Callout>
|
|
@@ -109,3 +114,4 @@ If you want the child workflow to run on the latest deployment rather than the c
|
|
|
109
114
|
- [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions with full Node.js access
|
|
110
115
|
- [`start()`](/docs/api-reference/workflow-api/start) — spawn a child workflow as a separate run
|
|
111
116
|
- [`getRun()`](/docs/api-reference/workflow-api/get-run) — retrieve a workflow run's status and return value
|
|
117
|
+
- [Idempotency](/docs/foundations/idempotency) — deduplicate step side effects and workflow starts
|
package/docs/cookbook/index.mdx
CHANGED
|
@@ -21,7 +21,7 @@ A curated collection of workflow patterns with clean, copy-paste code examples f
|
|
|
21
21
|
- [**Rate Limiting**](/cookbook/common-patterns/rate-limiting) — Handle 429 responses and transient failures with RetryableError and backoff
|
|
22
22
|
- [**Scheduling**](/cookbook/common-patterns/scheduling) — Use durable sleep to schedule actions minutes, hours, or weeks ahead
|
|
23
23
|
- [**Timeouts**](/cookbook/common-patterns/timeouts) — Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep
|
|
24
|
-
- [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects
|
|
24
|
+
- [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects and duplicate starts are safe to retry
|
|
25
25
|
- [**Webhooks**](/cookbook/common-patterns/webhooks) — Receive HTTP callbacks from external services and process them durably
|
|
26
26
|
|
|
27
27
|
## Integrations
|
|
@@ -130,6 +130,10 @@ export async function supportWorkflow(initialMessages: ModelMessage[]) {
|
|
|
130
130
|
|
|
131
131
|
One endpoint handles first turn, follow-ups, and the `/done` exit. The client sends `runId` in the body to distinguish first vs follow-up.
|
|
132
132
|
|
|
133
|
+
<Callout type="info">
|
|
134
|
+
The first turn calls `start()` and then returns the `runId`. If your client or platform can retry that first request before it receives and stores the `runId`, use an atomic conversation or request key before starting the workflow. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
135
|
+
</Callout>
|
|
136
|
+
|
|
133
137
|
```typescript title="app/api/support/route.ts" lineNumbers
|
|
134
138
|
import type { UIMessage, UIMessageChunk } from "ai";
|
|
135
139
|
import { convertToModelMessages, createUIMessageStreamResponse } from "ai";
|
|
@@ -350,6 +354,10 @@ In `sliceUntilFinish`, use `reader.releaseLock()` in the `finally` block rather
|
|
|
350
354
|
|
|
351
355
|
Clients can send a `runId` from a long-gone workflow (localStorage, back button, server restart). Wrap the follow-up path in a try/catch for `not found` / `expired` and fall through to the first-turn code path to start a fresh workflow.
|
|
352
356
|
|
|
357
|
+
### Make the first turn idempotent when needed
|
|
358
|
+
|
|
359
|
+
This example stores the `runId` after the first response. For strict one-session-per-thread behavior, use a deterministic hook token derived from the thread ID or conversation ID and route retries through the active hook. See [Idempotency](/docs/foundations/idempotency).
|
|
360
|
+
|
|
353
361
|
## streamText vs DurableAgent
|
|
354
362
|
|
|
355
363
|
| | `streamText()` (this pattern) | `DurableAgent` |
|
|
@@ -376,8 +384,9 @@ Use `DurableAgent` for most agent use cases. Use `streamText` when you need the
|
|
|
376
384
|
|
|
377
385
|
**Workflow SDK**
|
|
378
386
|
|
|
379
|
-
* [`"use step"`](/docs/
|
|
387
|
+
* [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — applied to `runTurn` to make each turn a durable, retryable unit
|
|
380
388
|
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for follow-up messages
|
|
381
389
|
* [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable stream output
|
|
382
390
|
* [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.getReadable({ startIndex })` for slicing per-turn streams
|
|
383
391
|
* [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) — passes `runId` between turns
|
|
392
|
+
* [Idempotency](/docs/foundations/idempotency) — protect duplicate-sensitive first turns and side effects
|
|
@@ -176,6 +176,10 @@ export type ChatTurnPayload = {
|
|
|
176
176
|
|
|
177
177
|
Handlers live outside the workflow file so adapter dependencies don't leak in. They decide whether to start a new workflow or resume an existing one, then store the `runId` in thread state:
|
|
178
178
|
|
|
179
|
+
<Callout type="info">
|
|
180
|
+
If the platform can deliver the same first message concurrently, use a deterministic hook token derived from the thread ID so duplicate handlers route to the active chat session hook. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
181
|
+
</Callout>
|
|
182
|
+
|
|
179
183
|
```typescript title="lib/chat-session-handlers.ts" lineNumbers
|
|
180
184
|
import type { Message, Thread } from "chat";
|
|
181
185
|
import { getRun, resumeHook, start } from "workflow/api";
|
|
@@ -289,6 +293,10 @@ Adapter packages (`@chat-adapter/slack`, `@chat-adapter/telegram`, etc.) depend
|
|
|
289
293
|
|
|
290
294
|
A workflow run ends but its `runId` is still cached in thread state. The next message calls `resumeHook` on a dead run and throws `not found` / `expired`. Gate on `getRun(runId).exists` before resuming, or catch the error and fall through to `startSession`. Either way the user's message must not be dropped.
|
|
291
295
|
|
|
296
|
+
### Make first-message routing atomic
|
|
297
|
+
|
|
298
|
+
Thread state is also the idempotency boundary for starting sessions. Back it with a state adapter or database operation that can atomically claim the thread before `startSession()` runs when duplicate sessions would be harmful.
|
|
299
|
+
|
|
292
300
|
### Keep the hook outside the loop
|
|
293
301
|
|
|
294
302
|
One `chatTurnHook.create({ token: workflowRunId })` per workflow run, reused every iteration. Creating a new hook with the same token throws `HookConflictError`. This is the same rule as the [AI SDK](/docs/cookbook/integrations/ai-sdk) and [Sandbox](/docs/cookbook/integrations/sandbox) session patterns.
|
|
@@ -305,3 +313,4 @@ Slack wants a 200 within 3 seconds. The webhook handler returns immediately afte
|
|
|
305
313
|
- [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.exists` before resuming, to detect stale `runId`s.
|
|
306
314
|
- [`defineHook()`](/docs/api-reference/workflow/define-hook) — per-turn suspension point inside the workflow.
|
|
307
315
|
- [`registerSingleton()`](https://chat-sdk.dev/docs/api/chat) — makes the bot resolvable from inside step functions.
|
|
316
|
+
- [Idempotency](/docs/foundations/idempotency) — protect duplicate-sensitive first messages and side effects.
|
|
@@ -300,6 +300,10 @@ export async function sandboxSessionWorkflow() {
|
|
|
300
300
|
|
|
301
301
|
Two endpoints. `/start` accepts an optional `{ runId }` — if the run still exists, it replays the event log from index 0 so a returning client fully rehydrates. `/command` resumes the hook and returns immediately; command output lands on the `/start` stream.
|
|
302
302
|
|
|
303
|
+
<Callout type="info">
|
|
304
|
+
This example starts a fresh sandbox session when no `runId` is provided. If your product needs one sandbox session per user, project, or task, use a deterministic hook token derived from that session key and route retries through the active hook. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
305
|
+
</Callout>
|
|
306
|
+
|
|
303
307
|
```typescript title="app/api/sandbox/start/route.ts" lineNumbers
|
|
304
308
|
import { start, getRun } from "workflow/api";
|
|
305
309
|
import { sandboxSessionWorkflow } from "@/workflows/sandbox-session";
|
|
@@ -505,6 +509,10 @@ Stream closure must happen inside a `"use step"` function. Calling `writable.clo
|
|
|
505
509
|
|
|
506
510
|
Clients can hold `runId`s from long-gone workflow runs (localStorage, back button, server restart). Gate the reconnect path on `run.exists` and fall through to starting fresh. On `hook.resume`, catch `not found` / `expired` and return 410 so the client clears its state.
|
|
507
511
|
|
|
512
|
+
### Decide whether `/start` should be idempotent
|
|
513
|
+
|
|
514
|
+
The sample treats a missing or stale `runId` as a request for a new session. For one-session-per-resource behavior, use a durable resource key, such as `projectId` or `taskId`, to claim or retrieve the run before starting a new one.
|
|
515
|
+
|
|
508
516
|
### Keep the hook outside the loop
|
|
509
517
|
|
|
510
518
|
Each iteration's `hook.then(...)` attaches a listener to the same hook instance. Creating a new hook per iteration with the same token throws `HookConflictError`. One hook, one token (`workflowRunId`), reused every iteration.
|
|
@@ -518,3 +526,4 @@ Each iteration's `hook.then(...)` attaches a listener to the same hook instance.
|
|
|
518
526
|
- [`sleep()`](/docs/api-reference/workflow/sleep) — durable timer that powers both idle hibernation and proactive refresh
|
|
519
527
|
- [`getRun()`](/docs/api-reference/workflow-api/get-run) — look up a run and replay its event log for reconnection
|
|
520
528
|
- [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable NDJSON event stream
|
|
529
|
+
- [Idempotency](/docs/foundations/idempotency) — choose when `/start` should reuse an existing run
|
|
@@ -4,7 +4,7 @@ description: A step function is not registered in the current deployment.
|
|
|
4
4
|
type: troubleshooting
|
|
5
5
|
summary: Resolve step not registered errors caused by build issues.
|
|
6
6
|
prerequisites:
|
|
7
|
-
- /docs/foundations/steps
|
|
7
|
+
- /docs/foundations/workflows-and-steps
|
|
8
8
|
related:
|
|
9
9
|
- /docs/errors/workflow-not-registered
|
|
10
10
|
- /docs/api-reference/workflow-errors/step-not-registered-error
|
|
@@ -6,7 +6,6 @@ summary: Cancel in-flight work with AbortSignal or stop entire workflow runs.
|
|
|
6
6
|
prerequisites:
|
|
7
7
|
- /docs/foundations/workflows-and-steps
|
|
8
8
|
related:
|
|
9
|
-
- /docs/foundations/common-patterns
|
|
10
9
|
- /docs/foundations/hooks
|
|
11
10
|
- /docs/how-it-works/cancellation
|
|
12
11
|
---
|
|
@@ -455,6 +454,6 @@ This is safe even if both steps have already completed — aborting a finished o
|
|
|
455
454
|
|
|
456
455
|
- [How Cancellation Works](/docs/how-it-works/cancellation) — Hook and stream backing, serialization internals
|
|
457
456
|
- [Serialization](/docs/foundations/serialization) — Understanding serializable types
|
|
458
|
-
- [
|
|
457
|
+
- [Cookbook](/v5/cookbook) — Timeout, race, and other reliability patterns
|
|
459
458
|
- [Hooks](/docs/foundations/hooks) — Pausing workflows for external events
|
|
460
459
|
- [Errors and Retries](/docs/foundations/errors-and-retries) — Handling step failures
|
|
@@ -112,7 +112,7 @@ export async function orderWorkflow(orderId: string) {
|
|
|
112
112
|
}
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). For `hook_conflict` events persisted by older worlds that did not record the owning run's ID, `getConflict()` rejects with `HookConflictError` instead of resolving with an incomplete handle. The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [
|
|
115
|
+
Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). For `hook_conflict` events persisted by older worlds that did not record the owning run's ID, `getConflict()` rejects with `HookConflictError` instead of resolving with an incomplete handle. The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.
|
|
116
116
|
|
|
117
117
|
### Custom Tokens for Deterministic Hooks
|
|
118
118
|
|
|
@@ -1,23 +1,27 @@
|
|
|
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: conceptual
|
|
5
|
-
summary:
|
|
5
|
+
summary: Use step IDs for retry-safe external calls, and route duplicate workflow-start requests through deterministic hook tokens.
|
|
6
6
|
prerequisites:
|
|
7
7
|
- /docs/foundations/workflows-and-steps
|
|
8
8
|
related:
|
|
9
9
|
- /docs/foundations/errors-and-retries
|
|
10
|
+
- /docs/foundations/starting-workflows
|
|
11
|
+
- /docs/foundations/hooks
|
|
10
12
|
---
|
|
11
13
|
|
|
12
|
-
Idempotency is a property of an operation that ensures
|
|
14
|
+
Idempotency is a property of an operation that ensures repeated attempts have the same effect as a single attempt.
|
|
15
|
+
|
|
16
|
+
In Workflow, idempotency shows up in two related places: step idempotency makes external calls safe when a step retries, and run idempotency coordinates duplicate requests that try to start the same workflow.
|
|
17
|
+
|
|
18
|
+
## Step Idempotency
|
|
13
19
|
|
|
14
20
|
In distributed systems (calling external APIs), it is not always possible to ensure an operation has only been performed once just by seeing if it succeeds.
|
|
15
21
|
Consider a payment API that charges the user $10, but due to network failures, the confirmation response is lost. When the step retries (because the previous attempt was considered a failure), it will charge the user again.
|
|
16
22
|
|
|
17
23
|
To prevent this, many external APIs support idempotency keys. An idempotency key is a unique identifier for an operation that can be used to deduplicate requests.
|
|
18
24
|
|
|
19
|
-
## The core pattern: use the step ID as your idempotency key
|
|
20
|
-
|
|
21
25
|
Every step invocation has a stable `stepId` that stays the same across retries.
|
|
22
26
|
Use it as the idempotency key when calling third-party APIs.
|
|
23
27
|
|
|
@@ -27,7 +31,7 @@ import { getStepMetadata } from "workflow";
|
|
|
27
31
|
async function chargeUser(userId: string, amount: number) {
|
|
28
32
|
"use step";
|
|
29
33
|
|
|
30
|
-
const { stepId } = getStepMetadata();
|
|
34
|
+
const { stepId } = getStepMetadata(); // [!code highlight]
|
|
31
35
|
|
|
32
36
|
// Example: Stripe-style idempotency key
|
|
33
37
|
// This guarantees only one charge is created even if the step retries
|
|
@@ -49,14 +53,235 @@ Why this works:
|
|
|
49
53
|
- **Stable across retries**: `stepId` does not change between attempts.
|
|
50
54
|
- **Globally unique per step**: Fulfills the uniqueness requirement for an idempotency key.
|
|
51
55
|
|
|
52
|
-
##
|
|
56
|
+
## Run idempotency
|
|
57
|
+
|
|
58
|
+
Step idempotency protects side effects **inside** a workflow run. Run idempotency answers a different question: if the same API request is sent twice, should it create one workflow run or two?
|
|
59
|
+
|
|
60
|
+
Because [hooks](/docs/foundations/hooks) already ensure globally unique active tokens, Workflow can use the same mechanism to coordinate duplicate requests while a run is active.
|
|
61
|
+
|
|
62
|
+
Use a hook token as the idempotency key for an active workflow run. Hook tokens are globally unique while they are active: if another run tries to create a hook with the same token, the runtime records a conflict, `hook.getConflict()` resolves with a `Run` handle for the run that owns the token, and the hook rejects with [`HookConflictError`](/docs/errors/hook-conflict) when the workflow awaits or iterates its payload.
|
|
63
|
+
|
|
64
|
+
The token should come from your domain, such as an order ID, invoice ID, import ID, or request ID. Create the hook near the beginning of the workflow and check `await hook.getConflict()` before doing duplicate-sensitive work that depends on owning the active token. Calling `createHook()` alone does not register the hook — awaiting `getConflict()` suspends the workflow to commit the registration.
|
|
65
|
+
|
|
66
|
+
```typescript lineNumbers
|
|
67
|
+
import { createHook } from "workflow";
|
|
68
|
+
|
|
69
|
+
type OrderRequest = { confirmed: boolean };
|
|
70
|
+
type OrderResult =
|
|
71
|
+
| { status: "processed" | "cancelled" }
|
|
72
|
+
| { status: "duplicate"; runId: string };
|
|
73
|
+
declare function chargeOrder(orderId: string): Promise<void>; // @setup
|
|
74
|
+
|
|
75
|
+
export async function processOrder(orderId: string): Promise<OrderResult> {
|
|
76
|
+
"use workflow";
|
|
77
|
+
|
|
78
|
+
using request = createHook<OrderRequest>({ // [!code highlight]
|
|
79
|
+
token: `order:${orderId}`, // [!code highlight]
|
|
80
|
+
}); // [!code highlight]
|
|
81
|
+
|
|
82
|
+
const conflict = await request.getConflict(); // [!code highlight]
|
|
83
|
+
if (conflict) { // [!code highlight]
|
|
84
|
+
// Another active run already owns this order's token. // [!code highlight]
|
|
85
|
+
return { status: "duplicate" as const, runId: conflict.runId }; // [!code highlight]
|
|
86
|
+
} // [!code highlight]
|
|
87
|
+
|
|
88
|
+
const { confirmed } = await request;
|
|
89
|
+
|
|
90
|
+
if (!confirmed) {
|
|
91
|
+
return { status: "cancelled" as const };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await chargeOrder(orderId);
|
|
95
|
+
return { status: "processed" as const };
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The runtime creates the hook atomically. At most one active hook can own `order:${orderId}`, so duplicate workflow runs converge on one active owner. A duplicate run observes `getConflict()` resolving with the owner's `Run` and returns before it reaches `chargeOrder()`. The conflicting run's accessors (`status`, `returnValue`, `cancel()`, …) are durable steps, so the duplicate run can do more than report the owner — see [conflict-handling strategies](#conflict-handling-strategies) below.
|
|
100
|
+
|
|
101
|
+
Outside the workflow, try to resume the hook first. If the hook is not registered yet, start the workflow and retry the resume until the new run creates the hook:
|
|
102
|
+
|
|
103
|
+
```typescript lineNumbers
|
|
104
|
+
import { resumeHook, start } from "workflow/api";
|
|
105
|
+
import { HookNotFoundError } from "workflow/errors";
|
|
106
|
+
import { processOrder } from "./workflows/process-order";
|
|
107
|
+
|
|
108
|
+
type OrderRequest = { confirmed: boolean };
|
|
109
|
+
|
|
110
|
+
async function resumeOrder(token: string, payload: OrderRequest) {
|
|
111
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
112
|
+
try {
|
|
113
|
+
return await resumeHook(token, payload); // [!code highlight]
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (!HookNotFoundError.is(error)) throw error;
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw new Error("Order workflow did not register its hook in time");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function POST(request: Request) {
|
|
124
|
+
const { orderId, confirmed } = await request.json();
|
|
125
|
+
const token = `order:${orderId}`;
|
|
126
|
+
const payload = { confirmed };
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const hook = await resumeHook(token, payload); // [!code highlight]
|
|
130
|
+
return Response.json({ runId: hook.runId, reused: true });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!HookNotFoundError.is(error)) throw error;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const run = await start(processOrder, [orderId]); // [!code highlight]
|
|
136
|
+
const resumed = await resumeOrder(token, payload);
|
|
137
|
+
|
|
138
|
+
// A concurrent request's run may have won the race between `start()` // [!code highlight]
|
|
139
|
+
// and hook registration. The resume always reaches the actual active // [!code highlight]
|
|
140
|
+
// owner, so compare run IDs instead of waiting for this run to finish. // [!code highlight]
|
|
141
|
+
return Response.json({ // [!code highlight]
|
|
142
|
+
runId: resumed.runId, // [!code highlight]
|
|
143
|
+
reused: resumed.runId !== run.runId, // [!code highlight]
|
|
144
|
+
}); // [!code highlight]
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
<Callout type="warn">
|
|
149
|
+
This avoids creating a new run only after the first run has registered its hook. Because `start()` returns before the run body executes and calls `createHook()`, two concurrent requests can both observe "no hook yet" and each call `start()`. The race is resolved inside the workflow body, where the losing run observes `getConflict()` resolving with the active owner and returns without doing duplicate-sensitive work — and the route detects it by comparing the resumed hook's `runId` against the run it just started, without waiting for either run to finish. A native API for atomically starting a run and registering a hook is in the works. Until then, model recovery inside the workflow by checking `hook.getConflict()`.
|
|
150
|
+
</Callout>
|
|
151
|
+
|
|
152
|
+
This is active-run coordination. When the workflow completes and disposes the hook, the token can be used again. If a duplicate request after completion must return the original result instead of starting fresh work, persist that completed result under the same domain key.
|
|
153
|
+
|
|
154
|
+
### Conflict-handling strategies
|
|
155
|
+
|
|
156
|
+
Some workflow systems resolve duplicate IDs with a fixed, pre-declared policy — typically a static choice between rejecting the new execution, deferring to the existing one, or terminating it. Workflow has no policy enum. `hook.getConflict()` hands the duplicate run the conflicting `Run` itself, and the policy is ordinary code — including policies that inspect state before deciding, which static configuration can't express.
|
|
157
|
+
|
|
158
|
+
The example above implements **reject the duplicate**: return the owner's `runId` and let the caller decide. Other common strategies:
|
|
159
|
+
|
|
160
|
+
**Adopt the owner's result.** Wait for the active run to finish and return its result, so callers cannot tell which run did the work:
|
|
161
|
+
|
|
162
|
+
```typescript lineNumbers
|
|
163
|
+
import { createHook } from "workflow";
|
|
164
|
+
|
|
165
|
+
type OrderRequest = { confirmed: boolean };
|
|
166
|
+
declare function processOwnedOrder(orderId: string): Promise<{ status: string }>; // @setup
|
|
167
|
+
|
|
168
|
+
export async function processOrder(orderId: string) {
|
|
169
|
+
"use workflow";
|
|
170
|
+
|
|
171
|
+
using request = createHook<OrderRequest>({
|
|
172
|
+
token: `order:${orderId}`,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const conflict = await request.getConflict();
|
|
176
|
+
if (conflict) {
|
|
177
|
+
// Callers get the same result regardless of which run did the work.
|
|
178
|
+
return await conflict.returnValue; // [!code highlight]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return await processOwnedOrder(orderId);
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Inspect the owner before deciding.** Branch on the owner's live state:
|
|
186
|
+
|
|
187
|
+
```typescript lineNumbers
|
|
188
|
+
import { createHook } from "workflow";
|
|
189
|
+
|
|
190
|
+
type OrderRequest = { confirmed: boolean };
|
|
191
|
+
declare function processOwnedOrder(orderId: string): Promise<{ status: string }>; // @setup
|
|
192
|
+
|
|
193
|
+
export async function processOrder(orderId: string) {
|
|
194
|
+
"use workflow";
|
|
195
|
+
|
|
196
|
+
using request = createHook<OrderRequest>({
|
|
197
|
+
token: `order:${orderId}`,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const conflict = await request.getConflict();
|
|
201
|
+
if (conflict) {
|
|
202
|
+
const status = await conflict.status; // [!code highlight]
|
|
203
|
+
if (status === "running") {
|
|
204
|
+
return { status: "duplicate" as const, runId: conflict.runId };
|
|
205
|
+
}
|
|
206
|
+
// Owner already reached a terminal state; its hook will be released.
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return await processOwnedOrder(orderId);
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**Signal the owner instead of doing the work.** The duplicate run knows the token, so it can deliver this run's input to the owner's hook from a step:
|
|
214
|
+
|
|
215
|
+
```typescript lineNumbers
|
|
216
|
+
import { createHook } from "workflow";
|
|
217
|
+
import { resumeHook } from "workflow/api";
|
|
218
|
+
|
|
219
|
+
type OrderRequest = { confirmed: boolean };
|
|
220
|
+
|
|
221
|
+
async function forwardToOwner(token: string, payload: OrderRequest) {
|
|
222
|
+
"use step";
|
|
223
|
+
await resumeHook(token, payload); // [!code highlight]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function processOrder(orderId: string, confirmed: boolean) {
|
|
227
|
+
"use workflow";
|
|
228
|
+
|
|
229
|
+
const token = `order:${orderId}`;
|
|
230
|
+
using request = createHook<OrderRequest>({ token });
|
|
231
|
+
|
|
232
|
+
const conflict = await request.getConflict();
|
|
233
|
+
if (conflict) {
|
|
234
|
+
await forwardToOwner(token, { confirmed }); // [!code highlight]
|
|
235
|
+
return { status: "forwarded" as const, runId: conflict.runId };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ... own the token and do the work
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Supersede the owner.** Newest-wins: cancel the active run, then claim the released token. Cancellation disposes the owner's hooks; the retry loop covers the window where that disposal has not propagated yet:
|
|
243
|
+
|
|
244
|
+
```typescript lineNumbers
|
|
245
|
+
import { createHook } from "workflow";
|
|
246
|
+
|
|
247
|
+
type OrderRequest = { confirmed: boolean };
|
|
248
|
+
declare function chargeOrder(orderId: string): Promise<void>; // @setup
|
|
249
|
+
|
|
250
|
+
export async function processOrderNewestWins(orderId: string) {
|
|
251
|
+
"use workflow";
|
|
252
|
+
|
|
253
|
+
const token = `order:${orderId}`;
|
|
254
|
+
|
|
255
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
256
|
+
using request = createHook<OrderRequest>({ token });
|
|
257
|
+
|
|
258
|
+
const conflict = await request.getConflict();
|
|
259
|
+
if (!conflict) {
|
|
260
|
+
// Token claimed — this run is now the owner.
|
|
261
|
+
const { confirmed } = await request;
|
|
262
|
+
if (confirmed) {
|
|
263
|
+
await chargeOrder(orderId);
|
|
264
|
+
}
|
|
265
|
+
return { status: "processed" as const };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await conflict.cancel(); // [!code highlight]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
throw new Error(`Could not claim ${token} after cancelling the owner`);
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
If duplicate requests should only reuse the active run without sending data, use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) as an advisory pre-check before calling `start()`. The workflow should still check `hook.getConflict()`, because the lookup and `start()` are not atomic.
|
|
53
276
|
|
|
54
|
-
|
|
55
|
-
- **Prefer `stepId` as your key**; it is stable across retries and unique per step.
|
|
56
|
-
- **Keep keys deterministic**; avoid including timestamps or attempt counters.
|
|
57
|
-
- **Handle 409/conflict responses** gracefully; treat them as success if the prior attempt completed.
|
|
277
|
+
Because this pattern uses hooks for idempotency, duplicate requests can also inject additional data and steer the existing run. The route example above uses `resumeHook()` for that: if the hook already exists, the duplicate request resumes the active workflow; if the hook is not registered yet, the route starts the workflow and retries `resumeHook()` so the payload is not dropped.
|
|
58
278
|
|
|
59
279
|
## Related docs
|
|
60
280
|
|
|
61
281
|
- Learn about retries in [Errors & Retrying](/docs/foundations/errors-and-retries)
|
|
62
282
|
- API reference: [`getStepMetadata`](/docs/api-reference/workflow/get-step-metadata)
|
|
283
|
+
- API reference: [`createHook()`](/docs/api-reference/workflow/create-hook)
|
|
284
|
+
- API reference: [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token)
|
|
285
|
+
- API reference: [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook)
|
|
286
|
+
- API reference: [`start()`](/docs/api-reference/workflow-api/start)
|
|
287
|
+
- Learn about deterministic hook tokens in [Hooks](/docs/foundations/hooks)
|
|
@@ -295,7 +295,7 @@ Remove the Worker process, `@temporalio/*` dependencies, and the Temporal Server
|
|
|
295
295
|
- **Event history archival.** Temporal archives histories to S3/GCS for long-term retention. Workflow SDK event logs are durable, but retention depends on the integration you are using. For example, see [Vercel Workflow Storage Retention](https://vercel.com/docs/workflows/pricing#storage-retention) for Vercel.
|
|
296
296
|
- **Per-activity timeouts (`startToCloseTimeout`, `scheduleToCloseTimeout`, `heartbeatTimeout`).** Implement deadlines inside the step with `AbortSignal.timeout(ms)`, or wrap the call in `Promise.race(step(), sleep(...))` from the workflow.
|
|
297
297
|
- **Rich retry policy (`initialInterval`, `backoffCoefficient`, `maximumInterval`, `nonRetryableErrorTypes`).** Only `maxRetries` is configurable. Classify retryability with `RetryableError`/`FatalError`; control delay between attempts via `new RetryableError(msg, { retryAfter: '5s' })`.
|
|
298
|
-
- **Workers + task queues.** Managed deployments replace workers; self-hosted deployments still need a `World` implementation (see [/docs/deploying/world](/docs/deploying/world)).
|
|
298
|
+
- **Workers + task queues.** Managed deployments replace workers; self-hosted deployments still need a `World` implementation (see [/docs/deploying/building-a-world](/docs/deploying/building-a-world)).
|
|
299
299
|
|
|
300
300
|
## Quick-start checklist
|
|
301
301
|
|
|
@@ -62,6 +62,20 @@ export async function cleanupAttributes() {
|
|
|
62
62
|
|
|
63
63
|
Attribute keys must be 1-256 characters, values must be strings up to 256 bytes, and each run can have up to 64 attributes. Keys that start with `$` are reserved for framework and library code.
|
|
64
64
|
|
|
65
|
+
## Viewing attributes
|
|
66
|
+
|
|
67
|
+
The run details panel in the observability UI shows the run's current attributes as key-value rows. Reserved `$`-prefixed keys are marked with a badge and sorted after user keys:
|
|
68
|
+
|
|
69
|
+

|
|
70
|
+
|
|
71
|
+
Each `experimental_setAttributes` call appears on the trace timeline as a diamond marker at the moment the attributes were written:
|
|
72
|
+
|
|
73
|
+

|
|
74
|
+
|
|
75
|
+
Expanding an `attr_set` event — in the run sidebar or the Events tab — shows the changed keys, removed keys, and whether the write came from the workflow body or a step (with the attempt number):
|
|
76
|
+
|
|
77
|
+

|
|
78
|
+
|
|
65
79
|
## Experimental Behavior
|
|
66
80
|
|
|
67
81
|
While attributes are experimental:
|
|
@@ -67,6 +67,9 @@ When deployed to Vercel, workflow data is [encrypted end-to-end](/docs/how-it-wo
|
|
|
67
67
|
## More Observability Features
|
|
68
68
|
|
|
69
69
|
<Cards>
|
|
70
|
+
<Card href="/docs/observability/tracing" title="Tracing">
|
|
71
|
+
Distributed tracing with OpenTelemetry for workflow runs, steps, and queue deliveries.
|
|
72
|
+
</Card>
|
|
70
73
|
<Card href="/docs/observability/attributes" title="Attributes">
|
|
71
74
|
Attach experimental metadata to workflow runs for observability.
|
|
72
75
|
</Card>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Tracing
|
|
3
|
+
description: Distributed tracing with OpenTelemetry for workflow runs, steps, and queue deliveries.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Trace workflow execution end to end with OpenTelemetry.
|
|
6
|
+
prerequisites:
|
|
7
|
+
- /docs/foundations/workflows-and-steps
|
|
8
|
+
related:
|
|
9
|
+
- /docs/observability
|
|
10
|
+
- /docs/observability/attributes
|
|
11
|
+
- /docs/how-it-works/event-sourcing
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
The Workflow SDK is instrumented with [OpenTelemetry](https://opentelemetry.io) out of the box. It emits spans for workflow starts, every workflow and step invocation, and the HTTP calls it makes to the workflow backend — and it propagates trace context across queue deliveries so a run remains traceable end to end.
|
|
15
|
+
|
|
16
|
+
The SDK only depends on the OpenTelemetry **API**, never on an SDK or exporter. If your application does not register an OpenTelemetry SDK, all tracing code is a silent no-op with no overhead and no behavior change.
|
|
17
|
+
|
|
18
|
+
## Enabling tracing
|
|
19
|
+
|
|
20
|
+
Register any OpenTelemetry Node SDK in your application. On Vercel with Next.js, the simplest setup is [`@vercel/otel`](https://vercel.com/docs/observability/otel-overview) in `instrumentation.ts`:
|
|
21
|
+
|
|
22
|
+
```typescript title="instrumentation.ts" lineNumbers
|
|
23
|
+
import { registerOTel } from "@vercel/otel"
|
|
24
|
+
|
|
25
|
+
export function register() {
|
|
26
|
+
registerOTel({ serviceName: "my-app" })
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
No workflow-specific configuration is required. As soon as a tracer provider and propagator are registered, the SDK's spans, context propagation, and span links activate automatically.
|
|
31
|
+
|
|
32
|
+
## Spans
|
|
33
|
+
|
|
34
|
+
| Span name | Kind | Emitted when |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `workflow.start <name>` | internal | `start()` is called in your application code |
|
|
37
|
+
| `workflow.execute <name>` | consumer (root) | a queue delivery invokes the workflow — replay, orchestration, and inline steps run under it |
|
|
38
|
+
| `step.execute <name>` | internal (inline) / consumer + root (queue-delivered) | a step function executes |
|
|
39
|
+
| `http <method>` | client | the SDK calls the workflow backend (event reads/writes) |
|
|
40
|
+
|
|
41
|
+
`<name>` is the short function name (for example `processOrder`); the full machine name, including the source module, is available in the `workflow.name` / `step.name` attributes.
|
|
42
|
+
|
|
43
|
+
## Key attributes
|
|
44
|
+
|
|
45
|
+
| Attribute | Description |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `workflow.run.id` | The run ID (`wrun_...`). Present on every workflow and step span — the primary key for finding all spans of a run. |
|
|
48
|
+
| `workflow.name` | The workflow function name. |
|
|
49
|
+
| `workflow.trace.mode` | The active trace mode (`linked` or `continuous`). |
|
|
50
|
+
| `workflow.trace.propagated` | Whether the invocation received trace context from the queue message. |
|
|
51
|
+
| `workflow.queue.overhead_ms` | Time between the message being enqueued and the handler starting — queue dwell plus any cold start. |
|
|
52
|
+
|
|
53
|
+
## Trace shape: one trace per invocation
|
|
54
|
+
|
|
55
|
+
A single workflow run can span hours or days across many separate function invocations: every step completion, `sleep()` wake-up, and retry is a new queue delivery. Stitching all of that into one trace produces giant, slow-loading traces that most tracing backends truncate.
|
|
56
|
+
|
|
57
|
+
Instead, the SDK creates **one bounded trace per invocation**. Each `workflow.execute` (or background `step.execute`) span starts a new trace root and attaches two **span links**:
|
|
58
|
+
|
|
59
|
+
- a link to the **enqueue site** — the span that queued the message which triggered this invocation, and
|
|
60
|
+
- a link to the **run origin** — the trace in which `start()` was originally called.
|
|
61
|
+
|
|
62
|
+
A span link is OpenTelemetry's relationship for "causally related, but in a different trace." It is the standard pattern for asynchronous messaging, where producing and consuming a message can be separated by arbitrary time.
|
|
63
|
+
|
|
64
|
+
```mermaid
|
|
65
|
+
flowchart LR
|
|
66
|
+
O["start() request trace"]
|
|
67
|
+
A["invocation 1"]
|
|
68
|
+
B["invocation 2"]
|
|
69
|
+
C["invocation 3 ..."]
|
|
70
|
+
A -. "link" .-> O
|
|
71
|
+
B -. "link" .-> O
|
|
72
|
+
C -. "link" .-> O
|
|
73
|
+
B -. "link" .-> A
|
|
74
|
+
C -. "link" .-> B
|
|
75
|
+
|
|
76
|
+
style O fill:#a78bfa,stroke:#8b5cf6,color:#000
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Each invocation links back to the trace that enqueued it and to the run origin.
|
|
80
|
+
|
|
81
|
+
To see a whole run, query by attribute rather than by trace ID — for example `workflow.run.id = wrun_...` in your tracing backend — or follow the span links between invocation traces.
|
|
82
|
+
|
|
83
|
+
## Trace modes
|
|
84
|
+
|
|
85
|
+
The `WORKFLOW_TRACE_MODE` environment variable controls the shape:
|
|
86
|
+
|
|
87
|
+
| Mode | Behavior |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `linked` (default) | Each invocation is its own trace root with span links to the enqueue site and the run origin. Traces stay small; sampling is decided per invocation. |
|
|
90
|
+
| `continuous` | The run-origin context becomes the **parent** of every invocation, so the entire run shares one trace ID. |
|
|
91
|
+
|
|
92
|
+
<Callout type="warn">
|
|
93
|
+
This is a behavior change from v4, which always used `continuous`-style tracing. If you have dashboards or queries that assume one trace ID per run, either update them to use `workflow.run.id` and span links, or set `WORKFLOW_TRACE_MODE=continuous` to restore the previous shape. Note that in `linked` mode each invocation root makes its own sampling decision, and the number of root spans increases to one per invocation.
|
|
94
|
+
</Callout>
|
|
95
|
+
|
|
96
|
+
## Context propagation
|
|
97
|
+
|
|
98
|
+
When tracing is enabled, the SDK propagates [W3C Trace Context](https://www.w3.org/TR/trace-context/) on its outbound calls:
|
|
99
|
+
|
|
100
|
+
- **Backend requests** carry `traceparent`, `tracestate`, and `baggage` headers, so backend spans can join your trace.
|
|
101
|
+
- **Queue messages** carry the run-origin trace context in the message payload, and the queue re-delivers the producer's context to the workflow handler, where it becomes the enqueue-site span link.
|
|
102
|
+
- **Baggage** carries `workflow.run_id` and `workflow.name` entries during workflow execution, allowing downstream services you call from steps to tag their own telemetry with the run ID.
|
|
103
|
+
|
|
104
|
+
<Callout>
|
|
105
|
+
Baggage entries set by your application are propagated as a `baggage` HTTP header on the SDK's backend requests, like any other OpenTelemetry-instrumented HTTP call. Avoid placing sensitive values in baggage.
|
|
106
|
+
</Callout>
|
package/docs/testing/index.mdx
CHANGED
|
@@ -109,7 +109,7 @@ That's it. The plugin automatically:
|
|
|
109
109
|
|
|
110
110
|
1. Transforms `"use workflow"` and `"use step"` directives via SWC
|
|
111
111
|
2. Builds workflow and step bundles before tests run
|
|
112
|
-
3. Sets up an in-process workflow runtime using a fresh [Local World](/
|
|
112
|
+
3. Sets up an in-process workflow runtime using a fresh [Local World](/worlds/local) instance in each test worker — all workflow data is cleared automatically between test files for full isolation
|
|
113
113
|
|
|
114
114
|
<Callout type="info">
|
|
115
115
|
Use a separate Vitest configuration and a distinct file naming convention (e.g. `*.integration.test.ts`) to keep unit tests and integration tests separate. Unit tests run with a standard Vitest config without the workflow plugin, while integration tests use the config above.
|
|
@@ -117,7 +117,7 @@ Use a separate Vitest configuration and a distinct file naming convention (e.g.
|
|
|
117
117
|
|
|
118
118
|
### Writing Integration Tests
|
|
119
119
|
|
|
120
|
-
Use [`start()`](/docs/api-reference/workflow-api/start) to trigger a workflow and [`run.returnValue`](/docs/api-reference/workflow-api/start#
|
|
120
|
+
Use [`start()`](/docs/api-reference/workflow-api/start) to trigger a workflow and [`run.returnValue`](/docs/api-reference/workflow-api/start#returns) to get the result. `returnValue` is a promise that blocks until the workflow completes (or throws if it fails):
|
|
121
121
|
|
|
122
122
|
```typescript title="workflows/calculate.integration.test.ts" lineNumbers
|
|
123
123
|
import { describe, it, expect } from "vitest";
|