workflow 5.0.0-beta.55 → 5.0.0-beta.57
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/internal/errors.d.ts +1 -1
- package/dist/internal/errors.d.ts.map +1 -1
- package/dist/internal/errors.js +2 -2
- package/docs/api-reference/workflow/create-hook.mdx +77 -0
- package/docs/api-reference/workflow/create-webhook.mdx +1 -1
- package/docs/api-reference/workflow/define-hook.mdx +2 -0
- package/docs/api-reference/workflow-api/get-hook-by-token.mdx +2 -0
- package/docs/api-reference/workflow-api/register-lifecycle-hooks.mdx +6 -4
- package/docs/api-reference/workflow-api/start.mdx +1 -0
- package/docs/api-reference/workflow-errors/hook-conflict-error.mdx +1 -1
- package/docs/api-reference/workflow-errors/hook-force-claimed-error.mdx +70 -0
- package/docs/api-reference/workflow-errors/index.mdx +3 -0
- package/docs/api-reference/workflow-errors/meta.json +1 -0
- package/docs/api-reference/workflow-errors/workflow-run-cancelled-error.mdx +7 -0
- package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +7 -0
- package/docs/api-reference/workflow-runtime/world/storage.mdx +3 -1
- package/docs/changelog/batched-event-writes.mdx +2 -2
- package/docs/configuration/runtime-tuning.mdx +11 -2
- package/docs/configuration/worlds.mdx +5 -5
- package/docs/cookbook/advanced/child-workflows.mdx +3 -1
- package/docs/cookbook/advanced/upgrading-workflows.mdx +1 -1
- package/docs/cookbook/common-patterns/batching.mdx +2 -0
- package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +2 -2
- package/docs/errors/hook-conflict.mdx +23 -0
- package/docs/errors/hook-force-claimed.mdx +96 -0
- package/docs/errors/index.mdx +21 -0
- package/docs/foundations/errors-and-retries.mdx +3 -1
- package/docs/foundations/hooks.mdx +34 -0
- package/docs/foundations/idempotency.mdx +44 -21
- package/docs/foundations/starting-workflows.mdx +2 -0
- package/docs/how-it-works/event-sourcing.mdx +7 -1
- package/docs/observability/lifecycle-hooks.mdx +6 -4
- package/docs/whats-new.mdx +4 -1
- package/docs/worlds/building-a-world.mdx +537 -0
- package/docs/worlds/local.mdx +129 -0
- package/docs/worlds/meta.json +10 -0
- package/docs/worlds/postgres.mdx +424 -0
- package/docs/worlds/upgrading-to-v5.mdx +162 -0
- package/docs/worlds/vercel.mdx +345 -0
- package/package.json +11 -11
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { EntityConflictError, HookConflictError, HookNotFoundError, PreconditionFailedError, RunExpiredError, RunNotSupportedError, StepNotRegisteredError, StreamError, ThrottleError, TooEarlyError, WorkflowError, WorkflowNotRegisteredError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotCompletedError, WorkflowRunNotFoundError, WorkflowRuntimeError, WorkflowWorldError, } from '@workflow/errors';
|
|
1
|
+
export { EntityConflictError, HookConflictError, HookForceClaimedError, HookNotFoundError, PreconditionFailedError, RunExpiredError, RunNotSupportedError, StepNotRegisteredError, StreamError, ThrottleError, TooEarlyError, WorkflowError, WorkflowNotRegisteredError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotCompletedError, WorkflowRunNotFoundError, WorkflowRuntimeError, WorkflowWorldError, } from '@workflow/errors';
|
|
2
2
|
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/internal/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,iBAAiB,EACjB,uBAAuB,EACvB,eAAe,EACf,oBAAoB,EACpB,sBAAsB,EACtB,WAAW,EACX,aAAa,EACb,aAAa,EACb,aAAa,EACb,0BAA0B,EAC1B,yBAAyB,EACzB,sBAAsB,EACtB,4BAA4B,EAC5B,wBAAwB,EACxB,oBAAoB,EACpB,kBAAkB,GACnB,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/internal/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,qBAAqB,EACrB,iBAAiB,EACjB,uBAAuB,EACvB,eAAe,EACf,oBAAoB,EACpB,sBAAsB,EACtB,WAAW,EACX,aAAa,EACb,aAAa,EACb,aAAa,EACb,0BAA0B,EAC1B,yBAAyB,EACzB,sBAAsB,EACtB,4BAA4B,EAC5B,wBAAwB,EACxB,oBAAoB,EACpB,kBAAkB,GACnB,MAAM,kBAAkB,CAAC"}
|
package/dist/internal/errors.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { EntityConflictError, HookConflictError, HookNotFoundError, PreconditionFailedError, RunExpiredError, RunNotSupportedError, StepNotRegisteredError, StreamError, ThrottleError, TooEarlyError, WorkflowError, WorkflowNotRegisteredError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotCompletedError, WorkflowRunNotFoundError, WorkflowRuntimeError, WorkflowWorldError, } from '@workflow/errors';
|
|
2
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
1
|
+
export { EntityConflictError, HookConflictError, HookForceClaimedError, HookNotFoundError, PreconditionFailedError, RunExpiredError, RunNotSupportedError, StepNotRegisteredError, StreamError, ThrottleError, TooEarlyError, WorkflowError, WorkflowNotRegisteredError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotCompletedError, WorkflowRunNotFoundError, WorkflowRuntimeError, WorkflowWorldError, } from '@workflow/errors';
|
|
2
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZXJyb3JzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL2ludGVybmFsL2Vycm9ycy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQ0wsbUJBQW1CLEVBQ25CLGlCQUFpQixFQUNqQixxQkFBcUIsRUFDckIsaUJBQWlCLEVBQ2pCLHVCQUF1QixFQUN2QixlQUFlLEVBQ2Ysb0JBQW9CLEVBQ3BCLHNCQUFzQixFQUN0QixXQUFXLEVBQ1gsYUFBYSxFQUNiLGFBQWEsRUFDYixhQUFhLEVBQ2IsMEJBQTBCLEVBQzFCLHlCQUF5QixFQUN6QixzQkFBc0IsRUFDdEIsNEJBQTRCLEVBQzVCLHdCQUF3QixFQUN4QixvQkFBb0IsRUFDcEIsa0JBQWtCLEdBQ25CLE1BQU0sa0JBQWtCLENBQUMiLCJzb3VyY2VzQ29udGVudCI6WyJleHBvcnQge1xuICBFbnRpdHlDb25mbGljdEVycm9yLFxuICBIb29rQ29uZmxpY3RFcnJvcixcbiAgSG9va0ZvcmNlQ2xhaW1lZEVycm9yLFxuICBIb29rTm90Rm91bmRFcnJvcixcbiAgUHJlY29uZGl0aW9uRmFpbGVkRXJyb3IsXG4gIFJ1bkV4cGlyZWRFcnJvcixcbiAgUnVuTm90U3VwcG9ydGVkRXJyb3IsXG4gIFN0ZXBOb3RSZWdpc3RlcmVkRXJyb3IsXG4gIFN0cmVhbUVycm9yLFxuICBUaHJvdHRsZUVycm9yLFxuICBUb29FYXJseUVycm9yLFxuICBXb3JrZmxvd0Vycm9yLFxuICBXb3JrZmxvd05vdFJlZ2lzdGVyZWRFcnJvcixcbiAgV29ya2Zsb3dSdW5DYW5jZWxsZWRFcnJvcixcbiAgV29ya2Zsb3dSdW5GYWlsZWRFcnJvcixcbiAgV29ya2Zsb3dSdW5Ob3RDb21wbGV0ZWRFcnJvcixcbiAgV29ya2Zsb3dSdW5Ob3RGb3VuZEVycm9yLFxuICBXb3JrZmxvd1J1bnRpbWVFcnJvcixcbiAgV29ya2Zsb3dXb3JsZEVycm9yLFxufSBmcm9tICdAd29ya2Zsb3cvZXJyb3JzJztcbiJdfQ==
|
|
@@ -143,6 +143,35 @@ async function processOrder(orderId: string) {
|
|
|
143
143
|
|
|
144
144
|
Because `createHook()` alone does not suspend the workflow, awaiting `hook.getConflict()` is what actually suspends the run and commits the hook registration. It only waits for registration. To receive payload data from a future `resumeHook()` call, await the hook itself or iterate it with `for await...of`.
|
|
145
145
|
|
|
146
|
+
### Registering a hook before a step uses it
|
|
147
|
+
|
|
148
|
+
A hook's registration is committed alongside everything else the workflow started before it suspended, not ahead of it. When a workflow creates a hook and calls a step without awaiting anything in between, the step can start running before the hook is registered, and it can run even if the registration turns out to conflict. That matters in two cases:
|
|
149
|
+
|
|
150
|
+
- The step hands the token to something that may call `resumeHook()` right away, which throws `HookNotFoundError` until the hook exists.
|
|
151
|
+
- The hook guards against duplicate runs. A run that only learns of the conflict after calling the step, for example by awaiting the hook and letting `HookConflictError` end the run, may already have started that step.
|
|
152
|
+
|
|
153
|
+
In either case, await `hook.getConflict()` before calling the step:
|
|
154
|
+
|
|
155
|
+
```typescript lineNumbers
|
|
156
|
+
import { createHook } from "workflow";
|
|
157
|
+
|
|
158
|
+
declare function requestApproval(token: string): Promise<void>; // @setup
|
|
159
|
+
|
|
160
|
+
async function approvalWorkflow() {
|
|
161
|
+
"use workflow";
|
|
162
|
+
|
|
163
|
+
using hook = createHook<{ approved: boolean }>();
|
|
164
|
+
await hook.getConflict(); // [!code highlight]
|
|
165
|
+
|
|
166
|
+
// The hook is registered, so an approver that resumes it immediately
|
|
167
|
+
// finds it.
|
|
168
|
+
await requestApproval(hook.token);
|
|
169
|
+
|
|
170
|
+
const { approved } = await hook;
|
|
171
|
+
return approved;
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
146
175
|
On a conflict, the resolved value is a `Run` handle for the run that owns the token, with durable step-backed accessors. The duplicate run can decide in code how to handle it: return or log `conflict.runId`, inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` and continue in the current run. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies in context.
|
|
147
176
|
|
|
148
177
|
<Callout type="info">
|
|
@@ -190,6 +219,54 @@ After the workflow ends, [`getHookByToken()`](/docs/api-reference/workflow-api/g
|
|
|
190
219
|
This option is experimental. Worlds can limit how long tokens are retained; see [World configuration](/docs/configuration/worlds) for each World's limit. If the configured World does not support minimum retention, the workflow fails when registering the Hook. `createWebhook()` does not accept this option.
|
|
191
220
|
</Callout>
|
|
192
221
|
|
|
222
|
+
### Take over a token another run holds
|
|
223
|
+
|
|
224
|
+
By default, a token that another active run already registered makes the new Hook reject with [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error). Set `experimental_force` when the newest run should own the token instead, for example when a fresh deployment or a restarted conversation must replace a run that is still waiting:
|
|
225
|
+
|
|
226
|
+
```typescript lineNumbers
|
|
227
|
+
import { createHook } from "workflow";
|
|
228
|
+
|
|
229
|
+
declare function processMessage(message: SlackMessage): Promise<void>; // @setup
|
|
230
|
+
type SlackMessage = { text: string }; // @setup
|
|
231
|
+
|
|
232
|
+
export async function slackChannelWorkflow(channelId: string) {
|
|
233
|
+
"use workflow";
|
|
234
|
+
|
|
235
|
+
// Whichever run for this channel started most recently owns the token.
|
|
236
|
+
const hook = createHook<SlackMessage>({ // [!code highlight]
|
|
237
|
+
token: `slack_messages:${channelId}`, // [!code highlight]
|
|
238
|
+
experimental_force: true, // [!code highlight]
|
|
239
|
+
}); // [!code highlight]
|
|
240
|
+
|
|
241
|
+
for await (const message of hook) {
|
|
242
|
+
await processMessage(message);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
With `experimental_force`, this run always ends up owning the token:
|
|
248
|
+
|
|
249
|
+
- The previous owner's Hook is disposed, recorded in that run's event log, and the previous owner is woken. If it was awaiting the Hook, that `await` rejects with [`HookForceClaimedError`](/docs/api-reference/workflow-errors/hook-force-claimed-error), which names the run that took the token. Payloads it received before the takeover stay with it; a `for await...of` loop drains them before it throws.
|
|
250
|
+
- Every [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) for the token from then on reaches this run, including a call that was already in flight when the takeover happened. Callers never see the token move; a delivery aimed at the previous owner is redirected to this run inside `resumeHook()`.
|
|
251
|
+
- Any number of runs forcing the same token at the same time converge on a single owner. The takeovers form a chain: each run that loses the token gets `HookForceClaimedError`, exactly one run ends up owning it, and none of them can get stuck. Which run wins among simultaneous claimers is not defined; if the order matters, start them in order.
|
|
252
|
+
- A finished run that still holds the token under [`experimental_minRetention`](#keep-a-token-unavailable-after-the-run-ends) is taken over silently, since there is nothing left to wake. A run can also take over a token held by its own earlier Hook.
|
|
253
|
+
|
|
254
|
+
The takeover is durable. If either run's compute fails partway through, the next request for the token completes it, so the token never ends up held by nobody or by both runs. The previous owner's wake is durable too: if the new owner's compute fails between registering the Hook and waking the previous owner, the new owner's next invocation republishes the wake, whatever else the new owner has recorded since (a step it started alongside the Hook, for example). Every invocation of the new owner within 24 hours of the takeover republishes it under the same idempotency key, which collapses the repeats into one wake; a repeat that does get through only replays the previous owner, which finds nothing new.
|
|
255
|
+
|
|
256
|
+
<Callout type="info">
|
|
257
|
+
A token can only be taken from a run whose runtime understands being taken from. Runs started at a Workflow spec version below 8, which includes every run started by an older SDK release, a Python SDK run, or a deployment with `WORKFLOW_SEALED_LOG=0`, would never learn that their Hook was disposed. The World declines to take their token and the forced Hook rejects with the ordinary [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error) instead, exactly as if `experimental_force` had not been set. Finished runs holding a retained token are taken over at any version.
|
|
258
|
+
</Callout>
|
|
259
|
+
|
|
260
|
+
`hook.getConflict()` on a forced Hook resolves with `null` once the takeover succeeds: the token is this run's by construction. If the World declines the takeover because the current owner predates spec version 8, `getConflict()` behaves as it does for an ordinary conflict and resolves with that owner's `Run`. Read `hook.claimedFrom` on the value returned by [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to find out which run, if any, the token was taken from.
|
|
261
|
+
|
|
262
|
+
<Callout type="warn">
|
|
263
|
+
This option is experimental. It requires an explicit `token` (a generated token can never conflict) and is not accepted by `createWebhook()`. If the configured World does not support force-claiming at all, the workflow fails when registering the Hook; a World that declines a specific takeover because the current owner cannot be woken answers with `HookConflictError` instead.
|
|
264
|
+
|
|
265
|
+
Senders on an older SDK release are not redirected. A `resumeHook()` from a deployment that predates this option and that looked the token up inside the short handoff window gets an error (`EntityConflictError`) instead of following the token; the payload is refused, never delivered to the wrong run, and a retry resolves the new owner. Upgrade the sending deployment for the transparent redirect.
|
|
266
|
+
|
|
267
|
+
Webhook requests are not redirected either. [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) streams the request body once, and buffering a copy of every webhook body on the chance that its token is being taken over at that moment would be a cost paid by everyone who never uses this option. A webhook delivery that lands inside the handoff window fails with a retryable error naming the takeover; nothing is delivered anywhere, and the sender's retry reaches the new owner. A forced `createHook()` can take over a token that a webhook holds, but the token then belongs to a Hook that is not a webhook, so `resumeWebhook()` answers every later request for it as not found, exactly as it does for any `createHook()` token, and never redirects one into it.
|
|
268
|
+
</Callout>
|
|
269
|
+
|
|
193
270
|
### Waiting for multiple payloads
|
|
194
271
|
|
|
195
272
|
You can also wait for multiple payloads by using the `for await...of` syntax.
|
|
@@ -55,7 +55,7 @@ The returned `Webhook` object has:
|
|
|
55
55
|
|
|
56
56
|
- `url`: The HTTP endpoint URL that external systems can call
|
|
57
57
|
- `token`: The unique token identifying this webhook
|
|
58
|
-
- `getConflict()`: A promise that resolves with the conflicting run if another active hook already owns this token, or `null` once the webhook endpoint has been registered
|
|
58
|
+
- `getConflict()`: A promise that resolves with the conflicting run if another active hook already owns this token, or `null` once the webhook endpoint has been registered. The endpoint is registered alongside the steps the workflow starts at the same time, not ahead of them, so await `getConflict()` before a step that hands `url` to a caller who may request it right away. See [Registering a hook before a step uses it](/docs/api-reference/workflow/create-hook#registering-a-hook-before-a-step-uses-it).
|
|
59
59
|
- Implements `AsyncIterable<T>` for handling multiple requests, where `T` is `Request` (default) or `RequestWithResponse` (manual mode)
|
|
60
60
|
|
|
61
61
|
When using `createWebhook({ respondWith: 'manual' })`, the resolved request type is `RequestWithResponse`, which extends the standard `Request` interface with a `respondWith(response: Response): Promise<void>` method for sending custom responses back to the caller.
|
|
@@ -211,6 +211,8 @@ export async function slackBotWorkflow(channelId: string) {
|
|
|
211
211
|
}
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
+
`create()` accepts the same options as `createHook()`. If a newer run should replace one that still holds the token, pass [`experimental_force: true`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) to take the token over instead of getting [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error).
|
|
215
|
+
|
|
214
216
|
## Related functions
|
|
215
217
|
|
|
216
218
|
- [`createHook()`](/docs/api-reference/workflow/create-hook): Create a hook in a workflow.
|
|
@@ -13,6 +13,8 @@ Retrieves a hook by its unique token, returning the associated workflow run info
|
|
|
13
13
|
|
|
14
14
|
When `experimental_minRetention` is set, this function continues to return the Hook after its workflow ends until retention ends. That Hook cannot be resumed. Use `getRun(hook.runId)` to inspect the finished run.
|
|
15
15
|
|
|
16
|
+
When the Hook was created with [`experimental_force`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) and took its token from another run, `hook.claimedFrom` names that run and its Hook. A lookup always follows the token to its current owner, so the same token returns the new Hook as soon as the takeover happens.
|
|
17
|
+
|
|
16
18
|
<Callout type="warn">
|
|
17
19
|
`getHookByToken` is a runtime function that must be called from outside a workflow function.
|
|
18
20
|
</Callout>
|
|
@@ -49,11 +49,11 @@ showSections={["parameters"]}
|
|
|
49
49
|
|
|
50
50
|
### Returns
|
|
51
51
|
|
|
52
|
-
Returns a function that unregisters these hooks.
|
|
52
|
+
Returns a function that unregisters these hooks. Registrations are not deduplicated. Register each hook set once per process, and unregister the previous hooks before registering again during hot reload or module re-evaluation.
|
|
53
53
|
|
|
54
54
|
## Handlers
|
|
55
55
|
|
|
56
|
-
Both handlers receive a `workflowName` string and a lazily hydrated [`Run`](/docs/api-reference/workflow-api/get-run) instance. Use the `workflowName` parameter to filter without a backend read; `run.runId` also requires no read. Accessors such as `run.workflowName`, `run.status`, and `run.returnValue` still fetch from the backend when used. Lazy access defers those reads rather than eliminating them.
|
|
56
|
+
Both handlers receive a `workflowName` string and a lazily hydrated [`Run`](/docs/api-reference/workflow-api/get-run) instance. Use the `workflowName` parameter to filter without a backend read; `run.runId` also requires no read. Accessors such as `run.workflowName`, `run.status`, and `run.returnValue` still fetch from the backend when used. In particular, `workflowName` is a string, while `run.workflowName` is a `Promise<string>`. Lazy access defers those reads rather than eliminating them.
|
|
57
57
|
|
|
58
58
|
### `onRunCompleted`
|
|
59
59
|
|
|
@@ -72,9 +72,11 @@ Invoked when a workflow run fails terminally (after any retries).
|
|
|
72
72
|
| --- | --- | --- |
|
|
73
73
|
| `params.run` | `Run` | The failed run. |
|
|
74
74
|
| `params.workflowName` | `string` | The machine-readable workflow identifier, such as `workflow//./src/workflows/order//processOrder`. Available without a backend read. |
|
|
75
|
-
| `params.error` | `WorkflowRunFailedError` | The failure
|
|
75
|
+
| `params.error` | `WorkflowRunFailedError` | The persisted failure hydrated for reporting: `error.errorCode` carries the classification (e.g. `USER_ERROR`) and `error.cause` is the hydrated thrown value. |
|
|
76
76
|
|
|
77
|
-
`error.cause`
|
|
77
|
+
Unlike `run.returnValue`, `error.cause` defers readable stream I/O until consumption and revives abort signals as persisted snapshots without live subscriptions. Writable streams retain their normal forwarding pipe and lock-polling setup during hydration. If hydration fails, the cause is a generic `Error`, matching `run.returnValue`'s fallback. In `onRunFailed`, `run.returnValue` rejects because the run failed. Use `error.cause` to inspect or report the thrown value instead.
|
|
78
|
+
|
|
79
|
+
The invocation's `waitUntil` scope includes background stream operations from the hydrated cause, even after a handler returns or throws. Close or release stream reader and writer locks when finished so that work can settle. Await other asynchronous reporting work in your handler to keep it in the same lifetime scope.
|
|
78
80
|
|
|
79
81
|
## Behavior
|
|
80
82
|
|
|
@@ -59,6 +59,7 @@ Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow-
|
|
|
59
59
|
* Each call to `start()` creates a new workflow run. If retried requests must route to one active workflow, have the workflow create a deterministic hook token and use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to reuse an already-registered active hook. The lookup is not atomic with `start()`, so concurrent callers can still create extra runs before the hook is registered. Handle that race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work. On a conflict, it resolves with the run that owns the token, so the duplicate can return the active owner to the caller. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
60
60
|
* All arguments must be [serializable](/docs/foundations/serialization).
|
|
61
61
|
* When you provide `deploymentId`, the argument types and return type become `unknown` because the workflow function's types may differ across deployments.
|
|
62
|
+
* When `deploymentId` names a deployment other than the caller's, the run is stamped with the spec version the *target* deployment reports on its capability probe (capped at the caller's own), since the target is what executes it. `start()` waits up to 10 seconds for the first probe to a deployment and returns as soon as the target answers; later starts to the same deployment reuse the answer. If the target does not answer in time, the run is stamped with spec version 6, the lowest version a v5 runtime executes; a target on an older major version (such as `stable`) cannot execute such a run, so it logs a warning. In either case, the `attributes` and `experimental_retention` checks below apply to the stamped version, and fail naming the target deployment.
|
|
62
63
|
* `attributes` seeds plaintext run metadata as part of creation and requires a World implementing spec version 4 or later. Keys that start with `$` are reserved for framework and library code; framework-level callers can pass `allowReservedAttributes: true` to seed reserved keys, with the same semantics as the [`setAttributes`](/docs/api-reference/workflow/set-attributes) option of the same name.
|
|
63
64
|
* `region` pins the new run to a specific region on Worlds with a regional dimension. The [Vercel World](/worlds/vercel#explicit-region-selection) then serves the run's storage, queue dispatch, and streams from that region. When you omit `region`, the run is pinned to the region where it was created. Worlds without regions ignore the option.
|
|
64
65
|
* `experimental_retention` asks the World to delete the run's user data as soon as the run completes or fails, instead of keeping it for the World's default window. `0` requests immediate deletion; `'default'` is identical to omitting the option. These are the only two values accepted — the value is a duration and zero is the only one implemented, and its unit is not yet decided. Recorded as the reserved `$retention` attribute, so it needs a World implementing spec version 4 or later. Retention is enforced by the World, not the SDK: the first-party Worlds implement it and a World that does not keeps the data. Note that `await run.returnValue` on a run started with `experimental_retention: 0` usually throws [`RunExpiredError`](/docs/errors/run-expired) rather than resolving, because the deletion races the read. See [Data retention](/docs/observability/retention).
|
|
@@ -9,7 +9,7 @@ related:
|
|
|
9
9
|
- /docs/errors/hook-conflict
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
`HookConflictError` is thrown when creating a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows. See the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies.
|
|
12
|
+
`HookConflictError` is thrown when creating a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows. See the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies. To take the token over instead, create the hook with [`experimental_force: true`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds).
|
|
13
13
|
|
|
14
14
|
```typescript lineNumbers
|
|
15
15
|
import { HookConflictError } from "workflow/errors"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: HookForceClaimedError
|
|
3
|
+
description: Thrown to a hook's awaiters when another workflow run takes its token over with experimental_force.
|
|
4
|
+
type: reference
|
|
5
|
+
summary: Catch HookForceClaimedError when a run created with experimental_force took a hook token this run was waiting on.
|
|
6
|
+
related:
|
|
7
|
+
- /docs/api-reference/workflow/create-hook
|
|
8
|
+
- /docs/foundations/hooks
|
|
9
|
+
- /docs/errors/hook-force-claimed
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
`HookForceClaimedError` is thrown when awaiting a hook whose token another workflow run took over with [`createHook({ experimental_force: true })`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). The hook is disposed and receives no further payloads; the error names the run that now owns the token. See the [hook-force-claimed](/docs/errors/hook-force-claimed) error guide for how to react.
|
|
13
|
+
|
|
14
|
+
```typescript lineNumbers
|
|
15
|
+
import { createHook } from "workflow";
|
|
16
|
+
import { HookForceClaimedError } from "workflow/errors";
|
|
17
|
+
|
|
18
|
+
export async function channelWorkflow(channelId: string) {
|
|
19
|
+
"use workflow";
|
|
20
|
+
|
|
21
|
+
const hook = createHook<{ text: string }>({ token: `channel:${channelId}` });
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const message = await hook;
|
|
25
|
+
return { message };
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (HookForceClaimedError.is(error)) { // [!code highlight]
|
|
28
|
+
console.log(
|
|
29
|
+
`Token "${error.token}" now belongs to run ${error.claimedByRunId}`
|
|
30
|
+
);
|
|
31
|
+
return { replacedBy: error.claimedByRunId };
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## API signature
|
|
39
|
+
|
|
40
|
+
### Properties
|
|
41
|
+
|
|
42
|
+
<TSDoc
|
|
43
|
+
definition={`
|
|
44
|
+
interface HookForceClaimedError {
|
|
45
|
+
/** The hook token that was taken over. */
|
|
46
|
+
token: string;
|
|
47
|
+
/** The run that took the token. */
|
|
48
|
+
claimedByRunId: string;
|
|
49
|
+
/** The hook in that run the token now belongs to, when known. */
|
|
50
|
+
claimedByHookId?: string;
|
|
51
|
+
/** The error message. */
|
|
52
|
+
message: string;
|
|
53
|
+
}
|
|
54
|
+
export default HookForceClaimedError;`}
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
### Static methods
|
|
58
|
+
|
|
59
|
+
#### `HookForceClaimedError.is(value)`
|
|
60
|
+
|
|
61
|
+
Type-safe check for `HookForceClaimedError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts.
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { HookForceClaimedError } from "workflow/errors"
|
|
65
|
+
declare const error: unknown; // @setup
|
|
66
|
+
|
|
67
|
+
if (HookForceClaimedError.is(error)) {
|
|
68
|
+
// error is typed as HookForceClaimedError
|
|
69
|
+
}
|
|
70
|
+
```
|
|
@@ -68,6 +68,9 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow
|
|
|
68
68
|
<Card href="/docs/api-reference/workflow-errors/hook-conflict-error" title="HookConflictError">
|
|
69
69
|
Thrown when creating a hook with a token that is already in use by another workflow run.
|
|
70
70
|
</Card>
|
|
71
|
+
<Card href="/docs/api-reference/workflow-errors/hook-force-claimed-error" title="HookForceClaimedError">
|
|
72
|
+
Thrown to a hook's awaiters when another run takes its token over with `experimental_force`.
|
|
73
|
+
</Card>
|
|
71
74
|
</Cards>
|
|
72
75
|
|
|
73
76
|
## Backend errors
|
|
@@ -12,6 +12,8 @@ related:
|
|
|
12
12
|
|
|
13
13
|
You can check for cancellation before awaiting by inspecting `run.status`.
|
|
14
14
|
|
|
15
|
+
A canceled run is terminal, so this error is non-retryable (`fatal: true`). Inside a workflow, `await run.returnValue` runs as a step, and that step fails on its first attempt instead of spending its retry budget re-reading a run that cannot change. Errors from *failing to read* the run, such as a transport blip, stay retryable.
|
|
16
|
+
|
|
15
17
|
```typescript lineNumbers
|
|
16
18
|
import { WorkflowRunCancelledError } from "workflow/errors"
|
|
17
19
|
declare const run: { status: Promise<string>; returnValue: Promise<any> }; // @setup
|
|
@@ -34,6 +36,11 @@ definition={`
|
|
|
34
36
|
interface WorkflowRunCancelledError {
|
|
35
37
|
/** The ID of the canceled run. */
|
|
36
38
|
runId: string;
|
|
39
|
+
/**
|
|
40
|
+
* Always \`true\`. A canceled run is terminal, so a step that reads one is
|
|
41
|
+
* not retried.
|
|
42
|
+
*/
|
|
43
|
+
fatal: true;
|
|
37
44
|
/** The error message. */
|
|
38
45
|
message: string;
|
|
39
46
|
}
|
|
@@ -13,6 +13,8 @@ related:
|
|
|
13
13
|
|
|
14
14
|
The `cause` property holds the original thrown value, hydrated through the workflow serialization pipeline so its type identity (e.g. `FatalError`, `RetryableError`, custom `Error` subclasses), `cause` chain, and custom properties are preserved. Because any JavaScript value can be thrown, `cause` is typed as `unknown`, so narrow it with `instanceof Error` (or a more specific check) before accessing fields like `message`. The high-level error classification is exposed as the top-level `errorCode` property.
|
|
15
15
|
|
|
16
|
+
A failed run is terminal, so this error is non-retryable (`fatal: true`). Inside a workflow, `await run.returnValue` runs as a step, and that step fails on its first attempt instead of spending its retry budget re-reading a run that cannot change: the remote failure reaches the caller immediately, and the caller catches a `WorkflowRunFailedError` rather than a retry-exhaustion wrapper. Errors from *failing to read* the run, such as a transport blip, stay retryable.
|
|
17
|
+
|
|
16
18
|
```typescript lineNumbers
|
|
17
19
|
import { WorkflowRunFailedError } from "workflow/errors"
|
|
18
20
|
declare const run: { status: Promise<string>; returnValue: Promise<any> }; // @setup
|
|
@@ -50,6 +52,11 @@ interface WorkflowRunFailedError {
|
|
|
50
52
|
cause: unknown;
|
|
51
53
|
/** The high-level error category (e.g. \`USER_ERROR\`, \`RUNTIME_ERROR\`). */
|
|
52
54
|
errorCode?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Always \`true\`. A failed run is terminal, so a step that reads one is not
|
|
57
|
+
* retried.
|
|
58
|
+
*/
|
|
59
|
+
fatal: true;
|
|
53
60
|
/** The error message. */
|
|
54
61
|
message: string;
|
|
55
62
|
}
|
|
@@ -23,6 +23,7 @@ keywords:
|
|
|
23
23
|
- Event
|
|
24
24
|
- cursor pagination
|
|
25
25
|
- resolveData
|
|
26
|
+
- skip-step-inputs
|
|
26
27
|
- run_cancelled
|
|
27
28
|
- correlation ID
|
|
28
29
|
- parseStepName
|
|
@@ -94,7 +95,7 @@ const result = await world.events.list({ runId, pagination: { cursor } }); // [!
|
|
|
94
95
|
| `params.pagination.cursor` | `string` | Cursor for the next page |
|
|
95
96
|
| `params.pagination.limit` | `number` | Maximum events to return. When omitted, returns every remaining event up to the World's event ceiling. |
|
|
96
97
|
| `params.pagination.sortOrder` | `"asc" \| "desc"` | Event order |
|
|
97
|
-
| `params.resolveData` | `"all" \| "none"` | Include or omit event payload data |
|
|
98
|
+
| `params.resolveData` | `"all" \| "none" \| "skip-step-inputs"` | Include or omit event payload data. `"skip-step-inputs"` is `"all"` without the `input` of `step_created` and `step_started` events, which replay does not read. |
|
|
98
99
|
|
|
99
100
|
**Returns:** `{ data: Event[], cursor: string | null, hasMore: boolean }`
|
|
100
101
|
|
|
@@ -359,6 +360,7 @@ const result = await world.hooks.list({ // [!code highlight]
|
|
|
359
360
|
| `environment` | `string` | Deployment environment |
|
|
360
361
|
| `metadata` | `object` | Custom metadata attached to the hook |
|
|
361
362
|
| `isWebhook` | `boolean` | Whether this is a webhook-style hook |
|
|
363
|
+
| `claimedFrom` | `{ runId: string; hookId: string } \| undefined` | Set when this hook took its token from another run with [`experimental_force`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). Names that run and its hook |
|
|
362
364
|
|
|
363
365
|
---
|
|
364
366
|
|
|
@@ -62,11 +62,11 @@ The contract:
|
|
|
62
62
|
|
|
63
63
|
## The runtime integration (suspension fan-out fold)
|
|
64
64
|
|
|
65
|
-
**On by default.** The suspension handler folds a **clean fan-out** (the suspension's eager `step_created` and `wait_created` writes) into `createBatch` calls of at most 32 events (mirroring the server's transaction budgets). Chunks of a larger fan-out commit **concurrently**: slot assignment is the World's, so parallel chunks race for slot ranges exactly like the pre-fold path's parallel single writes did, and per-entity conditions, not commit order, carry correctness. The fold only engages when the World implements `createBatch`, the run is on slot identity, and the suspension carries no attribute writes
|
|
65
|
+
**On by default.** The suspension handler folds a **clean fan-out** (the suspension's eager `step_created` and `wait_created` writes) into `createBatch` calls of at most 32 events (mirroring the server's transaction budgets). Chunks of a larger fan-out commit **concurrently**: slot assignment is the World's, so parallel chunks race for slot ranges exactly like the pre-fold path's parallel single writes did, and per-entity conditions, not commit order, carry correctness. The fold only engages when the World implements `createBatch`, the run is on slot identity, and the suspension carries no attribute writes and no resilient step dispatch; everything else keeps the single-event path byte-for-byte. A suspension that also creates or disposes hooks still folds: hook writes are not batchable, so they go through the single-event path **concurrently** with the fold rather than ahead of it.
|
|
66
66
|
|
|
67
67
|
**Per-chunk continuation.** Each chunk's follow-on work starts the moment **that chunk** commits, not when the whole fold does: a chunk's step-execution queue messages publish right off its own commit (publish-after-create holds per step), and only the chunk carrying the inline pairs gates the replay's continuation: trailing chunks' commits and publishes are joined before the invocation can acknowledge its message, so the durability contract ("every create durable before ack") is unchanged.
|
|
68
68
|
|
|
69
|
-
**Pre-claimed inline pairs.** When the fold engages with at least two inline steps, the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The pairs commit in a chunk of their own, ahead of the plain `step_created` and `wait_created` chunks, so the write the inline bodies wait for carries only two rows per inline step (a small transaction that commits faster than a full 32-event chunk) while the plain creates commit concurrently beside it. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and the sibling chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step keeps the optimistic lazy-start path (one row, whose claim overlaps the body) even when eager creates batch beside it: the pairs share no round trip with those creates, so only two or more inline steps make a pair chunk worth the trade. A plain partition of exactly one `step_created` or `wait_created` beside the pairs is written through the ordinary single path rather than a one-row batch, and its queue message still waits for that write.
|
|
69
|
+
**Pre-claimed inline pairs.** When the fold engages with at least two inline steps, the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The pairs commit in a chunk of their own, ahead of the plain `step_created` and `wait_created` chunks, so the write the inline bodies wait for carries only two rows per inline step (a small transaction that commits faster than a full 32-event chunk) while the plain creates commit concurrently beside it. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and the sibling chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step keeps the optimistic lazy-start path (one row, whose claim overlaps the body) even when eager creates batch beside it: the pairs share no round trip with those creates, so only two or more inline steps make a pair chunk worth the trade. The exception is a lone inline step in a suspension that creates a hook: the runtime never starts a body before its claim settles while a hook is being created, and a lazy claim could only be sent after the hook write committed, so the step's pair is folded instead and its claim commits concurrently with the hook write. A plain partition of exactly one `step_created` or `wait_created` beside the pairs is written through the ordinary single path rather than a one-row batch, and its queue message still waits for that write.
|
|
70
70
|
|
|
71
71
|
Per-event `409`s are tolerated the same way the single path tolerates `EntityConflictError` (a concurrent delivery already created the entity); any other per-event failure fails the suspension write the way a single-path rejection would. A batch carrying a `step_started` (that is, any batch with inline pairs) is **not** retried in-process on a transport blip: a pair's `409` cannot be told apart from the caller's own earlier attempt having committed it, so recovery goes through queue redelivery instead, where the step's ownership stamp routes it back to the same invocation.
|
|
72
72
|
|
|
@@ -73,7 +73,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
|
|
|
73
73
|
- Default: `25000`
|
|
74
74
|
- Positive-integer event limit reported by the Local World and enforced by the runtime as `MAX_EVENTS_EXCEEDED`.
|
|
75
75
|
- The Local and Postgres Worlds also use it as the maximum number of events returned when `events.list()` is called without a limit. If more events exist, the response includes `hasMore: true` and a continuation cursor.
|
|
76
|
-
- The Vercel World receives its event limit from the service; this environment variable does not override that service-owned value.
|
|
76
|
+
- The Vercel World receives its event limit from the service; this environment variable does not override that service-owned value. See [Vercel World limits](/worlds/vercel#per-run-limits).
|
|
77
77
|
- Invalid or non-positive values fall back to the default.
|
|
78
78
|
|
|
79
79
|
### `WORKFLOW_REPLAY_DIVERGENCE_MAX_RETRIES`
|
|
@@ -98,6 +98,14 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
|
|
|
98
98
|
- Producer-side recoveries are reported on the suspension span as `workflow.step.resilient_dispatch_recovered`; a consumer that materialized the event reports `workflow.step.resilient_dispatch_materialized`.
|
|
99
99
|
- Set `1` to enable it.
|
|
100
100
|
|
|
101
|
+
### `WORKFLOW_OPEN_WAIT_CLOCK_SKEW_MS`
|
|
102
|
+
|
|
103
|
+
- Default: `30000` (30 seconds)
|
|
104
|
+
- A pending wait (a `sleep()` whose timer has not fired, including one that lost a `Promise.race()` against a hook) keeps the per-step event-log delta optimization off only if it can fire while the current invocation is still running steps inline: its target time must fall before the end of the invocation's inline window ([`WORKFLOW_V2_TIMEOUT_MS`](#workflow_v2_timeout_ms), which is otherwise derived from the platform's function deadline and tops out at 10 minutes) plus this allowance for clock skew between the runtime and the wait timer. A wait due later than that does not cost the extra `events.list()` per step. A `wait_created` whose target time cannot be read is treated as due now.
|
|
105
|
+
- Before an invocation parks on a wait over a log it last extended from a step's inline delta, it re-reads the log once, so a wait completed early through `run.wakeUp()` (the dashboard's "cancel sleeps" action) is acted on then rather than when the wait's own timer would have fired.
|
|
106
|
+
- Turbo's forced optimistic start is unaffected: any open wait keeps it off, whatever the target time.
|
|
107
|
+
- Must be a finite integer. `31536000000` (one year) restores the older behavior of gating on any pending wait; `Infinity` is rejected and falls back to the default. Open hooks are unaffected: they gate regardless of this setting.
|
|
108
|
+
|
|
101
109
|
### Stale reads, and why nothing has to be rejected
|
|
102
110
|
|
|
103
111
|
- Not a variable: this is how a replay working from an out-of-date event log stays correct, and why no World needs a precondition guard to make it so.
|
|
@@ -121,6 +129,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
|
|
|
121
129
|
- New runs are created at the sealed-log spec version, in which the World's backend assigns each event its position *before* the write commits rather than letting concurrent writers race for one. Concurrent writes then never contend for a position, which is what makes a wide fan-out cheap.
|
|
122
130
|
- The price of assigning positions in advance is that a writer which claims one and then dies leaves a position no writer will ever fill. The backend closes such a position by writing a `noop` event into it once it can prove the position was abandoned, so a reader still sees the dense log it needs. Replay steps over a `noop` without delivering it to the workflow or advancing the deterministic clock. Its timestamp belongs to whichever reader sealed it, not to the run.
|
|
123
131
|
- Set `0` to put a deployment back on the previous scheme, where each position is allocated by the write that occupies it. Use this as the kill switch if position assignment turns out to be at fault for event-log problems.
|
|
132
|
+
- Setting `0` also stamps new runs below spec version 8, so another run can't take their hook tokens over with [`experimental_force`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). A forced hook in another run gets `HookConflictError` instead. Runs created with `0` can still take tokens over from others.
|
|
124
133
|
- Existing runs are unaffected either way. A run's spec version is stamped once, at creation, and read from the run for the rest of its life, so flipping this changes only what *new* runs get, and a run in flight keeps the scheme it started on. Every build reads sealed logs regardless of the setting.
|
|
125
134
|
- A run created at the sealed-log version can only be replayed by a reader that knows to skip `noop` events. That includes every runtime on this release train, but a runtime that pins its own accepted spec range separately, such as the Python runtime, has to catch up before it can read these runs. Switch this off in an environment where it has not.
|
|
126
135
|
- Only the Vercel World seals. The Local and Postgres Worlds allocate each position at the commit that occupies it, so they cannot leave a hole and never write a `noop`; the setting still moves the version they stamp, so the fleet stays on one spec.
|
|
@@ -378,4 +387,4 @@ These variables are primarily for tests, debugging, or unusual deployments.
|
|
|
378
387
|
- Default: unset
|
|
379
388
|
- Lowers the per-run event ceiling supplied by the World. A run whose event log reaches the ceiling fails with `MAX_EVENTS_EXCEEDED`, which stops a runaway loop from growing its log without bound.
|
|
380
389
|
- Clamp-down only: it never raises the World's limit, and it applies even when the World supplies none. With no World limit and no override, nothing is enforced.
|
|
381
|
-
- The Local and Vercel Worlds both supply a limit; the Local World defaults to 25,000 and is configurable with [`WORKFLOW_MAX_EVENTS`](/docs/configuration/worlds#workflow_max_events).
|
|
390
|
+
- The Local and Vercel Worlds both supply a limit; the Local World defaults to 25,000 and is configurable with [`WORKFLOW_MAX_EVENTS`](/docs/configuration/worlds#workflow_max_events). The Vercel World's ceiling is documented under [Workflow run limits](https://vercel.com/docs/workflows/pricing#workflow-run-limits).
|
|
@@ -91,14 +91,14 @@ The Local World is the default outside Vercel and is intended for development.
|
|
|
91
91
|
### `WORKFLOW_LOCAL_HEADERS_TIMEOUT_MS`
|
|
92
92
|
|
|
93
93
|
- Factory option: none
|
|
94
|
-
- Default: `
|
|
95
|
-
- Maximum milliseconds to wait for a local queue handler to begin responding before the durable message is redelivered.
|
|
94
|
+
- Default: `0` (no deadline)
|
|
95
|
+
- Maximum milliseconds to wait for a local queue handler to begin responding before the durable message is redelivered. A value below your longest inline step re-executes that step while it is still running.
|
|
96
96
|
|
|
97
97
|
### `WORKFLOW_LOCAL_BODY_TIMEOUT_MS`
|
|
98
98
|
|
|
99
99
|
- Factory option: none
|
|
100
|
-
- Default: `
|
|
101
|
-
- Maximum gap in milliseconds between response body chunks from a local queue handler before the durable message is redelivered.
|
|
100
|
+
- Default: `0` (no deadline)
|
|
101
|
+
- Maximum gap in milliseconds between response body chunks from a local queue handler before the durable message is redelivered.
|
|
102
102
|
|
|
103
103
|
### `recoverActiveRuns`
|
|
104
104
|
|
|
@@ -304,7 +304,7 @@ Platform-provided values such as `VERCEL_DEPLOYMENT_ID`, `VERCEL_PROJECT_ID`, an
|
|
|
304
304
|
- Default: on
|
|
305
305
|
- Set to `0` (or `false`) to **disable** batched event writes, the escape hatch that restores the exact prior one-write-per-event path.
|
|
306
306
|
|
|
307
|
-
When enabled (the default), a suspension's eager `step_created` and `wait_created` writes fold into batched `events.createBatch` calls (one durable write with per-event outcomes) on Worlds that implement the optional batch API. The fold only engages when the World implements `events.createBatch` (the Vercel World does; Local and Postgres do not), the run's spec version supports slot identity (≥ 6), and the suspension carries no attribute writes
|
|
307
|
+
When enabled (the default), a suspension's eager `step_created` and `wait_created` writes fold into batched `events.createBatch` calls (one durable write with per-event outcomes) on Worlds that implement the optional batch API. The fold only engages when the World implements `events.createBatch` (the Vercel World does; Local and Postgres do not), the run's spec version supports slot identity (≥ 6), and the suspension carries no attribute writes or resilient step dispatch. Hook writes in the same suspension go through the single-event path concurrently with the batch. Everything else keeps the single-event path unchanged, so disabling is only needed as an operational escape hatch. Batches are capped at 32 events; larger fan-outs commit in successive batches. See the [batched event writes changelog](/docs/changelog/batched-event-writes) for the World API contract.
|
|
308
308
|
|
|
309
309
|
### `WORKFLOW_EVENTS_TRANSPORT`
|
|
310
310
|
|
|
@@ -19,8 +19,9 @@ Child workflows are the right choice when:
|
|
|
19
19
|
|
|
20
20
|
- **Work units are independent**: Each child can run without knowing about the others (for example, when processing individual documents or generating separate reports).
|
|
21
21
|
- **You need isolated failure boundaries**: A failing child should not abort unrelated work. The parent decides how to handle failures.
|
|
22
|
-
- **You want large fan-out**: Spawning 50 or 500 children is practical because each runs on its own infrastructure.
|
|
22
|
+
- **You want large fan-out**: Spawning 50 or 500 children is practical because each runs on its own infrastructure. The parent still pays for each spawn, so start them in chunks rather than all at once (see [Chunked spawning](#fan-out-pattern-chunked-spawning)).
|
|
23
23
|
- **You need per-item observability**: Each child workflow has its own run ID, status, and event log for monitoring.
|
|
24
|
+
- **One run would otherwise get too big**: A run's event log and step count are both capped, see [Vercel World limits](/worlds/vercel#per-run-limits). Replay reads the whole log, so split before the cap: a run headed for more than a few thousand events belongs in several runs.
|
|
24
25
|
|
|
25
26
|
For simpler cases where steps share a single event log, use [direct await composition](/cookbook/common-patterns/workflow-composition#direct-await-flattening) instead.
|
|
26
27
|
|
|
@@ -306,6 +307,7 @@ async function startAndWaitWithRetries(
|
|
|
306
307
|
- **Export wrapped children at module scope**: The SDK registers `"use workflow"` functions statically, so a runtime higher-order function returned from `withChildCompletionHook()` cannot be passed to `start()`.
|
|
307
308
|
- **Use stable hook keys**: Document IDs, job IDs, or indexes prevent token collisions between parallel children in one parent run.
|
|
308
309
|
- **Use chunked spawning for large batches**: Starting 500 children at once can create a large burst of work. Break the work into chunks of 10–50.
|
|
310
|
+
- **Watch the parent's log too**: Each child bounds its own log, but the parent records events for spawning and for collecting every child, so the parent's log grows with the number of children. Count those events per child against the parent's own [run limits](/worlds/vercel#per-run-limits); when the parent alone would exceed them, add a layer, so each parent spawns a modest number of intermediate runs that in turn spawn the leaves.
|
|
309
311
|
- **Account for each child's retry semantics**: Steps inside child workflows retry independently. The parent sees the final `{ status, value | error }` payload from the hook.
|
|
310
312
|
- **Use `deploymentId: "latest"` when children should run on the most recent deployment**: See [Versioning](/docs/foundations/versioning) for the full model and the [`start()` API reference](/docs/api-reference/workflow-api/start#using-deploymentid-latest) for compatibility considerations.
|
|
311
313
|
|
|
@@ -171,7 +171,7 @@ To upgrade a fleet of runs after a deployment, list active runs from a tracking
|
|
|
171
171
|
|
|
172
172
|
## How it works
|
|
173
173
|
|
|
174
|
-
1. **`deploymentId: "latest"` is the upgrade knob.** Without it, the spawn pins to the current deployment. With it, the new run resolves to whatever deployment is current when the runtime picks it up, so any shipped fix applies starting from that respawn. Both methods rely on this.
|
|
174
|
+
1. **`deploymentId: "latest"` is the upgrade knob.** Without it, the spawn pins to the current deployment. With it, the new run resolves to whatever deployment is current when the runtime picks it up, so any shipped fix applies starting from that respawn. The respawned run runs at the spec version of the deployment it lands on. Both methods rely on this.
|
|
175
175
|
2. **`start()` runs directly from the workflow body.** In v5, [`start()`](/docs/api-reference/workflow-api/start) is step-backed, so it can be called from a workflow function and still records a deterministic step boundary in the event log, so no manual `"use step"` wrapper is required.
|
|
176
176
|
3. **State carries through the function argument.** The accumulating context flows from run N to run N+1 as a serialized argument. No external store is required for the state itself.
|
|
177
177
|
4. **Per-run hook tokens.** Using `workflowRunId` as the hook token scopes each iteration's wait to its own run, so multiple chains can run concurrently without interfering.
|
|
@@ -17,6 +17,7 @@ Use batching when you need to process a large list of items in parallel while co
|
|
|
17
17
|
- Processing hundreds or thousands of items against external APIs
|
|
18
18
|
- Calling rate-limited APIs where you need to control concurrency
|
|
19
19
|
- Any fan-out where you want failure isolation between groups
|
|
20
|
+
- High-concurrency fan-out, where one flat `Promise.all` over the whole list would put more work in flight than your downstream services or the run's event log should carry at once
|
|
20
21
|
|
|
21
22
|
## How it works
|
|
22
23
|
|
|
@@ -98,6 +99,7 @@ async function processRecord(record: Record): Promise<string> {
|
|
|
98
99
|
|
|
99
100
|
- **Use `Promise.allSettled` instead of `Promise.all`**: Use this pattern when you want to continue even if some items fail. `Promise.all` rejects on the first failure, while `allSettled` waits for everything and identifies failures.
|
|
100
101
|
- **Tune batch size to your downstream API limits**: If the API allows 10 concurrent requests, use `batchSize: 10`.
|
|
102
|
+
- **Batching bounds concurrency, not the run's total size**: Every batch still appends to the same [event log](/docs/how-it-works/event-sourcing#how-fast-a-log-grows), so a long enough list walks one run toward its [run limits](/worlds/vercel#per-run-limits) no matter how small the batches are. To shrink the run itself, bundle more items per step so one step covers many items, or spawn a [child workflow](/cookbook/advanced/child-workflows) per batch. Reach for one of those once a single run would grow past a few thousand events.
|
|
101
103
|
- **Add pacing with `sleep()`**: Add a delay between batches to respect rate limits. The sleep is durable and survives cold starts.
|
|
102
104
|
- **Treat each `processRecord` call as an independent step**: If one call fails, it retries up to three times without affecting other items in the batch.
|
|
103
105
|
|
|
@@ -10,7 +10,7 @@ related:
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
12
|
<CopyPrompt
|
|
13
|
-
text="Compose these workflow steps with standard async/await patterns. In the exported "use workflow" function, chain dependent "use step" calls with sequential `await`; run independent steps concurrently by starting them without `await` and awaiting `Promise.all([...])`; and use `Promise.race([...])` to act on whichever promise settles first. These compose with durable primitives: race a step or a webhook from `createWebhook()` against `sleep()` from `workflow` for deadlines. Keep every step input and output serializable, and remember `Promise.race` does not cancel the losing branch (it keeps running), so side-effectful losers need idempotency keys. Verify sequential ordering, parallel execution, and both race outcomes."
|
|
13
|
+
text="Compose these workflow steps with standard async/await patterns. In the exported "use workflow" function, chain dependent "use step" calls with sequential `await`; run independent steps concurrently by starting them without `await` and awaiting `Promise.all([...])`; and use `Promise.race([...])` to act on whichever promise settles first. These compose with durable primitives: race a step or a webhook from `createWebhook()` against `sleep()` from `workflow` for deadlines. Keep every step input and output serializable, and remember `Promise.race` does not cancel the losing branch (it keeps running), so side-effectful losers need idempotency keys. When the fan-out is wide, consider keeping concurrency bounded by chunking the array into batches or bundling several items per step rather than one flat `Promise.all` over everything. Verify sequential ordering, parallel execution, and both race outcomes."
|
|
14
14
|
/>
|
|
15
15
|
|
|
16
16
|
Workflows are written in plain async/await: there's no new control-flow API to learn. Sequential awaits chain steps that depend on each other, `Promise.all` runs independent steps in parallel, and `Promise.race` returns whichever finishes first. These compose with workflow primitives like [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) since those are also promises.
|
|
@@ -144,7 +144,7 @@ export async function birthdayWorkflow(
|
|
|
144
144
|
## Adapting to your use case
|
|
145
145
|
|
|
146
146
|
- **Replace `Promise.all` with `Promise.allSettled`**: Use this option when partial failures shouldn't abort the remaining operations. You'll get an array of `{ status, value | reason }` instead of an error on the first rejection.
|
|
147
|
-
- **Bound the parallelism**: `Promise.all` over
|
|
147
|
+
- **Bound the parallelism**: `Promise.all` over a large array fans out one concurrent step per item, all in the same run, and each item's events land in that one log. When concurrency gets high, batch the array into chunks or bundle several items into each step (see [Batching](/cookbook/common-patterns/batching)) so fewer, larger units of work are in flight. Splitting a wide fan-out across [child workflows](/cookbook/advanced/child-workflows) keeps each log shorter and isolates failures, but it does not by itself narrow the fan-out. Both the log and the step count are capped per run: see [Vercel World limits](/worlds/vercel#per-run-limits), and split before a run would grow past a few thousand events.
|
|
148
148
|
- **Add a deadline to any race**: Pair the operation with `sleep("30s").then(() => "timeout" as const)` and check the discriminated result. See [Timeouts](/cookbook/common-patterns/timeouts).
|
|
149
149
|
- **Mix steps and hooks in a race**: Wait for an external signal, a deadline, or a step result in the same `Promise.race`. The first promise to resolve wins.
|
|
150
150
|
|
|
@@ -156,6 +156,28 @@ export async function POST(request: Request) {
|
|
|
156
156
|
|
|
157
157
|
If the caller needs live output instead of the final result, return `activeRun.getReadable()` from the same branch. If the duplicate request should replace the active work, call `await activeRun.cancel()` after inspecting the run.
|
|
158
158
|
|
|
159
|
+
### Take the token over
|
|
160
|
+
|
|
161
|
+
If the newest run should always own the token, create the hook with [`experimental_force: true`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). The new run takes the token instead of getting `HookConflictError`, and the previous owner's `await hook` rejects with [`HookForceClaimedError`](/docs/errors/hook-force-claimed).
|
|
162
|
+
|
|
163
|
+
This enables zero-downtime transfers for hooks so one run can hand off a hook to another without dropping messages.
|
|
164
|
+
|
|
165
|
+
```typescript lineNumbers
|
|
166
|
+
import { createHook } from "workflow";
|
|
167
|
+
|
|
168
|
+
export async function processPayment(orderId: string) {
|
|
169
|
+
"use workflow";
|
|
170
|
+
|
|
171
|
+
const hook = createHook({
|
|
172
|
+
token: `payment-${orderId}`,
|
|
173
|
+
experimental_force: true, // [!code highlight]
|
|
174
|
+
});
|
|
175
|
+
const payment = await hook;
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
A run started at a Workflow spec version below 8, including runs started by older SDK releases, can't be taken from, so the forced hook still gets `HookConflictError` in that case.
|
|
180
|
+
|
|
159
181
|
## When hook tokens are released
|
|
160
182
|
|
|
161
183
|
Hook tokens are automatically released when:
|
|
@@ -172,6 +194,7 @@ After a workflow completes, its hook tokens become available for reuse by other
|
|
|
172
194
|
2. **Include unique identifiers** if you need custom tokens (order ID, user ID, etc.)
|
|
173
195
|
3. **Avoid reusing the same token** across multiple concurrent workflow runs
|
|
174
196
|
4. **Consider using webhooks** (`createWebhook`) if you need a fixed, predictable URL that can receive multiple payloads
|
|
197
|
+
5. **Use `experimental_force`** when a newer run should replace the run holding the token
|
|
175
198
|
|
|
176
199
|
## Related
|
|
177
200
|
|