From eec5fcad83271fb7430c570724b58a4e39f27efb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 17:59:55 +0100 Subject: [PATCH 1/6] docs: combined concurrency limits and queue gates --- docs/docs.json | 4 +- .../queues/combined-concurrency-override.mdx | 4 + .../queues/combined-concurrency-reset.mdx | 4 + docs/queue-concurrency.mdx | 96 +++++++++++ docs/v3-openapi.yaml | 157 ++++++++++++++++++ 5 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 docs/management/queues/combined-concurrency-override.mdx create mode 100644 docs/management/queues/combined-concurrency-reset.mdx diff --git a/docs/docs.json b/docs/docs.json index d7524a9664b..60aedbe6a62 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -379,7 +379,9 @@ "management/queues/retrieve", "management/queues/pause", "management/queues/concurrency-override", - "management/queues/concurrency-reset" + "management/queues/concurrency-reset", + "management/queues/combined-concurrency-override", + "management/queues/combined-concurrency-reset" ] }, { diff --git a/docs/management/queues/combined-concurrency-override.mdx b/docs/management/queues/combined-concurrency-override.mdx new file mode 100644 index 00000000000..40aba8ae062 --- /dev/null +++ b/docs/management/queues/combined-concurrency-override.mdx @@ -0,0 +1,4 @@ +--- +title: "Override Combined Concurrency Limit" +openapi: "v3-openapi POST /api/v1/queues/{queueParam}/concurrency/combined/override" +--- diff --git a/docs/management/queues/combined-concurrency-reset.mdx b/docs/management/queues/combined-concurrency-reset.mdx new file mode 100644 index 00000000000..4d7031d354c --- /dev/null +++ b/docs/management/queues/combined-concurrency-reset.mdx @@ -0,0 +1,4 @@ +--- +title: "Reset Combined Concurrency Limit" +openapi: "v3-openapi POST /api/v1/queues/{queueParam}/concurrency/combined/reset" +--- diff --git a/docs/queue-concurrency.mdx b/docs/queue-concurrency.mdx index b832ffc26da..58a92f2ee6a 100644 --- a/docs/queue-concurrency.mdx +++ b/docs/queue-concurrency.mdx @@ -157,6 +157,86 @@ export async function POST(request: Request) { } ``` +## Combined concurrency across keys + +`concurrencyKey` gives every key value its own copy of the queue, each with the queue's full `concurrencyLimit`. That means the queue's total concurrency grows with the number of active keys: ten active users on a queue with `concurrencyLimit: 5` can run 50 at once. + +To bound the whole queue, set `combinedConcurrencyLimit`. Each key still gets at most `concurrencyLimit`, and the queue as a whole never exceeds the combined limit across all keys: + +```ts /trigger/per-user.ts +export const perUserQueue = queue({ + name: "per-user-queue", + //each user runs at most 1 at a time... + concurrencyLimit: 1, + //...and at most 10 users can be running at once + combinedConcurrencyLimit: 10, +}); +``` + +The combined limit only applies to runs triggered with a `concurrencyKey`; runs without a key are governed by `concurrencyLimit` alone. On the Queues page in the dashboard, a queue with a combined limit shows it in brackets next to the per-key limit, e.g. `1 (10)`. + + + If you self-host, combined limits are enforced by default and can be disabled with + `RUN_ENGINE_TOTAL_CONCURRENCY_LIMITS_ENABLED=0`. When enforcement is disabled the limit is + still accepted, stored, and shown, but runs are not held back by it. + + +## Holding slots in more than one queue (queue gates) + +Sometimes one limit isn't enough: a webhook processor should be capped as a task, but each tenant should also have a global cap across every task they run. Queue gates let a run hold a concurrency slot in more than one queue at once. + +Pass an array as `queue`: the first entry is the run's home queue (where it waits), and up to two more entries name gates — other queues the run must also have capacity in before it starts, and occupies while it executes: + +```ts /trigger/webhooks.ts +export const tenantQueue = queue({ name: "tenant", concurrencyLimit: 10 }); + +export const processWebhook = task({ + id: "process-webhook", + queue: [{ name: "webhooks", concurrencyLimit: 2 }, "tenant"], + run: async (payload) => { + //... + }, +}); +``` + +```ts app/api/webhook/route.ts +//the run waits in "webhooks" and also counts towards this tenant's cap +await processWebhook.trigger(payload, { concurrencyKey: tenantId }); +``` + +A gate without a `concurrencyKey` uses the run's own key, so the shared `tenant` queue above caps each tenant across every task that names it as a gate. Give the gate a literal key to pin it to a single slot pool instead, for example capping all traffic to one external provider across your whole environment: + +```ts /trigger/sync.ts +export const syncToProvider = task({ + id: "sync-to-provider", + queue: [ + { name: "sync-home", concurrencyLimit: 20 }, + //every run shares one "provider-api" pool regardless of its own key + { name: "provider-api", concurrencyKey: "shared" }, + ], + run: async (payload) => { + //... + }, +}); +``` + +The same array form works when you trigger, replacing the task's gates for that run: + +```ts +await processWebhook.trigger(payload, { + queue: ["webhooks", "tenant"], + concurrencyKey: tenantId, +}); +``` + +A run starts only when its home queue and every gate all have capacity, and it releases all of its slots together when it finishes or suspends. + + + Queue gates are enforced when the server has them enabled. If you self-host, set + `RUN_ENGINE_QUEUE_GATES_ENABLED=1`; servers without gates enabled accept the option but run + without it. + + ## Concurrency and subtasks When you trigger a task that has subtasks, the subtasks will not inherit the queue from the parent task. Unless otherwise specified, subtasks will run on their own queue @@ -356,3 +436,19 @@ await queues.resetConcurrencyLimit("queue_1234"); // Or using type and name await queues.resetConcurrencyLimit({ type: "task", name: "my-task-id" }); ``` + +### Overriding the combined concurrency limit + +Queues with a `combinedConcurrencyLimit` can have that cap overridden and reset in the same way: + +```ts +import { queues } from "@trigger.dev/sdk"; + +// Allow up to 100 runs across all concurrency keys +await queues.overrideCombinedConcurrencyLimit("queue_1234", 100); + +// Revert to the combinedConcurrencyLimit declared in your code +await queues.resetCombinedConcurrencyLimit("queue_1234"); +``` + +Overrides survive deploys: redeploying your code keeps an active override until you reset it. diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index a97ae70307e..f4cb46040c0 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -3048,6 +3048,132 @@ paths: 20 ); + "/api/v1/queues/{queueParam}/concurrency/combined/override": + post: + operationId: override_queue_combined_concurrency_v1 + summary: Override combined concurrency limit + description: | + Override the combined concurrency limit of a queue: the cap on concurrent runs across + all of the queue's `concurrencyKey` values. Useful for temporarily scaling a whole + keyed queue up or down without changing each key's own limit. + parameters: + - in: path + name: queueParam + required: true + schema: + type: string + description: The queue ID (e.g., `queue_1234`), or the name of the queue when using the `type` body parameter. + example: queue_1234 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: ["combinedConcurrencyLimit"] + properties: + type: + type: string + enum: [id, task, custom] + default: id + description: | + How to interpret the `queueParam` path parameter: + - `id`: Treat as a queue ID (default) + - `task`: Treat as a task ID to get the task's default queue + - `custom`: Treat as a custom queue name + combinedConcurrencyLimit: + type: integer + minimum: 0 + maximum: 100000 + description: | + The new combined concurrency limit to set for the queue. It may not exceed + your environment's maximum concurrency limit: a higher value is rejected + with a 400, not capped to the maximum. + responses: + "200": + description: Combined concurrency limit overridden successfully + content: + application/json: + schema: + "$ref": "#/components/schemas/QueueObject" + "400": + description: | + Invalid request parameters, or the requested combined concurrency limit exceeds + the environment's maximum concurrency limit. + "401": + description: Unauthorized request + "404": + description: Queue not found + tags: + - queues + security: + - secretKey: [] + x-codeSamples: + - lang: typescript + source: |- + import { queues } from "@trigger.dev/sdk"; + + // Allow up to 100 runs across all concurrency keys + await queues.overrideCombinedConcurrencyLimit("queue_1234", 100); + + // Using type and name + await queues.overrideCombinedConcurrencyLimit( + { type: "custom", name: "per-user-queue" }, + 100 + ); + + "/api/v1/queues/{queueParam}/concurrency/combined/reset": + post: + operationId: reset_queue_combined_concurrency_v1 + summary: Reset combined concurrency limit + description: Reset the combined concurrency limit of a queue back to the `combinedConcurrencyLimit` declared in your code. + parameters: + - in: path + name: queueParam + required: true + schema: + type: string + description: The queue ID (e.g., `queue_1234`), or the name of the queue when using the `type` body parameter. + example: queue_1234 + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: [id, task, custom] + default: id + description: | + How to interpret the `queueParam` path parameter: + - `id`: Treat as a queue ID (default) + - `task`: Treat as a task ID to get the task's default queue + - `custom`: Treat as a custom queue name + responses: + "200": + description: Combined concurrency limit reset successfully + content: + application/json: + schema: + "$ref": "#/components/schemas/QueueObject" + "401": + description: Unauthorized request + "404": + description: Queue not found + tags: + - queues + security: + - secretKey: [] + x-codeSamples: + - lang: typescript + source: |- + import { queues } from "@trigger.dev/sdk"; + + // Revert to the combinedConcurrencyLimit declared in code + await queues.resetCombinedConcurrencyLimit("queue_1234"); + "/api/v1/queues/{queueParam}/concurrency/reset": post: operationId: reset_queue_concurrency_v1 @@ -4321,6 +4447,37 @@ components: format: date-time nullable: true description: When the concurrency limit was overridden + combined: + type: object + description: | + The combined concurrency cap across all `concurrencyKey` values of the queue. + Present when the queue has a `combinedConcurrencyLimit`. + properties: + current: + type: integer + nullable: true + description: The current combined concurrency limit as declared or overridden (null = no cap). Enforcement clamps it to the environment concurrency limit at admit time. + example: 10 + base: + type: integer + nullable: true + description: The declared combined limit an override reverts to on reset + example: 10 + override: + type: integer + nullable: true + description: The overridden combined limit, when an override is active + example: null + overriddenAt: + type: string + format: date-time + nullable: true + description: When the combined override was applied + running: + type: integer + nullable: true + description: Runs currently in flight across all concurrencyKey values + example: 4 example: null overriddenBy: type: string From e05ad8c363299d0dae84a783680f40775b30263f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 18:07:29 +0100 Subject: [PATCH 2/6] docs: correct the combined override body field, reset 400, and gate example --- docs/queue-concurrency.mdx | 3 +++ docs/v3-openapi.yaml | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/queue-concurrency.mdx b/docs/queue-concurrency.mdx index 58a92f2ee6a..993782c77ce 100644 --- a/docs/queue-concurrency.mdx +++ b/docs/queue-concurrency.mdx @@ -207,6 +207,9 @@ await processWebhook.trigger(payload, { concurrencyKey: tenantId }); A gate without a `concurrencyKey` uses the run's own key, so the shared `tenant` queue above caps each tenant across every task that names it as a gate. Give the gate a literal key to pin it to a single slot pool instead, for example capping all traffic to one external provider across your whole environment: ```ts /trigger/sync.ts +//the gate's capacity comes from the queue's own declaration +export const providerApiQueue = queue({ name: "provider-api", concurrencyLimit: 5 }); + export const syncToProvider = task({ id: "sync-to-provider", queue: [ diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index f4cb46040c0..6715986cfab 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -3070,7 +3070,7 @@ paths: application/json: schema: type: object - required: ["combinedConcurrencyLimit"] + required: ["concurrencyLimit"] properties: type: type: string @@ -3081,7 +3081,7 @@ paths: - `id`: Treat as a queue ID (default) - `task`: Treat as a task ID to get the task's default queue - `custom`: Treat as a custom queue name - combinedConcurrencyLimit: + concurrencyLimit: type: integer minimum: 0 maximum: 100000 @@ -3158,6 +3158,8 @@ paths: application/json: schema: "$ref": "#/components/schemas/QueueObject" + "400": + description: The queue's combined concurrency limit is not overridden, or invalid request parameters "401": description: Unauthorized request "404": From 6d1b03a0b67c9e6aaf5193a0e109795ea313f706 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 18:12:54 +0100 Subject: [PATCH 3/6] docs: per-key home cap wording, combined field always present, reset body required The gates intro promised a task-wide cap the keyed example does not deliver (a concurrencyKey splits the home queue per key); the combined object is emitted on every queue with null fields rather than omitted; and both reset endpoints reject a zero-length body, so the body is required. Also restores the example that drifted off overriddenAt. --- docs/queue-concurrency.mdx | 4 +++- docs/v3-openapi.yaml | 11 +++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/queue-concurrency.mdx b/docs/queue-concurrency.mdx index 993782c77ce..91319367fdd 100644 --- a/docs/queue-concurrency.mdx +++ b/docs/queue-concurrency.mdx @@ -183,7 +183,7 @@ The combined limit only applies to runs triggered with a `concurrencyKey`; runs ## Holding slots in more than one queue (queue gates) -Sometimes one limit isn't enough: a webhook processor should be capped as a task, but each tenant should also have a global cap across every task they run. Queue gates let a run hold a concurrency slot in more than one queue at once. +Sometimes one limit isn't enough: each tenant's webhook processing should be capped, but the tenant should also have a global cap across every task they run. Queue gates let a run hold a concurrency slot in more than one queue at once. Pass an array as `queue`: the first entry is the run's home queue (where it waits), and up to two more entries name gates — other queues the run must also have capacity in before it starts, and occupies while it executes: @@ -204,6 +204,8 @@ export const processWebhook = task({ await processWebhook.trigger(payload, { concurrencyKey: tenantId }); ``` +Because the trigger passes a `concurrencyKey`, the home queue splits per key as usual: `concurrencyLimit: 2` caps each tenant's webhook runs, not the task overall (add a `combinedConcurrencyLimit` to the home queue to bound it across all tenants). + A gate without a `concurrencyKey` uses the run's own key, so the shared `tenant` queue above caps each tenant across every task that names it as a gate. Give the gate a literal key to pin it to a single slot pool instead, for example capping all traffic to one external provider across your whole environment: ```ts /trigger/sync.ts diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index 6715986cfab..1590a86c926 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -3136,7 +3136,8 @@ paths: description: The queue ID (e.g., `queue_1234`), or the name of the queue when using the `type` body parameter. example: queue_1234 requestBody: - required: false + required: true + description: At least an empty JSON object `{}` must be sent; a zero-length body is rejected with a 400. content: application/json: schema: @@ -3190,7 +3191,8 @@ paths: description: The queue ID (e.g., `queue_1234`), or the name of the queue when using the `type` body parameter. example: queue_1234 requestBody: - required: false + required: true + description: At least an empty JSON object `{}` must be sent; a zero-length body is rejected with a 400. content: application/json: schema: @@ -4449,11 +4451,13 @@ components: format: date-time nullable: true description: When the concurrency limit was overridden + example: null combined: type: object description: | The combined concurrency cap across all `concurrencyKey` values of the queue. - Present when the queue has a `combinedConcurrencyLimit`. + Always present; `current` is null when the queue has no combined limit, so + check `combined.current !== null` rather than the field's presence. properties: current: type: integer @@ -4480,7 +4484,6 @@ components: nullable: true description: Runs currently in flight across all concurrencyKey values example: 4 - example: null overriddenBy: type: string nullable: true From f31efe870b783a4bfe54d75ba37b8111b4aa3104 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 18:49:09 +0100 Subject: [PATCH 4/6] docs: older servers may omit the combined field, so check it defensively --- docs/v3-openapi.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index 1590a86c926..b764b59de3a 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -4456,8 +4456,9 @@ components: type: object description: | The combined concurrency cap across all `concurrencyKey` values of the queue. - Always present; `current` is null when the queue has no combined limit, so - check `combined.current !== null` rather than the field's presence. + Servers on this version always emit it, with `current` null when the queue + has no combined limit; older servers may omit the field entirely, so check + `combined?.current != null` rather than relying on its presence. properties: current: type: integer From ab4c60749a04fa80b3397385e2fbd971c70301ef Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 19:13:05 +0100 Subject: [PATCH 5/6] docs: use-case-first structure for queue concurrency A Use cases index links each goal to its section, the multi-queue section names the home queue and gate concepts once and gives each pattern its own worked example (per-tenant cap across tasks, global cap for a shared resource via a combined-only queue, pinned-key shared pool), and the per-key-except-combined rule gets a warning callout. Folds in the simplified wording and removes self-hosting notes. --- docs/queue-concurrency.mdx | 87 ++++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/docs/queue-concurrency.mdx b/docs/queue-concurrency.mdx index 91319367fdd..ff37f88e9b7 100644 --- a/docs/queue-concurrency.mdx +++ b/docs/queue-concurrency.mdx @@ -11,6 +11,16 @@ Controlling concurrency is useful when you have a task that can't be run concurr It's important to note that only actively executing runs count towards concurrency limits. Runs that are delayed or waiting in a queue do not consume concurrency slots until they begin execution. +## Use cases + +- **Limit how many runs of a task execute at once**: [Setting task concurrency](#setting-task-concurrency) +- **Share one limit across several tasks**: [Sharing concurrency between tasks](#sharing-concurrency-between-tasks) +- **Give each tenant its own separate concurrency**: [Concurrency keys and per-tenant queuing](#concurrency-keys-and-per-tenant-queuing) +- **Per-tenant limits with a ceiling on the whole queue**: [Combined concurrency across keys](#combined-concurrency-across-keys) +- **Cap a tenant across every task they run**: [A per-tenant cap across multiple tasks](#a-per-tenant-cap-across-multiple-tasks) +- **Cap a shared resource, like an external API, across tasks and tenants**: [A global cap for a shared resource](#a-global-cap-for-a-shared-resource) +- **Funnel every run into one shared pool**: [One shared pool ignoring keys](#one-shared-pool-ignoring-keys) + ## Default concurrency By default, all tasks have an unbounded concurrency limit, limited only by the overall concurrency limits of your environment. @@ -168,26 +178,37 @@ export const perUserQueue = queue({ name: "per-user-queue", //each user runs at most 1 at a time... concurrencyLimit: 1, - //...and at most 10 users can be running at once + //...and at most 10 total runs across all users combinedConcurrencyLimit: 10, }); ``` The combined limit only applies to runs triggered with a `concurrencyKey`; runs without a key are governed by `concurrencyLimit` alone. On the Queues page in the dashboard, a queue with a combined limit shows it in brackets next to the per-key limit, e.g. `1 (10)`. - - If you self-host, combined limits are enforced by default and can be disabled with - `RUN_ENGINE_TOTAL_CONCURRENCY_LIMITS_ENABLED=0`. When enforcement is disabled the limit is - still accepted, stored, and shown, but runs are not held back by it. - + + On a queue used with `concurrencyKey`, every limit applies per key value except + `combinedConcurrencyLimit`, which is the only cap that spans the whole queue. + -## Holding slots in more than one queue (queue gates) +## Using multiple queues at once -Sometimes one limit isn't enough: each tenant's webhook processing should be capped, but the tenant should also have a global cap across every task they run. Queue gates let a run hold a concurrency slot in more than one queue at once. +Sometimes one limit isn't enough. A run always waits in one queue, its **home queue**, but it can also hold a concurrency slot in up to two more queues, called **gates**. A run starts only when its home queue and every gate all have capacity, occupies a slot in each while it executes, and releases them together when it finishes or suspends. -Pass an array as `queue`: the first entry is the run's home queue (where it waits), and up to two more entries name gates — other queues the run must also have capacity in before it starts, and occupies while it executes: +Pass an array as `queue`: the first entry is the home queue, the rest name gates. The same array form works when you trigger, replacing the task's gates for that run: + +```ts +await processWebhook.trigger(payload, { + queue: ["webhooks", "tenant"], + concurrencyKey: tenantId, +}); +``` + +### A per-tenant cap across multiple tasks + +A gate without a `concurrencyKey` uses the run's own key. Declare a shared queue and gate every relevant task on it, and each tenant gets one cap spanning all of those tasks: ```ts /trigger/webhooks.ts +//each tenant runs at most 10 at once across every task that gates on this queue export const tenantQueue = queue({ name: "tenant", concurrencyLimit: 10 }); export const processWebhook = task({ @@ -204,43 +225,47 @@ export const processWebhook = task({ await processWebhook.trigger(payload, { concurrencyKey: tenantId }); ``` -Because the trigger passes a `concurrencyKey`, the home queue splits per key as usual: `concurrencyLimit: 2` caps each tenant's webhook runs, not the task overall (add a `combinedConcurrencyLimit` to the home queue to bound it across all tenants). +Because the trigger passes a `concurrencyKey`, the home queue splits per key as usual: `concurrencyLimit: 2` caps each tenant's webhook runs, not the task overall. -A gate without a `concurrencyKey` uses the run's own key, so the shared `tenant` queue above caps each tenant across every task that names it as a gate. Give the gate a literal key to pin it to a single slot pool instead, for example capping all traffic to one external provider across your whole environment: +### A global cap for a shared resource + +To cap something global, like total traffic to an external API, across many tasks and all tenants: declare a queue with only a `combinedConcurrencyLimit` and gate on it. Tenant keys still split the gate into per-key pools, but with no per-key limit the combined cap is the only constraint: ```ts /trigger/sync.ts -//the gate's capacity comes from the queue's own declaration -export const providerApiQueue = queue({ name: "provider-api", concurrencyLimit: 5 }); +//at most 10 concurrent provider calls across every task and every tenant +export const providerApiQueue = queue({ + name: "provider-api", + combinedConcurrencyLimit: 10, +}); export const syncToProvider = task({ id: "sync-to-provider", - queue: [ - { name: "sync-home", concurrencyLimit: 20 }, - //every run shares one "provider-api" pool regardless of its own key - { name: "provider-api", concurrencyKey: "shared" }, - ], + queue: [{ name: "sync-home", concurrencyLimit: 20 }, "provider-api"], run: async (payload) => { //... }, }); ``` -The same array form works when you trigger, replacing the task's gates for that run: +### One shared pool ignoring keys -```ts -await processWebhook.trigger(payload, { - queue: ["webhooks", "tenant"], - concurrencyKey: tenantId, -}); -``` +Give a gate a literal `concurrencyKey` to pin every run into a single first-come-first-served pool, regardless of each run's own key: -A run starts only when its home queue and every gate all have capacity, and it releases all of its slots together when it finishes or suspends. +```ts /trigger/print.ts +export const printerQueue = queue({ name: "printer", concurrencyLimit: 1 }); - - Queue gates are enforced when the server has them enabled. If you self-host, set - `RUN_ENGINE_QUEUE_GATES_ENABLED=1`; servers without gates enabled accept the option but run - without it. - +export const printLabel = task({ + id: "print-label", + queue: [ + { name: "print-home", concurrencyLimit: 5 }, + //every run shares the single "printer" slot no matter its own key + { name: "printer", concurrencyKey: "shared" }, + ], + run: async (payload) => { + //... + }, +}); +``` ## Concurrency and subtasks From e73840db2dbe897cc33098345e7e5b0284d514eb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 19:21:55 +0100 Subject: [PATCH 6/6] docs: the global-cap gate pattern requires keyed triggers The combined limit only counts keyed runs, so the shared-resource example now shows the keyed trigger and warns that keyless runs bypass the cap, pointing those cases at the pinned-key pool. --- docs/queue-concurrency.mdx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/queue-concurrency.mdx b/docs/queue-concurrency.mdx index ff37f88e9b7..64c1f575589 100644 --- a/docs/queue-concurrency.mdx +++ b/docs/queue-concurrency.mdx @@ -247,6 +247,17 @@ export const syncToProvider = task({ }); ``` +```ts app/api/sync/route.ts +//the combined cap only counts keyed runs, so every trigger passes a key +await syncToProvider.trigger(payload, { concurrencyKey: tenantId }); +``` + + + This pattern requires every trigger to pass a `concurrencyKey`. A run triggered without one + bypasses the combined limit entirely. If some runs have no natural key, use the pinned-key + pool below instead. + + ### One shared pool ignoring keys Give a gate a literal `concurrencyKey` to pin every run into a single first-come-first-served pool, regardless of each run's own key: