Skip to content
AI NEWS JOURNAL
VercelAI NEWS / JOURNAL

Workflow SDK adds idempotent starts

The Workflow SDK's start() function now allows initiating a workflow run and claiming a hook atomically, eliminating the race condition present in the previous getHookByToken()-then-start() pattern. This change ensures idempotency using a token derived from domain-specific identifiers like order or invoice IDs. Duplicate starts fail safely with a HookConflictError, which includes the conflicting run's ID. The claimed hook is available immediately, enabling early payload buffering and ordered delivery even before execution begins. Inside workflows, createHook with the same token binds to the existing hook without conflict or additional steps. Experimental settings like minRetention prevent late duplicates by keeping tokens reserved after run completion.

The Workflow SDK's start() function can now admit a run and claim a hook in one atomic step. Duplicate workflow starts fail safely. The atomic start replaces the previous getHookByToken()-then-start() pattern, whose gap between the lookup and the start left a documented duplicate-start race. import { resumeHook, start } from "workflow/api";import { HookConflictError } from "workflow/errors";import { processOrder } from "./workflows/process-order"; export async function POST(request: Request) { const { orderId, note } = await request.json(); const token = `order:${orderId}`; try { const run = await start(processOrder, [orderId, note], { experimental_hook: { token }, }); return Response.json({ runId: run.runId, started: true }); } catch (error) { if (!HookConflictError.is(error)) throw error; // Route the duplicate's payload to the run that owns the token const hook = await resumeHook(token, { note }); return Response.json({ runId: hook.runId, started: false }); }} Starting a run idempotently and routing a duplicate's payload to the run that already owns the token Copy link to headingSubscribe to messages inside the run The claimed Hook exists from the moment the run is admitted, so resumeHook works even before the run begins executing, and early payloads are buffered and delivered in order. Inside the workflow, createHook({ token }) with the same token binds to the already-claimed hook: guaranteed, registering nothing new, adding no step, and resolving getConflict() to null. import { createHook } from "workflow"; type OrderUpdate = { note: string }; export async function processOrder(orderId: string, note: string) { "use workflow"; await recordNote(orderId, note); // Binds to the hook claimed at start: no conflict possible, no extra step const updates = createHook({ token: `order:${orderId}` }); for await (const update of updates) { await recordNote(orderId, update.note); }} Binding to the start Hook inside the run to consume payloads in order Copy link to headingIdempotency with no extra code The token is the idempotency key, and it comes from your own domain: an order ID, an invoice ID, an import ID, or a request ID. The token alone guarantees idempotency. Exactly one run owns it, and a duplicate start() throws HookConflictError carrying the owner's conflictingRunId and never becomes a run. The workflow doesn't have to open the hook at all, and if it never does, the token was only an idempotency key and the run completes normally. Setting experimental_minRetention keeps the token unavailable after the run ends, counted from start(), so late duplicates are still refused. Upgrade to workflow 5.4.0 and read the idempotency documentation to get started.

Sources

Read original ↗

Related topics

← Continue to all updates