Skip to content

Commit c49fd05

Browse files
matt-aitkenTrigger.dev RepoOps
authored andcommitted
feat(run-engine,sdk,webapp): per-key override on a concurrency limit
Override a concurrency limit's `perKey` for a single `concurrencyKey`. `concurrencyLimits.override(name, { concurrencyKey, perKey, ttl })` caps that one key under that one limit for a while (7 days by default, 30 at most), `reset(name, { concurrencyKey })` clears it early, and `retrieve(name, { concurrencyKey })` shows the key's bound, its override and when it expires. Other keys and other limits are untouched, and a paused limit stays paused. The queue detail page shows each key's override in the concurrency keys table. ```ts import { concurrencyLimit, concurrencyLimits, task } from "@trigger.dev/sdk"; // one slot per session by default export const subagentsLimit = concurrencyLimit({ name: "subagents", perKey: 1 }); export const subagent = task({ id: "subagent", concurrency: subagentsLimit, run: async (payload: { sessionId: string }) => {}, }); // Existing: override the limit itself, for every key await concurrencyLimits.override("subagents", { perKey: 2 }); await concurrencyLimits.override("subagents", { total: 50 }); await concurrencyLimits.reset("subagents"); // New: override one key only, for a while await concurrencyLimits.override("subagents", { concurrencyKey: sessionId, perKey: 3, // this session may run 3 at once; every other key keeps the limit's perKey ttl: "2h", // optional; default 7d, max 30d }); const view = await concurrencyLimits.retrieve("subagents", { concurrencyKey: sessionId }); console.log(view.perKey.override, view.perKey.expiresAt, view.running); await concurrencyLimits.reset("subagents", { concurrencyKey: sessionId }); // Runs opt in by carrying the key await subagent.trigger({ sessionId }, { concurrencyKey: sessionId }); ``` `concurrencyKey` goes with `perKey` (and optionally `ttl`), never with `total`; the SDK's types enforce this. Mono-RevId: 5b43862fbebf5583aa4c762a6c0660e001cc0a44
1 parent e6b6b52 commit c49fd05

27 files changed

Lines changed: 2053 additions & 142 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Override a concurrency limit's `perKey` for a single `concurrencyKey`: `concurrencyLimits.override(name, { concurrencyKey, perKey, ttl })` caps that one key under that one limit for a while (7 days by default, 30 at most), `reset(name, { concurrencyKey })` clears it, and `retrieve(name, { concurrencyKey })` shows the key's bound and when the override expires.

‎apps/webapp/app/env.server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,6 +1464,7 @@ const EnvironmentSchema = z
14641464
.default("info"),
14651465
RUN_ENGINE_TOTAL_CONCURRENCY_LIMITS_ENABLED: z.string().default("1"),
14661466
RUN_ENGINE_QUEUE_GATES_ENABLED: z.string().default("0"),
1467+
RUN_ENGINE_CONCURRENCY_KEY_LIMITS_ENABLED: z.string().default("0"),
14671468
RUN_ENGINE_QUEUE_RECONCILE_ENABLED: z.string().default("1"),
14681469
RUN_ENGINE_QUEUE_RECONCILE_SCAN_COUNT: z.coerce.number().int().positive().default(100),
14691470
RUN_ENGINE_QUEUE_RECONCILE_LOCK_TTL_SECONDS: z.coerce.number().int().positive().default(10),

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency_.$queueParam/route.tsx‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,7 @@ function KeyStatsTable({
10131013
const { data, isLoading } = useConcurrencyKeys({ ids, timeRange, queueName, search, page });
10141014

10151015
const rows: ConcurrencyKeyRow[] = data?.success ? data.rows : [];
1016+
const loadedAt = data?.success ? data.loadedAt : 0;
10161017
const total = data?.success ? data.total : 0;
10171018
const perPage = data?.success ? data.perPage : 25;
10181019
const totalPages = Math.max(1, Math.ceil(total / perPage));
@@ -1046,6 +1047,7 @@ function KeyStatsTable({
10461047
<TableHeaderCell>Key</TableHeaderCell>
10471048
<TableHeaderCell alignment="right">Queued now</TableHeaderCell>
10481049
<TableHeaderCell alignment="right">Running now</TableHeaderCell>
1050+
<TableHeaderCell alignment="right">Override</TableHeaderCell>
10491051
<TableHeaderCell alignment="right">Oldest wait</TableHeaderCell>
10501052
<TableHeaderCell alignment="right">Started</TableHeaderCell>
10511053
<TableHeaderCell alignment="right">Peak backlog</TableHeaderCell>
@@ -1054,11 +1056,11 @@ function KeyStatsTable({
10541056
</TableHeader>
10551057
<TableBody>
10561058
{showLoading ? (
1057-
<TableBlankRow colSpan={7} className="text-text-dimmed">
1059+
<TableBlankRow colSpan={8} className="text-text-dimmed">
10581060
Loading…
10591061
</TableBlankRow>
10601062
) : rows.length === 0 ? (
1061-
<TableBlankRow colSpan={7} className="text-text-dimmed">
1063+
<TableBlankRow colSpan={8} className="text-text-dimmed">
10621064
{search ? `No keys match “${search}”` : "No concurrency keys"}
10631065
</TableBlankRow>
10641066
) : (
@@ -1072,6 +1074,21 @@ function KeyStatsTable({
10721074
<TableCell>{row.key}</TableCell>
10731075
<TableCell alignment="right">{row.queued.toLocaleString()}</TableCell>
10741076
<TableCell alignment="right">{row.running.toLocaleString()}</TableCell>
1077+
<TableCell alignment="right">
1078+
{row.override === null ? (
1079+
<span className="text-text-dimmed">–</span>
1080+
) : (
1081+
<span
1082+
title={
1083+
row.overrideExpiresAt === null
1084+
? "Per-key override"
1085+
: `Per-key override, expires in ${formatWaitMs(Math.max(0, row.overrideExpiresAt - loadedAt))}`
1086+
}
1087+
>
1088+
{row.override.toLocaleString()}
1089+
</span>
1090+
)}
1091+
</TableCell>
10751092
<TableCell alignment="right">
10761093
{row.oldestWaitMs === null ? "–" : formatWaitMs(row.oldestWaitMs)}
10771094
</TableCell>

‎apps/webapp/app/routes/api.v1.concurrency-limits.$name.override.ts‎

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,40 @@ const route = createActionApiRoute(
1919
corsStrategy: "all",
2020
},
2121
async ({ params, body, authentication }) => {
22-
return concurrencyLimitsSystem.limits
23-
.override(authentication.environment, params.name, body)
24-
.match(
25-
(limit) => json(limit),
26-
(error) => {
27-
switch (error.type) {
28-
case "limit_not_found":
29-
return json({ error: "Concurrency limit not found" }, { status: 404 });
30-
case "conflict":
31-
return json(
32-
{ error: "The limit changed concurrently; retry the request" },
33-
{ status: 409 }
34-
);
35-
case "invalid_override":
36-
return json({ error: error.message }, { status: 400 });
37-
default:
38-
return json({ error: "Failed to override concurrency limit" }, { status: 500 });
39-
}
22+
const result =
23+
body.concurrencyKey !== undefined
24+
? concurrencyLimitsSystem.limits.overrideKey(authentication.environment, params.name, {
25+
concurrencyKey: body.concurrencyKey,
26+
perKey: body.perKey,
27+
ttl: body.ttl,
28+
})
29+
: concurrencyLimitsSystem.limits.override(authentication.environment, params.name, {
30+
perKey: body.perKey,
31+
total: body.total,
32+
});
33+
return result.match(
34+
(limit) => json(limit),
35+
(error) => {
36+
switch (error.type) {
37+
case "limit_not_found":
38+
return json({ error: "Concurrency limit not found" }, { status: 404 });
39+
case "conflict":
40+
return json(
41+
{ error: "The limit changed concurrently; retry the request" },
42+
{ status: 409 }
43+
);
44+
case "invalid_override":
45+
return json({ error: error.message }, { status: 400 });
46+
case "feature_disabled":
47+
return json(
48+
{ error: "Per-key concurrency overrides are not enabled on this server" },
49+
{ status: 400 }
50+
);
51+
default:
52+
return json({ error: "Failed to override concurrency limit" }, { status: 500 });
4053
}
41-
);
54+
}
55+
);
4256
}
4357
);
4458

‎apps/webapp/app/routes/api.v1.concurrency-limits.$name.reset.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { json } from "@remix-run/server-runtime";
2+
import { ResetConcurrencyLimitRequestBody } from "@trigger.dev/core/v3";
23
import { z } from "zod";
34
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
5+
import { safeJsonParse } from "~/utils/json";
46
import { concurrencyLimitsSystem } from "~/v3/services/concurrencyLimitsSystemInstance.server";
57

68
const ParamsSchema = z.object({
@@ -16,8 +18,29 @@ const route = createActionApiRoute(
1618
},
1719
corsStrategy: "all",
1820
},
19-
async ({ params, authentication }) => {
20-
return concurrencyLimitsSystem.limits.reset(authentication.environment, params.name).match(
21+
async ({ params, request, authentication }) => {
22+
/** The body is optional: an empty POST resets the limit itself, as it always has. */
23+
const rawBody = await request.text();
24+
let concurrencyKey: string | undefined;
25+
if (rawBody.length > 0) {
26+
const parsed = ResetConcurrencyLimitRequestBody.safeParse(safeJsonParse(rawBody));
27+
if (!parsed.success) {
28+
return json(
29+
{ error: "Invalid body: expected { concurrencyKey?: string }" },
30+
{ status: 400 }
31+
);
32+
}
33+
concurrencyKey = parsed.data.concurrencyKey;
34+
}
35+
const result =
36+
concurrencyKey !== undefined
37+
? concurrencyLimitsSystem.limits.resetKey(
38+
authentication.environment,
39+
params.name,
40+
concurrencyKey
41+
)
42+
: concurrencyLimitsSystem.limits.reset(authentication.environment, params.name);
43+
return result.match(
2144
(limit) => json(limit),
2245
(error) => {
2346
switch (error.type) {

‎apps/webapp/app/routes/api.v1.concurrency-limits.$name.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,31 @@ const ParamsSchema = z.object({
77
name: z.string(),
88
});
99

10+
const SearchParamsSchema = z.object({
11+
concurrencyKey: z.string().min(1).max(128).optional(),
12+
});
13+
1014
export const loader = createLoaderApiRoute(
1115
{
1216
params: ParamsSchema,
17+
searchParams: SearchParamsSchema,
1318
findResource: async () => 1,
1419
authorization: {
1520
action: "read",
1621
resource: () => ({ type: "queues" }),
1722
},
1823
corsStrategy: "all",
1924
},
20-
async ({ params, authentication }) => {
21-
return concurrencyLimitsSystem.limits.retrieve(authentication.environment, params.name).match(
25+
async ({ params, searchParams, authentication }) => {
26+
const result =
27+
searchParams.concurrencyKey !== undefined
28+
? concurrencyLimitsSystem.limits.retrieveKey(
29+
authentication.environment,
30+
params.name,
31+
searchParams.concurrencyKey
32+
)
33+
: concurrencyLimitsSystem.limits.retrieve(authentication.environment, params.name);
34+
return result.match(
2235
(limit) => json(limit),
2336
(error) => {
2437
if (error.type === "limit_not_found") {

‎apps/webapp/app/routes/resources.queues.concurrency-keys.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export type ConcurrencyKeyRow = {
3838
key: string;
3939
queued: number;
4040
running: number;
41+
/** A per-key override of the queue's perKey for this key, and when it lapses; null when none. */
42+
override: number | null;
43+
overrideExpiresAt: number | null;
4144
oldestWaitMs: number | null;
4245
started: number;
4346
peakBacklog: number;
@@ -163,6 +166,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
163166
key: r.concurrency_key,
164167
queued: l?.queued ?? 0,
165168
running: l?.running ?? 0,
169+
override: l?.limit ?? null,
170+
overrideExpiresAt: l?.limitExpiresAt?.getTime() ?? null,
166171
oldestWaitMs,
167172
started: r.started,
168173
peakBacklog: r.peak_backlog,

‎apps/webapp/app/v3/runEngine.server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ function createRunEngine() {
6464
defaultEnvConcurrencyBurstFactor: env.DEFAULT_ENV_EXECUTION_CONCURRENCY_BURST_FACTOR,
6565
totalConcurrencyEnabled: env.RUN_ENGINE_TOTAL_CONCURRENCY_LIMITS_ENABLED === "1",
6666
gatesEnabled: env.RUN_ENGINE_QUEUE_GATES_ENABLED === "1",
67+
concurrencyKeyLimitsEnabled: env.RUN_ENGINE_CONCURRENCY_KEY_LIMITS_ENABLED === "1",
6768
reconcile: {
6869
enabled: env.RUN_ENGINE_QUEUE_RECONCILE_ENABLED === "1",
6970
scanCount: env.RUN_ENGINE_QUEUE_RECONCILE_SCAN_COUNT,

‎apps/webapp/app/v3/runQueue.server.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,38 @@ export async function removeQueueConcurrencyLimits(
6464
) {
6565
await engine.runQueue.removeQueueConcurrencyLimits(environment, queueName);
6666
}
67+
68+
/** Sets a temporary per-key override of a queue's perKey for one concurrencyKey, expiring after ttlSeconds */
69+
export async function updateQueueConcurrencyKeyLimit(
70+
environment: AuthenticatedEnvironment,
71+
queueName: string,
72+
concurrencyKey: string,
73+
concurrency: number,
74+
ttlSeconds: number
75+
) {
76+
await engine.runQueue.updateQueueConcurrencyKeyLimit(
77+
environment,
78+
queueName,
79+
concurrencyKey,
80+
concurrency,
81+
ttlSeconds
82+
);
83+
}
84+
85+
/** Removes a per-key override of a queue's perKey */
86+
export async function removeQueueConcurrencyKeyLimit(
87+
environment: AuthenticatedEnvironment,
88+
queueName: string,
89+
concurrencyKey: string
90+
) {
91+
await engine.runQueue.removeQueueConcurrencyKeyLimit(environment, queueName, concurrencyKey);
92+
}
93+
94+
/** Reads a per-key override of a queue's perKey, with when it expires */
95+
export async function getQueueConcurrencyKeyLimit(
96+
environment: AuthenticatedEnvironment,
97+
queueName: string,
98+
concurrencyKey: string
99+
) {
100+
return engine.runQueue.getQueueConcurrencyKeyLimit(environment, queueName, concurrencyKey);
101+
}

0 commit comments

Comments
 (0)