diff --git a/docs/content/docs/v5/api-reference/workflow-api/start.mdx b/docs/content/docs/v5/api-reference/workflow-api/start.mdx index 556e803189..9447e6978b 100644 --- a/docs/content/docs/v5/api-reference/workflow-api/start.mdx +++ b/docs/content/docs/v5/api-reference/workflow-api/start.mdx @@ -56,7 +56,7 @@ Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow- * In v5, `start()` can also be called directly from a workflow function to spawn a child run or continue work in a new run. See [Workflow Composition](/cookbook/common-patterns/workflow-composition) and [Versioning](/docs/foundations/versioning). * This is different from calling workflow functions directly, which is the typical pattern in Next.js applications. * The function returns immediately after enqueuing the workflow - it doesn't wait for the workflow to complete. -* 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). +* Pass a deterministic `hook.token` to atomically prevent duplicate runs from starting. See [Prevent duplicate starts](#prevent-duplicate-starts) and [Idempotency](/docs/foundations/idempotency#run-idempotency). * All arguments must be [serializable](/docs/foundations/serialization). * When `deploymentId` is provided, the argument types and return type become `unknown` since there is no guarantee the workflow function's types will be consistent across different deployments. * `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. @@ -67,6 +67,52 @@ If `start()` throws `'start' received an invalid workflow function. Ensure the W ## Examples +### Prevent duplicate starts + +Use a hook token as an idempotency key. The World reserves the token while it admits the run, before the workflow body executes: + +```typescript lineNumbers +import { start } from "workflow/api"; +import { HookConflictError, WorkflowStartError } from "workflow/errors"; +import { processOrder } from "./workflows/process-order"; + +export async function POST(request: Request) { + const { orderId } = await request.json(); + + try { + const run = await start(processOrder, [orderId], { + hook: { // [!code highlight] + token: `order:${orderId}`, // [!code highlight] + experimental_minRetention: "30 days", // [!code highlight] + }, // [!code highlight] + }); + return Response.json({ runId: run.runId, duplicate: false }); + } catch (error) { + if (HookConflictError.is(error)) { + return Response.json({ + runId: error.conflictingRunId, + duplicate: true, + }); + } + + if (WorkflowStartError.is(error)) { + console.error(`Could not confirm start candidate ${error.runId}`); + } + throw error; + } +} +``` + +Exactly one run can own the token. A duplicate throws [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error) with the owner's run ID, without creating or executing another run. + +`experimental_minRetention` accepts the same duration strings, millisecond numbers, and absolute `Date` values as [`sleep()`](/docs/api-reference/workflow/sleep). A duration starts when `start()` is called. The token remains unavailable until both the run has ended and that time has passed. + +You do not need to call `createHook()` inside the workflow when the token is only an idempotency key. If the workflow also receives data through a Hook, create one with the same token. Its `experimental_minRetention` may extend the original time, but cannot shorten it. + +If the SDK cannot confirm whether the candidate was queued or admitted, `start()` throws [`WorkflowStartError`](/docs/api-reference/workflow-errors/workflow-start-error). Its `runId` identifies that candidate, and its `stage` identifies the failed operation. Retrying the logical request with the same token cannot admit two owners. + +Worlds that do not support atomic start Hooks reject this option before sending the workflow message. + ### With Arguments ```typescript diff --git a/docs/content/docs/v5/api-reference/workflow-errors/hook-conflict-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/hook-conflict-error.mdx index 90467b1864..48e01a96e9 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/hook-conflict-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/hook-conflict-error.mdx @@ -1,6 +1,6 @@ --- title: HookConflictError -description: Thrown when creating a hook with a token that is already in use by another workflow run. +description: Thrown when creating or atomically starting with a Hook token that is already in use by another workflow run. type: reference summary: Catch HookConflictError when a hook token is already claimed by another active workflow run. related: @@ -9,7 +9,9 @@ related: - /docs/errors/hook-conflict --- -`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. +`HookConflictError` is thrown when creating a Hook or calling `start({ hook })` with a token that another workflow run owns. See the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies. + +Atomic `start({ hook })` conflicts always include `conflictingRunId`. It remains optional on the general error type because old persisted Hook conflicts may not contain the owner's run ID. ```typescript lineNumbers import { HookConflictError } from "workflow/errors" diff --git a/docs/content/docs/v5/api-reference/workflow-errors/index.mdx b/docs/content/docs/v5/api-reference/workflow-errors/index.mdx index bd4c061ada..761e8fc861 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/index.mdx @@ -51,6 +51,9 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow Thrown when the workflow runtime encounters an execution error, such as serialization failures or timeouts. + + Thrown when an atomic-start candidate's queue or admission outcome cannot be confirmed. + Thrown when a workflow run has expired and can no longer be operated on. diff --git a/docs/content/docs/v5/api-reference/workflow-errors/meta.json b/docs/content/docs/v5/api-reference/workflow-errors/meta.json index a84eddd1ad..87606787d5 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/meta.json +++ b/docs/content/docs/v5/api-reference/workflow-errors/meta.json @@ -11,6 +11,7 @@ "workflow-run-cancelled-error", "workflow-run-not-completed-error", "workflow-runtime-error", + "workflow-start-error", "workflow-world-error", "throttle-error", "entity-conflict-error", diff --git a/docs/content/docs/v5/api-reference/workflow-errors/workflow-start-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/workflow-start-error.mdx new file mode 100644 index 0000000000..d26defcea8 --- /dev/null +++ b/docs/content/docs/v5/api-reference/workflow-errors/workflow-start-error.mdx @@ -0,0 +1,48 @@ +--- +title: WorkflowStartError +description: Thrown when start with a Hook cannot confirm whether a workflow candidate was queued or admitted. +type: reference +summary: Use WorkflowStartError to identify an atomic-start candidate whose outcome could not be confirmed. +related: + - /docs/api-reference/workflow-api/start + - /docs/foundations/idempotency +--- + +`WorkflowStartError` is thrown when [`start()`](/docs/api-reference/workflow-api/start#prevent-duplicate-starts) uses a Hook token but cannot confirm whether its candidate was queued or admitted. + +```typescript lineNumbers +import { start } from "workflow/api"; +import { WorkflowStartError } from "workflow/errors"; +import { processOrder } from "./workflows/process-order"; +declare const orderId: string; // @setup + +try { + await start(processOrder, [orderId], { + hook: { token: `order:${orderId}` }, + }); +} catch (error) { + if (WorkflowStartError.is(error)) { + console.error(error.runId, error.stage); + } +} +``` + +The candidate might never become a run. Keep `runId` for inspection and retry the logical request with the same Hook token. + +## API Signature + +### Properties + + + +`stage` is `"queue"` when the SDK could not confirm queueing and `"admission"` when the workflow message was queued but the SDK could not confirm admission. + +### Static Methods + +#### `WorkflowStartError.is(value)` + +Type-safe check for `WorkflowStartError` instances across module boundaries and VM contexts. diff --git a/docs/content/docs/v5/api-reference/workflow-errors/workflow-world-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/workflow-world-error.mdx index ac12e75ef3..ad7fbf36ae 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/workflow-world-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/workflow-world-error.mdx @@ -74,6 +74,7 @@ if (WorkflowWorldError.is(error)) { The following error types extend `WorkflowWorldError`: - [`EntityConflictError`](/docs/api-reference/workflow-errors/entity-conflict-error) — operation conflicts with entity state +- [`WorkflowStartError`](/docs/api-reference/workflow-errors/workflow-start-error) — atomic-start queue or admission outcome could not be confirmed - [`RunExpiredError`](/docs/api-reference/workflow-errors/run-expired-error) — run has expired - [`TooEarlyError`](/docs/api-reference/workflow-errors/too-early-error) — request made before system is ready - [`ThrottleError`](/docs/api-reference/workflow-errors/throttle-error) — request was rate-limited diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx index aa9d3f4f5e..cbabcf19be 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx @@ -43,6 +43,18 @@ const world = await getWorld(); // [!code highlight] The World SDK is the low-level foundation that higher-level functions like [`getRun()`](/docs/api-reference/workflow-api/get-run) and [`start()`](/docs/api-reference/workflow-api/start) are built on. Use it when you need capabilities beyond what those functions provide. +## Capabilities + +`world.capabilities` declares optional behavior implemented by the complete World, including its storage and queue runtime. The SDK returns the same object from deployment health checks so `start()` can verify both the calling and target deployments before using a feature. + +A World may advertise `atomicStartHook: { active: true }` only when `events.create()` atomically admits a `run_created` or resilient `run_started` event containing `startHook`: + +- Reserve the token and create the run as one operation. +- If another run owns the token, throw `HookConflictError` with its `conflictingRunId` and create no run or events for the candidate. +- Keep each candidate `runId`'s decision immutable while a queued copy can still arrive. Repeating admission for that candidate must return the same accepted run or the same conflict, even if the token becomes available between attempts. + +This contract lets the direct `start()` request and its queued copy safely race without executing a candidate after the caller was told it lost. + ## Data Hydration Step input/output data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. To display this data in your UI, use the hydration utilities from `workflow/observability`: diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx index 6637882677..63acf71413 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx @@ -64,6 +64,8 @@ await world.events.create(runId, { // [!code highlight] **Returns:** `EventResult` — The created event and the affected entity (run/step/hook) +Worlds that advertise `capabilities.atomicStartHook.active` must apply the [atomic start Hook contract](/docs/api-reference/workflow-runtime/world#capabilities) when `run_created` or resilient `run_started` includes `startHook`. + ### events.get() Retrieve a single event by run ID and event ID. diff --git a/docs/content/docs/v5/foundations/idempotency.mdx b/docs/content/docs/v5/foundations/idempotency.mdx index ed51c86faa..6fac3e352d 100644 --- a/docs/content/docs/v5/foundations/idempotency.mdx +++ b/docs/content/docs/v5/foundations/idempotency.mdx @@ -57,11 +57,43 @@ Why this works: 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? -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. +Use a deterministic [Hook](/docs/foundations/hooks) token from your domain, such as an order ID, invoice ID, import ID, or request ID. -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. +### Atomic admission -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. +Pass the token to `start()`. The World reserves it while admitting the run, so a duplicate is rejected before another run or any duplicate-sensitive work is created: + +```typescript lineNumbers +import { start } from "workflow/api"; +import { HookConflictError } from "workflow/errors"; +import { processOrder } from "./workflows/process-order"; + +export async function POST(request: Request) { + const { orderId } = await request.json(); + + try { + const run = await start(processOrder, [orderId], { + hook: { + token: `order:${orderId}`, // [!code highlight] + experimental_minRetention: "30 days", // [!code highlight] + }, + }); + return Response.json({ runId: run.runId, duplicate: false }); + } catch (error) { + if (!HookConflictError.is(error)) throw error; + return Response.json({ + runId: error.conflictingRunId, + duplicate: true, + }); + } +} +``` + +The workflow does not need to call `createHook()` unless it also receives data through that Hook. See [`start()`](/docs/api-reference/workflow-api/start#prevent-duplicate-starts) for retention and uncertain-start behavior. + +### Workflow-side fallback + +If a World does not support atomic start Hooks, create a Hook near the beginning of the workflow and check `await hook.getConflict()` before duplicate-sensitive work. Calling `createHook()` alone does not register the Hook; awaiting `getConflict()` suspends the workflow to commit its registration. ```typescript lineNumbers import { createHook } from "workflow"; @@ -146,10 +178,10 @@ export async function POST(request: Request) { ``` -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()`. +This fallback can create duplicate runs during the gap between `start()` and Hook registration. The losing workflow returns before duplicate-sensitive work, but it was still created, queued, and started. Use atomic admission when the World supports it. -This coordinates active runs by default: the token becomes available when its workflow ends. Set `experimental_minRetention` to keep it unavailable to late duplicates. After the workflow ends, the Hook can still be found with `getHookByToken()` until retention ends, but it cannot be resumed. See [`createHook()` minimum retention](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) for examples and supported values. +In either pattern, the token becomes available when its workflow ends by default. Set `experimental_minRetention` to keep it unavailable to late duplicates. See [`createHook()` minimum retention](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) for examples and supported values. ### Conflict-handling strategies