Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/atomic-start-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@workflow/core": minor
"@workflow/world": minor
"@workflow/world-local": minor
"@workflow/world-postgres": minor
"@workflow/world-vercel": patch
---

Add experimental atomic start-hook idempotency for worlds that opt into start-time hook admission.
13 changes: 13 additions & 0 deletions .changeset/idempotent-start-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@workflow/core": minor
"workflow": minor
"@workflow/errors": minor
"@workflow/world": minor
"@workflow/world-local": minor
"@workflow/world-postgres": minor
"@workflow/world-vercel": minor
"@workflow/cli": patch
"@workflow/web-shared": patch
---

Add queue-first experimental start-hook idempotency for Vercel worlds.
26 changes: 25 additions & 1 deletion docs/content/docs/v5/api-reference/workflow-api/start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@ 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).
* Each call to `start()` creates a new workflow run unless you opt into an idempotency mechanism. The stable pattern is to 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. If duplicates must be rejected before duplicate workflow code can run, use `experimentalStartHook` in a supporting World. See [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 [`experimental_setAttributes`](/docs/api-reference/workflow/experimental-set-attributes) option of the same name.
* `experimentalStartHook` reserves a deterministic hook token as part of start admission in Worlds that support it. On Vercel, this uses queue-first admission, requires the target deployment to support queued start-hook handling, disables turbo mode for the first workflow invocation, rejects TTLs longer than 30 days or tokens larger than 255 bytes, and may throw [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error) or [`WorkflowStartError`](/docs/api-reference/workflow-errors/workflow-start-error). See [Run idempotency](/docs/foundations/idempotency#atomic-start-hook-idempotency).

<Callout type="info">
If `start()` throws `'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.`, the passed function was not transformed as a workflow. The two most common causes are a missing `"use workflow"` directive or missing framework integration. See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function).
Expand Down Expand Up @@ -88,6 +89,29 @@ const run = await start(myWorkflow, ["arg1", "arg2"], { // [!code highlight]
}); // [!code highlight]
```

### With `experimentalStartHook`

Use `experimentalStartHook` when duplicate starts must be rejected before duplicate workflow code can run. The workflow should still create the same hook token and check `hook.getConflict()` so the queued invocation can materialize the reserved hook and so non-supporting Worlds have the same in-workflow guard.

```typescript
import { start } from "workflow/api";
import { processOrder } from "./workflows/process-order";

try {
const run = await start(processOrder, ["order_123"], {
experimentalStartHook: {
token: "order:order_123",
experimental_ttl: "30 days",
},
});
console.log("Started run", run.runId);
} catch (error) {
// HookConflictError means another active or retained run owns the token.
// WorkflowStartError means the run may already be queued; inspect error.runId.
throw error;
}
```

### Using `deploymentId: "latest"`

Set `deploymentId` to `"latest"` to automatically resolve the most recent deployment for the current environment. This is useful when you want to ensure a workflow run targets the latest deployed version of your application rather than the deployment that initiated the call. For when to use this and how it fits with default run pinning, see [Versioning](/docs/foundations/versioning).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow
<Card href="/docs/api-reference/workflow-errors/workflow-runtime-error" title="WorkflowRuntimeError">
Thrown when the workflow runtime encounters an execution error, such as serialization failures or timeouts.
</Card>
<Card href="/docs/api-reference/workflow-errors/workflow-start-error" title="WorkflowStartError">
Thrown when start() cannot synchronously confirm queueing or start-hook admission.
</Card>
<Card href="/docs/api-reference/workflow-errors/run-expired-error" title="RunExpiredError">
Thrown when a workflow run has expired and can no longer be operated on.
</Card>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
title: WorkflowStartError
description: Thrown when start() cannot synchronously confirm queueing or start-hook admission.
type: reference
summary: Catch WorkflowStartError when a start call may have queued a run but could not confirm admission.
related:
- /docs/api-reference/workflow-api/start
- /docs/foundations/idempotency
---

`WorkflowStartError` is thrown by [`start()`](/docs/api-reference/workflow-api/start) when the SDK cannot synchronously confirm a run started successfully. It carries the generated `runId` so callers can inspect the run, poll later, or retry the start call.

This is most important for `experimentalStartHook` on Vercel, where the queue is contacted before start-hook admission. If the queue accepts the message but admission cannot be confirmed, the original run may still start later. Retrying the same request can then return a [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error) if the original run wins the token claim.

```typescript lineNumbers
import { start } from "workflow/api";
import { WorkflowStartError } from "workflow/errors";
import { processOrder } from "./workflows/process-order";

try {
await start(processOrder, ["order_123"], {
experimentalStartHook: {
token: "order:order_123",
experimental_ttl: "30 days",
},
});
} catch (error) {
if (WorkflowStartError.is(error)) { // [!code highlight]
console.log("Run may already be queued:", error.runId);
}
}
```

## API Signature

### Properties

<TSDoc
definition={`
interface WorkflowStartError {
/** The generated workflow run ID. */
runId: string;
/** The phase that failed: queue send or admission confirmation. */
stage: "queue" | "admission";
/** Whether the queue accepted the message, when known. */
queued: true | "unknown";
/** Whether retrying the start call is expected to be safe/useful. */
retryable: boolean;
/** HTTP status code from the world backend, if available. */
status?: number;
/** Machine-readable error code, if available. */
code?: string;
/** Retry-After value in seconds, present on 429 and 425 responses. */
retryAfter?: number;
/** The error message. */
message: string;
}
export default WorkflowStartError;`}
/>

### Static Methods

#### `WorkflowStartError.is(value)`

Type-safe check for `WorkflowStartError` instances.

```typescript
import { WorkflowStartError } from "workflow/errors"
declare const error: unknown; // @setup

if (WorkflowStartError.is(error)) {
// error is typed as WorkflowStartError
}
```
71 changes: 70 additions & 1 deletion docs/content/docs/v5/foundations/idempotency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,80 @@ export async function POST(request: Request) {
```

<Callout type="warn">
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 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. If duplicates must be rejected before duplicate workflow code can run, use experimental start-hook admission in a supporting World.
</Callout>

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.

### Atomic start-hook idempotency

`start()` can reserve the same hook token at admission time with the experimental `experimentalStartHook` option. In supporting Worlds, the run and hook-token claim are admitted atomically. On Vercel, the queue message is accepted first, turbo mode is disabled for that start, and the workflow-server claim decides whether this run owns the token.

```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();
const token = `order:${orderId}`;

try {
const run = await start(processOrder, [orderId], {
experimentalStartHook: {
token,
experimental_ttl: "30 days",
},
});

return Response.json({ runId: run.runId, reused: false });
} catch (error) {
if (HookConflictError.is(error)) {
return Response.json({
runId: error.conflictingRunId,
reused: true,
});
}

if (WorkflowStartError.is(error)) {
// The queue may already have accepted this run. Keep error.runId so
// callers can poll it or retry the start call.
return Response.json(
{ runId: error.runId, queued: error.queued },
{ status: 202 }
);
}

throw error;
}
}
```

The workflow should still create the same hook token and check `hook.getConflict()`. That materializes the reserved hook for the owning run and keeps the workflow body correct in Worlds that do not support atomic admission.

```typescript lineNumbers
import { createHook } from "workflow";

type OrderRequest = { confirmed: boolean };

export async function processOrder(orderId: string) {
"use workflow";

using request = createHook<OrderRequest>({
token: `order:${orderId}`,
});

const conflict = await request.getConflict();
if (conflict) {
return { status: "duplicate" as const, runId: conflict.runId };
}

// Continue with the owning run.
}
```

On Vercel, `experimental_ttl` is capped at 30 days and hook tokens are capped at 255 bytes; values above either cap fail with a world contract error before queueing. Cross-deployment starts also fail before queueing unless the target deployment reports support for queued start-hook handling. The token claim is retained until at least the later of hook creation plus TTL and run completion, so duplicates can be rejected while the run is active and for the configured retention window. Cancelling a run before it has created its hook releases the claim immediately, so an explicit cancel-then-retry can reuse the token without waiting out the TTL.

### Conflict-handling strategies

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.
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/lib/inspect/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ const ERROR_REVIVER_KEYS = [
'SyntaxError',
'TypeError',
'URIError',
'WorkflowStartError',
] as const;

/**
Expand Down
32 changes: 32 additions & 0 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import fs from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
Expand Down Expand Up @@ -1880,6 +1880,38 @@
}
);

test.skipIf(!!process.env.WORKFLOW_VERCEL_ENV)(
'experimentalStartHook rejects duplicate starts before hook materialization',
{ timeout: 60_000 },
async () => {
const token = Math.random().toString(36).slice(2);
const workflow = await e2e('experimentalStartHookWorkflow');
const startOptions = {
experimentalStartHook: {
token,
experimental_ttl: 60_000,
},
};

const run = await start(workflow, [token], startOptions);

const duplicateError = await start(workflow, [token], startOptions).catch(
(error: unknown) => error
);
expect(HookConflictError.is(duplicateError)).toBe(true);
assert(HookConflictError.is(duplicateError));
expect(duplicateError.conflictingRunId).toBe(run.runId);

const hook = await waitForHook(token, { runId: run.runId });
await resumeHook(hook, { message: 'accepted' });

await expect(run.returnValue).resolves.toEqual({
role: 'owner',
message: 'accepted',
});
}
);

test('hookGetConflictWorkflow - awaiting hook.getConflict() registers hook without payload', async () => {
const token = Math.random().toString(36).slice(2);
const customData = Math.random().toString(36).slice(2);
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,31 @@ describe('getRunCapabilities', () => {
expect(getRunCapabilities(version).framedByteStreams).toBe(true);
});
});

describe('experimentalStartHookLoserAck', () => {
it('is false when version is missing or invalid', () => {
expect(getRunCapabilities(undefined).experimentalStartHookLoserAck).toBe(
false
);
expect(
getRunCapabilities('not-a-version').experimentalStartHookLoserAck
).toBe(false);
});

it('is false before the loser-ack cutoff', () => {
// beta.26 was published from main without loser-ack handling.
expect(
getRunCapabilities('5.0.0-beta.26').experimentalStartHookLoserAck
).toBe(false);
});

it('is true at and after the loser-ack cutoff', () => {
expect(
getRunCapabilities('5.0.0-beta.27').experimentalStartHookLoserAck
).toBe(true);
expect(getRunCapabilities('5.0.0').experimentalStartHookLoserAck).toBe(
true
);
});
});
});
23 changes: 22 additions & 1 deletion packages/core/src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
* - `gzip` (gzip payload compression): added in `5.0.0-beta.18`
* - `zstd` (zstd payload compression, preferred codec): added in `5.0.0-beta.18`
* alongside gzip — they co-ship, so any run that can read one can read both.
* - `experimentalStartHookLoserAck` (queued start-hook loser handling): added in `5.0.0-beta.27`
*/

import semver from 'semver';
Expand Down Expand Up @@ -59,6 +60,13 @@ export interface RunCapabilities {
* raw bytes (the legacy format) for compatibility with older runs.
*/
framedByteStreams: boolean;

/**
* Whether the target workflow handler safely handles a queued start-hook
* run that LOST its token claim: turbo is disabled and `HookConflictError`
* during `run_started` is acknowledged without running user workflow code.
*/
experimentalStartHookLoserAck: boolean;
}

/**
Expand Down Expand Up @@ -96,7 +104,18 @@ const CAPABILITY_VERSION_TABLE: ReadonlyArray<{
// to the next beta. A too-low cutoff makes new producers write framed bytes to
// consumers that cannot unframe them (silent corruption); too-high merely
// delays the optimization (safe).
}> = [{ capability: 'framedByteStreams', minVersion: '5.0.0-beta.15' }];
}> = [
{ capability: 'framedByteStreams', minVersion: '5.0.0-beta.15' },
// beta.26 was published from main WITHOUT this change, so the cutoff must
// point past it. TODO(release): bump again if another "Version Packages
// (beta)" PR merges before this change ships. A too-low cutoff runs user
// workflow code on a lost claim (unsafe); too-high merely rejects
// cross-deployment start-hook starts (safe).
{
capability: 'experimentalStartHookLoserAck',
minVersion: '5.0.0-beta.27',
},
];

/**
* The set of formats supported by all specVersion 2 runs, regardless of
Expand All @@ -123,6 +142,7 @@ export function getRunCapabilities(
return {
supportedFormats: BASELINE_FORMATS,
framedByteStreams: false,
experimentalStartHookLoserAck: false,
};
}

Expand All @@ -137,6 +157,7 @@ export function getRunCapabilities(
const result: RunCapabilities = {
supportedFormats: formats,
framedByteStreams: false,
experimentalStartHookLoserAck: false,
};

for (const { capability, minVersion } of CAPABILITY_VERSION_TABLE) {
Expand Down
Loading
Loading