From 0e0eebee225369dfbe5c6d53cbb450e9b02d12a3 Mon Sep 17 00:00:00 2001 From: sancar Date: Wed, 7 Oct 2026 10:54:03 +0300 Subject: [PATCH 1/8] docs(workflow): add singleton workflows how-to Document running at most one workflow run at a time by reusing the same workflowRunId (requires @upstash/workflow 1.4.0): - keep the running one: a trigger while a run is in progress is rejected - replace the running one: client.cancel, then client.trigger Co-Authored-By: Claude Opus 5.5 --- docs.json | 1 + workflow/howto/singleton.mdx | 116 +++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 workflow/howto/singleton.mdx diff --git a/docs.json b/docs.json index 98369b6d..96e2d96d 100644 --- a/docs.json +++ b/docs.json @@ -1840,6 +1840,7 @@ "workflow/howto/start", "workflow/howto/configure", "workflow/howto/cancel", + "workflow/howto/singleton", "workflow/howto/retry-now", "workflow/howto/security", "workflow/howto/get-token", diff --git a/workflow/howto/singleton.mdx b/workflow/howto/singleton.mdx new file mode 100644 index 00000000..20adb811 --- /dev/null +++ b/workflow/howto/singleton.mdx @@ -0,0 +1,116 @@ +--- +title: "Singleton Workflows" +description: "Make sure only one run of a workflow is in progress at a time by reusing the same workflowRunId." +--- + +A singleton workflow has at most one run in progress at a time. A nightly sync, +a per-user onboarding flow or a cache rebuild are typical examples: starting a +second copy while the first one is still running would do the same work twice. + +You get this behavior by starting every run with the **same `workflowRunId`**. +A run ID is reserved while its run is in progress, and released once the run +finishes, fails or is cancelled. + + + Singleton workflows require `@upstash/workflow` **1.4.0** or later. + + With older versions, a run started again with the same `workflowRunId` and the + same body within 10 minutes of the previous one is deduplicated by QStash: it + either doesn't start or gets stuck on its first step. + + +There are two ways to handle a new trigger while a run is in progress: + +| Pattern | When a run is already in progress | +| --- | --- | +| [Keep the running one](#keep-the-running-one) | The new trigger is rejected. The running run continues. | +| [Replace the running one](#replace-the-running-one) | The running run is cancelled and a new one starts. | + +In both patterns, a trigger after the previous run has ended starts a new run, +even with exactly the same body. + +## Keep the running one + +Trigger the workflow with a fixed `workflowRunId`. If a run with that ID is +still in progress, `client.trigger` throws and no new run is started. + +```ts +import { Client } from "@upstash/workflow"; + +const client = new Client({ token: "" }); + +try { + const { workflowRunId } = await client.trigger({ + url: "https:///nightly-sync", + workflowRunId: "nightly-sync", + body: { source: "crm" }, + }); + console.log(`started ${workflowRunId}`); // started wfr_nightly-sync +} catch (error) { + if (error instanceof Error && error.message.includes("a workflow already exists")) { + console.log("nightly-sync is already running, skipping"); + } else { + throw error; + } +} +``` + +What `client.trigger` returns: + +- **No run in progress:** a new run starts and the call resolves to + `{ workflowRunId: "wfr_nightly-sync" }`. +- **A run is in progress:** the call throws a `QstashError` with `status: 400`. + Its message contains + `a workflow already exists, can not initialize a new one with same id`. + The running run is not affected. + + + The ID you pass is prefixed with `wfr_`. `nightly-sync` becomes + `wfr_nightly-sync`, which is the ID you see in the logs and pass to + `client.cancel`. + + +## Replace the running one + +To always run the latest request, cancel the run with the ID first and then +start a new one with the same ID. If the previous run hasn't finished yet, it is +stopped and the new run takes its place. + +```ts +import { Client } from "@upstash/workflow"; + +const client = new Client({ token: "" }); + +const { cancelled } = await client.cancel("wfr_rebuild-cache"); +console.log(`cancelled ${cancelled} run(s)`); + +const { workflowRunId } = await client.trigger({ + url: "https:///rebuild-cache", + workflowRunId: "rebuild-cache", + body: { reason: "settings changed" }, +}); +console.log(`started ${workflowRunId}`); // started wfr_rebuild-cache +``` + +What each call returns: + +- `client.cancel` resolves to `{ cancelled: 1 }` when a run was in progress and + got cancelled, and to `{ cancelled: 0 }` when there was nothing to cancel. It + doesn't throw in either case, so you can call it unconditionally. +- `client.trigger` resolves to `{ workflowRunId: "wfr_rebuild-cache" }`. The + cancel is completed before `client.cancel` returns, so the ID is free when you + trigger the new run. + +The cancelled run stops right away. If one of its steps is executing on your +endpoint at that moment, that code runs to the end, but its result is +discarded. Steps, waits and `context.call` results of the cancelled run are +never added to the new run, even though both runs share the same ID. + + + If two callers cancel and trigger the same ID at the same time, both cancels + can complete before either trigger. Then the first trigger starts a run, and + the second one throws the `a workflow already exists` error described in + [Keep the running one](#keep-the-running-one). Catch it and retry the cancel + and trigger if the latest request must win. + + From 9902062849a008bb2f191839ea1f743467fd0941 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:48:49 +0000 Subject: [PATCH 2/8] chore(llms): regenerate llms.txt and llms-full.txt --- llms-full.txt | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++ llms.txt | 1 + 2 files changed, 115 insertions(+) diff --git a/llms-full.txt b/llms-full.txt index 55526efc..b327b79a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -116592,6 +116592,120 @@ You can use them to pass your custom authentication token to verify the requests The failure function executes independently of the route function, so without this check, unauthorized requests could trigger the failure function +# Singleton Workflows +Source: https://upstash.com/docs/workflow/howto/singleton + +A singleton workflow has at most one run in progress at a time. A nightly sync, +a per-user onboarding flow or a cache rebuild are typical examples: starting a +second copy while the first one is still running would do the same work twice. + +You get this behavior by starting every run with the **same `workflowRunId`**. +A run ID is reserved while its run is in progress, and released once the run +finishes, fails or is cancelled. + + + Singleton workflows require `@upstash/workflow` **1.4.0** or later. + + With older versions, a run started again with the same `workflowRunId` and the + same body within 10 minutes of the previous one is deduplicated by QStash: it + either doesn't start or gets stuck on its first step. + + +There are two ways to handle a new trigger while a run is in progress: + +| Pattern | When a run is already in progress | +| --- | --- | +| [Keep the running one](#keep-the-running-one) | The new trigger is rejected. The running run continues. | +| [Replace the running one](#replace-the-running-one) | The running run is cancelled and a new one starts. | + +In both patterns, a trigger after the previous run has ended starts a new run, +even with exactly the same body. + +## Keep the running one + +Trigger the workflow with a fixed `workflowRunId`. If a run with that ID is +still in progress, `client.trigger` throws and no new run is started. + +```ts +import { Client } from "@upstash/workflow"; + +const client = new Client({ token: "" }); + +try { + const { workflowRunId } = await client.trigger({ + url: "https:///nightly-sync", + workflowRunId: "nightly-sync", + body: { source: "crm" }, + }); + console.log(`started ${workflowRunId}`); // started wfr_nightly-sync +} catch (error) { + if (error instanceof Error && error.message.includes("a workflow already exists")) { + console.log("nightly-sync is already running, skipping"); + } else { + throw error; + } +} +``` + +What `client.trigger` returns: + +* **No run in progress:** a new run starts and the call resolves to + `{ workflowRunId: "wfr_nightly-sync" }`. +* **A run is in progress:** the call throws a `QstashError` with `status: 400`. + Its message contains + `a workflow already exists, can not initialize a new one with same id`. + The running run is not affected. + + + The ID you pass is prefixed with `wfr_`. `nightly-sync` becomes + `wfr_nightly-sync`, which is the ID you see in the logs and pass to + `client.cancel`. + + +## Replace the running one + +To always run the latest request, cancel the run with the ID first and then +start a new one with the same ID. If the previous run hasn't finished yet, it is +stopped and the new run takes its place. + +```ts +import { Client } from "@upstash/workflow"; + +const client = new Client({ token: "" }); + +const { cancelled } = await client.cancel("wfr_rebuild-cache"); +console.log(`cancelled ${cancelled} run(s)`); + +const { workflowRunId } = await client.trigger({ + url: "https:///rebuild-cache", + workflowRunId: "rebuild-cache", + body: { reason: "settings changed" }, +}); +console.log(`started ${workflowRunId}`); // started wfr_rebuild-cache +``` + +What each call returns: + +* `client.cancel` resolves to `{ cancelled: 1 }` when a run was in progress and + got cancelled, and to `{ cancelled: 0 }` when there was nothing to cancel. It + doesn't throw in either case, so you can call it unconditionally. +* `client.trigger` resolves to `{ workflowRunId: "wfr_rebuild-cache" }`. The + cancel is completed before `client.cancel` returns, so the ID is free when you + trigger the new run. + +The cancelled run stops right away. If one of its steps is executing on your +endpoint at that moment, that code runs to the end, but its result is +discarded. Steps, waits and `context.call` results of the cancelled run are +never added to the new run, even though both runs share the same ID. + + + If two callers cancel and trigger the same ID at the same time, both cancels + can complete before either trigger. Then the first trigger starts a run, and + the second one throws the `a workflow already exists` error described in + [Keep the running one](#keep-the-running-one). Catch it and retry the cancel + and trigger if the latest request must win. + + # Start a Run Source: https://upstash.com/docs/workflow/howto/start diff --git a/llms.txt b/llms.txt index 445da897..82a0b9a1 100644 --- a/llms.txt +++ b/llms.txt @@ -1478,6 +1478,7 @@ Search these docs: GET https://upstash.com/docs/search?q= returns JSON { - [Run a Step Now](https://upstash.com/docs/workflow/howto/retry-now.md) - [Schedule a Workflow](https://upstash.com/docs/workflow/howto/schedule.md) - [Secure a Workflow](https://upstash.com/docs/workflow/howto/security.md) +- [Singleton Workflows](https://upstash.com/docs/workflow/howto/singleton.md): Make sure only one run of a workflow is in progress at a time by reusing the same workflowRunId. - [Start a Run](https://upstash.com/docs/workflow/howto/start.md) - [Webhooks](https://upstash.com/docs/workflow/howto/use-webhooks.md) - [Vercel AI SDK](https://upstash.com/docs/workflow/integrations/aisdk.md) From 1bedeab8bebc6ee21b76f66c4ff51cee835bf9bf Mon Sep 17 00:00:00 2001 From: sancar Date: Wed, 7 Oct 2026 15:00:51 +0300 Subject: [PATCH 3/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- workflow/howto/singleton.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflow/howto/singleton.mdx b/workflow/howto/singleton.mdx index 20adb811..4c9b3697 100644 --- a/workflow/howto/singleton.mdx +++ b/workflow/howto/singleton.mdx @@ -23,7 +23,7 @@ There are two ways to handle a new trigger while a run is in progress: | Pattern | When a run is already in progress | | --- | --- | -| [Keep the running one](#keep-the-running-one) | The new trigger is rejected. The running run continues. | +| [Keep the running one](#keep-the-running-one) | The new trigger is rejected. The existing run continues. | | [Replace the running one](#replace-the-running-one) | The running run is cancelled and a new one starts. | In both patterns, a trigger after the previous run has ended starts a new run, From 291db56c079ae976d188505da3d259c8f2c7aeb7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:01:12 +0000 Subject: [PATCH 4/8] chore(llms): regenerate llms.txt and llms-full.txt --- llms-full.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/llms-full.txt b/llms-full.txt index b327b79a..8ad4d8ec 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -116615,7 +116615,7 @@ There are two ways to handle a new trigger while a run is in progress: | Pattern | When a run is already in progress | | --- | --- | -| [Keep the running one](#keep-the-running-one) | The new trigger is rejected. The running run continues. | +| [Keep the running one](#keep-the-running-one) | The new trigger is rejected. The existing run continues. | | [Replace the running one](#replace-the-running-one) | The running run is cancelled and a new one starts. | In both patterns, a trigger after the previous run has ended starts a new run, From 80b5039bea4a1ce8cd5ed2bd4ac1e13966dd2008 Mon Sep 17 00:00:00 2001 From: sancar Date: Wed, 7 Oct 2026 15:08:57 +0300 Subject: [PATCH 5/8] docs(workflow): check singleton trigger rejection by error type, not message Match QstashError with status 400 instead of the error text, and confirm a run with the id is in progress with client.logs, since 400 also covers other invalid trigger requests. Co-Authored-By: Claude Opus 5.5 --- workflow/howto/singleton.mdx | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/workflow/howto/singleton.mdx b/workflow/howto/singleton.mdx index 4c9b3697..8568972e 100644 --- a/workflow/howto/singleton.mdx +++ b/workflow/howto/singleton.mdx @@ -36,6 +36,7 @@ still in progress, `client.trigger` throws and no new run is started. ```ts import { Client } from "@upstash/workflow"; +import { QstashError } from "@upstash/qstash"; const client = new Client({ token: "" }); @@ -47,11 +48,18 @@ try { }); console.log(`started ${workflowRunId}`); // started wfr_nightly-sync } catch (error) { - if (error instanceof Error && error.message.includes("a workflow already exists")) { - console.log("nightly-sync is already running, skipping"); - } else { + if (!(error instanceof QstashError) || error.status !== 400) { throw error; } + + // a 400 can also mean an invalid request, so check that a run is in progress + const { runs } = await client.logs({ + filter: { workflowRunId: "wfr_nightly-sync", state: "RUN_STARTED" }, + }); + if (runs.length === 0) { + throw error; + } + console.log("nightly-sync is already running, skipping"); } ``` @@ -60,10 +68,22 @@ What `client.trigger` returns: - **No run in progress:** a new run starts and the call resolves to `{ workflowRunId: "wfr_nightly-sync" }`. - **A run is in progress:** the call throws a `QstashError` with `status: 400`. - Its message contains - `a workflow already exists, can not initialize a new one with same id`. The running run is not affected. +QStash returns `400` for other invalid trigger requests too, such as a malformed +URL, so `status` alone doesn't tell you that the ID is taken. The example checks +with [`client.logs`](/workflow/basics/client/logs) that a run with the ID is in +progress before skipping. If that run ends between the two calls, `runs` is +empty and the error is rethrown, so you can retry the trigger. + + + `QstashError` comes from `@upstash/qstash`, which `@upstash/workflow` depends + on. If your package manager doesn't let you import a dependency of a + dependency, add `@upstash/qstash` to your own dependencies. Keep it on the + version `@upstash/workflow` uses: with two copies of the package, + `instanceof QstashError` doesn't match. + + The ID you pass is prefixed with `wfr_`. `nightly-sync` becomes `wfr_nightly-sync`, which is the ID you see in the logs and pass to @@ -109,7 +129,7 @@ never added to the new run, even though both runs share the same ID. If two callers cancel and trigger the same ID at the same time, both cancels can complete before either trigger. Then the first trigger starts a run, and - the second one throws the `a workflow already exists` error described in + the second one throws the `400` error described in [Keep the running one](#keep-the-running-one). Catch it and retry the cancel and trigger if the latest request must win. From cea45488dbad2b70f087a8519b80bb54ddc058e9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:09:34 +0000 Subject: [PATCH 6/8] chore(llms): regenerate llms.txt and llms-full.txt --- llms-full.txt | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 8ad4d8ec..d0cc4d58 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -116628,6 +116628,7 @@ still in progress, `client.trigger` throws and no new run is started. ```ts import { Client } from "@upstash/workflow"; +import { QstashError } from "@upstash/qstash"; const client = new Client({ token: "" }); @@ -116639,11 +116640,18 @@ try { }); console.log(`started ${workflowRunId}`); // started wfr_nightly-sync } catch (error) { - if (error instanceof Error && error.message.includes("a workflow already exists")) { - console.log("nightly-sync is already running, skipping"); - } else { + if (!(error instanceof QstashError) || error.status !== 400) { + throw error; + } + + // a 400 can also mean an invalid request, so check that a run is in progress + const { runs } = await client.logs({ + filter: { workflowRunId: "wfr_nightly-sync", state: "RUN_STARTED" }, + }); + if (runs.length === 0) { throw error; } + console.log("nightly-sync is already running, skipping"); } ``` @@ -116652,10 +116660,22 @@ What `client.trigger` returns: * **No run in progress:** a new run starts and the call resolves to `{ workflowRunId: "wfr_nightly-sync" }`. * **A run is in progress:** the call throws a `QstashError` with `status: 400`. - Its message contains - `a workflow already exists, can not initialize a new one with same id`. The running run is not affected. +QStash returns `400` for other invalid trigger requests too, such as a malformed +URL, so `status` alone doesn't tell you that the ID is taken. The example checks +with [`client.logs`](/docs/workflow/basics/client/logs) that a run with the ID is in +progress before skipping. If that run ends between the two calls, `runs` is +empty and the error is rethrown, so you can retry the trigger. + + + `QstashError` comes from `@upstash/qstash`, which `@upstash/workflow` depends + on. If your package manager doesn't let you import a dependency of a + dependency, add `@upstash/qstash` to your own dependencies. Keep it on the + version `@upstash/workflow` uses: with two copies of the package, + `instanceof QstashError` doesn't match. + + The ID you pass is prefixed with `wfr_`. `nightly-sync` becomes `wfr_nightly-sync`, which is the ID you see in the logs and pass to @@ -116701,7 +116721,7 @@ never added to the new run, even though both runs share the same ID. If two callers cancel and trigger the same ID at the same time, both cancels can complete before either trigger. Then the first trigger starts a run, and - the second one throws the `a workflow already exists` error described in + the second one throws the `400` error described in [Keep the running one](#keep-the-running-one). Catch it and retry the cancel and trigger if the latest request must win. From c301414686fbc6df7d7472c0c4128f1d6050d62d Mon Sep 17 00:00:00 2001 From: sancar Date: Wed, 7 Oct 2026 16:46:04 +0300 Subject: [PATCH 7/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- workflow/howto/singleton.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/workflow/howto/singleton.mdx b/workflow/howto/singleton.mdx index 8568972e..3bd024a6 100644 --- a/workflow/howto/singleton.mdx +++ b/workflow/howto/singleton.mdx @@ -92,9 +92,10 @@ empty and the error is rethrown, so you can retry the trigger. ## Replace the running one -To always run the latest request, cancel the run with the ID first and then -start a new one with the same ID. If the previous run hasn't finished yet, it is -stopped and the new run takes its place. +To always run the latest request, first cancel the existing run by its +prefixed run ID (for example, `wfr_rebuild-cache`), then start a new one with +the unprefixed `workflowRunId` (for example, `rebuild-cache`). If the previous +run hasn't finished yet, it is stopped and the new run takes its place. ```ts import { Client } from "@upstash/workflow"; From 568de1346c4e8ecc5e5740126772798a15fc24e2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:46:29 +0000 Subject: [PATCH 8/8] chore(llms): regenerate llms.txt and llms-full.txt --- llms-full.txt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index d0cc4d58..f77057f6 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -116684,9 +116684,10 @@ empty and the error is rethrown, so you can retry the trigger. ## Replace the running one -To always run the latest request, cancel the run with the ID first and then -start a new one with the same ID. If the previous run hasn't finished yet, it is -stopped and the new run takes its place. +To always run the latest request, first cancel the existing run by its +prefixed run ID (for example, `wfr_rebuild-cache`), then start a new one with +the unprefixed `workflowRunId` (for example, `rebuild-cache`). If the previous +run hasn't finished yet, it is stopped and the new run takes its place. ```ts import { Client } from "@upstash/workflow";