Files
claude[bot] 69f396fbef 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>
2026-08-14 22:12:25 +01:00

50 lines
1.8 KiB
TypeScript

import { type PrismaClientOrTransaction } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { engine } from "./runEngine.server";
/** Updates the RunQueue env concurrency limits */
export async function updateEnvConcurrencyLimits(
environment: AuthenticatedEnvironment,
maximumConcurrencyLimit?: number,
db: PrismaClientOrTransaction = prisma
) {
let limit = maximumConcurrencyLimit;
if (limit === undefined) {
// A paused env is only enforced by a 0 limit in the RunQueue, so a push without an explicit
// limit must not resurrect the real limit. Callers hold an environment read at auth time, so
// resolve both values here instead of trusting it: a stale `paused: false` silently resumes a
// paused env, and a stale `paused: true` strands a resumed one at 0 with nothing to restore it.
const current = await db.runtimeEnvironment.findFirst({
where: { id: environment.id },
select: { paused: true, maximumConcurrencyLimit: true },
});
const resolved = current ?? environment;
limit = resolved.paused ? 0 : resolved.maximumConcurrencyLimit;
}
await engine.runQueue.updateEnvConcurrencyLimits({
...environment,
maximumConcurrencyLimit: limit,
});
}
/** Updates the RunQueue limits for a queue */
export async function updateQueueConcurrencyLimits(
environment: AuthenticatedEnvironment,
queueName: string,
concurrency: number
) {
await engine.runQueue.updateQueueConcurrencyLimits(environment, queueName, concurrency);
}
/** Removes the RunQueue limits for a queue */
export async function removeQueueConcurrencyLimits(
environment: AuthenticatedEnvironment,
queueName: string
) {
await engine.runQueue.removeQueueConcurrencyLimits(environment, queueName);
}