From 7d8c1f5d678cec6c859b350f6b722000a8e99811 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 15 May 2026 14:25:51 -0700 Subject: [PATCH 1/3] docs(cookbook): add resume-or-start by hook token recipe Document getHookByToken (or world.hooks.getByToken) before start(), concurrency limits, and register the recipe in v4/v5 sidebars and cookbook-tree metadata. Co-authored-by: Cursor --- .../v4/cookbook/common-patterns/meta.json | 1 + .../common-patterns/resume-or-start.mdx | 89 +++++++++++++++++++ docs/content/docs/v4/cookbook/index.mdx | 1 + .../v5/cookbook/common-patterns/meta.json | 1 + .../common-patterns/resume-or-start.mdx | 86 ++++++++++++++++++ docs/content/docs/v5/cookbook/index.mdx | 1 + docs/lib/cookbook-tree.ts | 8 ++ 7 files changed, 187 insertions(+) create mode 100644 docs/content/docs/v4/cookbook/common-patterns/resume-or-start.mdx create mode 100644 docs/content/docs/v5/cookbook/common-patterns/resume-or-start.mdx diff --git a/docs/content/docs/v4/cookbook/common-patterns/meta.json b/docs/content/docs/v4/cookbook/common-patterns/meta.json index 3c1ed7e585..e5b397f663 100644 --- a/docs/content/docs/v4/cookbook/common-patterns/meta.json +++ b/docs/content/docs/v4/cookbook/common-patterns/meta.json @@ -10,6 +10,7 @@ "scheduling", "timeouts", "idempotency", + "resume-or-start", "webhooks" ] } diff --git a/docs/content/docs/v4/cookbook/common-patterns/resume-or-start.mdx b/docs/content/docs/v4/cookbook/common-patterns/resume-or-start.mdx new file mode 100644 index 0000000000..f90af65a64 --- /dev/null +++ b/docs/content/docs/v4/cookbook/common-patterns/resume-or-start.mdx @@ -0,0 +1,89 @@ +--- +title: Resume or start by hook token +description: Look up an existing workflow run by a deterministic hook token before calling start(), and why this pattern is not fully atomic under concurrency. +type: guide +summary: Use getHookByToken (or world.hooks.getByToken) to attach to an in-flight run keyed by a business token; otherwise start a new run. Pair with stronger idempotency when duplicate starts must be impossible. +--- + +Some HTTP or RPC handlers need **one durable session per business key** (for example one workflow per `orderId` or `threadId`). If a run is already active and has registered a hook with a deterministic token, you can **look up that hook**, reuse `hook.runId`, and avoid starting a second workflow. If nothing is registered yet, you **`start()`** a new run. + +This is the same idea as the [`DistributedAbortController.create()`](/cookbook/advanced/distributed-abort-controller) helper, extracted as a general pattern. + +## When to use this + +- **Session handoff** — A client or edge handler hits your API without a `runId`, but you can derive a stable token from a business ID. +- **Reconnect before resume** — You want `getRun(runId)` (streams, `returnValue`, status) without persisting `runId` in your own store, as long as the workflow registers a hook with that token early in the run. +- **Custom worlds / admin tools** — You already use [`world.hooks.getByToken()`](/docs/api-reference/workflow-api/world/storage#look-up-hook-by-token) against storage; the app-level pattern is the same. + +## Pattern + +1. Build a **deterministic token** from your business key (for example `` `session:${threadId}` ``). +2. Call **`getHookByToken(token)`** from `workflow/api` (or `world.hooks.getByToken(token)` if you hold a `World` instance). +3. If a hook exists, use **`hook.runId`** with [`getRun()`](/docs/api-reference/workflow-api/get-run) — for example await **`run.returnValue`** when you need the workflow outcome. +4. If no hook is found (typically `404` / not found), call **[`start()`](/docs/api-reference/workflow-api/start)** with the same arguments your workflow would use to create that hook on first execution. + +```typescript lineNumbers +import { start, getRun, getHookByToken } from "workflow/api"; +import { sessionWorkflow } from "./workflows/session"; + +function sessionToken(threadId: string) { + return `session:${threadId}`; +} + +export async function getOrStartSession(threadId: string) { + const token = sessionToken(threadId); + + const existing = await getHookByToken(token).catch(() => null); // [!code highlight] + + if (existing) { + const run = getRun(existing.runId); // [!code highlight] + return run.returnValue; + } + + const run = await start(sessionWorkflow, [threadId]); // [!code highlight] + return run.returnValue; +} +``` + +Your workflow should create the hook with the **same token shape** early (before long-running work), so the lookup succeeds once the run is registered: + +```typescript lineNumbers +import { defineHook } from "workflow"; + +export const sessionHook = defineHook(); + +export async function sessionWorkflow(threadId: string) { + "use workflow"; + + const token = `session:${threadId}`; + const hook = sessionHook.create({ token }); // [!code highlight] + + // ... durable session logic, await hook, etc. +} +``` + +## Concurrency and atomicity + + +This pattern is **not atomic**. Two concurrent requests can both observe “no hook” **before** the first run has written its `hook_created` event, so both can call **`start()`** and you can get **two runs** for the same business key. + + +Mitigations: + +- **Accept rare duplicates** and detect them downstream (second run fails on `HookConflictError`, or you branch on run state). +- **Add an outside-workflow idempotency layer** — for example a database unique constraint on `thread_id`, a single-row transaction, or (when available on your stack) an idempotency key on **`start()`** so only one run is created per key. +- **Serialize creates** for the same key (queue, advisory lock, or other mutual exclusion) if you must not spin up a second run even briefly. + +For replay-safe **step** side effects inside a workflow, still follow [Idempotency](/cookbook/common-patterns/idempotency) (`getStepMetadata().stepId`, external idempotency keys). + +## Key APIs + +- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) — resolve `runId` (and metadata) from a hook token in app code. +- [`world.hooks.getByToken()`](/docs/api-reference/workflow-api/world/storage#look-up-hook-by-token) — same lookup against a `World` storage interface. +- [`start()`](/docs/api-reference/workflow-api/start) — enqueue a new run when no hook exists yet. +- [`getRun()`](/docs/api-reference/workflow-api/get-run) — attach to an existing run by `runId` (`returnValue`, streams, status). + +## Related + +- [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller) — full example of reconnect-or-`start()` for a cross-process abort controller. +- [Idempotency](/cookbook/common-patterns/idempotency) — keys for external APIs and duplicate-safe side effects inside steps. diff --git a/docs/content/docs/v4/cookbook/index.mdx b/docs/content/docs/v4/cookbook/index.mdx index 94cb6d9d18..b04b3fb524 100644 --- a/docs/content/docs/v4/cookbook/index.mdx +++ b/docs/content/docs/v4/cookbook/index.mdx @@ -22,6 +22,7 @@ A curated collection of workflow patterns with clean, copy-paste code examples f - [**Scheduling**](/cookbook/common-patterns/scheduling) — Use durable sleep to schedule actions minutes, hours, or weeks ahead - [**Timeouts**](/cookbook/common-patterns/timeouts) — Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep - [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects happen exactly once, even when steps retry +- [**Resume or start by hook token**](/cookbook/common-patterns/resume-or-start) — Look up an existing run via `getHookByToken` before `start()`, and understand the concurrency limits - [**Webhooks**](/cookbook/common-patterns/webhooks) — Receive HTTP callbacks from external services and process them durably ## Integrations diff --git a/docs/content/docs/v5/cookbook/common-patterns/meta.json b/docs/content/docs/v5/cookbook/common-patterns/meta.json index 3c1ed7e585..e5b397f663 100644 --- a/docs/content/docs/v5/cookbook/common-patterns/meta.json +++ b/docs/content/docs/v5/cookbook/common-patterns/meta.json @@ -10,6 +10,7 @@ "scheduling", "timeouts", "idempotency", + "resume-or-start", "webhooks" ] } diff --git a/docs/content/docs/v5/cookbook/common-patterns/resume-or-start.mdx b/docs/content/docs/v5/cookbook/common-patterns/resume-or-start.mdx new file mode 100644 index 0000000000..d8024d7682 --- /dev/null +++ b/docs/content/docs/v5/cookbook/common-patterns/resume-or-start.mdx @@ -0,0 +1,86 @@ +--- +title: Resume or start by hook token +description: Look up an existing workflow run by a deterministic hook token before calling start(), and why this pattern is not fully atomic under concurrency. +type: guide +summary: Use getHookByToken (or world.hooks.getByToken) to attach to an in-flight run keyed by a business token; otherwise start a new run. Pair with stronger idempotency when duplicate starts must be impossible. +--- + +Some HTTP or RPC handlers need **one durable session per business key** (for example one workflow per `orderId` or `threadId`). If a run is already active and has registered a hook with a deterministic token, you can **look up that hook**, reuse `hook.runId`, and avoid starting a second workflow. If nothing is registered yet, you **`start()`** a new run. + +## When to use this + +- **Session handoff** — A client or edge handler hits your API without a `runId`, but you can derive a stable token from a business ID. +- **Reconnect before resume** — You want `getRun(runId)` (streams, `returnValue`, status) without persisting `runId` in your own store, as long as the workflow registers a hook with that token early in the run. +- **Custom worlds / admin tools** — You already use [`world.hooks.getByToken()`](/docs/api-reference/workflow-api/world/storage#look-up-hook-by-token) against storage; the app-level pattern is the same. + +## Pattern + +1. Build a **deterministic token** from your business key (for example `` `session:${threadId}` ``). +2. Call **`getHookByToken(token)`** from `workflow/api` (or `world.hooks.getByToken(token)` if you hold a `World` instance). +3. If a hook exists, use **`hook.runId`** with [`getRun()`](/docs/api-reference/workflow-api/get-run) — for example await **`run.returnValue`** when you need the workflow outcome. +4. If no hook is found (typically `404` / not found), call **[`start()`](/docs/api-reference/workflow-api/start)** with the same arguments your workflow would use to create that hook on first execution. + +```typescript lineNumbers +import { start, getRun, getHookByToken } from "workflow/api"; +import { sessionWorkflow } from "./workflows/session"; + +function sessionToken(threadId: string) { + return `session:${threadId}`; +} + +export async function getOrStartSession(threadId: string) { + const token = sessionToken(threadId); + + const existing = await getHookByToken(token).catch(() => null); // [!code highlight] + + if (existing) { + const run = getRun(existing.runId); // [!code highlight] + return run.returnValue; + } + + const run = await start(sessionWorkflow, [threadId]); // [!code highlight] + return run.returnValue; +} +``` + +Your workflow should create the hook with the **same token shape** early (before long-running work), so the lookup succeeds once the run is registered: + +```typescript lineNumbers +import { defineHook } from "workflow"; + +export const sessionHook = defineHook(); + +export async function sessionWorkflow(threadId: string) { + "use workflow"; + + const token = `session:${threadId}`; + const hook = sessionHook.create({ token }); // [!code highlight] + + // ... durable session logic, await hook, etc. +} +``` + +## Concurrency and atomicity + + +This pattern is **not atomic**. Two concurrent requests can both observe “no hook” **before** the first run has written its `hook_created` event, so both can call **`start()`** and you can get **two runs** for the same business key. + + +Mitigations: + +- **Accept rare duplicates** and detect them downstream (second run fails on `HookConflictError`, or you branch on run state). +- **Add an outside-workflow idempotency layer** — for example a database unique constraint on `thread_id`, a single-row transaction, or (when available on your stack) an idempotency key on **`start()`** so only one run is created per key. +- **Serialize creates** for the same key (queue, advisory lock, or other mutual exclusion) if you must not spin up a second run even briefly. + +For replay-safe **step** side effects inside a workflow, still follow [Idempotency](/cookbook/common-patterns/idempotency) (`getStepMetadata().stepId`, external idempotency keys). + +## Key APIs + +- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) — resolve `runId` (and metadata) from a hook token in app code. +- [`world.hooks.getByToken()`](/docs/api-reference/workflow-api/world/storage#look-up-hook-by-token) — same lookup against a `World` storage interface. +- [`start()`](/docs/api-reference/workflow-api/start) — enqueue a new run when no hook exists yet. +- [`getRun()`](/docs/api-reference/workflow-api/get-run) — attach to an existing run by `runId` (`returnValue`, streams, status). + +## Related + +- [Idempotency](/cookbook/common-patterns/idempotency) — keys for external APIs and duplicate-safe side effects inside steps. diff --git a/docs/content/docs/v5/cookbook/index.mdx b/docs/content/docs/v5/cookbook/index.mdx index 0dc1ce62ab..6dab34fad8 100644 --- a/docs/content/docs/v5/cookbook/index.mdx +++ b/docs/content/docs/v5/cookbook/index.mdx @@ -22,6 +22,7 @@ A curated collection of workflow patterns with clean, copy-paste code examples f - [**Scheduling**](/cookbook/common-patterns/scheduling) — Use durable sleep to schedule actions minutes, hours, or weeks ahead - [**Timeouts**](/cookbook/common-patterns/timeouts) — Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep - [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects happen exactly once, even when steps retry +- [**Resume or start by hook token**](/cookbook/common-patterns/resume-or-start) — Look up an existing run via `getHookByToken` before `start()`, and understand the concurrency limits - [**Webhooks**](/cookbook/common-patterns/webhooks) — Receive HTTP callbacks from external services and process them durably ## Integrations diff --git a/docs/lib/cookbook-tree.ts b/docs/lib/cookbook-tree.ts index 2189235df8..144e9cb01e 100644 --- a/docs/lib/cookbook-tree.ts +++ b/docs/lib/cookbook-tree.ts @@ -41,6 +41,7 @@ export const slugToCategory: Record = { scheduling: 'common-patterns', timeouts: 'common-patterns', idempotency: 'common-patterns', + 'resume-or-start': 'common-patterns', webhooks: 'common-patterns', // Agent Patterns @@ -119,6 +120,13 @@ export const recipes: Record = { 'Ensure external side effects happen exactly once, even when steps are retried or workflows are replayed.', category: 'common-patterns', }, + 'resume-or-start': { + slug: 'resume-or-start', + title: 'Resume or start by hook token', + description: + 'Look up an existing run with getHookByToken (or world.hooks.getByToken) before start(); pair with stronger idempotency when duplicate starts must be impossible.', + category: 'common-patterns', + }, webhooks: { slug: 'webhooks', title: 'Webhooks & External Callbacks', From afdd9a0a9e66bd9466edc21fdd81e7497f5a73ef Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 15 May 2026 14:33:49 -0700 Subject: [PATCH 2/3] docs(cookbook): clarify stable application ID in resume-or-start Spell out stable app identifiers vs runId, tighten examples and cookbook tree copy. Co-authored-by: Cursor --- .../common-patterns/resume-or-start.mdx | 31 ++++++++++--------- docs/content/docs/v4/cookbook/index.mdx | 2 +- .../common-patterns/resume-or-start.mdx | 31 ++++++++++--------- docs/content/docs/v5/cookbook/index.mdx | 2 +- docs/lib/cookbook-tree.ts | 2 +- 5 files changed, 37 insertions(+), 31 deletions(-) diff --git a/docs/content/docs/v4/cookbook/common-patterns/resume-or-start.mdx b/docs/content/docs/v4/cookbook/common-patterns/resume-or-start.mdx index f90af65a64..26e247c5e5 100644 --- a/docs/content/docs/v4/cookbook/common-patterns/resume-or-start.mdx +++ b/docs/content/docs/v4/cookbook/common-patterns/resume-or-start.mdx @@ -2,22 +2,24 @@ title: Resume or start by hook token description: Look up an existing workflow run by a deterministic hook token before calling start(), and why this pattern is not fully atomic under concurrency. type: guide -summary: Use getHookByToken (or world.hooks.getByToken) to attach to an in-flight run keyed by a business token; otherwise start a new run. Pair with stronger idempotency when duplicate starts must be impossible. +summary: Use getHookByToken (or world.hooks.getByToken) to attach to an in-flight run keyed by a stable app ID encoded into the hook token; otherwise start a new run. Pair with stronger idempotency when duplicate starts must be impossible. --- -Some HTTP or RPC handlers need **one durable session per business key** (for example one workflow per `orderId` or `threadId`). If a run is already active and has registered a hook with a deterministic token, you can **look up that hook**, reuse `hook.runId`, and avoid starting a second workflow. If nothing is registered yet, you **`start()`** a new run. +Some HTTP or RPC handlers need **one durable session per stable application identifier**—an ID your app already uses for something in the product domain, such as a **chat thread ID**, **order ID**, **invoice ID**, or **tenant-scoped resource key** (often a primary key, slug, or composite you store or pass in URLs). That value is **not** the Workflow **`runId`**, which the runtime assigns when you call **`start()`**. Instead you **derive a hook token** from the stable ID (for example `` `session:${chatThreadId}` `` for a chat) so every request about the same real-world entity computes the **same** token. + +If a run is already active and has registered a hook with that token, you can **look up the hook**, reuse `hook.runId`, and avoid starting a second workflow. If nothing is registered yet, you **`start()`** a new run. This is the same idea as the [`DistributedAbortController.create()`](/cookbook/advanced/distributed-abort-controller) helper, extracted as a general pattern. ## When to use this -- **Session handoff** — A client or edge handler hits your API without a `runId`, but you can derive a stable token from a business ID. +- **Session handoff** — A client or edge handler hits your API without a `runId`, but you already know a **stable application ID** (path param, session subject, database row key, etc.) and can derive the hook token from it. - **Reconnect before resume** — You want `getRun(runId)` (streams, `returnValue`, status) without persisting `runId` in your own store, as long as the workflow registers a hook with that token early in the run. - **Custom worlds / admin tools** — You already use [`world.hooks.getByToken()`](/docs/api-reference/workflow-api/world/storage#look-up-hook-by-token) against storage; the app-level pattern is the same. ## Pattern -1. Build a **deterministic token** from your business key (for example `` `session:${threadId}` ``). +1. Build a **deterministic hook token** from your **stable application ID** plus a fixed prefix or namespace so different features do not collide (for example `` `session:${chatThreadId}` ``). 2. Call **`getHookByToken(token)`** from `workflow/api` (or `world.hooks.getByToken(token)` if you hold a `World` instance). 3. If a hook exists, use **`hook.runId`** with [`getRun()`](/docs/api-reference/workflow-api/get-run) — for example await **`run.returnValue`** when you need the workflow outcome. 4. If no hook is found (typically `404` / not found), call **[`start()`](/docs/api-reference/workflow-api/start)** with the same arguments your workflow would use to create that hook on first execution. @@ -26,12 +28,13 @@ This is the same idea as the [`DistributedAbortController.create()`](/cookbook/a import { start, getRun, getHookByToken } from "workflow/api"; import { sessionWorkflow } from "./workflows/session"; -function sessionToken(threadId: string) { - return `session:${threadId}`; +function sessionToken(chatThreadId: string) { + return `session:${chatThreadId}`; } -export async function getOrStartSession(threadId: string) { - const token = sessionToken(threadId); +/** `chatThreadId` = your app's stable ID for the conversation (not the Workflow runId). */ +export async function getOrStartSession(chatThreadId: string) { + const token = sessionToken(chatThreadId); const existing = await getHookByToken(token).catch(() => null); // [!code highlight] @@ -40,7 +43,7 @@ export async function getOrStartSession(threadId: string) { return run.returnValue; } - const run = await start(sessionWorkflow, [threadId]); // [!code highlight] + const run = await start(sessionWorkflow, [chatThreadId]); // [!code highlight] return run.returnValue; } ``` @@ -52,10 +55,10 @@ import { defineHook } from "workflow"; export const sessionHook = defineHook(); -export async function sessionWorkflow(threadId: string) { +export async function sessionWorkflow(chatThreadId: string) { "use workflow"; - const token = `session:${threadId}`; + const token = `session:${chatThreadId}`; const hook = sessionHook.create({ token }); // [!code highlight] // ... durable session logic, await hook, etc. @@ -65,14 +68,14 @@ export async function sessionWorkflow(threadId: string) { ## Concurrency and atomicity -This pattern is **not atomic**. Two concurrent requests can both observe “no hook” **before** the first run has written its `hook_created` event, so both can call **`start()`** and you can get **two runs** for the same business key. +This pattern is **not atomic**. Two concurrent requests can both observe “no hook” **before** the first run has written its `hook_created` event, so both can call **`start()`** and you can get **two runs** for the same stable application ID (the same derived hook token). Mitigations: - **Accept rare duplicates** and detect them downstream (second run fails on `HookConflictError`, or you branch on run state). -- **Add an outside-workflow idempotency layer** — for example a database unique constraint on `thread_id`, a single-row transaction, or (when available on your stack) an idempotency key on **`start()`** so only one run is created per key. -- **Serialize creates** for the same key (queue, advisory lock, or other mutual exclusion) if you must not spin up a second run even briefly. +- **Add an outside-workflow idempotency layer** — for example a database unique constraint on **your stable application ID** (the same value you embed in the hook token), a single-row transaction, or (when available on your stack) an idempotency key on **`start()`** so only one run is created per ID. +- **Serialize creates** for the same stable ID (queue, advisory lock, or other mutual exclusion) if you must not spin up a second run even briefly. For replay-safe **step** side effects inside a workflow, still follow [Idempotency](/cookbook/common-patterns/idempotency) (`getStepMetadata().stepId`, external idempotency keys). diff --git a/docs/content/docs/v4/cookbook/index.mdx b/docs/content/docs/v4/cookbook/index.mdx index b04b3fb524..39ec4d0240 100644 --- a/docs/content/docs/v4/cookbook/index.mdx +++ b/docs/content/docs/v4/cookbook/index.mdx @@ -22,7 +22,7 @@ A curated collection of workflow patterns with clean, copy-paste code examples f - [**Scheduling**](/cookbook/common-patterns/scheduling) — Use durable sleep to schedule actions minutes, hours, or weeks ahead - [**Timeouts**](/cookbook/common-patterns/timeouts) — Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep - [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects happen exactly once, even when steps retry -- [**Resume or start by hook token**](/cookbook/common-patterns/resume-or-start) — Look up an existing run via `getHookByToken` before `start()`, and understand the concurrency limits +- [**Resume or start by hook token**](/cookbook/common-patterns/resume-or-start) — Encode a stable application ID in the hook token, look up an existing run with `getHookByToken` before `start()`, and understand the concurrency limits - [**Webhooks**](/cookbook/common-patterns/webhooks) — Receive HTTP callbacks from external services and process them durably ## Integrations diff --git a/docs/content/docs/v5/cookbook/common-patterns/resume-or-start.mdx b/docs/content/docs/v5/cookbook/common-patterns/resume-or-start.mdx index d8024d7682..7ef02e990c 100644 --- a/docs/content/docs/v5/cookbook/common-patterns/resume-or-start.mdx +++ b/docs/content/docs/v5/cookbook/common-patterns/resume-or-start.mdx @@ -2,20 +2,22 @@ title: Resume or start by hook token description: Look up an existing workflow run by a deterministic hook token before calling start(), and why this pattern is not fully atomic under concurrency. type: guide -summary: Use getHookByToken (or world.hooks.getByToken) to attach to an in-flight run keyed by a business token; otherwise start a new run. Pair with stronger idempotency when duplicate starts must be impossible. +summary: Use getHookByToken (or world.hooks.getByToken) to attach to an in-flight run keyed by a stable app ID encoded into the hook token; otherwise start a new run. Pair with stronger idempotency when duplicate starts must be impossible. --- -Some HTTP or RPC handlers need **one durable session per business key** (for example one workflow per `orderId` or `threadId`). If a run is already active and has registered a hook with a deterministic token, you can **look up that hook**, reuse `hook.runId`, and avoid starting a second workflow. If nothing is registered yet, you **`start()`** a new run. +Some HTTP or RPC handlers need **one durable session per stable application identifier**—an ID your app already uses for something in the product domain, such as a **chat thread ID**, **order ID**, **invoice ID**, or **tenant-scoped resource key** (often a primary key, slug, or composite you store or pass in URLs). That value is **not** the Workflow **`runId`**, which the runtime assigns when you call **`start()`**. Instead you **derive a hook token** from the stable ID (for example `` `session:${chatThreadId}` `` for a chat) so every request about the same real-world entity computes the **same** token. + +If a run is already active and has registered a hook with that token, you can **look up the hook**, reuse `hook.runId`, and avoid starting a second workflow. If nothing is registered yet, you **`start()`** a new run. ## When to use this -- **Session handoff** — A client or edge handler hits your API without a `runId`, but you can derive a stable token from a business ID. +- **Session handoff** — A client or edge handler hits your API without a `runId`, but you already know a **stable application ID** (path param, session subject, database row key, etc.) and can derive the hook token from it. - **Reconnect before resume** — You want `getRun(runId)` (streams, `returnValue`, status) without persisting `runId` in your own store, as long as the workflow registers a hook with that token early in the run. - **Custom worlds / admin tools** — You already use [`world.hooks.getByToken()`](/docs/api-reference/workflow-api/world/storage#look-up-hook-by-token) against storage; the app-level pattern is the same. ## Pattern -1. Build a **deterministic token** from your business key (for example `` `session:${threadId}` ``). +1. Build a **deterministic hook token** from your **stable application ID** plus a fixed prefix or namespace so different features do not collide (for example `` `session:${chatThreadId}` ``). 2. Call **`getHookByToken(token)`** from `workflow/api` (or `world.hooks.getByToken(token)` if you hold a `World` instance). 3. If a hook exists, use **`hook.runId`** with [`getRun()`](/docs/api-reference/workflow-api/get-run) — for example await **`run.returnValue`** when you need the workflow outcome. 4. If no hook is found (typically `404` / not found), call **[`start()`](/docs/api-reference/workflow-api/start)** with the same arguments your workflow would use to create that hook on first execution. @@ -24,12 +26,13 @@ Some HTTP or RPC handlers need **one durable session per business key** (for exa import { start, getRun, getHookByToken } from "workflow/api"; import { sessionWorkflow } from "./workflows/session"; -function sessionToken(threadId: string) { - return `session:${threadId}`; +function sessionToken(chatThreadId: string) { + return `session:${chatThreadId}`; } -export async function getOrStartSession(threadId: string) { - const token = sessionToken(threadId); +/** `chatThreadId` = your app's stable ID for the conversation (not the Workflow runId). */ +export async function getOrStartSession(chatThreadId: string) { + const token = sessionToken(chatThreadId); const existing = await getHookByToken(token).catch(() => null); // [!code highlight] @@ -38,7 +41,7 @@ export async function getOrStartSession(threadId: string) { return run.returnValue; } - const run = await start(sessionWorkflow, [threadId]); // [!code highlight] + const run = await start(sessionWorkflow, [chatThreadId]); // [!code highlight] return run.returnValue; } ``` @@ -50,10 +53,10 @@ import { defineHook } from "workflow"; export const sessionHook = defineHook(); -export async function sessionWorkflow(threadId: string) { +export async function sessionWorkflow(chatThreadId: string) { "use workflow"; - const token = `session:${threadId}`; + const token = `session:${chatThreadId}`; const hook = sessionHook.create({ token }); // [!code highlight] // ... durable session logic, await hook, etc. @@ -63,14 +66,14 @@ export async function sessionWorkflow(threadId: string) { ## Concurrency and atomicity -This pattern is **not atomic**. Two concurrent requests can both observe “no hook” **before** the first run has written its `hook_created` event, so both can call **`start()`** and you can get **two runs** for the same business key. +This pattern is **not atomic**. Two concurrent requests can both observe “no hook” **before** the first run has written its `hook_created` event, so both can call **`start()`** and you can get **two runs** for the same stable application ID (the same derived hook token). Mitigations: - **Accept rare duplicates** and detect them downstream (second run fails on `HookConflictError`, or you branch on run state). -- **Add an outside-workflow idempotency layer** — for example a database unique constraint on `thread_id`, a single-row transaction, or (when available on your stack) an idempotency key on **`start()`** so only one run is created per key. -- **Serialize creates** for the same key (queue, advisory lock, or other mutual exclusion) if you must not spin up a second run even briefly. +- **Add an outside-workflow idempotency layer** — for example a database unique constraint on **your stable application ID** (the same value you embed in the hook token), a single-row transaction, or (when available on your stack) an idempotency key on **`start()`** so only one run is created per ID. +- **Serialize creates** for the same stable ID (queue, advisory lock, or other mutual exclusion) if you must not spin up a second run even briefly. For replay-safe **step** side effects inside a workflow, still follow [Idempotency](/cookbook/common-patterns/idempotency) (`getStepMetadata().stepId`, external idempotency keys). diff --git a/docs/content/docs/v5/cookbook/index.mdx b/docs/content/docs/v5/cookbook/index.mdx index 6dab34fad8..d3507251b3 100644 --- a/docs/content/docs/v5/cookbook/index.mdx +++ b/docs/content/docs/v5/cookbook/index.mdx @@ -22,7 +22,7 @@ A curated collection of workflow patterns with clean, copy-paste code examples f - [**Scheduling**](/cookbook/common-patterns/scheduling) — Use durable sleep to schedule actions minutes, hours, or weeks ahead - [**Timeouts**](/cookbook/common-patterns/timeouts) — Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep - [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects happen exactly once, even when steps retry -- [**Resume or start by hook token**](/cookbook/common-patterns/resume-or-start) — Look up an existing run via `getHookByToken` before `start()`, and understand the concurrency limits +- [**Resume or start by hook token**](/cookbook/common-patterns/resume-or-start) — Encode a stable application ID in the hook token, look up an existing run with `getHookByToken` before `start()`, and understand the concurrency limits - [**Webhooks**](/cookbook/common-patterns/webhooks) — Receive HTTP callbacks from external services and process them durably ## Integrations diff --git a/docs/lib/cookbook-tree.ts b/docs/lib/cookbook-tree.ts index 144e9cb01e..4624ff610f 100644 --- a/docs/lib/cookbook-tree.ts +++ b/docs/lib/cookbook-tree.ts @@ -124,7 +124,7 @@ export const recipes: Record = { slug: 'resume-or-start', title: 'Resume or start by hook token', description: - 'Look up an existing run with getHookByToken (or world.hooks.getByToken) before start(); pair with stronger idempotency when duplicate starts must be impossible.', + 'Look up an existing run with getHookByToken (or world.hooks.getByToken) before start(); encode a stable application ID (not runId) in the hook token. Pair with stronger idempotency when duplicate starts must be impossible.', category: 'common-patterns', }, webhooks: { From 1bd0047b4ddd852b09c328be7042d91201022ea9 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 15 May 2026 14:39:55 -0700 Subject: [PATCH 3/3] docs(cookbook): tighten resume-or-start mitigations Drop DistributedAbortController aside; fix outside-workflow dedupe copy (no start() idempotency key; mapping/lease vs PK-only uniqueness). Co-authored-by: Cursor --- .../docs/v4/cookbook/common-patterns/resume-or-start.mdx | 4 +--- .../docs/v5/cookbook/common-patterns/resume-or-start.mdx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/v4/cookbook/common-patterns/resume-or-start.mdx b/docs/content/docs/v4/cookbook/common-patterns/resume-or-start.mdx index 26e247c5e5..ee32c1ed01 100644 --- a/docs/content/docs/v4/cookbook/common-patterns/resume-or-start.mdx +++ b/docs/content/docs/v4/cookbook/common-patterns/resume-or-start.mdx @@ -9,8 +9,6 @@ Some HTTP or RPC handlers need **one durable session per stable application iden If a run is already active and has registered a hook with that token, you can **look up the hook**, reuse `hook.runId`, and avoid starting a second workflow. If nothing is registered yet, you **`start()`** a new run. -This is the same idea as the [`DistributedAbortController.create()`](/cookbook/advanced/distributed-abort-controller) helper, extracted as a general pattern. - ## When to use this - **Session handoff** — A client or edge handler hits your API without a `runId`, but you already know a **stable application ID** (path param, session subject, database row key, etc.) and can derive the hook token from it. @@ -74,7 +72,7 @@ This pattern is **not atomic**. Two concurrent requests can both observe “no h Mitigations: - **Accept rare duplicates** and detect them downstream (second run fails on `HookConflictError`, or you branch on run state). -- **Add an outside-workflow idempotency layer** — for example a database unique constraint on **your stable application ID** (the same value you embed in the hook token), a single-row transaction, or (when available on your stack) an idempotency key on **`start()`** so only one run is created per ID. +- **Add an outside-workflow coordination layer** — for example a **row keyed by your stable application ID** (the same value you embed in the hook token) with a **unique constraint** that stores the authoritative **`runId`**, using **insert-if-absent**, **`INSERT … ON CONFLICT`**, or a **transaction with row-level locking** so only one concurrent handler reaches **`start()`** for that ID. A bare unique constraint on your domain table primary key does not by itself dedupe workflow runs; you need a **dedicated mapping or lease** tied to that ID. **`start()`** does not expose an idempotency-key option today (only `world`, `specVersion`, and `deploymentId` in [`StartOptions`](/docs/api-reference/workflow-api/start)). - **Serialize creates** for the same stable ID (queue, advisory lock, or other mutual exclusion) if you must not spin up a second run even briefly. For replay-safe **step** side effects inside a workflow, still follow [Idempotency](/cookbook/common-patterns/idempotency) (`getStepMetadata().stepId`, external idempotency keys). diff --git a/docs/content/docs/v5/cookbook/common-patterns/resume-or-start.mdx b/docs/content/docs/v5/cookbook/common-patterns/resume-or-start.mdx index 7ef02e990c..8dab98eca4 100644 --- a/docs/content/docs/v5/cookbook/common-patterns/resume-or-start.mdx +++ b/docs/content/docs/v5/cookbook/common-patterns/resume-or-start.mdx @@ -72,7 +72,7 @@ This pattern is **not atomic**. Two concurrent requests can both observe “no h Mitigations: - **Accept rare duplicates** and detect them downstream (second run fails on `HookConflictError`, or you branch on run state). -- **Add an outside-workflow idempotency layer** — for example a database unique constraint on **your stable application ID** (the same value you embed in the hook token), a single-row transaction, or (when available on your stack) an idempotency key on **`start()`** so only one run is created per ID. +- **Add an outside-workflow coordination layer** — for example a **row keyed by your stable application ID** (the same value you embed in the hook token) with a **unique constraint** that stores the authoritative **`runId`**, using **insert-if-absent**, **`INSERT … ON CONFLICT`**, or a **transaction with row-level locking** so only one concurrent handler reaches **`start()`** for that ID. A bare unique constraint on your domain table primary key does not by itself dedupe workflow runs; you need a **dedicated mapping or lease** tied to that ID. **`start()`** does not expose an idempotency-key option today (only `world`, `specVersion`, and `deploymentId` in [`StartOptions`](/docs/api-reference/workflow-api/start)). - **Serialize creates** for the same stable ID (queue, advisory lock, or other mutual exclusion) if you must not spin up a second run even briefly. For replay-safe **step** side effects inside a workflow, still follow [Idempotency](/cookbook/common-patterns/idempotency) (`getStepMetadata().stepId`, external idempotency keys).