Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
fa0a0b3
feat(run-engine): per-concurrency-key limit override storage and methods
matt-aitken Aug 29, 2026
43b478d
feat(run-engine): enforce per-key limit overrides at admit time
matt-aitken Aug 29, 2026
a3cdd3b
feat(database): total-override bookkeeping and per-key override table
matt-aitken Aug 29, 2026
eeb3e44
feat(sdk,core,webapp): runtime overrides for total and per-key limits
matt-aitken Aug 29, 2026
7927d1d
fix(run-engine,webapp,sdk): converge override failures and unpin bloc…
matt-aitken Aug 29, 2026
a3c8c6a
fix(run-engine,webapp): gate admission honors per-key overrides; hard…
matt-aitken Aug 29, 2026
7b2f366
fix(run-engine,webapp): flag-consistent gate overrides; generation-sa…
matt-aitken Aug 29, 2026
0e29d90
fix(webapp): export queue concurrency route handlers by property access
matt-aitken Aug 29, 2026
109540f
refactor(sdk,core,webapp): combined concurrency override API names
matt-aitken Aug 29, 2026
c7927de
refactor(run-engine,webapp,sdk,database): remove per-key concurrency …
matt-aitken Aug 31, 2026
119a962
refactor(run-engine,webapp): drop per-key override reads and endpoints
matt-aitken Aug 31, 2026
84a9619
fix(run-engine): keep the ck-limits key builders while the Lua reads …
matt-aitken Aug 31, 2026
f69ba93
fix(webapp): combined override error messages use the public name
matt-aitken Aug 31, 2026
2a38daa
feat(database): TaskQueue concurrencyVersion and role columns
matt-aitken Sep 6, 2026
905d688
feat(sdk,core): drop the combined concurrency override client methods
matt-aitken Sep 6, 2026
6c3b417
feat(webapp): compile task concurrency declarations at deploy
matt-aitken Sep 6, 2026
e314856
fix(webapp): deploy-time guards for the limit namespace
matt-aitken Sep 6, 2026
61b32fb
chore(webapp): the limit-name helpers are module-local
matt-aitken Sep 6, 2026
550985c
fix(webapp): validate concurrency declarations before any worker rows…
matt-aitken Sep 6, 2026
056b4f3
fix(webapp): strict limit names at deploy and collision-proof anonymo…
matt-aitken Sep 6, 2026
87ee30a
fix(webapp): queue override and reset APIs resolve queue rows only
matt-aitken Sep 6, 2026
1a43646
fix(webapp): trigger-time concurrency keeps the task's inline limit gate
matt-aitken Sep 6, 2026
8336d5c
fix(webapp): deploy upserts never clobber concurrent overrides, V2 on…
matt-aitken Sep 6, 2026
010742b
fix(webapp): re-sync engine limits when an override lands during a de…
matt-aitken Sep 6, 2026
5722cfe
fix(webapp): converge the post-deploy engine re-sync when markers kee…
matt-aitken Sep 6, 2026
6eb28d4
fix(webapp): raw gate replacement keeps the inline gate, V4 deploys v…
matt-aitken Sep 6, 2026
979b99c
fix(webapp): reject gate requests that exceed the three-gate capacity
matt-aitken Sep 6, 2026
2b2a798
fix(webapp): deploys re-assert pause, reserve limit/ in task gates, f…
matt-aitken Sep 6, 2026
bcee429
fix(webapp): resume syncs the fresh row's limit and honors a zero limit
matt-aitken Sep 7, 2026
235e628
chore: drop the combined concurrency changeset, the surface never ships
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: 0 additions & 5 deletions .changeset/queue-combined-concurrency-stats.md

This file was deleted.

1 change: 1 addition & 0 deletions apps/webapp/app/presenters/v3/QueueListPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function buildQueueListWhere(

return {
runtimeEnvironmentId: environmentId,
role: "QUEUE" as const,
version: "V2",
name: trimmedQuery
? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export async function getQueue(
where: {
friendlyId: queue,
runtimeEnvironmentId: environment.id,
role: "QUEUE",
},
})
);
Expand All @@ -44,6 +45,7 @@ export async function getQueue(
where: {
name: queueName,
runtimeEnvironmentId: environment.id,
role: "QUEUE",
},
})
);
Expand Down
Comment thread
matt-aitken marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { json } from "@remix-run/server-runtime";
import { type RetrieveQueueParam, RetrieveQueueType } from "@trigger.dev/core/v3";
import { z } from "zod";
import { toQueueItem } from "~/presenters/v3/QueueRetrievePresenter.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";

const BodySchema = z.object({
type: RetrieveQueueType.default("id"),
concurrencyLimit: z.number().int().min(0).max(100000),
});

const route = createActionApiRoute(
{
body: BodySchema,
params: z.object({
queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")),
}),
authorization: {
action: "write",
resource: () => ({ type: "queues" }),
},
},
Comment thread
matt-aitken marked this conversation as resolved.
async ({ params, body, authentication }) => {
const input: RetrieveQueueParam =
body.type === "id"
? params.queueParam
: {
type: body.type,
name: decodeURIComponent(params.queueParam).replace(/%2F/g, "/"),
};

return concurrencySystem.queues
.overrideTotalConcurrencyLimit(authentication.environment, input, body.concurrencyLimit)
.match(
(queue) => {
return json(
toQueueItem({
friendlyId: queue.friendlyId,
name: queue.name,
type: queue.type,
running: queue.running,
queued: queue.queued,
concurrencyLimit: queue.concurrencyLimit,
concurrencyLimitBase: queue.concurrencyLimitBase,
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt,
concurrencyLimitOverriddenBy: null,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
paused: queue.paused,
}),
{ status: 200 }
);
},
(error) => {
switch (error.type) {
case "queue_not_found": {
return json({ error: "Queue not found" }, { status: 404 });
}
case "invalid_override":
case "concurrency_limit_exceeds_maximum": {
return json({ error: error.message }, { status: 400 });
}
case "queue_update_failed": {
return json(
{ error: "Failed to update queue total concurrency limit" },
{ status: 500 }
);
}
case "sync_queue_concurrency_to_engine_failed": {
return json({ error: "Failed to sync the total concurrency limit" }, { status: 500 });
}
case "get_queue_stats_failed": {
return json({ error: "Failed to read queue stats" }, { status: 500 });
}
case "other": {
return json(
{ error: "Failed to update queue total concurrency limit" },
{
status: 500,
}
);
}
default: {
return json(
{ error: "Failed to update queue total concurrency limit" },
{
status: 500,
}
);
}
}
}
);
}
);

export const action = route.action;
/** The builder's loader answers non-POST methods with a 405. */
export const loader = route.loader;
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { json } from "@remix-run/server-runtime";
import { type RetrieveQueueParam, RetrieveQueueType } from "@trigger.dev/core/v3";
import { z } from "zod";
import { toQueueItem } from "~/presenters/v3/QueueRetrievePresenter.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";

const BodySchema = z.object({
type: RetrieveQueueType.default("id"),
});

const route = createActionApiRoute(
{
body: BodySchema,
params: z.object({
queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")),
}),
authorization: {
action: "write",
resource: () => ({ type: "queues" }),
},
},
async ({ params, body, authentication }) => {
const input: RetrieveQueueParam =
body.type === "id"
? params.queueParam
: {
type: body.type,
name: decodeURIComponent(params.queueParam).replace(/%2F/g, "/"),
};

return concurrencySystem.queues
.resetTotalConcurrencyLimit(authentication.environment, input)
.match(
(queue) => {
return json(
toQueueItem({
friendlyId: queue.friendlyId,
name: queue.name,
type: queue.type,
running: queue.running,
queued: queue.queued,
concurrencyLimit: queue.concurrencyLimit,
concurrencyLimitBase: queue.concurrencyLimitBase,
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt,
concurrencyLimitOverriddenBy: null,
paused: queue.paused,
}),
{ status: 200 }
);
},
(error) => {
switch (error.type) {
case "queue_not_found": {
return json({ error: "Queue not found" }, { status: 404 });
}
case "queue_not_overridden": {
return json(
{ error: "The queue total concurrency limit is not overridden" },
{ status: 400 }
);
}
case "queue_update_failed": {
return json(
{ error: "Failed to reset the queue total concurrency limit" },
{ status: 500 }
);
}
case "sync_queue_concurrency_to_engine_failed": {
return json({ error: "Failed to sync the total concurrency limit" }, { status: 500 });
}
case "get_queue_stats_failed": {
return json({ error: "Failed to read queue stats" }, { status: 500 });
}
case "other": {
return json(
{ error: "Failed to reset the queue total concurrency limit" },
{
status: 500,
}
);
}
default: {
return json(
{ error: "Failed to reset the queue total concurrency limit" },
{
status: 500,
}
);
}
}
}
);
}
);

export const action = route.action;
/** The builder's loader answers non-POST methods with a 405. */
export const loader = route.loader;
72 changes: 65 additions & 7 deletions apps/webapp/app/runEngine/concerns/queues.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,71 @@ 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 }] : [];
})
.slice(0, 2);
const triggerLimits = request.body.options?.concurrency;

for (const name of triggerLimits ?? []) {
if (!/^[a-zA-Z0-9_-]{1,122}$/.test(name)) {
throw new ServiceValidationError(
`Invalid concurrency limit name "${name}": names are 1-122 characters using only letters, numbers, underscores and hyphens.`
);
}
}

/**
* Trigger-time names replace the task's declared NAMED limits only. The task's
* inline limit rides in its stored gates as an anonymous "limit/task/" gate and
* always applies, so it is carried over into the replacement (an empty array
* clears the named limits but keeps the inline one).
*/
const inlineTaskGates = (taskGates ?? []).filter((gate) =>
gate.queue.startsWith("limit/task/")
);
const concurrencyGates = triggerLimits
? [
...inlineTaskGates,
...triggerLimits.map((name): { queue: string; concurrencyKey?: string } => ({
queue: `limit/${name}`,
})),
]
: undefined;

/**
* The raw gates option replaces stored gates the same way concurrency does, so
* it also carries the inline gate over; a replay resending the stored gates
* collapses back to the original set through the dedupe below.
*/
const rawGates = request.body.options?.gates;
const requestedGates =
concurrencyGates ??
(rawGates ? [...inlineTaskGates, ...rawGates] : undefined) ??
Comment thread
matt-aitken marked this conversation as resolved.
taskGates ??
undefined;

const seenGates = new Set<string>();
const gates = requestedGates?.flatMap((gate) => {
const sanitized = sanitizeQueueName(gate.queue);
if (!sanitized) {
return [];
}
const dedupeKey = `${sanitized}${gate.concurrencyKey ?? ""}`;
if (seenGates.has(dedupeKey)) {
return [];
}
seenGates.add(dedupeKey);
return [{ queue: sanitized, concurrencyKey: gate.concurrencyKey }];
});

/**
* Unreachable through the public schemas (three requested gates plus one inline
* gate is the ceiling), kept as a backstop so an overflowing set can never be
* silently truncated downstream. Replays of three-gate runs against a task that
* later gained an inline limit resolve to four and stay valid.
*/
if (gates && gates.length > 4) {
throw new ServiceValidationError(
`A run can hold at most four gates; this request resolves to ${gates.length}.`
);
}

return {
queueName,
Expand Down
Loading