Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .server-changes/pause-returns-waiting-runs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Pausing a queue or an environment now also holds back runs that were already waiting to start. Previously those runs would still go ahead, so a pause could take effect a little later than expected.
45 changes: 45 additions & 0 deletions apps/webapp/app/v3/runQueue.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { engine } from "./runEngine.server";

/** Updates the RunQueue env concurrency limits */
Expand Down Expand Up @@ -30,3 +31,47 @@ export async function removeQueueConcurrencyLimits(
) {
await engine.runQueue.removeQueueConcurrencyLimits(environment, queueName);
}

/**
* Returns runs waiting to be picked up by a worker back into their queue.
*
* Only safe once the concurrency limit has been set to 0, otherwise a newer run can be
* admitted and overtake one on its way back.
*/
export async function returnUnclaimedMessagesToQueue({
environment,
queue,
}: {
environment: AuthenticatedEnvironment;
queue?: string;
}) {
return engine.returnUnclaimedMessagesToQueue({ environment, queue });
}

/**
* Best-effort sweep for the pause paths.
*
* By the time this runs the pause is already durable and in force, so a failure here must
* not roll it back or surface as a failed pause — the worst case is that some already-admitted
* runs still execute, which is the behaviour that existed before the sweep.
*/
export async function sweepUnclaimedRuns(
environment: AuthenticatedEnvironment,
queue?: string
): Promise<void> {
try {
const result = await returnUnclaimedMessagesToQueue({ environment, queue });

logger.debug("sweepUnclaimedRuns", {
environmentId: environment.id,
queue,
...result,
});
} catch (error) {
logger.error("sweepUnclaimedRuns failed", {
environmentId: environment.id,
queue,
error,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@ type UpdateEnvConcurrency = (
maximumConcurrencyLimit?: number
) => Promise<void>;

type ReturnUnclaimedMessages = (environment: EnvironmentWithRelations) => Promise<void>;

export async function convergeBillingLimitEnvironmentsForOrg(
organizationId: string,
targetState: BillingLimitConvergeTargetState,
options?: {
batchSize?: number;
prismaClient?: PrismaClient;
updateConcurrency?: UpdateEnvConcurrency;
returnUnclaimed?: ReturnUnclaimedMessages;
}
): Promise<ConvergeOrgResult> {
const db = options?.prismaClient ?? prisma;
Expand All @@ -51,18 +54,32 @@ export async function convergeBillingLimitEnvironmentsForOrg(
return updateEnvConcurrencyLimits(environment, maximumConcurrencyLimit);
});

const returnUnclaimed =
options?.returnUnclaimed ??
(async (environment) => {
const { returnUnclaimedMessagesToQueue } = await import("~/v3/runQueue.server");
await returnUnclaimedMessagesToQueue({ environment });
});

if (targetState === "ok") {
return unpauseBillingLimitEnvironments(organizationId, db, batchSize, updateConcurrency);
}

return pauseBillingLimitEnvironments(organizationId, db, batchSize, updateConcurrency);
return pauseBillingLimitEnvironments(
organizationId,
db,
batchSize,
updateConcurrency,
returnUnclaimed
);
}

async function pauseBillingLimitEnvironments(
organizationId: string,
db: PrismaClient,
batchSize: number,
updateConcurrency: UpdateEnvConcurrency
updateConcurrency: UpdateEnvConcurrency,
returnUnclaimed: ReturnUnclaimedMessages
): Promise<ConvergeOrgResult> {
let paused = 0;
let cursor: string | undefined;
Expand All @@ -88,7 +105,7 @@ async function pauseBillingLimitEnvironments(
}

for (const environment of environments) {
await pauseEnvironmentForBillingLimit(environment, db, updateConcurrency);
await pauseEnvironmentForBillingLimit(environment, db, updateConcurrency, returnUnclaimed);
paused++;
}

Expand Down Expand Up @@ -156,7 +173,8 @@ async function unpauseBillingLimitEnvironments(
async function pauseEnvironmentForBillingLimit(
environment: EnvironmentWithRelations,
db: PrismaClient,
updateConcurrency: UpdateEnvConcurrency
updateConcurrency: UpdateEnvConcurrency,
returnUnclaimed: ReturnUnclaimedMessages
) {
const updated = await db.runtimeEnvironment.update({
where: { id: environment.id },
Expand All @@ -182,6 +200,15 @@ async function pauseEnvironmentForBillingLimit(
// The env's paused state changed (or was rolled back); drop any cached copy either way.
controlPlaneResolver.invalidateEnvironment(environment.id);
}

try {
await returnUnclaimed(updated);
} catch (error) {
logger.error("Billing limit converge failed to return unclaimed runs", {
environmentId: environment.id,
error,
});
}
}

async function resumeEnvironmentFromBillingLimit(
Expand Down
6 changes: 5 additions & 1 deletion apps/webapp/app/v3/services/pauseEnvironment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { EnvironmentPauseSource, type PrismaClientOrTransaction } from "@trigger
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { getManualPauseEnvironmentResult } from "~/v3/services/billingLimit/manualPauseEnvironmentGuard.server";
import { updateEnvConcurrencyLimits } from "../runQueue.server";
import { sweepUnclaimedRuns, updateEnvConcurrencyLimits } from "../runQueue.server";
import { WithRunEngine } from "./baseService.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
Expand Down Expand Up @@ -136,6 +136,10 @@ export class PauseEnvironmentService extends WithRunEngine {
// The env's `paused` state changed in the control-plane; drop any cached copy.
controlPlaneResolver.invalidateEnvironment(environment.id);

if (action === "paused") {
await sweepUnclaimedRuns(environment);
}

return {
success: true,
state: action,
Expand Down
10 changes: 9 additions & 1 deletion apps/webapp/app/v3/services/pauseQueue.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { BaseService } from "./baseService.server";
import { determineEngineVersion } from "../engineVersion.server";
import { removeQueueConcurrencyLimits, updateQueueConcurrencyLimits } from "../runQueue.server";
import {
removeQueueConcurrencyLimits,
sweepUnclaimedRuns,
updateQueueConcurrencyLimits,
} from "../runQueue.server";
Comment on lines +7 to +11

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Missing rollback on failure, unlike sibling pause paths.

If updateQueueConcurrencyLimits or the new returnUnclaimedMessagesToQueue call throws here, the outer catch just logs and returns success: false — but taskQueue.paused was already committed to true above, and there's no rollback. pauseEnvironment.server.ts and billingLimitConvergeEnvironments.server.ts both wrap the equivalent concurrency-update + returnUnclaimed sequence in an inner try/catch that reverts the DB pause state on error; this file lacks that, so a failed pause here can leave the queue marked paused in the DB while the caller is told the operation failed.

Consider wrapping the action === "paused" branch's two calls in a try/catch that reverts taskQueue.paused (and the concurrency limit, if previously unset) on failure, mirroring the other two pause flows.

Also applies to: 55-73

import { engine } from "../runEngine.server";

export type PauseStatus = "paused" | "resumed";
Expand Down Expand Up @@ -73,6 +77,10 @@ export class PauseQueueService extends BaseService {
environmentId: environment.id,
});

if (action === "paused") {
await sweepUnclaimedRuns(environment, queue.name);
}

const results = await Promise.all([
engine.lengthOfQueues(environment, [queue.name]),
engine.currentConcurrencyOfQueues(environment, [queue.name]),
Expand Down
80 changes: 80 additions & 0 deletions apps/webapp/test/billingLimitConvergeEnvironments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,86 @@ describe("convergeBillingLimitEnvironmentsForOrg", () => {
expect(envAfter.pauseSource).toBeNull();
});

postgresTest("returns unclaimed runs after pausing for a billing limit", async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const environment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});

const calls: Array<{ concurrency?: number; returnedFor?: string }> = [];

Comment on lines +61 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Duplicate const calls declaration — will not compile.

The calls array is declared twice in a row with identical content, which is a duplicate const redeclaration in the same block scope and will fail TypeScript compilation.

🐛 Proposed fix
-    const calls: Array<{ concurrency?: number; returnedFor?: string }> = [];
     const calls: Array<{ concurrency?: number; returnedFor?: string }> = [];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const calls: Array<{ concurrency?: number; returnedFor?: string }> = [];
const calls: Array<{ concurrency?: number; returnedFor?: string }> = [];

const result = await convergeBillingLimitEnvironmentsForOrg(organization.id, "grace", {
prismaClient: prisma,
updateConcurrency: async (_env, maximumConcurrencyLimit) => {
calls.push({ concurrency: maximumConcurrencyLimit });
},
returnUnclaimed: async (env) => {
calls.push({ returnedFor: env.id });
},
});

expect(result).toEqual({ paused: 1, unpaused: 0 });

expect(calls).toEqual([{ concurrency: 0 }, { returnedFor: environment.id }]);

const envAfter = await prisma.runtimeEnvironment.findUniqueOrThrow({
where: { id: environment.id },
});
expect(envAfter.paused).toBe(true);
expect(envAfter.pauseSource).toBe(EnvironmentPauseSource.BILLING_LIMIT);
});

postgresTest("does not return unclaimed runs when unpausing", async ({ prisma }) => {
const { organization } = await createBillingPausedProductionEnv(prisma);

const returnUnclaimed = vi.fn(async () => undefined);

await convergeBillingLimitEnvironmentsForOrg(organization.id, "ok", {
prismaClient: prisma,
updateConcurrency: async () => undefined,
returnUnclaimed,
});

expect(returnUnclaimed).not.toHaveBeenCalled();
});

postgresTest("keeps the pause when returning unclaimed runs fails", async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const environment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});
const second = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});

const result = await convergeBillingLimitEnvironmentsForOrg(organization.id, "grace", {
prismaClient: prisma,
updateConcurrency: async () => undefined,
returnUnclaimed: async () => {
throw new Error("run queue unavailable");
},
});

expect(result).toEqual({ paused: 2, unpaused: 0 });

for (const env of [environment, second]) {
const envAfter = await prisma.runtimeEnvironment.findUniqueOrThrow({
where: { id: env.id },
});
expect(envAfter.paused).toBe(true);
expect(envAfter.pauseSource).toBe(EnvironmentPauseSource.BILLING_LIMIT);
}
});

postgresTest("rolls back pause when concurrency update fails", async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const environment = await createRuntimeEnvironment(prisma, {
Expand Down
94 changes: 94 additions & 0 deletions apps/webapp/test/pauseSweepWiring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { type PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { postgresTest } from "@internal/testcontainers";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import {
createRuntimeEnvironment,
createTestOrgProjectWithMember,
uniqueId,
} from "./fixtures/environmentVariablesFixtures";

vi.setConfig({ testTimeout: 60_000 });

const calls: string[] = [];

vi.mock("~/v3/runQueue.server", () => ({
updateEnvConcurrencyLimits: vi.fn(async (_env: unknown, limit?: number) => {
calls.push(`updateEnvConcurrencyLimits:${limit}`);
}),
updateQueueConcurrencyLimits: vi.fn(async (_env: unknown, name: string, limit: number) => {
calls.push(`updateQueueConcurrencyLimits:${name}:${limit}`);
}),
removeQueueConcurrencyLimits: vi.fn(async () => {
calls.push("removeQueueConcurrencyLimits");
}),
returnUnclaimedMessagesToQueue: vi.fn(async () => ({
returned: 0,
skippedLastPass: 0,
errors: 0,
passes: 1,
})),
sweepUnclaimedRuns: vi.fn(async (_env: unknown, queue?: string) => {
calls.push(`sweepUnclaimedRuns:${queue ?? "*"}`);
}),
}));

async function loadServices() {
const [{ PauseEnvironmentService }, { authIncludeBase, toAuthenticated }] = await Promise.all([
import("~/v3/services/pauseEnvironment.server"),
import("~/models/runtimeEnvironment.server"),
]);
return { PauseEnvironmentService, authIncludeBase, toAuthenticated };
}

type Loaded = Awaited<ReturnType<typeof loadServices>>;

async function seedEnv(
loaded: Loaded,
prisma: PrismaClient
): Promise<{ environment: AuthenticatedEnvironment; environmentId: string }> {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const created = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});

const row = await prisma.runtimeEnvironment.findFirstOrThrow({
where: { id: created.id },
include: loaded.authIncludeBase,
});

return { environment: loaded.toAuthenticated(row), environmentId: created.id };
}

describe("pause sweep wiring", () => {
postgresTest("pausing an environment sweeps after the limit is zeroed", async ({ prisma }) => {
calls.length = 0;
const loaded = await loadServices();
const { environment } = await seedEnv(loaded, prisma);

const result = await new loaded.PauseEnvironmentService(prisma).call(environment, "paused");

expect(result).toEqual({ success: true, state: "paused" });
expect(calls).toEqual(["updateEnvConcurrencyLimits:0", "sweepUnclaimedRuns:*"]);
});

postgresTest("resuming an environment does not sweep", async ({ prisma }) => {
const loaded = await loadServices();
const { environment, environmentId } = await seedEnv(loaded, prisma);

await prisma.runtimeEnvironment.update({
where: { id: environmentId },
data: { paused: true },
});

calls.length = 0;

const result = await new loaded.PauseEnvironmentService(prisma).call(environment, "resumed");

expect(result).toEqual({ success: true, state: "resumed" });
expect(calls).toEqual(["updateEnvConcurrencyLimits:undefined"]);
});
});
Loading
Loading