Skip to content

Commit 69f396f

Browse files
claude[bot]claude
andauthored
fix(webapp): keep paused environments paused when concurrency limits are pushed (#4625)
<!-- ccr-slack-attribution --> _Requested by **Matt Aitken** · [Slack thread](https://triggerdotdev.slack.com/archives/C045W9WM3E1/p1786732623292829?thread_ts=1786732623.292829&cid=C045W9WM3E1)_ **Before:** you pause an environment, then a deploy lands (or a background worker is created, or an admin changes the concurrency/burst-factor). The environment starts picking up runs again even though the dashboard still shows it as paused. **After:** a paused environment stays paused until it is resumed, no matter what else pushes its concurrency limit. Pausing an environment sets `paused` in the database and writes a `0` env concurrency limit into the run queue — the `0` is the only thing that actually stops dequeueing. Any caller that pushed the limit without an explicit value (`finalizeDeployment`, `createBackgroundWorker`, the two admin environment routes) rewrote the real limit and silently un-paused the environment. ## ✅ Checklist - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works --- ## Testing `apps/webapp/test/pauseEnvironment.server.test.ts` gains two `containerTest` cases that wire a real `RunEngine` (real Redis) in place of the stubbed app singleton and assert the actual run-queue env limit: - pause a PRODUCTION env → limit is `0` → run the real `FinalizeDeploymentService` → limit is still `0`, plus a control on a running env in the same test proving that deploy path really does push the limit (so the `0` can't just mean "nothing happened"). - pause → resume → the real limit is restored, so the clamp can't regress resuming. Both cases fail on `main` (`expected 17 to be +0` and `expected +0 to be 17`) and pass with this change. `pnpm run typecheck --filter webapp` is clean. --- ## Changelog Fix paused environments starting to run work again after a deploy. --- ## How The clamp lives in the shared `updateEnvConcurrencyLimits` helper in `apps/webapp/app/v3/runQueue.server.ts`, so every present and future caller is covered: when no explicit limit is passed and the environment is paused, `0` is written instead of the stored maximum. An explicitly-passed limit still wins, which is what pausing itself relies on. The resume path now passes the post-update environment state (its in-memory copy was read before the un-pause and would otherwise be clamped back to `0`), and the helper no longer mutates the caller's environment object — that aliasing made a pause followed by a resume on the same object write `0` twice. The existing `!paused` guards in `allocateConcurrency` and the queue-level guard in `createBackgroundWorker` are left in place as defence in depth, and queue-level `TaskQueue.paused` behaviour is untouched. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc8f90e commit 69f396f

7 files changed

Lines changed: 251 additions & 9 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Fix paused environments starting to run work again after a deploy: a paused environment now stays paused until you resume it.

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

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
1+
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
2+
import { prisma } from "~/db.server";
13
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
24
import { engine } from "./runEngine.server";
35

46
/** Updates the RunQueue env concurrency limits */
57
export async function updateEnvConcurrencyLimits(
68
environment: AuthenticatedEnvironment,
7-
maximumConcurrencyLimit?: number
9+
maximumConcurrencyLimit?: number,
10+
db: PrismaClientOrTransaction = prisma
811
) {
9-
let updatedEnvironment = environment;
10-
if (maximumConcurrencyLimit !== undefined) {
11-
updatedEnvironment.maximumConcurrencyLimit = maximumConcurrencyLimit;
12+
let limit = maximumConcurrencyLimit;
13+
14+
if (limit === undefined) {
15+
// A paused env is only enforced by a 0 limit in the RunQueue, so a push without an explicit
16+
// limit must not resurrect the real limit. Callers hold an environment read at auth time, so
17+
// resolve both values here instead of trusting it: a stale `paused: false` silently resumes a
18+
// paused env, and a stale `paused: true` strands a resumed one at 0 with nothing to restore it.
19+
const current = await db.runtimeEnvironment.findFirst({
20+
where: { id: environment.id },
21+
select: { paused: true, maximumConcurrencyLimit: true },
22+
});
23+
24+
const resolved = current ?? environment;
25+
limit = resolved.paused ? 0 : resolved.maximumConcurrencyLimit;
1226
}
1327

14-
await engine.runQueue.updateEnvConcurrencyLimits(updatedEnvironment);
28+
await engine.runQueue.updateEnvConcurrencyLimits({
29+
...environment,
30+
maximumConcurrencyLimit: limit,
31+
});
1532
}
1633

1734
/** Updates the RunQueue limits for a queue */

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export class AllocateConcurrencyService extends BaseService {
8888
});
8989

9090
if (!updatedEnvironment.paused) {
91-
await updateEnvConcurrencyLimits(updatedEnvironment);
91+
await updateEnvConcurrencyLimits(updatedEnvironment, undefined, this._prisma);
9292
}
9393

9494
// Percent-based queue overrides follow the environment limit automatically. Note the

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ export class CreateBackgroundWorkerService extends BaseService {
238238
}
239239

240240
const [updateConcurrencyLimitsError] = await tryCatch(
241-
updateEnvConcurrencyLimits(environment)
241+
updateEnvConcurrencyLimits(environment, undefined, this._prisma)
242242
);
243243

244244
if (updateConcurrencyLimitsError) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export class FinalizeDeploymentService extends BaseService {
123123
}
124124
);
125125

126-
await updateEnvConcurrencyLimits(authenticatedEnv);
126+
await updateEnvConcurrencyLimits(authenticatedEnv, undefined, this._prisma);
127127
} catch (err) {
128128
logger.error("Failed to publish WORKER_CREATED event", { err });
129129
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ export class PauseEnvironmentService extends WithRunEngine {
118118
logger.debug("PauseEnvironmentService: resuming environment", {
119119
environmentId: environment.id,
120120
});
121-
await updateEnvConcurrencyLimits(environment);
121+
// `environment` was read before the update above, so its `paused` is stale. The helper
122+
// resolves the current state itself - hand it the client that wrote the resume.
123+
await updateEnvConcurrencyLimits(environment, undefined, this._prisma);
122124
}
123125
} catch (error) {
124126
await this._prisma.runtimeEnvironment.update({
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { RunEngine } from "@internal/run-engine";
2+
import { containerTest } from "@internal/testcontainers";
3+
import { trace } from "@opentelemetry/api";
4+
import type { PrismaClient } from "@trigger.dev/database";
5+
import type { RedisOptions } from "ioredis";
6+
import { describe, expect, onTestFinished, vi } from "vitest";
7+
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
8+
import {
9+
createRuntimeEnvironment,
10+
createTestOrgProjectWithMember,
11+
uniqueId,
12+
} from "./fixtures/environmentVariablesFixtures";
13+
14+
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
15+
16+
// test/setup.ts replaces the app's engine singleton with a no-op for every webapp suite, which
17+
// would make any assertion about the RunQueue limits vacuous. Every test in this file asserts on
18+
// real RunQueue state, so put a real RunEngine - built on the test's own Redis container - back
19+
// behind the singleton. No test here uses the no-op default.
20+
const { engineHolder } = vi.hoisted(() => ({
21+
engineHolder: { current: undefined as any },
22+
}));
23+
24+
vi.mock("~/v3/runEngine.server", () => ({
25+
engine: new Proxy({} as Record<string, any>, {
26+
get: (_target, prop) => engineHolder.current?.[prop as string],
27+
}),
28+
}));
29+
30+
function useEngine(prisma: PrismaClient, redisOptions: RedisOptions) {
31+
const engine = new RunEngine({
32+
prisma,
33+
worker: { redis: redisOptions, disabled: true },
34+
queue: { redis: redisOptions, masterQueueConsumersDisabled: true },
35+
runLock: { redis: redisOptions },
36+
machines: {
37+
defaultMachine: "small-1x",
38+
machines: {
39+
"small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
40+
},
41+
baseCostInCents: 0.0001,
42+
},
43+
tracer: trace.getTracer("test", "0.0.0"),
44+
});
45+
46+
engineHolder.current = engine;
47+
onTestFinished(async () => {
48+
engineHolder.current = undefined;
49+
await engine.quit();
50+
});
51+
52+
return engine;
53+
}
54+
55+
// The import chain reaches module-level singletons that throw at load time when
56+
// REDIS_HOST/REDIS_PORT are unset (autoIncrementCounter via triggerTaskV1), so the env must point
57+
// at the redis container BEFORE the modules are imported. Hence dynamic imports; vitest runs each
58+
// file in its own fork, so the env mutation cannot leak into other suites.
59+
async function loadServices(redisOptions: RedisOptions) {
60+
process.env.REDIS_HOST = redisOptions.host;
61+
process.env.REDIS_PORT = String(redisOptions.port);
62+
process.env.REDIS_TLS_DISABLED = "true";
63+
const [{ updateEnvConcurrencyLimits }, { PauseEnvironmentService }, runtimeEnvironment] =
64+
await Promise.all([
65+
import("~/v3/runQueue.server"),
66+
import("~/v3/services/pauseEnvironment.server"),
67+
import("~/models/runtimeEnvironment.server"),
68+
]);
69+
return {
70+
updateEnvConcurrencyLimits,
71+
PauseEnvironmentService,
72+
authIncludeBase: runtimeEnvironment.authIncludeBase,
73+
toAuthenticated: runtimeEnvironment.toAuthenticated,
74+
};
75+
}
76+
77+
type Loaded = Awaited<ReturnType<typeof loadServices>>;
78+
79+
async function authEnv(
80+
loaded: Loaded,
81+
prisma: PrismaClient,
82+
environmentId: string
83+
): Promise<AuthenticatedEnvironment> {
84+
const row = await prisma.runtimeEnvironment.findFirstOrThrow({
85+
where: { id: environmentId },
86+
include: loaded.authIncludeBase,
87+
});
88+
return loaded.toAuthenticated(row);
89+
}
90+
91+
async function seedProductionEnv(prisma: PrismaClient, maximumConcurrencyLimit: number) {
92+
const { organization, project } = await createTestOrgProjectWithMember(prisma);
93+
const environment = await createRuntimeEnvironment(prisma, {
94+
projectId: project.id,
95+
organizationId: organization.id,
96+
type: "PRODUCTION",
97+
slug: uniqueId("prod"),
98+
});
99+
100+
await prisma.runtimeEnvironment.update({
101+
where: { id: environment.id },
102+
data: { maximumConcurrencyLimit },
103+
});
104+
105+
return { organization, project, environment };
106+
}
107+
108+
// An unset RunQueue limit reads back as the engine default (10), so neither the 0 nor the 17
109+
// assertions below can pass just because a push never happened.
110+
describe("updateEnvConcurrencyLimits", () => {
111+
containerTest(
112+
"clamps to 0 when the environment is paused, even though the caller's copy says otherwise",
113+
async ({ prisma, redisOptions }) => {
114+
const loaded = await loadServices(redisOptions);
115+
const engine = useEngine(prisma, redisOptions);
116+
117+
const { environment } = await seedProductionEnv(prisma, 17);
118+
// What an argument-less caller holds: an environment read when the request authenticated,
119+
// before the pause landed (finalizing a deployment, registering a background worker).
120+
const atAuthTime = await authEnv(loaded, prisma, environment.id);
121+
expect(atAuthTime.paused).toBe(false);
122+
123+
await prisma.runtimeEnvironment.update({
124+
where: { id: environment.id },
125+
data: { paused: true },
126+
});
127+
128+
await loaded.updateEnvConcurrencyLimits(atAuthTime, undefined, prisma);
129+
130+
// The 0 limit is the only thing stopping dequeues, so the real limit must not go back in.
131+
expect(await engine.runQueue.getEnvConcurrencyLimit(atAuthTime)).toBe(0);
132+
}
133+
);
134+
135+
containerTest(
136+
"pushes the real limit for a running environment",
137+
async ({ prisma, redisOptions }) => {
138+
const loaded = await loadServices(redisOptions);
139+
const engine = useEngine(prisma, redisOptions);
140+
141+
const { environment } = await seedProductionEnv(prisma, 17);
142+
const env = await authEnv(loaded, prisma, environment.id);
143+
144+
await loaded.updateEnvConcurrencyLimits(env, undefined, prisma);
145+
146+
expect(await engine.runQueue.getEnvConcurrencyLimit(env)).toBe(17);
147+
}
148+
);
149+
150+
containerTest(
151+
"restores the real limit when the environment was resumed while the request was in flight",
152+
async ({ prisma, redisOptions }) => {
153+
const loaded = await loadServices(redisOptions);
154+
const engine = useEngine(prisma, redisOptions);
155+
156+
const { environment } = await seedProductionEnv(prisma, 17);
157+
await prisma.runtimeEnvironment.update({
158+
where: { id: environment.id },
159+
data: { paused: true },
160+
});
161+
162+
// Captured while paused, then resumed before the push. Trusting this copy would write 0 over
163+
// the restored limit and leave the env stalled with `paused: false` and nothing to fix it.
164+
const whilePaused = await authEnv(loaded, prisma, environment.id);
165+
expect(whilePaused.paused).toBe(true);
166+
167+
await prisma.runtimeEnvironment.update({
168+
where: { id: environment.id },
169+
data: { paused: false },
170+
});
171+
172+
await loaded.updateEnvConcurrencyLimits(whilePaused, undefined, prisma);
173+
174+
expect(await engine.runQueue.getEnvConcurrencyLimit(whilePaused)).toBe(17);
175+
}
176+
);
177+
178+
containerTest(
179+
"an explicit limit wins over the stored pause state",
180+
async ({ prisma, redisOptions }) => {
181+
const loaded = await loadServices(redisOptions);
182+
const engine = useEngine(prisma, redisOptions);
183+
184+
const { environment } = await seedProductionEnv(prisma, 17);
185+
await prisma.runtimeEnvironment.update({
186+
where: { id: environment.id },
187+
data: { paused: true },
188+
});
189+
const env = await authEnv(loaded, prisma, environment.id);
190+
191+
// How billing-limit converge restores a limit as it unpauses: the caller decides, no read.
192+
await loaded.updateEnvConcurrencyLimits(env, 9, prisma);
193+
194+
expect(await engine.runQueue.getEnvConcurrencyLimit(env)).toBe(9);
195+
}
196+
);
197+
198+
containerTest(
199+
"a pause writes 0 and a resume restores the limit",
200+
async ({ prisma, redisOptions }) => {
201+
const loaded = await loadServices(redisOptions);
202+
const engine = useEngine(prisma, redisOptions);
203+
204+
const { environment } = await seedProductionEnv(prisma, 17);
205+
const service = new loaded.PauseEnvironmentService(prisma);
206+
const env = await authEnv(loaded, prisma, environment.id);
207+
208+
expect(await service.call(env, "paused")).toEqual({ success: true, state: "paused" });
209+
expect(await engine.runQueue.getEnvConcurrencyLimit(env)).toBe(0);
210+
211+
// The service holds an environment read before its own resume update, so `env.paused` is
212+
// stale here too.
213+
expect(await service.call(env, "resumed")).toEqual({ success: true, state: "resumed" });
214+
expect(await engine.runQueue.getEnvConcurrencyLimit(env)).toBe(17);
215+
}
216+
);
217+
});

0 commit comments

Comments
 (0)