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
7f937e7
feat(run-engine): per-concurrency-key limit override storage and methods
matt-aitken Aug 29, 2026
1b3b5a3
feat(run-engine): enforce per-key limit overrides at admit time
matt-aitken Aug 29, 2026
a674876
feat(database): total-override bookkeeping and per-key override table
matt-aitken Aug 29, 2026
b622847
feat(sdk,core,webapp): runtime overrides for total and per-key limits
matt-aitken Aug 29, 2026
5a70b75
fix(run-engine,webapp,sdk): converge override failures and unpin bloc…
matt-aitken Aug 29, 2026
1ace507
fix(run-engine,webapp): gate admission honors per-key overrides; hard…
matt-aitken Aug 29, 2026
d142d1c
fix(run-engine,webapp): flag-consistent gate overrides; generation-sa…
matt-aitken Aug 29, 2026
2ea853f
fix(webapp): export queue concurrency route handlers by property access
matt-aitken Aug 29, 2026
9d72d2d
refactor(sdk,core,webapp): combined concurrency override API names
matt-aitken Aug 29, 2026
fd07298
refactor(run-engine,webapp,sdk,database): remove per-key concurrency …
matt-aitken Aug 31, 2026
fd5c4b5
refactor(run-engine,webapp): drop per-key override reads and endpoints
matt-aitken Aug 31, 2026
4971284
fix(run-engine): keep the ck-limits key builders while the Lua reads …
matt-aitken Aug 31, 2026
3924765
fix(webapp): combined override error messages use the public name
matt-aitken Aug 31, 2026
7e69dc5
feat(database): TaskQueue concurrencyVersion and role columns
matt-aitken Sep 6, 2026
5ff3590
feat(sdk,core): drop the combined concurrency override client methods
matt-aitken Sep 6, 2026
6785519
feat(webapp): compile task concurrency declarations at deploy
matt-aitken Sep 6, 2026
437c964
fix(webapp): deploy-time guards for the limit namespace
matt-aitken Sep 6, 2026
668227a
chore(webapp): the limit-name helpers are module-local
matt-aitken Sep 6, 2026
12750a4
fix(webapp): validate concurrency declarations before any worker rows…
matt-aitken Sep 6, 2026
a14d359
fix(webapp): strict limit names at deploy and collision-proof anonymo…
matt-aitken Sep 6, 2026
aecfb4f
fix(webapp): queue override and reset APIs resolve queue rows only
matt-aitken Sep 6, 2026
f48d640
fix(webapp): trigger-time concurrency keeps the task's inline limit gate
matt-aitken Sep 6, 2026
ac09238
fix(webapp): deploy upserts never clobber concurrent overrides, V2 on…
matt-aitken Sep 6, 2026
aead915
fix(webapp): re-sync engine limits when an override lands during a de…
matt-aitken Sep 6, 2026
5ec586e
fix(webapp): converge the post-deploy engine re-sync when markers kee…
matt-aitken Sep 6, 2026
d41e437
fix(webapp): raw gate replacement keeps the inline gate, V4 deploys v…
matt-aitken Sep 6, 2026
4d347de
fix(webapp): reject gate requests that exceed the three-gate capacity
matt-aitken Sep 6, 2026
deef18a
fix(webapp): deploys re-assert pause, reserve limit/ in task gates, f…
matt-aitken Sep 6, 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
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
Loading