Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6e3bd4f
feat(sdk,core,webapp,run-engine): declare queue gates on tasks and tr…
matt-aitken Aug 29, 2026
87ff489
fix(core,sdk,webapp): bound gate tuples at two and normalize queues once
matt-aitken Aug 29, 2026
d161de5
fix(core,database): mirror gates into the run-ops schema and reject e…
matt-aitken Aug 29, 2026
4d9ae99
fix(react-hooks): accept queue gate tuples in useTaskTrigger
matt-aitken Aug 29, 2026
3f70ebf
fix(react-hooks): one-element queue tuple clears task-level gates
matt-aitken Aug 29, 2026
141b9fa
refactor(core,sdk): rename combined concurrency fields in gate types
matt-aitken Aug 29, 2026
aac307f
refactor(sdk,core,webapp): rename totalConcurrencyLimit to combinedCo…
matt-aitken Aug 29, 2026
3838001
fix(core): duplicate queue warning covers the combined limit
matt-aitken Aug 31, 2026
e102a86
fix(sdk): string queue names in tasks are references, not definitions
matt-aitken Aug 31, 2026
8dd95d4
fix(webapp): order reference-created named queues by queue name
matt-aitken Aug 31, 2026
c1dff1b
fix(webapp,run-engine): stored gates obey the gate contract
matt-aitken Sep 6, 2026
944cb08
refactor(run-engine,webapp): one shared gate parser with tests
matt-aitken Sep 6, 2026
034e326
feat(sdk,core,cli): the concurrency option and named concurrency limits
matt-aitken Sep 6, 2026
852f990
fix(core,sdk,react-hooks): finish the concurrency-option surface swap
matt-aitken Sep 6, 2026
3a8e251
fix(webapp): a replayed run keeps its original gate set, empty included
matt-aitken Sep 6, 2026
305b2a3
fix(core): the worker manifest carries the declared concurrency limits
matt-aitken Sep 6, 2026
42ab3c1
fix(sdk): reject more than two trigger-time named limits instead of d…
matt-aitken Sep 6, 2026
3309c5d
fix(sdk): concurrency reaches every trigger path
matt-aitken Sep 6, 2026
806ef18
fix(sdk): concurrencyLimit names are 1-122 characters without slashes
matt-aitken Sep 6, 2026
d511ac6
fix(sdk): inline named limits share the factory's name validation
matt-aitken Sep 6, 2026
7ca6506
fix(sdk): restrict concurrency limit names to a queue-safe charset
matt-aitken Sep 6, 2026
833f0a2
fix(core,run-engine): gates carry a task's inline limit plus two name…
matt-aitken Sep 6, 2026
ff350ef
fix(webapp,run-engine): replays survive gate growth across deploys
matt-aitken Sep 6, 2026
951fadd
fix(core): the combined limit caps keyed and keyless runs together
matt-aitken Sep 7, 2026
c7d4ef6
feat(core): concurrency limit read and override schemas
matt-aitken Sep 7, 2026
13c5bc5
feat(sdk,core): the concurrencyLimits namespace
matt-aitken Sep 7, 2026
9807331
fix(react-hooks): reject more than two named limits instead of trunca…
matt-aitken Sep 7, 2026
f4cebc9
test(webapp): metadata-cache test entries carry the gates field the e…
matt-aitken Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/queue-combined-concurrency-stats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

Queue retrieve and list API responses now report combined concurrency usage. When a queue has a `combinedConcurrencyLimit`, `concurrency.combined` includes the effective cap, the declared base, any active override, and how many runs are in flight across all concurrency keys.
18 changes: 0 additions & 18 deletions .changeset/queue-total-concurrency-limit.md

This file was deleted.

23 changes: 23 additions & 0 deletions .changeset/task-concurrency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
"@trigger.dev/react-hooks": patch
---

Control a task's concurrency with the new `concurrency` option, and share limits across tasks with named concurrency limits. An inline shape caps the task itself; `concurrencyLimit()` declares a limit any task can hold (up to two named limits per task), and a trigger call can switch a run's named limits with its own `concurrency` option.

```ts
import { concurrencyLimit, task } from "@trigger.dev/sdk";

export const openaiLimit = concurrencyLimit({ name: "openai", total: 25 });

export const generateSummary = task({
id: "generate-summary",
concurrency: [{ perKey: 1, total: 5 }, openaiLimit],
run: async (payload) => {},
});
```

`perKey` caps each `concurrencyKey` pool and `total` caps across everything, keys or not. The queue-level `concurrencyLimit` option keeps working unchanged and is deprecated in favor of `concurrency`. Enforcement happens server-side; servers without support accept the option but do not enforce it yet.

Manage limits at runtime with the new `concurrencyLimits` namespace: `list()` and `retrieve(name)` report each limit's bounds plus its live `running` and `queued` counts, `override(name, { perKey, total })` changes only the given bounds (overriding `total` to `0` pauses the limit), and `reset(name)` restores the declared values.
48 changes: 42 additions & 6 deletions apps/webapp/app/runEngine/concerns/queues.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ import {
Namespace,
} from "@internal/cache";
import { singleton } from "~/utils/singleton";
import type { TaskMetadataCache, TaskMetadataEntry } from "~/services/taskMetadataCache.server";
import {
parseTaskGates,
type TaskMetadataCache,
type TaskMetadataEntry,
type TaskMetadataGate,
} from "~/services/taskMetadataCache.server";
import { taskMetadataCacheInstance } from "~/services/taskMetadataCacheInstance.server";
import {
recordTaskMetaResolve,
Expand Down Expand Up @@ -95,6 +100,7 @@ export class DefaultQueueManager implements QueueManager {
let lockedQueueId: string | undefined;
let taskTtl: string | null | undefined;
let taskKind: string | undefined;
let taskGates: TaskMetadataGate[] | null | undefined;

// Determine queue name based on lockToVersion and provided options
if (lockedBackgroundWorker) {
Expand Down Expand Up @@ -146,6 +152,7 @@ export class DefaultQueueManager implements QueueManager {
taskTtl = lockedMeta?.ttl ?? undefined;
}
taskKind = lockedMeta?.triggerSource;
taskGates = lockedMeta?.gates;
} else {
// No queue override - resolve default queue + TTL + triggerSource via cache,
// falling back to a single BackgroundWorkerTask lookup on miss.
Expand Down Expand Up @@ -184,6 +191,7 @@ export class DefaultQueueManager implements QueueManager {
queueName = lockedMeta.queueName;
lockedQueueId = lockedMeta.queueId ?? undefined;
taskKind = lockedMeta.triggerSource;
taskGates = lockedMeta.gates;
}
} else {
// Task is not locked to a specific version, use regular logic
Expand All @@ -199,6 +207,7 @@ export class DefaultQueueManager implements QueueManager {
queueName = taskInfo.queueName;
taskTtl = taskInfo.taskTtl;
taskKind = taskInfo.taskKind;
taskGates = taskInfo.taskGates;
}

// Sanitize the final determined queue name once
Expand All @@ -211,17 +220,29 @@ export class DefaultQueueManager implements QueueManager {
queueName = sanitizedQueueName;
}

const requestedGates = request.body.options?.gates ?? taskGates ?? undefined;
const gates = requestedGates
?.flatMap((gate) => {
const sanitized = sanitizeQueueName(gate.queue);
return sanitized ? [{ queue: sanitized, concurrencyKey: gate.concurrencyKey }] : [];
Comment thread
matt-aitken marked this conversation as resolved.
})
.slice(0, 2);

return {
queueName,
lockedQueueId,
taskTtl,
taskKind,
gates: gates && gates.length > 0 ? gates : undefined,
};
}

private async getTaskQueueInfo(
request: TriggerTaskRequest
): Promise<{ queueName: string; taskTtl?: string | null; taskKind?: string | undefined }> {
private async getTaskQueueInfo(request: TriggerTaskRequest): Promise<{
queueName: string;
taskTtl?: string | null;
taskKind?: string | undefined;
taskGates?: TaskMetadataGate[] | null;
}> {
const { taskId, environment, body } = request;
const { queue } = body.options ?? {};

Expand All @@ -243,6 +264,7 @@ export class DefaultQueueManager implements QueueManager {
queueName: overriddenQueueName,
taskTtl: meta?.ttl ?? undefined,
taskKind: meta?.triggerSource,
taskGates: meta?.gates,
};
}

Expand All @@ -259,10 +281,20 @@ export class DefaultQueueManager implements QueueManager {
taskId,
environmentId: environment.id,
});
return { queueName: defaultQueueName, taskTtl: meta.ttl, taskKind: meta.triggerSource };
return {
queueName: defaultQueueName,
taskTtl: meta.ttl,
taskKind: meta.triggerSource,
taskGates: meta.gates,
};
}

return { queueName: meta.queueName, taskTtl: meta.ttl, taskKind: meta.triggerSource };
return {
queueName: meta.queueName,
taskTtl: meta.ttl,
taskKind: meta.triggerSource,
taskGates: meta.gates,
};
}

/**
Expand Down Expand Up @@ -320,6 +352,7 @@ export class DefaultQueueManager implements QueueManager {
triggerSource: row.triggerSource,
queueId: row.queue?.id ?? null,
queueName: row.queue?.name ?? "",
gates: parseTaskGates(row.gates),
};

// Fire-and-forget back-fill — `setByWorker` upserts the single field and
Expand All @@ -340,6 +373,7 @@ export class DefaultQueueManager implements QueueManager {
select: {
ttl: true,
triggerSource: true,
gates: true,
queue: { select: { id: true, name: true } },
},
});
Expand Down Expand Up @@ -378,6 +412,7 @@ export class DefaultQueueManager implements QueueManager {
select: {
ttl: true,
triggerSource: true,
gates: true,
queue: { select: { id: true, name: true } },
},
});
Expand All @@ -395,6 +430,7 @@ export class DefaultQueueManager implements QueueManager {
triggerSource: row.triggerSource,
queueId: row.queue?.id ?? null,
queueName: row.queue?.name ?? "",
gates: parseTaskGates(row.gates),
};

// Fire-and-forget back-fill — atomically upserts the slug into both
Expand Down
6 changes: 5 additions & 1 deletion apps/webapp/app/runEngine/services/triggerTask.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ export class RunEngineTriggerTaskService {
const parkedOnExternalDeploymentId =
externalDeploymentResolution?.outcome === "park" ? externalDeploymentId : undefined;

const { queueName, lockedQueueId, taskTtl, taskKind } =
const { queueName, lockedQueueId, taskTtl, taskKind, gates } =
await this.queueConcern.resolveQueueProperties(
triggerRequest,
lockedToBackgroundWorker ?? undefined
Expand Down Expand Up @@ -663,6 +663,7 @@ export class RunEngineTriggerTaskService {
options,
queueName,
lockedQueueId,
gates,
workerQueue,
region: migrated.region,
enableFastPath: migrated.enableFastPath,
Expand Down Expand Up @@ -743,6 +744,7 @@ export class RunEngineTriggerTaskService {
options,
queueName,
lockedQueueId,
gates,
workerQueue,
region: migrated.region,
enableFastPath: migrated.enableFastPath,
Expand Down Expand Up @@ -905,6 +907,7 @@ export class RunEngineTriggerTaskService {
options: TriggerTaskServiceOptions;
queueName: string;
lockedQueueId?: string;
gates?: Array<{ queue: string; concurrencyKey?: string }>;
workerQueue?: string;
region?: string;
enableFastPath: boolean;
Expand Down Expand Up @@ -971,6 +974,7 @@ export class RunEngineTriggerTaskService {
: args.body.options?.concurrencyKey,
queue: args.queueName,
lockedQueueId: args.lockedQueueId,
gates: args.gates,
workerQueue: args.workerQueue,
region: args.region,
enableFastPath: args.enableFastPath,
Expand Down
2 changes: 2 additions & 0 deletions apps/webapp/app/runEngine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export type QueueProperties = {
lockedQueueId?: string;
taskTtl?: string | null;
taskKind?: string;
/** Other queues the run must also hold a concurrency slot in while executing. */
gates?: Array<{ queue: string; concurrencyKey?: string }>;
};

export type LockedBackgroundWorker = Pick<
Expand Down
17 changes: 17 additions & 0 deletions apps/webapp/app/services/taskMetadataCache.server.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import type { Redis, Result, Callback } from "ioredis";
import { parseGates } from "@internal/run-engine";
import type { TaskTriggerSource } from "@trigger.dev/database";
import { logger } from "./logger.server";

export type TaskMetadataGate = { queue: string; concurrencyKey?: string };

export type TaskMetadataEntry = {
slug: string;
ttl: string | null;
triggerSource: TaskTriggerSource;
queueId: string | null;
queueName: string;
/** Task-declared gates, applied to every trigger that does not override them. */
gates: TaskMetadataGate[] | null;
};

export interface TaskMetadataCache {
Expand Down Expand Up @@ -52,11 +57,21 @@ export type RedisTaskMetadataCacheOptions = {
byWorkerTtlSeconds?: number;
};

/**
* BackgroundWorkerTask.gates is an untyped Json column; keep only well-shaped
* entries so a malformed value can never fail a trigger.
*/
export function parseTaskGates(gates: unknown): TaskMetadataGate[] | null {
const parsed = parseGates(gates);
return parsed.length > 0 ? parsed : null;
}

type EncodedEntry = {
t: string | null;
k: TaskTriggerSource;
q: string | null;
n: string;
g?: TaskMetadataGate[] | null;
};

function encode(entry: TaskMetadataEntry): string {
Expand All @@ -65,6 +80,7 @@ function encode(entry: TaskMetadataEntry): string {
k: entry.triggerSource,
q: entry.queueId,
n: entry.queueName,
g: entry.gates,
};
return JSON.stringify(payload);
}
Expand All @@ -78,6 +94,7 @@ function decode(slug: string, raw: string): TaskMetadataEntry | null {
triggerSource: parsed.k,
queueId: parsed.q,
queueName: parsed.n,
gates: parseTaskGates(parsed.g ?? null),
};
Comment thread
matt-aitken marked this conversation as resolved.
} catch (error) {
logger.error("Failed to decode task metadata cache entry", { slug, error });
Expand Down
3 changes: 3 additions & 0 deletions apps/webapp/app/v3/services/changeCurrentDeployment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { logger } from "~/services/logger.server";
import { syncTaskIdentifiers } from "~/services/taskIdentifierRegistry.server";
import {
type TaskMetadataCache,
parseTaskGates,
type TaskMetadataEntry,
} from "~/services/taskMetadataCache.server";
import { taskMetadataCacheInstance } from "~/services/taskMetadataCacheInstance.server";
Expand Down Expand Up @@ -119,6 +120,7 @@ export class ChangeCurrentDeploymentService extends BaseService {
slug: true,
triggerSource: true,
ttl: true,
gates: true,
queue: { select: { id: true, name: true } },
},
})
Expand Down Expand Up @@ -157,6 +159,7 @@ export class ChangeCurrentDeploymentService extends BaseService {
triggerSource: t.triggerSource,
queueId: t.queue?.id ?? null,
queueName: t.queue?.name ?? "",
gates: parseTaskGates(t.gates),
}));

// Cache calls log+swallow internally.
Expand Down
9 changes: 6 additions & 3 deletions apps/webapp/app/v3/services/createBackgroundWorker.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,9 +403,9 @@ async function createWorkerTask(
{
name: task.queue?.name ?? `task/${task.id}`,
concurrencyLimit: task.queue?.concurrencyLimit,
totalConcurrencyLimit: task.queue?.totalConcurrencyLimit,
combinedConcurrencyLimit: task.queue?.combinedConcurrencyLimit,
},
task.id,
task.queue?.name ?? task.id,
task.queue?.name ? "NAMED" : "VIRTUAL",
worker,
environment,
Expand Down Expand Up @@ -437,6 +437,7 @@ async function createWorkerTask(
exportName: task.exportName,
retryConfig: task.retry,
queueConfig: task.queue,
gates: task.gates,
Comment thread
matt-aitken marked this conversation as resolved.
machineConfig: task.machine,
triggerSource: resolvedTriggerSource,
config: task.agentConfig ? (task.agentConfig as any) : undefined,
Expand All @@ -454,6 +455,7 @@ async function createWorkerTask(
triggerSource: resolvedTriggerSource,
queueId: queue.id,
queueName: queue.name,
gates: task.gates ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
Expand All @@ -477,6 +479,7 @@ async function createWorkerTask(
triggerSource: resolvedTriggerSource,
queueId: queue.id,
queueName: queue.name,
gates: task.gates ?? null,
};
}
} else {
Expand Down Expand Up @@ -555,7 +558,7 @@ async function createWorkerQueue(
const taskQueue = await upsertWorkerQueueRecord(
queueName,
baseConcurrencyLimit ?? null,
queue.totalConcurrencyLimit ?? null,
queue.combinedConcurrencyLimit ?? null,
orderableName,
queueType,
worker,
Expand Down
9 changes: 9 additions & 0 deletions apps/webapp/app/v3/services/replayTaskRun.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ export class ReplayTaskRunService extends BaseService {
: undefined,
concurrencyKey:
overrideOptions.concurrencyKey ?? existingTaskRun.concurrencyKey ?? undefined,
/**
* A run that held gates replays with those same gates. A run with none
* stored passes undefined (never a fabricated empty array, which reads
* as "clear the named limits") so the replay honors whatever limits the
* task declares now, like it honors the current queue and retry config.
*/
gates: Array.isArray(existingTaskRun.gates)
? (existingTaskRun.gates as Array<{ queue: string; concurrencyKey?: string }>)
: undefined,
maxAttempts: overrideOptions.maxAttempts,
maxDuration: overrideOptions.maxDurationSeconds,
machine:
Expand Down
Loading