Skip to content

Commit 6cdf287

Browse files
committed
fix(run-engine,webapp): close gaps found in adversarial review of the pause sweep
Restore the TTL registration when returning a run. Admission removes the run's member from the TTL sorted set, so returning it to the pending queue without re-adding left a run that could never expire: on the next admission the dequeue script takes its already-expired branch, which deliberately leaves cleanup to the TTL consumer, and that consumer no longer has an entry to find. The per-run expireRun job is not a backstop because it is only armed for development environments. Take the pause sweep out of the rollback window. It ran inside the try that compensates a failed pause by clearing the DB flag, but by then the Redis limit was already 0, so a sweep failure left the environment with no concurrency and no paused flag — dark, and unrecoverable without a manual pause/resume cycle. For the billing-limit path it also aborted the remaining environments in the org. The pause is durable and the sweep is idempotent, so it now runs after the pause has been committed and a failure is logged rather than propagated. That also stops the queue pause reporting failure for a pause that took effect. Retry passes while runs remain unclaimed. Admission is not atomic with the push onto the worker queue: the dequeue script claims the concurrency slot and a separate round trip does the RPUSH. A run caught in that gap fails the claim and would previously have been abandoned, then executed during the pause. Claim from both the resolver-mapped and the raw worker queue key. The trigger- time fast path pushes to the raw workerQueue on the message while the master queue consumer pushes to the resolver-mapped one, so with a region override active the two producers disagree and a single-key LREM missed fast-pathed runs. Batch the sweep. It read every payload in one MGET and then made one sequential round trip per run, on a request path, with no error isolation — one failure abandoned the remaining runs. Tests: cover the LREM claim directly by emptying the worker queue without marking the run dequeued (the previous test was excluded by the candidate set before ever reaching the script), the TTL round trip, and the concurrency key running counter across a return.
1 parent 90d8032 commit 6cdf287

7 files changed

Lines changed: 407 additions & 77 deletions

File tree

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
2+
import { logger } from "~/services/logger.server";
23
import { engine } from "./runEngine.server";
34

45
/** Updates the RunQueue env concurrency limits */
@@ -46,3 +47,31 @@ export async function returnUnclaimedMessagesToQueue({
4647
}) {
4748
return engine.returnUnclaimedMessagesToQueue({ environment, queue });
4849
}
50+
51+
/**
52+
* Best-effort sweep for the pause paths.
53+
*
54+
* By the time this runs the pause is already durable and in force, so a failure here must
55+
* not roll it back or surface as a failed pause — the worst case is that some already-admitted
56+
* runs still execute, which is the behaviour that existed before the sweep.
57+
*/
58+
export async function sweepUnclaimedRuns(
59+
environment: AuthenticatedEnvironment,
60+
queue?: string
61+
): Promise<void> {
62+
try {
63+
const result = await returnUnclaimedMessagesToQueue({ environment, queue });
64+
65+
logger.debug("sweepUnclaimedRuns", {
66+
environmentId: environment.id,
67+
queue,
68+
...result,
69+
});
70+
} catch (error) {
71+
logger.error("sweepUnclaimedRuns failed", {
72+
environmentId: environment.id,
73+
queue,
74+
error,
75+
});
76+
}
77+
}

apps/webapp/app/v3/services/billingLimit/billingLimitConvergeEnvironments.server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,6 @@ async function pauseEnvironmentForBillingLimit(
190190

191191
try {
192192
await updateConcurrency(updated, 0);
193-
await returnUnclaimed(updated);
194193
} catch (error) {
195194
await db.runtimeEnvironment.update({
196195
where: { id: environment.id },
@@ -201,6 +200,15 @@ async function pauseEnvironmentForBillingLimit(
201200
// The env's paused state changed (or was rolled back); drop any cached copy either way.
202201
controlPlaneResolver.invalidateEnvironment(environment.id);
203202
}
203+
204+
try {
205+
await returnUnclaimed(updated);
206+
} catch (error) {
207+
logger.error("Billing limit converge failed to return unclaimed runs", {
208+
environmentId: environment.id,
209+
error,
210+
});
211+
}
204212
}
205213

206214
async function resumeEnvironmentFromBillingLimit(

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { EnvironmentPauseSource, type PrismaClientOrTransaction } from "@trigger
22
import { prisma } from "~/db.server";
33
import { logger } from "~/services/logger.server";
44
import { getManualPauseEnvironmentResult } from "~/v3/services/billingLimit/manualPauseEnvironmentGuard.server";
5-
import { returnUnclaimedMessagesToQueue, updateEnvConcurrencyLimits } from "../runQueue.server";
5+
import { sweepUnclaimedRuns, updateEnvConcurrencyLimits } from "../runQueue.server";
66
import { WithRunEngine } from "./baseService.server";
77
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
88
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
@@ -114,7 +114,6 @@ export class PauseEnvironmentService extends WithRunEngine {
114114
environmentId: environment.id,
115115
});
116116
await updateEnvConcurrencyLimits(environment, 0);
117-
await returnUnclaimedMessagesToQueue({ environment });
118117
} else {
119118
logger.debug("PauseEnvironmentService: resuming environment", {
120119
environmentId: environment.id,
@@ -137,6 +136,10 @@ export class PauseEnvironmentService extends WithRunEngine {
137136
// The env's `paused` state changed in the control-plane; drop any cached copy.
138137
controlPlaneResolver.invalidateEnvironment(environment.id);
139138

139+
if (action === "paused") {
140+
await sweepUnclaimedRuns(environment);
141+
}
142+
140143
return {
141144
success: true,
142145
state: action,

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { BaseService } from "./baseService.server";
66
import { determineEngineVersion } from "../engineVersion.server";
77
import {
88
removeQueueConcurrencyLimits,
9-
returnUnclaimedMessagesToQueue,
9+
sweepUnclaimedRuns,
1010
updateQueueConcurrencyLimits,
1111
} from "../runQueue.server";
1212
import { engine } from "../runEngine.server";
@@ -63,7 +63,6 @@ export class PauseQueueService extends BaseService {
6363

6464
if (action === "paused") {
6565
await updateQueueConcurrencyLimits(environment, queue.name, 0);
66-
await returnUnclaimedMessagesToQueue({ environment, queue: queue.name });
6766
} else {
6867
if (queue.concurrencyLimit) {
6968
await updateQueueConcurrencyLimits(environment, queue.name, queue.concurrencyLimit);
@@ -78,6 +77,10 @@ export class PauseQueueService extends BaseService {
7877
environmentId: environment.id,
7978
});
8079

80+
if (action === "paused") {
81+
await sweepUnclaimedRuns(environment, queue.name);
82+
}
83+
8184
const results = await Promise.all([
8285
engine.lengthOfQueues(environment, [queue.name]),
8386
engine.currentConcurrencyOfQueues(environment, [queue.name]),

apps/webapp/test/billingLimitConvergeEnvironments.test.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -95,30 +95,38 @@ describe("convergeBillingLimitEnvironmentsForOrg", () => {
9595
expect(returnUnclaimed).not.toHaveBeenCalled();
9696
});
9797

98-
postgresTest("rolls back pause when returning unclaimed runs fails", async ({ prisma }) => {
98+
postgresTest("keeps the pause when returning unclaimed runs fails", async ({ prisma }) => {
9999
const { organization, project } = await createTestOrgProjectWithMember(prisma);
100100
const environment = await createRuntimeEnvironment(prisma, {
101101
projectId: project.id,
102102
organizationId: organization.id,
103103
type: "PRODUCTION",
104104
slug: uniqueId("prod"),
105105
});
106+
const second = await createRuntimeEnvironment(prisma, {
107+
projectId: project.id,
108+
organizationId: organization.id,
109+
type: "PRODUCTION",
110+
slug: uniqueId("prod"),
111+
});
106112

107-
await expect(
108-
convergeBillingLimitEnvironmentsForOrg(organization.id, "grace", {
109-
prismaClient: prisma,
110-
updateConcurrency: async () => undefined,
111-
returnUnclaimed: async () => {
112-
throw new Error("run queue unavailable");
113-
},
114-
})
115-
).rejects.toThrow("run queue unavailable");
116-
117-
const envAfter = await prisma.runtimeEnvironment.findUniqueOrThrow({
118-
where: { id: environment.id },
113+
const result = await convergeBillingLimitEnvironmentsForOrg(organization.id, "grace", {
114+
prismaClient: prisma,
115+
updateConcurrency: async () => undefined,
116+
returnUnclaimed: async () => {
117+
throw new Error("run queue unavailable");
118+
},
119119
});
120-
expect(envAfter.paused).toBe(false);
121-
expect(envAfter.pauseSource).toBeNull();
120+
121+
expect(result).toEqual({ paused: 2, unpaused: 0 });
122+
123+
for (const env of [environment, second]) {
124+
const envAfter = await prisma.runtimeEnvironment.findUniqueOrThrow({
125+
where: { id: env.id },
126+
});
127+
expect(envAfter.paused).toBe(true);
128+
expect(envAfter.pauseSource).toBe(EnvironmentPauseSource.BILLING_LIMIT);
129+
}
122130
});
123131

124132
postgresTest("rolls back pause when concurrency update fails", async ({ prisma }) => {

0 commit comments

Comments
 (0)