Skip to content

Commit 1a6194d

Browse files
committed
feat(webapp): compile task concurrency declarations at deploy
Named limits (declared or merely referenced) materialize as LIMIT-role queue rows under the reserved limit/ prefix, with a total-only limit storing its total as the per-key limit too so it truly caps keyless runs. A task's inline limit maps onto its own default queue (stamped concurrencyVersion V2) or, on a shared queue, onto an anonymous limit/task row holding one of the two gate slots. Declaring both the legacy queue concurrencyLimit and concurrency is a deploy error. Trigger-time concurrency names resolve to limit gates, replacing the task's declared set, and every queue read filters to QUEUE-role rows. Drops the release note for the combined override methods that no longer ship.
1 parent d60cc71 commit 1a6194d

5 files changed

Lines changed: 137 additions & 27 deletions

File tree

.changeset/queue-concurrency-overrides.md

Lines changed: 0 additions & 15 deletions
This file was deleted.

apps/webapp/app/presenters/v3/QueueListPresenter.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ function buildQueueListWhere(
7070

7171
return {
7272
runtimeEnvironmentId: environmentId,
73+
role: "QUEUE" as const,
7374
version: "V2",
7475
name: trimmedQuery
7576
? {

apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export async function getQueue(
3131
where: {
3232
friendlyId: queue,
3333
runtimeEnvironmentId: environment.id,
34+
role: "QUEUE",
3435
},
3536
})
3637
);
@@ -44,6 +45,7 @@ export async function getQueue(
4445
where: {
4546
name: queueName,
4647
runtimeEnvironmentId: environment.id,
48+
role: "QUEUE",
4749
},
4850
})
4951
);

apps/webapp/app/runEngine/concerns/queues.server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,15 @@ export class DefaultQueueManager implements QueueManager {
220220
queueName = sanitizedQueueName;
221221
}
222222

223-
const requestedGates = request.body.options?.gates ?? taskGates ?? undefined;
223+
const triggerLimits = request.body.options?.concurrency;
224+
const concurrencyGates = triggerLimits?.map(
225+
(name): { queue: string; concurrencyKey?: string } => ({
226+
queue: `limit/${sanitizeQueueName(name)}`,
227+
})
228+
);
229+
230+
const requestedGates =
231+
concurrencyGates ?? request.body.options?.gates ?? taskGates ?? undefined;
224232
const gates = requestedGates
225233
?.flatMap((gate) => {
226234
const sanitized = sanitizeQueueName(gate.queue);

apps/webapp/app/v3/services/createBackgroundWorker.server.ts

Lines changed: 125 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ import {
1616
stringifyDuration,
1717
} from "@trigger.dev/core/v3/isomorphic";
1818
import { randomBytes } from "node:crypto";
19-
import type { BackgroundWorker, TaskQueue, TaskQueueType } from "@trigger.dev/database";
19+
import type {
20+
BackgroundWorker,
21+
TaskQueue,
22+
TaskQueueConcurrencyVersion,
23+
TaskQueueRole,
24+
TaskQueueType,
25+
} from "@trigger.dev/database";
2026
import cronstrue from "cronstrue";
2127
import type { PrismaClientOrTransaction, WebhookDatabase } from "~/db.server";
2228
import { $transaction, Prisma, boundedIn, webhookPrisma } from "~/db.server";
@@ -337,6 +343,7 @@ export async function createWorkerResources(
337343

338344
// Create the queues
339345
const queues = await createWorkerQueues(metadata, worker, environment, prisma);
346+
await createWorkerConcurrencyLimits(metadata, worker, environment, prisma);
340347

341348
// Create the tasks
342349
const taskEntries = await createWorkerTasks(
@@ -391,25 +398,72 @@ async function createWorkerTask(
391398
): Promise<TaskMetadataEntry | null> {
392399
// Hoisted so the P2002 catch branch can return the same entry shape.
393400
let queue: TaskQueue | undefined;
401+
let compiledGates: Array<{ queue: string; concurrencyKey?: string }> = [];
394402
let resolvedTriggerSource: "SCHEDULED" | "AGENT" | "WEBHOOK" | "STANDARD" | undefined;
395403
let resolvedTtl: string | null | undefined;
396404

397405
try {
406+
const concurrency = task.concurrency;
407+
408+
if (concurrency && typeof task.queue?.concurrencyLimit === "number") {
409+
throw new ServiceValidationError(
410+
`Task "${task.id}" declares both a queue concurrencyLimit and the concurrency option; use concurrency.`
411+
);
412+
}
413+
414+
compiledGates = (concurrency?.limits ?? []).map((name) => ({
415+
queue: concurrencyLimitQueueName(name),
416+
}));
417+
418+
let queueConcurrencyLimit = task.queue?.concurrencyLimit;
419+
let queueTotalConcurrencyLimit = task.queue?.combinedConcurrencyLimit;
420+
421+
if (concurrency?.inline) {
422+
if (!task.queue?.name) {
423+
queueConcurrencyLimit = concurrency.inline.perKey ?? concurrency.inline.total;
424+
queueTotalConcurrencyLimit = concurrency.inline.total;
425+
} else {
426+
if (compiledGates.length > 1) {
427+
throw new ServiceValidationError(
428+
`Task "${task.id}": an inline limit on a shared queue uses a gate slot, so at most one named limit can be combined with it.`
429+
);
430+
}
431+
const anonymousName = `task/${task.id}`;
432+
await createWorkerQueue(
433+
{
434+
name: concurrencyLimitQueueName(anonymousName),
435+
concurrencyLimit: concurrency.inline.perKey ?? concurrency.inline.total ?? null,
436+
combinedConcurrencyLimit: concurrency.inline.total ?? null,
437+
},
438+
anonymousName,
439+
"NAMED",
440+
worker,
441+
environment,
442+
prisma,
443+
"LIMIT",
444+
"V2"
445+
);
446+
compiledGates = [{ queue: concurrencyLimitQueueName(anonymousName) }, ...compiledGates];
447+
}
448+
}
449+
398450
queue = queues.find((queue) => queue.name === task.queue?.name);
399451

400452
if (!queue) {
401453
// Create a TaskQueue
402454
queue = await createWorkerQueue(
403455
{
404456
name: task.queue?.name ?? `task/${task.id}`,
405-
concurrencyLimit: task.queue?.concurrencyLimit,
406-
combinedConcurrencyLimit: task.queue?.combinedConcurrencyLimit,
457+
concurrencyLimit: queueConcurrencyLimit,
458+
combinedConcurrencyLimit: queueTotalConcurrencyLimit,
407459
},
408460
task.queue?.name ?? task.id,
409461
task.queue?.name ? "NAMED" : "VIRTUAL",
410462
worker,
411463
environment,
412-
prisma
464+
prisma,
465+
"QUEUE",
466+
concurrency ? "V2" : "V1"
413467
);
414468
}
415469

@@ -437,7 +491,7 @@ async function createWorkerTask(
437491
exportName: task.exportName,
438492
retryConfig: task.retry,
439493
queueConfig: task.queue,
440-
gates: task.gates,
494+
gates: compiledGates.length > 0 ? compiledGates : task.gates,
441495
machineConfig: task.machine,
442496
triggerSource: resolvedTriggerSource,
443497
config: task.agentConfig ? (task.agentConfig as any) : undefined,
@@ -455,7 +509,7 @@ async function createWorkerTask(
455509
triggerSource: resolvedTriggerSource,
456510
queueId: queue.id,
457511
queueName: queue.name,
458-
gates: task.gates ?? null,
512+
gates: compiledGates.length > 0 ? compiledGates : (task.gates ?? null),
459513
};
460514
} catch (error) {
461515
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -479,7 +533,7 @@ async function createWorkerTask(
479533
triggerSource: resolvedTriggerSource,
480534
queueId: queue.id,
481535
queueName: queue.name,
482-
gates: task.gates ?? null,
536+
gates: compiledGates.length > 0 ? compiledGates : (task.gates ?? null),
483537
};
484538
}
485539
} else {
@@ -540,13 +594,63 @@ async function createWorkerQueues(
540594
return allQueues;
541595
}
542596

597+
/** Queue rows that back named concurrency limits live under this reserved prefix so
598+
* they can never collide with a user's queue names. */
599+
export const CONCURRENCY_LIMIT_QUEUE_PREFIX = "limit/";
600+
601+
export function concurrencyLimitQueueName(limitName: string): string {
602+
return `${CONCURRENCY_LIMIT_QUEUE_PREFIX}${sanitizeQueueName(limitName)}`;
603+
}
604+
605+
/**
606+
* Materializes the worker's declared named concurrency limits (plus any names tasks
607+
* reference without declaring, created uncapped) as LIMIT-role TaskQueue rows. A
608+
* total-only limit stores the total as its per-key limit too, so it truly caps
609+
* keyless runs as well as the keyed group.
610+
*/
611+
async function createWorkerConcurrencyLimits(
612+
metadata: BackgroundWorkerMetadata,
613+
worker: BackgroundWorker,
614+
environment: AuthenticatedEnvironment,
615+
prisma: PrismaClientOrTransaction
616+
) {
617+
const declared = new Map((metadata.concurrencyLimits ?? []).map((l) => [l.name, l]));
618+
619+
for (const task of metadata.tasks) {
620+
for (const name of task.concurrency?.limits ?? []) {
621+
if (!declared.has(name)) {
622+
declared.set(name, { name });
623+
}
624+
}
625+
}
626+
627+
for (const limit of declared.values()) {
628+
await createWorkerQueue(
629+
{
630+
name: concurrencyLimitQueueName(limit.name),
631+
concurrencyLimit: limit.perKey ?? limit.total ?? null,
632+
combinedConcurrencyLimit: limit.total ?? null,
633+
},
634+
limit.name,
635+
"NAMED",
636+
worker,
637+
environment,
638+
prisma,
639+
"LIMIT",
640+
"V2"
641+
);
642+
}
643+
}
644+
543645
async function createWorkerQueue(
544646
queue: QueueManifest,
545647
orderableName: string,
546648
queueType: TaskQueueType,
547649
worker: BackgroundWorker,
548650
environment: AuthenticatedEnvironment,
549-
prisma: PrismaClientOrTransaction
651+
prisma: PrismaClientOrTransaction,
652+
role: TaskQueueRole = "QUEUE",
653+
concurrencyVersion: TaskQueueConcurrencyVersion = "V1"
550654
) {
551655
let queueName = sanitizeQueueName(queue.name);
552656

@@ -562,7 +666,10 @@ async function createWorkerQueue(
562666
orderableName,
563667
queueType,
564668
worker,
565-
prisma
669+
prisma,
670+
0,
671+
role,
672+
concurrencyVersion
566673
);
567674

568675
const newConcurrencyLimit = taskQueue.concurrencyLimit;
@@ -625,7 +732,9 @@ async function upsertWorkerQueueRecord(
625732
queueType: TaskQueueType,
626733
worker: BackgroundWorker,
627734
prisma: PrismaClientOrTransaction,
628-
attempt: number = 0
735+
attempt: number = 0,
736+
role: TaskQueueRole = "QUEUE",
737+
concurrencyVersion: TaskQueueConcurrencyVersion = "V1"
629738
): Promise<TaskQueue> {
630739
if (attempt > 3) {
631740
throw new Error("Failed to insert queue record");
@@ -644,6 +753,8 @@ async function upsertWorkerQueueRecord(
644753
data: {
645754
friendlyId: generateFriendlyId("queue"),
646755
version: "V2",
756+
role,
757+
concurrencyVersion,
647758
name: queueName,
648759
orderableName,
649760
concurrencyLimit,
@@ -669,6 +780,7 @@ async function upsertWorkerQueueRecord(
669780
data: {
670781
workers: { connect: { id: worker.id } },
671782
version: "V2",
783+
concurrencyVersion,
672784
orderableName,
673785
// If overridden, keep current limit and update base; otherwise update limit normally
674786
concurrencyLimit: hasOverride ? undefined : concurrencyLimit,
@@ -691,7 +803,9 @@ async function upsertWorkerQueueRecord(
691803
queueType,
692804
worker,
693805
prisma,
694-
attempt + 1
806+
attempt + 1,
807+
role,
808+
concurrencyVersion
695809
);
696810
}
697811
throw error;

0 commit comments

Comments
 (0)