From a3bbdcf705465797f660cc4baee048efea38bc72 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 17:59:55 +0100 Subject: [PATCH 01/17] 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 55771592538..78e0115cb1e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -380,7 +380,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 cb820d0cbda5f8034c9301345f07dea4c6a9bb6f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 18:07:29 +0100 Subject: [PATCH 02/17] 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 761904e28749f173d232495f5f25c816e6637c1b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 18:12:54 +0100 Subject: [PATCH 03/17] 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 0652d123b46ecea7b3ff401dcd38b6d74227db1a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 18:49:09 +0100 Subject: [PATCH 04/17] 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 54b85f85c09012b1e134e3e41f7350c0e7da994f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 19:13:05 +0100 Subject: [PATCH 05/17] 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 5f3a8623f6c55530a63f61c66142c755b7d1ad0e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 19:21:55 +0100 Subject: [PATCH 06/17] 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: From 980ec735d74aadbbe85bce14eaf30c44e8c89e71 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 6 Sep 2026 13:37:20 +0100 Subject: [PATCH 07/17] docs: imports for the standalone queue examples --- docs/queue-concurrency.mdx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/queue-concurrency.mdx b/docs/queue-concurrency.mdx index 64c1f575589..6f198d88164 100644 --- a/docs/queue-concurrency.mdx +++ b/docs/queue-concurrency.mdx @@ -174,6 +174,8 @@ export async function POST(request: Request) { 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 +import { queue } from "@trigger.dev/sdk"; + export const perUserQueue = queue({ name: "per-user-queue", //each user runs at most 1 at a time... @@ -208,6 +210,8 @@ await processWebhook.trigger(payload, { 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 +import { queue, task } from "@trigger.dev/sdk"; + //each tenant runs at most 10 at once across every task that gates on this queue export const tenantQueue = queue({ name: "tenant", concurrencyLimit: 10 }); @@ -232,6 +236,8 @@ Because the trigger passes a `concurrencyKey`, the home queue splits per key as 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 +import { queue, task } from "@trigger.dev/sdk"; + //at most 10 concurrent provider calls across every task and every tenant export const providerApiQueue = queue({ name: "provider-api", @@ -263,6 +269,8 @@ await syncToProvider.trigger(payload, { 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: ```ts /trigger/print.ts +import { queue, task } from "@trigger.dev/sdk"; + export const printerQueue = queue({ name: "printer", concurrencyLimit: 1 }); export const printLabel = task({ From 282d0f070befaeb9400229ff1f38290a7088b139 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 6 Sep 2026 13:54:04 +0100 Subject: [PATCH 08/17] docs: import for the trigger-time snippet --- docs/queue-concurrency.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/queue-concurrency.mdx b/docs/queue-concurrency.mdx index 6f198d88164..56a54c99b8b 100644 --- a/docs/queue-concurrency.mdx +++ b/docs/queue-concurrency.mdx @@ -199,6 +199,8 @@ Sometimes one limit isn't enough. A run always waits in one queue, its **home qu 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 +import { processWebhook } from "~/trigger/webhooks"; + await processWebhook.trigger(payload, { queue: ["webhooks", "tenant"], concurrencyKey: tenantId, From d07b995de90186a0fc79c79e2b85dc92e1b148db Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 6 Sep 2026 15:16:02 +0100 Subject: [PATCH 09/17] docs: scope the combined-limit claim to keyed runs --- docs/queue-concurrency.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/queue-concurrency.mdx b/docs/queue-concurrency.mdx index 56a54c99b8b..d36c072a09f 100644 --- a/docs/queue-concurrency.mdx +++ b/docs/queue-concurrency.mdx @@ -171,7 +171,7 @@ export async function POST(request: Request) { `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: +To bound keyed runs as a group, set `combinedConcurrencyLimit`. Each key still gets at most `concurrencyLimit`, and keyed runs as a whole never exceed the combined limit; runs triggered without a key sit outside it: ```ts /trigger/per-user.ts import { queue } from "@trigger.dev/sdk"; From 9736cd075c94dcf5afa3aac44c0b6a0b1c9fc3c6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 05:58:35 +0100 Subject: [PATCH 10/17] docs: task concurrency, named limits and the concurrency limits API Rewrites the concurrency guide around the concurrency option: inline perKey/total shapes, named limits shared across tasks with concurrencyLimit(), trigger-time named-limit switching, and the concurrencyLimits management namespace including pause-by-zero. The combined concurrency limit surface (queue option, response field, override endpoints and their reference pages) is gone; the new concurrency-limits endpoints are documented with a reference group. --- docs/docs.json | 13 +- docs/management/concurrency-limits/list.mdx | 4 + .../concurrency-limits/override.mdx | 4 + docs/management/concurrency-limits/reset.mdx | 4 + .../concurrency-limits/retrieve.mdx | 4 + .../queues/combined-concurrency-override.mdx | 4 - .../queues/combined-concurrency-reset.mdx | 4 - docs/queue-concurrency.mdx | 388 ++++++++++-------- docs/v3-openapi.yaml | 342 +++++++++------ 9 files changed, 459 insertions(+), 308 deletions(-) create mode 100644 docs/management/concurrency-limits/list.mdx create mode 100644 docs/management/concurrency-limits/override.mdx create mode 100644 docs/management/concurrency-limits/reset.mdx create mode 100644 docs/management/concurrency-limits/retrieve.mdx delete mode 100644 docs/management/queues/combined-concurrency-override.mdx delete mode 100644 docs/management/queues/combined-concurrency-reset.mdx diff --git a/docs/docs.json b/docs/docs.json index 78e0115cb1e..f1409b9a37c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -380,9 +380,16 @@ "management/queues/retrieve", "management/queues/pause", "management/queues/concurrency-override", - "management/queues/concurrency-reset", - "management/queues/combined-concurrency-override", - "management/queues/combined-concurrency-reset" + "management/queues/concurrency-reset" + ] + }, + { + "group": "Concurrency limits API", + "pages": [ + "management/concurrency-limits/list", + "management/concurrency-limits/retrieve", + "management/concurrency-limits/override", + "management/concurrency-limits/reset" ] }, { diff --git a/docs/management/concurrency-limits/list.mdx b/docs/management/concurrency-limits/list.mdx new file mode 100644 index 00000000000..b0f3ca2eba4 --- /dev/null +++ b/docs/management/concurrency-limits/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List Concurrency Limits" +openapi: "v3-openapi GET /api/v1/concurrency-limits" +--- diff --git a/docs/management/concurrency-limits/override.mdx b/docs/management/concurrency-limits/override.mdx new file mode 100644 index 00000000000..b99d0f07196 --- /dev/null +++ b/docs/management/concurrency-limits/override.mdx @@ -0,0 +1,4 @@ +--- +title: "Override Concurrency Limit" +openapi: "v3-openapi POST /api/v1/concurrency-limits/{name}/override" +--- diff --git a/docs/management/concurrency-limits/reset.mdx b/docs/management/concurrency-limits/reset.mdx new file mode 100644 index 00000000000..606b36f0033 --- /dev/null +++ b/docs/management/concurrency-limits/reset.mdx @@ -0,0 +1,4 @@ +--- +title: "Reset Concurrency Limit" +openapi: "v3-openapi POST /api/v1/concurrency-limits/{name}/reset" +--- diff --git a/docs/management/concurrency-limits/retrieve.mdx b/docs/management/concurrency-limits/retrieve.mdx new file mode 100644 index 00000000000..47b2e570866 --- /dev/null +++ b/docs/management/concurrency-limits/retrieve.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve Concurrency Limit" +openapi: "v3-openapi GET /api/v1/concurrency-limits/{name}" +--- diff --git a/docs/management/queues/combined-concurrency-override.mdx b/docs/management/queues/combined-concurrency-override.mdx deleted file mode 100644 index 40aba8ae062..00000000000 --- a/docs/management/queues/combined-concurrency-override.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -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 deleted file mode 100644 index 4d7031d354c..00000000000 --- a/docs/management/queues/combined-concurrency-reset.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -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 d36c072a09f..0c7a4fa7175 100644 --- a/docs/queue-concurrency.mdx +++ b/docs/queue-concurrency.mdx @@ -5,7 +5,7 @@ description: "Configure what you want to happen when there is more than one run When you trigger a task, it isn't executed immediately. Instead, the task [run](/runs) is placed into a queue for execution. -By default, each task gets its own queue and the concurrency is only limited by your environment concurrency limit. If you need more control (for example, to limit concurrency or share limits across multiple tasks), you can define a custom queue as described later. +By default, each task gets its own queue and the concurrency is only limited by your environment concurrency limit. If you need more control (for example, to limit concurrency or share limits across multiple tasks), you can set a concurrency limit on the task, or declare a named limit and share it, as described below. Controlling concurrency is useful when you have a task that can't be run concurrently, or when you want to limit the number of runs to avoid overloading a resource. @@ -14,12 +14,12 @@ It's important to note that only actively executing runs count towards concurren ## 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) +- **Share one limit across several tasks**: [Sharing a limit between tasks](#sharing-a-limit-between-tasks) +- **Give each tenant its own separate concurrency**: [Concurrency keys and per-tenant limits](#concurrency-keys-and-per-tenant-limits) +- **Per-tenant limits with a ceiling on the total**: [Per-key and total limits together](#per-key-and-total-limits-together) - **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) +- **Switch a run's limits when you trigger it**: [Setting limits when you trigger a run](#setting-limits-when-you-trigger-a-run) ## Default concurrency @@ -27,23 +27,23 @@ By default, all tasks have an unbounded concurrency limit, limited only by the o Your environment has a base concurrency limit and a burstable limit (default burst factor of 2.0x - the base limit). Individual queues are limited by the base concurrency limit, not the burstable - limit. For example, if your base limit is 10, your environment can burst up to 20 concurrent runs, - but any single queue can have at most 10 concurrent runs. If you're a paying customer you can - request higher burst limits by [contacting us](https://www.trigger.dev/contact). + the base limit). Individual tasks and limits are capped by the base concurrency limit, not the + burstable limit. For example, if your base limit is 10, your environment can burst up to 20 + concurrent runs, but any single limit can allow at most 10 concurrent runs. If you're a paying + customer you can request higher burst limits by [contacting us](https://www.trigger.dev/contact). ## Setting task concurrency -You can set the concurrency limit for a task by setting the `concurrencyLimit` property on the task's queue. This limits the number of runs that can be executing at any one time: +Set the `concurrency` option on a task to limit how many of its runs execute at once. `{ total: n }` caps the task outright: ```ts /trigger/one-at-a-time.ts +import { task } from "@trigger.dev/sdk"; + // This task will only run one at a time export const oneAtATime = task({ id: "one-at-a-time", - queue: { - concurrencyLimit: 1, - }, + concurrency: { total: 1 }, run: async (payload) => { //... }, @@ -52,42 +52,85 @@ export const oneAtATime = task({ This is useful if you need to control access to a shared resource, like a database or an API that has rate limits. -## Sharing concurrency between tasks +There are two ways to bound a task, and you can combine them: + +- `total` caps every run of the task together, whether or not runs use a `concurrencyKey`. +- `perKey` caps each `concurrencyKey` pool separately; runs triggered without a key share one pool. + +```ts /trigger/per-user.ts +import { task } from "@trigger.dev/sdk"; + +export const generateReport = task({ + id: "generate-report", + // each user runs at most 1 at a time, and at most 10 run in total + concurrency: { perKey: 1, total: 10 }, + run: async (payload) => { + //... + }, +}); +``` + + + The `queue: {"{ concurrencyLimit: n }"}` option keeps working but is deprecated in favor of + `concurrency`. Its single number means "per key when runs pass a `concurrencyKey`, whole queue + when they don't" — the `concurrency` shape says which you mean explicitly. + + +## Sharing a limit between tasks + +Declare a named limit with `concurrencyLimit()` and put it in each task's `concurrency`. Every task holding the limit draws from the same pools: -As well as putting queue settings directly on a task, you can define a queue and reuse it across multiple tasks. This allows you to share the same concurrency limit: +```ts /trigger/limits.ts +import { concurrencyLimit, task } from "@trigger.dev/sdk"; -```ts /trigger/queue.ts -export const myQueue = queue({ - name: "my-queue", - concurrencyLimit: 1, +// at most 25 concurrent runs across every task that holds this limit +export const openaiLimit = concurrencyLimit({ name: "openai", total: 25 }); + +export const generateSummary = task({ + id: "generate-summary", + concurrency: openaiLimit, + run: async (payload) => { + // ... + }, }); -export const task1 = task({ - id: "task-1", - queue: myQueue, - run: async (payload: { message: string }) => { +export const generateTitle = task({ + id: "generate-title", + concurrency: openaiLimit, + run: async (payload) => { // ... }, }); +``` + +A task's `concurrency` takes a single item or an array: at most one inline shape (which caps that task alone) plus up to two named limits. A run starts only when every limit it holds has capacity, and it occupies a slot in each while it executes: + +```ts /trigger/summarize.ts +import { concurrencyLimit, task } from "@trigger.dev/sdk"; -export const task2 = task({ - id: "task-2", - queue: myQueue, - run: async (payload: { message: string }) => { +export const openaiLimit = concurrencyLimit({ name: "openai", total: 25 }); + +export const summarizeThread = task({ + id: "summarize-thread", + // this task runs at most 5 at once, and also counts towards the shared openai limit + concurrency: [{ total: 5 }, openaiLimit], + run: async (payload) => { // ... }, }); ``` -In this example, `task1` and `task2` share the same queue, so only one of them can run at a time. + + Limit names are 1-122 characters using only letters, numbers, underscores and hyphens. + ## Setting the queue when you trigger a run -When you trigger a task you can override the default queue. This is really useful if you sometimes have high priority runs. +When you trigger a task you can override its queue by name. This is really useful if you sometimes have high priority runs: -The task and queue definition: +```ts /trigger/override-queue.ts +import { queue, task } from "@trigger.dev/sdk"; -```ts /trigger/override-concurrency.ts const paidQueue = queue({ name: "paid-users", concurrencyLimit: 10, @@ -95,10 +138,8 @@ const paidQueue = queue({ export const generatePullRequest = task({ id: "generate-pull-request", - queue: { - //normally when triggering this task it will be limited to 1 run at a time - concurrencyLimit: 1, - }, + // normally this task is limited to 1 run at a time + concurrency: { total: 1 }, run: async (payload) => { //todo generate a PR using OpenAI }, @@ -108,7 +149,7 @@ export const generatePullRequest = task({ Triggering from your backend and overriding the queue: ```ts app/api/push/route.ts -import { generatePullRequest } from "~/trigger/override-concurrency"; +import { generatePullRequest } from "~/trigger/override-queue"; export async function POST(request: Request) { const data = await request.json(); @@ -122,104 +163,78 @@ export async function POST(request: Request) { return Response.json(handle); } else { - //triggered with the default queue (concurrency of 1) + //triggered with the default queue const handle = await generatePullRequest.trigger(data); return Response.json(handle); } } ``` -## Concurrency keys and per-tenant queuing +## Setting limits when you trigger a run -If you're building an application where you want to run tasks for your users, you might want a separate queue for each of your users (or orgs, projects, etc.). +The trigger-time `concurrency` option takes limit names and replaces the task's declared **named** limits for that run. The task's inline limit always applies: -You can do this by using `concurrencyKey`. It creates a copy of the queue for each unique value of the key. +```ts app/api/report/route.ts +import { generateReport } from "~/trigger/reports"; -Your backend code: - -```ts app/api/pr/route.ts -import { generatePullRequest } from "~/trigger/override-concurrency"; +// this run counts towards "priority" instead of the task's declared named limits +await generateReport.trigger(data, { concurrency: ["priority"] }); +``` -export async function POST(request: Request) { - const data = await request.json(); +Pass an empty array to run with only the task's inline limit. - if (data.isFreeUser) { - //the "free-users" queue has a concurrency limit of 1 - const handle = await generatePullRequest.trigger(data, { - queue: "free-users", - //this creates a free-users queue for each user - concurrencyKey: data.userId, - }); +## Concurrency keys and per-tenant limits - //return a success response with the handle - return Response.json(handle); - } else { - //the "paid-users" queue has a concurrency limit of 10 - const handle = await generatePullRequest.trigger(data, { - queue: "paid-users", - //this creates a paid-users queue for each user - concurrencyKey: data.userId, - }); +If you're building an application where you want to run tasks for your users, you might want a separate limit for each of your users (or orgs, projects, etc.). - //return a success response with the handle - return Response.json(handle); - } -} -``` +You can do this by passing a `concurrencyKey` when you trigger. Each unique key value gets its own pool under every `perKey` bound the run holds: -## Combined concurrency across keys +```ts app/api/pr/route.ts +import { generatePullRequest } from "~/trigger/override-queue"; -`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. +export async function POST(request: Request) { + const data = await request.json(); -To bound keyed runs as a group, set `combinedConcurrencyLimit`. Each key still gets at most `concurrencyLimit`, and keyed runs as a whole never exceed the combined limit; runs triggered without a key sit outside it: + const handle = await generatePullRequest.trigger(data, { + // every user gets their own concurrency pool + concurrencyKey: data.userId, + }); -```ts /trigger/per-user.ts -import { queue } from "@trigger.dev/sdk"; - -export const perUserQueue = queue({ - name: "per-user-queue", - //each user runs at most 1 at a time... - concurrencyLimit: 1, - //...and at most 10 total runs across all users - combinedConcurrencyLimit: 10, -}); + return Response.json(handle); +} ``` -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)`. +## Per-key and total limits together - - On a queue used with `concurrencyKey`, every limit applies per key value except - `combinedConcurrencyLimit`, which is the only cap that spans the whole queue. - +`perKey` on its own lets total concurrency grow with the number of active keys: ten active users under `perKey: 5` can run 50 at once. Add `total` to bound everything as a group. Each key still gets at most `perKey`, and all runs together — keyed or not — never exceed `total`: -## Using multiple queues 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 home queue, the rest name gates. The same array form works when you trigger, replacing the task's gates for that run: - -```ts -import { processWebhook } from "~/trigger/webhooks"; +```ts /trigger/per-user-capped.ts +import { task } from "@trigger.dev/sdk"; -await processWebhook.trigger(payload, { - queue: ["webhooks", "tenant"], - concurrencyKey: tenantId, +export const processUpload = task({ + id: "process-upload", + // each user runs at most 1 at a time, and at most 10 run in total + concurrency: { perKey: 1, total: 10 }, + run: async (payload) => { + //... + }, }); ``` -### A per-tenant cap across multiple tasks +## 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: +A named limit's `perKey` bound follows each run's own `concurrencyKey`, so one declaration caps each tenant across every task holding the limit: ```ts /trigger/webhooks.ts -import { queue, task } from "@trigger.dev/sdk"; +import { concurrencyLimit, task } from "@trigger.dev/sdk"; -//each tenant runs at most 10 at once across every task that gates on this queue -export const tenantQueue = queue({ name: "tenant", concurrencyLimit: 10 }); +// each tenant runs at most 10 at once across every task that holds this limit +export const tenantLimit = concurrencyLimit({ name: "tenant", perKey: 10 }); export const processWebhook = task({ id: "process-webhook", - queue: [{ name: "webhooks", concurrencyLimit: 2 }, "tenant"], + // webhooks themselves are capped at 2 per tenant, within the tenant's overall 10 + concurrency: [{ perKey: 2 }, tenantLimit], run: async (payload) => { //... }, @@ -227,70 +242,34 @@ export const processWebhook = task({ ``` ```ts app/api/webhook/route.ts -//the run waits in "webhooks" and also counts towards this tenant's cap +// the run counts towards this tenant's pool in both limits 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. +## A global cap for a shared resource -### 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: +To cap something global, like total traffic to an external API, across many tasks and all tenants: declare a limit with only a `total` and share it. It counts every run holding it, whether or not the run has a `concurrencyKey`: ```ts /trigger/sync.ts -import { queue, task } from "@trigger.dev/sdk"; +import { concurrencyLimit, task } from "@trigger.dev/sdk"; -//at most 10 concurrent provider calls across every task and every tenant -export const providerApiQueue = queue({ - name: "provider-api", - combinedConcurrencyLimit: 10, -}); +// at most 10 concurrent provider calls across every task and every tenant +export const providerApiLimit = concurrencyLimit({ name: "provider-api", total: 10 }); export const syncToProvider = task({ id: "sync-to-provider", - queue: [{ name: "sync-home", concurrencyLimit: 20 }, "provider-api"], + concurrency: [{ total: 20 }, providerApiLimit], run: async (payload) => { //... }, }); ``` -```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: - -```ts /trigger/print.ts -import { queue, task } from "@trigger.dev/sdk"; - -export const printerQueue = queue({ name: "printer", concurrencyLimit: 1 }); - -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) => { - //... - }, -}); -``` +Runs with and without a `concurrencyKey` share the same `total`, so this works even when only some of your triggers have a natural key. ## 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 +When you trigger a task that has subtasks, the subtasks will not inherit the parent's limits. Unless otherwise specified, subtasks run under their own task's configuration: ```ts /trigger/subtasks.ts export const parentTask = task({ @@ -301,7 +280,7 @@ export const parentTask = task({ }, }); -// This subtask will run on its own queue +// This subtask runs under its own limits export const subtask = task({ id: "subtask", run: async (payload) => { @@ -314,40 +293,38 @@ export const subtask = task({ With our [task checkpoint system](/how-it-works#the-checkpoint-resume-system), tasks can wait at various waitpoints (like waiting for subtasks to complete, delays, or external events). The way this system interacts with the concurrency system is important to understand. -Concurrency is only released when a run reaches a waitpoint and is checkpointed. When a run is checkpointed, it transitions to the `WAITING` state and releases its concurrency slot back to both the queue and the environment, allowing other runs to execute or resume. +Concurrency is only released when a run reaches a waitpoint and is checkpointed. When a run is checkpointed, it transitions to the `WAITING` state and releases its concurrency slots back to every limit it holds and the environment, allowing other runs to execute or resume. This means that: - Only actively executing runs count towards concurrency limits - Runs in the `WAITING` state (checkpointed at waitpoints) do not consume concurrency slots -- You can have more runs in the `WAITING` state than your queue's concurrency limit -- When a waiting run resumes (e.g., when a subtask completes), it must re-acquire a concurrency slot +- You can have more runs in the `WAITING` state than a limit allows to execute +- When a waiting run resumes (e.g., when a subtask completes), it must re-acquire its slots -For example, if you have a queue with a `concurrencyLimit` of 1: +For example, if a task has `concurrency: { total: 1 }`: - You can only have exactly 1 run executing at a time -- You may have multiple runs in the `WAITING` state that belong to that queue +- You may have multiple runs in the `WAITING` state for that task - When the executing run reaches a waitpoint and checkpoints, it releases its slot - The next queued run can then begin execution ### Short time-based waits keep their slot -Checkpointing takes time, so a run doesn't checkpoint the moment it reaches a waitpoint. For [`wait.for()`](/wait-for) and [`wait.until()`](/wait-until) it happens 60 seconds into the wait, so anything shorter stays `EXECUTING` and holds its slot for the whole wait. If you're polling in a loop, use an interval comfortably above 60 seconds so the slot is actually released between polls. +Checkpointing takes time, so a run doesn't checkpoint the moment it reaches a waitpoint. For [`wait.for()`](/wait-for) and [`wait.until()`](/wait-until) it happens 60 seconds into the wait, so anything shorter stays `EXECUTING` and holds its slots for the whole wait. If you're polling in a loop, use an interval comfortably above 60 seconds so the slots are actually released between polls. -### Waiting for a subtask on a different queue +### Waiting for a subtask -When a parent task triggers and waits for a subtask on a different queue, the parent task will checkpoint and release its concurrency slot once it reaches the wait point. This prevents environment deadlocks where all concurrency slots would be occupied by waiting tasks. +When a parent task triggers and waits for a subtask, the parent task will checkpoint and release its concurrency slots once it reaches the wait point. This prevents environment deadlocks where all concurrency slots would be occupied by waiting tasks. ```ts /trigger/waiting.ts export const parentTask = task({ id: "parent-task", - queue: { - concurrencyLimit: 1, - }, + concurrency: { total: 1 }, run: async (payload) => { //trigger a subtask and wait for it to complete await subtask.triggerAndWait(payload); - // The parent task checkpoints here and releases its concurrency slot + // The parent task checkpoints here and releases its concurrency slots // allowing other tasks to execute while waiting }, }); @@ -360,7 +337,82 @@ export const subtask = task({ }); ``` -When the parent task reaches the `triggerAndWait` call, it checkpoints and transitions to the `WAITING` state, releasing its concurrency slot back to both its queue and the environment. Once the subtask completes, the parent task will resume and re-acquire a concurrency slot. +When the parent task reaches the `triggerAndWait` call, it checkpoints and transitions to the `WAITING` state, releasing its slots. Once the subtask completes, the parent task will resume and re-acquire them. + +## Managing concurrency limits with the SDK + +The `concurrencyLimits` namespace manages your named limits at runtime (anonymous inline limits appear under derived `task/` names): + +```ts +import { concurrencyLimits } from "@trigger.dev/sdk"; +``` + +### Listing limits + +```ts +import { concurrencyLimits } from "@trigger.dev/sdk"; + +// List all limits (returns paginated results) +const allLimits = await concurrencyLimits.list(); + +// With pagination options +const pagedLimits = await concurrencyLimits.list({ + page: 1, + perPage: 20, +}); +``` + +### Retrieving a limit + +Retrieve a limit by its name to see its bounds and live counts: + +```ts +import { concurrencyLimits } from "@trigger.dev/sdk"; + +const limit = await concurrencyLimits.retrieve("openai"); +``` + +The limit object contains each bound plus live counts: + +```ts +{ + id: "climit_1234", + name: "openai", + perKey: { + current: null, // Enforced right now (null = no per-key bound) + base: null, // Declared in your code + override: null, // Override value (if set) + overriddenAt: null, // When the override was applied + }, + total: { + current: 25, + base: 25, + override: null, + overriddenAt: null, + }, + running: 14, // Runs executing that hold this limit + queued: 100, // Runs queued that must clear this limit to execute +} +``` + +### Overriding a limit + +Overrides change only the fields you pass; the declared values are kept and restored by `reset`. Overriding `total` to `0` blocks every run holding the limit, which is how you pause a limit: + +```ts +import { concurrencyLimits } from "@trigger.dev/sdk"; + +// Raise the shared cap +await concurrencyLimits.override("openai", { total: 50 }); + +// Pause the limit: nothing holding it can start +await concurrencyLimits.override("openai", { total: 0 }); + +// Back to the values declared in your code +await concurrencyLimits.reset("openai"); +``` + +Overrides survive deploys: redeploying your code keeps an active override until you reset it. ## Managing queues with the SDK @@ -488,18 +540,4 @@ await queues.resetConcurrencyLimit("queue_1234"); 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 b764b59de3a..844138cecf5 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -3048,14 +3048,11 @@ paths: 20 ); - "/api/v1/queues/{queueParam}/concurrency/combined/override": + "/api/v1/queues/{queueParam}/concurrency/reset": 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. + operationId: reset_queue_concurrency_v1 + summary: Reset queue concurrency limit + description: Reset the concurrency limit of a queue back to its base value defined in code. parameters: - in: path name: queueParam @@ -3066,11 +3063,11 @@ paths: example: queue_1234 requestBody: 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: type: object - required: ["concurrencyLimit"] properties: type: type: string @@ -3081,25 +3078,15 @@ 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 - concurrencyLimit: - 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 + description: Concurrency limit reset 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. + description: Queue is not overridden or invalid request parameters "401": description: Unauthorized request "404": @@ -3113,130 +3100,205 @@ paths: source: |- import { queues } from "@trigger.dev/sdk"; - // Allow up to 100 runs across all concurrency keys - await queues.overrideCombinedConcurrencyLimit("queue_1234", 100); + // Reset concurrency limit to the base value + await queues.resetConcurrencyLimit("queue_1234"); // Using type and name - await queues.overrideCombinedConcurrencyLimit( - { type: "custom", name: "per-user-queue" }, - 100 - ); + await queues.resetConcurrencyLimit({ + type: "task", + name: "my-task-id", + }); - "/api/v1/queues/{queueParam}/concurrency/combined/reset": + "/api/v1/concurrency-limits": + get: + operationId: list_concurrency_limits_v1 + summary: List concurrency limits + description: | + List the environment's named concurrency limits (anonymous inline limits appear + under their derived `task/` names), with each limit's bounds and its + live running and queued counts. + parameters: + - name: page + in: query + required: false + schema: + type: integer + default: 1 + - name: perPage + in: query + required: false + schema: + type: integer + default: 25 + maximum: 100 + responses: + "200": + description: Concurrency limits retrieved successfully + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + "$ref": "#/components/schemas/ConcurrencyLimitObject" + pagination: + type: object + properties: + currentPage: + type: integer + totalPages: + type: integer + count: + type: integer + "401": + description: Unauthorized request + tags: + - concurrency-limits + security: + - secretKey: [] + x-codeSamples: + - lang: typescript + source: |- + import { concurrencyLimits } from "@trigger.dev/sdk"; + + const limits = await concurrencyLimits.list({ page: 1, perPage: 20 }); + + "/api/v1/concurrency-limits/{name}": + get: + operationId: retrieve_concurrency_limit_v1 + summary: Retrieve concurrency limit + description: Retrieve a concurrency limit by name, with its bounds and live running and queued counts. + parameters: + - name: name + in: path + required: true + schema: + type: string + description: The limit's name, as declared with `concurrencyLimit()` + responses: + "200": + description: Concurrency limit retrieved successfully + content: + application/json: + schema: + "$ref": "#/components/schemas/ConcurrencyLimitObject" + "401": + description: Unauthorized request + "404": + description: Concurrency limit not found + tags: + - concurrency-limits + security: + - secretKey: [] + x-codeSamples: + - lang: typescript + source: |- + import { concurrencyLimits } from "@trigger.dev/sdk"; + + const limit = await concurrencyLimits.retrieve("openai"); + + "/api/v1/concurrency-limits/{name}/override": 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. + operationId: override_concurrency_limit_v1 + summary: Override concurrency limit + description: | + Override a concurrency limit's bounds. Only the given fields change; the declared + values are kept and restored by reset. Overriding `total` to `0` blocks every run + holding the limit, which is how a limit is paused. parameters: - - in: path - name: queueParam + - name: name + in: path 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 + description: The limit's name requestBody: 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: 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 + perKey: + type: integer + minimum: 0 + maximum: 100000 + description: Caps each concurrencyKey pool; runs without a key share one pool. May not exceed the environment concurrency limit. + total: + type: integer + minimum: 0 + maximum: 100000 + description: Caps every run holding this limit, keys or not. May not exceed the environment concurrency limit. responses: "200": - description: Combined concurrency limit reset successfully + description: Concurrency limit overridden successfully content: application/json: schema: - "$ref": "#/components/schemas/QueueObject" + "$ref": "#/components/schemas/ConcurrencyLimitObject" "400": - description: The queue's combined concurrency limit is not overridden, or invalid request parameters + description: Invalid request parameters, or a bound exceeds the environment concurrency limit "401": description: Unauthorized request "404": - description: Queue not found + description: Concurrency limit not found + "409": + description: The limit changed concurrently; retry the request tags: - - queues + - concurrency-limits security: - secretKey: [] x-codeSamples: - lang: typescript source: |- - import { queues } from "@trigger.dev/sdk"; + import { concurrencyLimits } from "@trigger.dev/sdk"; - // Revert to the combinedConcurrencyLimit declared in code - await queues.resetCombinedConcurrencyLimit("queue_1234"); + // Raise the shared cap + await concurrencyLimits.override("openai", { total: 50 }); - "/api/v1/queues/{queueParam}/concurrency/reset": + // Pause the limit + await concurrencyLimits.override("openai", { total: 0 }); + + "/api/v1/concurrency-limits/{name}/reset": post: - operationId: reset_queue_concurrency_v1 - summary: Reset queue concurrency limit - description: Reset the concurrency limit of a queue back to its base value defined in code. + operationId: reset_concurrency_limit_v1 + summary: Reset concurrency limit + description: Reset a concurrency limit back to the values declared in your code, clearing any override. parameters: - - in: path - name: queueParam + - name: name + in: path 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 - description: At least an empty JSON object `{}` must be sent; a zero-length body is rejected with a 400. - 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 + description: The limit's name responses: "200": description: Concurrency limit reset successfully content: application/json: schema: - "$ref": "#/components/schemas/QueueObject" + "$ref": "#/components/schemas/ConcurrencyLimitObject" "400": - description: Queue is not overridden or invalid request parameters + description: The limit has no override to reset "401": description: Unauthorized request "404": - description: Queue not found + description: Concurrency limit not found + "409": + description: The limit changed concurrently; retry the request tags: - - queues + - concurrency-limits security: - secretKey: [] x-codeSamples: - lang: typescript source: |- - import { queues } from "@trigger.dev/sdk"; + import { concurrencyLimits } from "@trigger.dev/sdk"; - // Reset concurrency limit to the base value - await queues.resetConcurrencyLimit("queue_1234"); + await concurrencyLimits.reset("openai"); - // Using type and name - await queues.resetConcurrencyLimit({ - type: "task", - name: "my-task-id", - }); "/api/v1/waitpoints/tokens": post: @@ -4390,6 +4452,75 @@ components: minimum: 0 maximum: 1000 description: An optional property that specifies the maximum number of concurrent run executions. If this property is omitted, the task can potentially use up the full concurrency of an environment. + ConcurrencyLimitObject: + type: object + properties: + id: + type: string + description: The limit's id, starting with `climit_` + example: "climit_abcdef123456" + name: + type: string + description: The limit's name, as declared with `concurrencyLimit()` + example: "openai" + perKey: + type: object + description: Caps each concurrencyKey pool; runs without a key share one pool + properties: + current: + type: integer + nullable: true + description: Enforced right now (null = no per-key bound). Enforcement clamps it to the environment concurrency limit at admit time. + example: null + base: + type: integer + nullable: true + description: The declared value an override reverts to on reset + example: null + override: + type: integer + nullable: true + description: The overridden value, when an override is active + example: null + overriddenAt: + type: string + format: date-time + nullable: true + description: When the override was applied + example: null + total: + type: object + description: Caps every run holding this limit, keys or not + properties: + current: + type: integer + nullable: true + description: Enforced right now (null = no total bound). Enforcement clamps it to the environment concurrency limit at admit time. + example: 25 + base: + type: integer + nullable: true + description: The declared value an override reverts to on reset + example: 25 + override: + type: integer + nullable: true + description: The overridden value, when an override is active + example: null + overriddenAt: + type: string + format: date-time + nullable: true + description: When the override was applied + example: null + running: + type: integer + description: Runs executing that hold this limit + example: 14 + queued: + type: integer + description: Runs that are queued and must clear this limit to execute + example: 100 QueueObject: type: object required: ["id", "name", "type", "running", "queued", "paused"] @@ -4452,39 +4583,6 @@ components: 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. - 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 - 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 overriddenBy: type: string nullable: true From fb584383bf904daeff78d6fbce43740a584d3921 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 07:07:51 +0100 Subject: [PATCH 11/17] docs: required response fields, minimum override body, scoped name-rule note --- docs/queue-concurrency.mdx | 3 ++- docs/v3-openapi.yaml | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/queue-concurrency.mdx b/docs/queue-concurrency.mdx index 0c7a4fa7175..ff930d6a94e 100644 --- a/docs/queue-concurrency.mdx +++ b/docs/queue-concurrency.mdx @@ -121,7 +121,8 @@ export const summarizeThread = task({ ``` - Limit names are 1-122 characters using only letters, numbers, underscores and hyphens. + Names you declare with `concurrencyLimit()` are 1-122 characters using only letters, numbers, + underscores and hyphens. ## Setting the queue when you trigger a run diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index 844138cecf5..79e6ea23a00 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -3220,6 +3220,8 @@ paths: application/json: schema: type: object + minProperties: 1 + description: At least one of `perKey` or `total` must be provided. properties: perKey: type: integer @@ -4454,6 +4456,13 @@ components: description: An optional property that specifies the maximum number of concurrent run executions. If this property is omitted, the task can potentially use up the full concurrency of an environment. ConcurrencyLimitObject: type: object + required: + - id + - name + - perKey + - total + - running + - queued properties: id: type: string From 804d677adef3f423206f90a187277f8bee66c5ae Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 07:39:50 +0100 Subject: [PATCH 12/17] docs: the override body admits only the supported fields --- docs/v3-openapi.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index 79e6ea23a00..105ac7ef6ab 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -3221,6 +3221,7 @@ paths: schema: type: object minProperties: 1 + additionalProperties: false description: At least one of `perKey` or `total` must be provided. properties: perKey: From 6a755003e5bf6996443051729aabed5782e4b35b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 11:44:30 +0100 Subject: [PATCH 13/17] docs: split the Concurrency & Queues page into Queues and Concurrency Queues covers ordering: runs start in trigger order, sharing a queue between tasks, switching queues at trigger time, priority (shared as a snippet with the existing Priority page, diagram included), and the queues SDK. Concurrency keeps the concurrency option, named limits, per-tenant keys and the concurrencyLimits SDK. The old /queue-concurrency URL permanently redirects to /concurrency and every internal link now points at the right half. --- ...{queue-concurrency.mdx => concurrency.mdx} | 188 +-------------- docs/database-connections.mdx | 6 +- docs/docs.json | 8 +- docs/introduction.mdx | 2 +- docs/queues.mdx | 226 ++++++++++++++++++ docs/reports.mdx | 2 +- docs/runs/priority.mdx | 27 +-- docs/snippets/priority.mdx | 27 +++ docs/tasks/overview.mdx | 2 +- docs/writing-tasks-introduction.mdx | 3 +- 10 files changed, 277 insertions(+), 214 deletions(-) rename docs/{queue-concurrency.mdx => concurrency.mdx} (71%) create mode 100644 docs/queues.mdx create mode 100644 docs/snippets/priority.mdx diff --git a/docs/queue-concurrency.mdx b/docs/concurrency.mdx similarity index 71% rename from docs/queue-concurrency.mdx rename to docs/concurrency.mdx index ff930d6a94e..02f17a8c055 100644 --- a/docs/queue-concurrency.mdx +++ b/docs/concurrency.mdx @@ -1,11 +1,11 @@ --- -title: "Concurrency & Queues" -description: "Configure what you want to happen when there is more than one run at a time." +title: "Concurrency" +description: "Limit how many runs execute at once: per task, per tenant, or shared across tasks." --- -When you trigger a task, it isn't executed immediately. Instead, the task [run](/runs) is placed into a queue for execution. +When you trigger a task, the [run](/runs) waits in a [queue](/queues) and runs start in trigger order as capacity allows. Concurrency is what decides how much capacity there is. -By default, each task gets its own queue and the concurrency is only limited by your environment concurrency limit. If you need more control (for example, to limit concurrency or share limits across multiple tasks), you can set a concurrency limit on the task, or declare a named limit and share it, as described below. +By default, concurrency is only limited by your environment concurrency limit. If you need more control (for example, to limit concurrency or share limits across multiple tasks), you can set a concurrency limit on the task, or declare a named limit and share it, as described below. Controlling concurrency is useful when you have a task that can't be run concurrently, or when you want to limit the number of runs to avoid overloading a resource. @@ -125,52 +125,6 @@ export const summarizeThread = task({ underscores and hyphens. -## Setting the queue when you trigger a run - -When you trigger a task you can override its queue by name. This is really useful if you sometimes have high priority runs: - -```ts /trigger/override-queue.ts -import { queue, task } from "@trigger.dev/sdk"; - -const paidQueue = queue({ - name: "paid-users", - concurrencyLimit: 10, -}); - -export const generatePullRequest = task({ - id: "generate-pull-request", - // normally this task is limited to 1 run at a time - concurrency: { total: 1 }, - run: async (payload) => { - //todo generate a PR using OpenAI - }, -}); -``` - -Triggering from your backend and overriding the queue: - -```ts app/api/push/route.ts -import { generatePullRequest } from "~/trigger/override-queue"; - -export async function POST(request: Request) { - const data = await request.json(); - - if (data.branch === "main") { - //trigger the task, with the paid users queue - const handle = await generatePullRequest.trigger(data, { - // Set the paid users queue - queue: "paid-users", - }); - - return Response.json(handle); - } else { - //triggered with the default queue - const handle = await generatePullRequest.trigger(data); - return Response.json(handle); - } -} -``` - ## Setting limits when you trigger a run The trigger-time `concurrency` option takes limit names and replaces the task's declared **named** limits for that run. The task's inline limit always applies: @@ -190,13 +144,13 @@ If you're building an application where you want to run tasks for your users, yo You can do this by passing a `concurrencyKey` when you trigger. Each unique key value gets its own pool under every `perKey` bound the run holds: -```ts app/api/pr/route.ts -import { generatePullRequest } from "~/trigger/override-queue"; +```ts app/api/report/route.ts +import { generateReport } from "~/trigger/per-user"; export async function POST(request: Request) { const data = await request.json(); - const handle = await generatePullRequest.trigger(data, { + const handle = await generateReport.trigger(data, { // every user gets their own concurrency pool concurrencyKey: data.userId, }); @@ -414,131 +368,3 @@ await concurrencyLimits.reset("openai"); ``` Overrides survive deploys: redeploying your code keeps an active override until you reset it. - -## Managing queues with the SDK - -The SDK provides a `queues` namespace that allows you to manage queues programmatically. You can list, retrieve, pause, resume, and modify concurrency limits for queues. - - - Import from `@trigger.dev/sdk`: - ```ts - import { queues } from "@trigger.dev/sdk"; - ``` - - -### Listing queues - -You can list all queues in your environment with pagination support: - -```ts -import { queues } from "@trigger.dev/sdk"; - -// List all queues (returns paginated results) -const allQueues = await queues.list(); - -// With pagination options -const pagedQueues = await queues.list({ - page: 1, - perPage: 20, -}); -``` - -### Retrieving a queue - -You can retrieve a specific queue by its ID, or by its type and name: - -```ts -import { queues } from "@trigger.dev/sdk"; - -// Using queue ID (starts with "queue_") -const queueById = await queues.retrieve("queue_1234"); - -// Using type and name for a task's default queue -const taskQueue = await queues.retrieve({ - type: "task", - name: "my-task-id", -}); - -// Using type and name for a custom queue -const customQueue = await queues.retrieve({ - type: "custom", - name: "my-custom-queue", -}); -``` - -The queue object contains useful information about the queue state: - -```ts -{ - id: "queue_1234", // Queue ID - name: "my-task-id", // Queue name - type: "task", // "task" or "custom" - running: 5, // Currently executing runs - queued: 10, // Runs waiting to execute - paused: false, // Whether the queue is paused - concurrencyLimit: 10, // Current concurrency limit - concurrency: { - current: 10, // Effective limit - base: 10, // Default limit from code - override: null, // Override value (if set) - overriddenAt: null, // When override was applied - overriddenBy: null, // Who applied the override - } -} -``` - -### Pausing and resuming queues - -You can pause a queue to prevent new runs from starting. Runs that are currently executing will continue to completion. - -```ts -import { queues } from "@trigger.dev/sdk"; - -// Pause a queue using its ID -await queues.pause("queue_1234"); - -// Or using type and name -await queues.pause({ type: "task", name: "my-task-id" }); -await queues.pause({ type: "custom", name: "my-custom-queue" }); -``` - -To resume a paused queue and allow new runs to start: - -```ts -import { queues } from "@trigger.dev/sdk"; - -// Resume a queue using its ID -await queues.resume("queue_1234"); - -// Or using type and name -await queues.resume({ type: "task", name: "my-task-id" }); -await queues.resume({ type: "custom", name: "my-custom-queue" }); -``` - -### Overriding concurrency limits - -You can temporarily override a queue's concurrency limit. This is useful for scaling up or down based on demand: - -```ts -import { queues } from "@trigger.dev/sdk"; - -// Set concurrency limit to 5 -await queues.overrideConcurrencyLimit("queue_1234", 5); - -// Or using type and name -await queues.overrideConcurrencyLimit({ type: "task", name: "my-task-id" }, 20); -``` - -To reset the concurrency limit back to the base value defined in your code: - -```ts -import { queues } from "@trigger.dev/sdk"; - -// Reset concurrency limit to the base value -await queues.resetConcurrencyLimit("queue_1234"); - -// Or using type and name -await queues.resetConcurrencyLimit({ type: "task", name: "my-task-id" }); -``` - -Overrides survive deploys: redeploying your code keeps an active override until you reset it. diff --git a/docs/database-connections.mdx b/docs/database-connections.mdx index df808e66088..caf6b0927fa 100644 --- a/docs/database-connections.mdx +++ b/docs/database-connections.mdx @@ -68,7 +68,7 @@ Set the pool small. A task usually runs its queries in sequence, so one connecti | Drizzle (node-postgres) | 10 (the underlying `pg` pool) | | [MongoDB driver](https://www.mongodb.com/docs/drivers/node/current/connect/connection-options/connection-pools/) | 100 (`maxPoolSize`) | -Keep `concurrent runs × pool size` under your provider's connection limit, and cap how many runs execute at once with [concurrency limits](/queue-concurrency) so runs queue instead of overrunning the database. Direct connection limits for common Postgres providers: +Keep `concurrent runs × pool size` under your provider's connection limit, and cap how many runs execute at once with [concurrency limits](/concurrency) so runs queue instead of overrunning the database. Direct connection limits for common Postgres providers: | Provider | Direct connection limit | | --- | --- | @@ -197,7 +197,7 @@ export const myChat = chat.agent({ ## Troubleshooting -`too many connections` or connection refused: `concurrent runs × pool size` is over your provider's limit. Lower the pool size, cap [concurrency](/queue-concurrency), or connect through a pooler. +`too many connections` or connection refused: `concurrent runs × pool size` is over your provider's limit. Lower the pool size, cap [concurrency](/concurrency), or connect through a pooler. The worker crashes right after resuming from a wait: an idle connection that closed during the suspend emitted an unhandled `error` event. Attach `pool.on("error", ...)` on a `pg` pool (node-postgres or Drizzle); Prisma and the MongoDB driver handle this internally. @@ -208,6 +208,6 @@ When a task waits, the runtime can [checkpoint](/how-it-works#the-checkpoint-res ## See also - [Wait](/wait) for the primitives that trigger a checkpoint. -- [Concurrency and queues](/queue-concurrency) to cap how many runs execute at once. +- [Concurrency](/concurrency) to cap how many runs execute at once. - [Lifecycle functions](/tasks/overview#onwait-and-onresume-functions) for global `tasks.onWait` and `tasks.onResume`. - [Chat agent lifecycle hooks](/ai-chat/lifecycle-hooks) for `onChatSuspend` and `onChatResume`. diff --git a/docs/docs.json b/docs/docs.json index f1409b9a37c..860233fa51a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -65,7 +65,8 @@ "group": "Wait", "pages": ["wait", "wait-for", "wait-until", "wait-for-token"] }, - "queue-concurrency", + "queues", + "concurrency", "versioning", "machines", "idempotency", @@ -738,6 +739,11 @@ ] }, "redirects": [ + { + "source": "/queue-concurrency", + "destination": "/concurrency", + "permanent": true + }, { "source": "/config/extensions", "destination": "/config/extensions/overview", diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 155965b2a73..a4d9e381725 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -71,7 +71,7 @@ We provide everything you need to build and manage background tasks: a CLI and S > Learn how to handle errors and retries. - + Configure what you want to happen when there is more than one run at a time. { + //... + }, +}); + +export const sendDigestEmail = task({ + id: "send-digest-email", + queue: emailQueue, + run: async (payload) => { + //... + }, +}); +``` + + + If what you want to share is a concurrency cap rather than ordering, you don't need a shared + queue: declare a named limit with `concurrencyLimit()` and each task keeps its own queue while + drawing from the shared limit. See [sharing a limit between + tasks](/concurrency#sharing-a-limit-between-tasks). + + +## Setting the queue when you trigger a run + +When you trigger a task you can override its queue by name. This is really useful if you sometimes have high priority runs: + +```ts /trigger/override-queue.ts +import { queue, task } from "@trigger.dev/sdk"; + +const paidQueue = queue({ + name: "paid-users", + concurrencyLimit: 10, +}); + +export const generatePullRequest = task({ + id: "generate-pull-request", + // normally this task is limited to 1 run at a time + concurrency: { total: 1 }, + run: async (payload) => { + //todo generate a PR using OpenAI + }, +}); +``` + +Triggering from your backend and overriding the queue: + +```ts app/api/push/route.ts +import { generatePullRequest } from "~/trigger/override-queue"; + +export async function POST(request: Request) { + const data = await request.json(); + + if (data.branch === "main") { + //trigger the task, with the paid users queue + const handle = await generatePullRequest.trigger(data, { + // Set the paid users queue + queue: "paid-users", + }); + + return Response.json(handle); + } else { + //triggered with the default queue + const handle = await generatePullRequest.trigger(data); + return Response.json(handle); + } +} +``` + +## Priority + +You can re-order runs within a queue at trigger time by giving them a priority: + + + +## Managing queues with the SDK + +The SDK provides a `queues` namespace that allows you to manage queues programmatically. You can list, retrieve, pause, resume, and modify concurrency limits for queues. + + + Import from `@trigger.dev/sdk`: + ```ts + import { queues } from "@trigger.dev/sdk"; + ``` + + +### Listing queues + +You can list all queues in your environment with pagination support: + +```ts +import { queues } from "@trigger.dev/sdk"; + +// List all queues (returns paginated results) +const allQueues = await queues.list(); + +// With pagination options +const pagedQueues = await queues.list({ + page: 1, + perPage: 20, +}); +``` + +### Retrieving a queue + +You can retrieve a specific queue by its ID, or by its type and name: + +```ts +import { queues } from "@trigger.dev/sdk"; + +// Using queue ID (starts with "queue_") +const queueById = await queues.retrieve("queue_1234"); + +// Using type and name for a task's default queue +const taskQueue = await queues.retrieve({ + type: "task", + name: "my-task-id", +}); + +// Using type and name for a custom queue +const customQueue = await queues.retrieve({ + type: "custom", + name: "my-custom-queue", +}); +``` + +The queue object contains useful information about the queue state: + +```ts +{ + id: "queue_1234", // Queue ID + name: "my-task-id", // Queue name + type: "task", // "task" or "custom" + running: 5, // Currently executing runs + queued: 10, // Runs waiting to execute + paused: false, // Whether the queue is paused + concurrencyLimit: 10, // Current concurrency limit + concurrency: { + current: 10, // Effective limit + base: 10, // Default limit from code + override: null, // Override value (if set) + overriddenAt: null, // When override was applied + overriddenBy: null, // Who applied the override + } +} +``` + +### Pausing and resuming queues + +You can pause a queue to prevent new runs from starting. Runs that are currently executing will continue to completion. + +```ts +import { queues } from "@trigger.dev/sdk"; + +// Pause a queue using its ID +await queues.pause("queue_1234"); + +// Or using type and name +await queues.pause({ type: "task", name: "my-task-id" }); +await queues.pause({ type: "custom", name: "my-custom-queue" }); +``` + +To resume a paused queue and allow new runs to start: + +```ts +import { queues } from "@trigger.dev/sdk"; + +// Resume a queue using its ID +await queues.resume("queue_1234"); + +// Or using type and name +await queues.resume({ type: "task", name: "my-task-id" }); +await queues.resume({ type: "custom", name: "my-custom-queue" }); +``` + +### Overriding concurrency limits + +You can temporarily override a queue's concurrency limit. This is useful for scaling up or down based on demand: + +```ts +import { queues } from "@trigger.dev/sdk"; + +// Set concurrency limit to 5 +await queues.overrideConcurrencyLimit("queue_1234", 5); + +// Or using type and name +await queues.overrideConcurrencyLimit({ type: "task", name: "my-task-id" }, 20); +``` + +To reset the concurrency limit back to the base value defined in your code: + +```ts +import { queues } from "@trigger.dev/sdk"; + +// Reset concurrency limit to the base value +await queues.resetConcurrencyLimit("queue_1234"); + +// Or using type and name +await queues.resetConcurrencyLimit({ type: "task", name: "my-task-id" }); +``` + +Overrides survive deploys: redeploying your code keeps an active override until you reset it. diff --git a/docs/reports.mdx b/docs/reports.mdx index 07d517bc3b0..c4a330a569c 100644 --- a/docs/reports.mdx +++ b/docs/reports.mdx @@ -148,7 +148,7 @@ An unknown report key returns `404` with the list of available keys. Every tool the MCP server exposes, including `get_report`. - + Configure the concurrency limits the Flow verdict checks against. diff --git a/docs/runs/priority.mdx b/docs/runs/priority.mdx index 20da1894629..374b248078f 100644 --- a/docs/runs/priority.mdx +++ b/docs/runs/priority.mdx @@ -3,29 +3,6 @@ title: "Priority" description: "Specify a priority when triggering a run." --- -You can set a priority when you trigger a run. This allows you to prioritize some of your runs over others, so they are started sooner. This is very useful when: +import Priority from "/snippets/priority.mdx"; -- You have critical work that needs to start more quickly (and you have long queues). -- You want runs for your premium users to take priority over free users. - -The value for priority is a time offset in seconds that determines the order of dequeuing. - -![Priority runs](/images/priority-runs.png) - -If you specify a priority of `10` the run will dequeue before runs that were triggered with no priority 8 seconds ago, like in this example: - -```ts -// no priority = 0 -await myTask.trigger({ foo: "bar" }); - -//... imagine 8s pass by - -// this run will start before the run above that was triggered 8s ago (with no priority) -await myTask.trigger({ foo: "bar" }, { priority: 10 }); -``` - -If you passed a value of `3600` the run would dequeue before runs that were triggered an hour ago (with no priority). - - - Setting a high priority will not allow you to beat runs from other organizations. It will only affect the order of your own runs. - + diff --git a/docs/snippets/priority.mdx b/docs/snippets/priority.mdx new file mode 100644 index 00000000000..91d99a6de6e --- /dev/null +++ b/docs/snippets/priority.mdx @@ -0,0 +1,27 @@ +You can set a priority when you trigger a run. This allows you to prioritize some of your runs over others, so they are started sooner. This is very useful when: + +- You have critical work that needs to start more quickly (and you have long queues). +- You want runs for your premium users to take priority over free users. + +The value for priority is a time offset in seconds that determines the order of dequeuing. + +![Priority runs](/images/priority-runs.png) + +If you specify a priority of `10` the run will dequeue before runs that were triggered with no priority 8 seconds ago, like in this example: + +```ts +// no priority = 0 +await myTask.trigger({ foo: "bar" }); + +//... imagine 8s pass by + +// this run will start before the run above that was triggered 8s ago (with no priority) +await myTask.trigger({ foo: "bar" }, { priority: 10 }); +``` + +If you passed a value of `3600` the run would dequeue before runs that were triggered an hour ago (with no priority). + + + Setting a high priority will not allow you to beat runs from other organizations. It will only + affect the order of your own runs. + diff --git a/docs/tasks/overview.mdx b/docs/tasks/overview.mdx index d7cb4d8f4d9..a3cd944ee8a 100644 --- a/docs/tasks/overview.mdx +++ b/docs/tasks/overview.mdx @@ -100,7 +100,7 @@ It's also worth mentioning that you can [retry a block of code](/errors-retrying ### `queue` options -Queues allow you to control the concurrency of your tasks. This allows you to have one-at-a-time execution and parallel executions. There are also more advanced techniques like having different concurrencies for different sets of your users. For more information read [the concurrency & queues guide](/queue-concurrency). +Queues control the order your runs execute in, and pair with concurrency limits for one-at-a-time execution, parallel executions, and more advanced techniques like separate concurrency for different sets of your users. For more information read the [Queues](/queues) and [Concurrency](/concurrency) guides. ```ts /trigger/one-at-a-time.ts export const oneAtATime = task({ diff --git a/docs/writing-tasks-introduction.mdx b/docs/writing-tasks-introduction.mdx index 5f0bc330912..fe738d4db23 100644 --- a/docs/writing-tasks-introduction.mdx +++ b/docs/writing-tasks-introduction.mdx @@ -15,7 +15,8 @@ Before digging deeper into the details of writing tasks, you should read the [fu | [Logging](/logging) | View and send logs and traces from your tasks. | | [Errors & retrying](/errors-retrying) | How to deal with errors and write reliable tasks. | | [Wait](/wait) | Wait for periods of time or for external events to occur before continuing. | -| [Concurrency & Queues](/queue-concurrency) | Configure what you want to happen when there is more than one run at a time. | +| [Queues](/queues) | Control the order your runs execute in. | +| [Concurrency](/concurrency) | Limit how many runs execute at once: per task, per tenant, or shared across tasks. | | [Realtime notifications](/realtime/overview) | Send realtime notifications from your task that you can subscribe to from your backend or frontend. | | [Versioning](/versioning) | How versioning works. | | [Machines](/machines) | Configure the CPU and RAM of the machine your task runs on | From dab810e45e96063712bcbd1ec0113428ff368803 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 11:49:33 +0100 Subject: [PATCH 14/17] docs: pagination minimums in the spec, priority qualifies the queue ordering claim --- docs/queues.mdx | 2 +- docs/v3-openapi.yaml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/queues.mdx b/docs/queues.mdx index b4494884f74..1f88b5d5793 100644 --- a/docs/queues.mdx +++ b/docs/queues.mdx @@ -5,7 +5,7 @@ description: "Control the order your runs execute in." import Priority from "/snippets/priority.mdx"; -When you trigger a task, it isn't executed immediately. Instead, the task [run](/runs) is placed into a queue for execution. Runs in a queue are started in the order they were triggered: first in, first out. +When you trigger a task, it isn't executed immediately. Instead, the task [run](/runs) is placed into a queue for execution. Runs in a queue are started in the order they were triggered, first in, first out, unless you give a run a [priority](#priority) at trigger time. By default, each task gets its own queue, so triggering the same task ten times executes those runs in trigger order. How many of them execute *at once* is a separate question, governed by [concurrency](/concurrency) — your environment's concurrency limit, plus any limits you set on the task. diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index 105ac7ef6ab..ec92a7b559b 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -3123,12 +3123,14 @@ paths: required: false schema: type: integer + minimum: 1 default: 1 - name: perPage in: query required: false schema: type: integer + minimum: 1 default: 25 maximum: 100 responses: From cec0e0d983d7b4953a7fa8194736481642b86edf Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 11:57:52 +0100 Subject: [PATCH 15/17] docs: queue examples drop the deprecated concurrencyLimit field, intro cards split The Queues page's queue() examples no longer set concurrencyLimit (marked deprecated in the SDK in favor of the task concurrency option, and not needed for ordering); the tasks overview example uses concurrency instead of the deprecated inline queue option; the introduction card grid links Queues and Concurrency separately. --- docs/introduction.mdx | 7 +++++-- docs/queues.mdx | 10 ++-------- docs/tasks/overview.mdx | 8 +++----- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/docs/introduction.mdx b/docs/introduction.mdx index a4d9e381725..e7fec1884b1 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -71,8 +71,11 @@ We provide everything you need to build and manage background tasks: a CLI and S > Learn how to handle errors and retries. - - Configure what you want to happen when there is more than one run at a time. + + Limit how many runs execute at once: per task, per tenant, or shared across tasks. + + + Control the order your runs execute in. { //... }, From 5b5e36c38f81215afcb87ebcbdd5078dfbd4eba3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 18:25:55 +0100 Subject: [PATCH 16/17] docs: queue reads are version-discriminated The spec's QueueObject gains the version discriminator (V1 keeps its own limit and override state; V2 queues carry no queue-level concurrency) and the Queues guide shows both shapes. --- docs/queues.mdx | 19 +++++++++++++++++-- docs/v3-openapi.yaml | 14 +++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/queues.mdx b/docs/queues.mdx index c78828e425b..10de0ea659e 100644 --- a/docs/queues.mdx +++ b/docs/queues.mdx @@ -142,17 +142,19 @@ const customQueue = await queues.retrieve({ }); ``` -The queue object contains useful information about the queue state: +The queue object contains useful information about the queue state, and its `version` discriminates the shape: ```ts +// V1: the queue carries its own concurrency limit and override state { id: "queue_1234", // Queue ID name: "my-task-id", // Queue name type: "task", // "task" or "custom" + version: "V1", running: 5, // Currently executing runs queued: 10, // Runs waiting to execute paused: false, // Whether the queue is paused - concurrencyLimit: 10, // Current concurrency limit + concurrencyLimit: 10, // The queue's own limit concurrency: { current: 10, // Effective limit base: 10, // Default limit from code @@ -161,6 +163,19 @@ The queue object contains useful information about the queue state: overriddenBy: null, // Who applied the override } } + +// V2: the queue is only the line runs wait in. Concurrency is declared with +// the task `concurrency` option and managed through `concurrencyLimits`. +{ + id: "queue_5678", + name: "my-v2-task-id", + type: "task", + version: "V2", + running: 3, + queued: 12, + paused: false, + concurrencyLimit: null, +} ``` ### Pausing and resuming queues diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index ec92a7b559b..0dc616de53c 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -4535,7 +4535,7 @@ components: example: 100 QueueObject: type: object - required: ["id", "name", "type", "running", "queued", "paused"] + required: ["id", "name", "type", "version", "running", "queued", "paused"] properties: id: type: string @@ -4553,6 +4553,14 @@ components: - `task`: Created automatically for each task - `custom`: Created explicitly in your code using `queue()` example: task + version: + type: string + enum: [V1, V2] + description: | + Discriminates the shape: + - `V1`: the queue carries its own `concurrencyLimit` (applied per key when runs pass a `concurrencyKey`, to the whole queue when they don't) and its `concurrency` override state + - `V2`: the queue is only the line runs wait in; concurrency is declared with the task `concurrency` option and managed through the concurrency-limits endpoints. `concurrencyLimit` is always null and `concurrency` is absent. + example: V1 running: type: integer description: The number of runs currently executing @@ -4568,11 +4576,11 @@ components: concurrencyLimit: type: integer nullable: true - description: The current concurrency limit of the queue + description: The queue's own concurrency limit. Meaningful on V1 queues; always null on V2 queues. example: 10 concurrency: type: object - description: Detailed concurrency information + description: Detailed concurrency information. V1 queues only. properties: current: type: integer From 4a851bb4ecdc9595c193c8d12ef02661021a3a1a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 18:59:32 +0100 Subject: [PATCH 17/17] docs: the concurrency-limits list orders by underlying row name --- docs/v3-openapi.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index 0dc616de53c..f7a225b0131 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -3114,9 +3114,10 @@ paths: operationId: list_concurrency_limits_v1 summary: List concurrency limits description: | - List the environment's named concurrency limits (anonymous inline limits appear - under their derived `task/` names), with each limit's bounds and its - live running and queued counts. + List the environment's declared concurrency limits (anonymous inline limits + appear under their derived `task/` names), with each limit's bounds + and its live running and queued counts. Results are ordered by the underlying + row name, so named limits sort before `task/`-derived inline limits. parameters: - name: page in: query