Skip to content

Commit 08a50d7

Browse files
committed
fix(run-engine,webapp): harden the pause sweep retry and report it honestly
Guard the pass count against non-finite input. Math.max(1, NaN) is NaN, so a NaN maxPasses skipped the loop entirely and reported a successful no-op sweep, and Infinity spun at one pass per 50ms inside an awaited request. The count is now clamped to a ceiling and falls back to the default when it is not finite. Retry a pass that errored, not just one that skipped. Rejected script calls never increment skipped, so a transient Redis failure part-way through a batch ended the sweep with those runs still admitted and still on the worker queue — the exact outcome the sweep exists to prevent — while the result read as a success with a non-zero error count nobody was gated on. Rename the residual count to skippedLastPass. It is the final pass's value rather than a total, and it conflates a run a worker claimed first (which escaped the pause and is executing) with a leaked concurrency slot that has no worker queue entry behind it. The name and its doc comment now say so instead of implying it partitions the candidate set alongside returned. Tests: pin the non-finite pass-count guard, cover that an acknowledged run is never resurrected by a later sweep, and stop the worker queue override test leaking its environment variable if the queue constructor throws.
1 parent 6cdf287 commit 08a50d7

5 files changed

Lines changed: 328 additions & 28 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { type PrismaClient } from "@trigger.dev/database";
2+
import { describe, expect, vi } from "vitest";
3+
import { postgresTest } from "@internal/testcontainers";
4+
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
5+
import {
6+
createRuntimeEnvironment,
7+
createTestOrgProjectWithMember,
8+
uniqueId,
9+
} from "./fixtures/environmentVariablesFixtures";
10+
11+
vi.setConfig({ testTimeout: 60_000 });
12+
13+
const calls: string[] = [];
14+
15+
vi.mock("~/v3/runQueue.server", () => ({
16+
updateEnvConcurrencyLimits: vi.fn(async (_env: unknown, limit?: number) => {
17+
calls.push(`updateEnvConcurrencyLimits:${limit}`);
18+
}),
19+
updateQueueConcurrencyLimits: vi.fn(async (_env: unknown, name: string, limit: number) => {
20+
calls.push(`updateQueueConcurrencyLimits:${name}:${limit}`);
21+
}),
22+
removeQueueConcurrencyLimits: vi.fn(async () => {
23+
calls.push("removeQueueConcurrencyLimits");
24+
}),
25+
returnUnclaimedMessagesToQueue: vi.fn(async () => ({
26+
returned: 0,
27+
skipped: 0,
28+
errors: 0,
29+
passes: 1,
30+
})),
31+
sweepUnclaimedRuns: vi.fn(async (_env: unknown, queue?: string) => {
32+
calls.push(`sweepUnclaimedRuns:${queue ?? "*"}`);
33+
}),
34+
}));
35+
36+
async function loadServices() {
37+
const [{ PauseEnvironmentService }, { authIncludeBase, toAuthenticated }] = await Promise.all([
38+
import("~/v3/services/pauseEnvironment.server"),
39+
import("~/models/runtimeEnvironment.server"),
40+
]);
41+
return { PauseEnvironmentService, authIncludeBase, toAuthenticated };
42+
}
43+
44+
type Loaded = Awaited<ReturnType<typeof loadServices>>;
45+
46+
async function seedEnv(
47+
loaded: Loaded,
48+
prisma: PrismaClient
49+
): Promise<{ environment: AuthenticatedEnvironment; environmentId: string }> {
50+
const { organization, project } = await createTestOrgProjectWithMember(prisma);
51+
const created = await createRuntimeEnvironment(prisma, {
52+
projectId: project.id,
53+
organizationId: organization.id,
54+
type: "PRODUCTION",
55+
slug: uniqueId("prod"),
56+
});
57+
58+
const row = await prisma.runtimeEnvironment.findFirstOrThrow({
59+
where: { id: created.id },
60+
include: loaded.authIncludeBase,
61+
});
62+
63+
return { environment: loaded.toAuthenticated(row), environmentId: created.id };
64+
}
65+
66+
describe("pause sweep wiring", () => {
67+
postgresTest("pausing an environment sweeps after the limit is zeroed", async ({ prisma }) => {
68+
calls.length = 0;
69+
const loaded = await loadServices();
70+
const { environment } = await seedEnv(loaded, prisma);
71+
72+
const result = await new loaded.PauseEnvironmentService(prisma).call(environment, "paused");
73+
74+
expect(result).toEqual({ success: true, state: "paused" });
75+
expect(calls).toEqual(["updateEnvConcurrencyLimits:0", "sweepUnclaimedRuns:*"]);
76+
});
77+
78+
postgresTest("resuming an environment does not sweep", async ({ prisma }) => {
79+
const loaded = await loadServices();
80+
const { environment, environmentId } = await seedEnv(loaded, prisma);
81+
82+
await prisma.runtimeEnvironment.update({
83+
where: { id: environmentId },
84+
data: { paused: true },
85+
});
86+
87+
calls.length = 0;
88+
89+
const result = await new loaded.PauseEnvironmentService(prisma).call(environment, "resumed");
90+
91+
expect(result).toEqual({ success: true, state: "resumed" });
92+
expect(calls).toEqual(["updateEnvConcurrencyLimits:undefined"]);
93+
});
94+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
3+
4+
const returnUnclaimedMessagesToQueue = vi.fn();
5+
6+
vi.mock("~/v3/runEngine.server", () => ({
7+
engine: { returnUnclaimedMessagesToQueue },
8+
}));
9+
10+
const environment = { id: "env_1234" } as AuthenticatedEnvironment;
11+
12+
describe("sweepUnclaimedRuns", () => {
13+
it("swallows a failing sweep so a pause that is already in force is not reported as failed", async () => {
14+
const { sweepUnclaimedRuns } = await import("~/v3/runQueue.server");
15+
16+
returnUnclaimedMessagesToQueue.mockRejectedValueOnce(new Error("run queue unavailable"));
17+
18+
await expect(sweepUnclaimedRuns(environment)).resolves.toBeUndefined();
19+
expect(returnUnclaimedMessagesToQueue).toHaveBeenCalledWith({ environment, queue: undefined });
20+
});
21+
22+
it("passes the queue through when one is targeted", async () => {
23+
const { sweepUnclaimedRuns } = await import("~/v3/runQueue.server");
24+
25+
returnUnclaimedMessagesToQueue.mockResolvedValueOnce({
26+
returned: 2,
27+
skipped: 0,
28+
errors: 0,
29+
passes: 1,
30+
});
31+
32+
await sweepUnclaimedRuns(environment, "task/my-task");
33+
34+
expect(returnUnclaimedMessagesToQueue).toHaveBeenCalledWith({
35+
environment,
36+
queue: "task/my-task",
37+
});
38+
});
39+
});

internal-packages/run-engine/src/engine/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import type {
4949
BatchCompletionCallback,
5050
} from "../batch-queue/types.js";
5151
import { FairQueueSelectionStrategy } from "../run-queue/fairQueueSelectionStrategy.js";
52-
import { RunQueue } from "../run-queue/index.js";
52+
import { RunQueue, type ReturnUnclaimedMessagesResult } from "../run-queue/index.js";
5353
import { RunQueueFullKeyProducer } from "../run-queue/keyProducer.js";
5454
import type { AuthenticatedEnvironment, MinimalAuthenticatedEnvironment } from "../shared/index.js";
5555
import { BillingCache } from "./billingCache.js";
@@ -1648,7 +1648,7 @@ export class RunEngine {
16481648
}: {
16491649
environment: MinimalAuthenticatedEnvironment;
16501650
queue?: string;
1651-
}): Promise<{ returned: number; skipped: number }> {
1651+
}): Promise<ReturnUnclaimedMessagesResult> {
16521652
return this.runQueue.returnUnclaimedMessagesToQueue({ env: environment, queue });
16531653
}
16541654

internal-packages/run-engine/src/run-queue/index.ts

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,25 @@ type MarkedRun = {
140140
const RETURN_UNCLAIMED_BATCH_SIZE = 50;
141141
const READ_MESSAGES_BATCH_SIZE = 200;
142142

143+
/**
144+
* One retry covers the admission-to-RPUSH gap, which is a single round trip. `skipped` cannot
145+
* distinguish that gap from a leaked concurrency slot, which never becomes claimable, so the
146+
* pass count is kept low enough that a leak costs one short delay rather than a stall — these
147+
* sweeps run per environment inside bulk pause loops.
148+
*/
149+
const RETURN_UNCLAIMED_MAX_PASSES = 2;
150+
const RETURN_UNCLAIMED_PASS_DELAY_MS = 50;
151+
const RETURN_UNCLAIMED_MAX_PASS_CEILING = 10;
152+
143153
export type ReturnUnclaimedMessagesResult = {
154+
/** Distinct runs put back on their queue. */
144155
returned: number;
145-
skipped: number;
156+
/**
157+
* Candidates the final pass could not claim. Each is either a run a worker took first —
158+
* which means it escaped the pause and is executing — or a leaked concurrency slot with no
159+
* worker queue entry behind it. The two are indistinguishable from here.
160+
*/
161+
skippedLastPass: number;
146162
errors: number;
147163
passes: number;
148164
};
@@ -1058,8 +1074,8 @@ export class RunQueue {
10581074
*
10591075
* Admission is not atomic with the push onto the worker queue: the dequeue script claims
10601076
* the concurrency slot and a separate round trip does the RPUSH. A run caught in that gap
1061-
* is a candidate whose claim fails, so a pass reporting skipped runs is retried — by then
1062-
* the run has either landed on the worker queue (and is claimable) or been picked up by a
1077+
* is a candidate whose claim fails, so a pass that skips or errors is retried — by then the
1078+
* run has either landed on the worker queue (and is claimable) or been picked up by a
10631079
* worker (and has left the candidate set).
10641080
*
10651081
* @param queue - Restrict to a single queue (including its concurrency key variants).
@@ -1068,8 +1084,8 @@ export class RunQueue {
10681084
public async returnUnclaimedMessagesToQueue({
10691085
env,
10701086
queue,
1071-
maxPasses = 3,
1072-
passDelayMs = 250,
1087+
maxPasses = RETURN_UNCLAIMED_MAX_PASSES,
1088+
passDelayMs = RETURN_UNCLAIMED_PASS_DELAY_MS,
10731089
}: {
10741090
env: MinimalAuthenticatedEnvironment;
10751091
queue?: string;
@@ -1080,30 +1096,39 @@ export class RunQueue {
10801096
"returnUnclaimedMessagesToQueue",
10811097
async (span) => {
10821098
const targetBaseQueueKey = queue ? this.keys.queueKey(env, queue) : undefined;
1099+
const totalPasses = Number.isFinite(maxPasses)
1100+
? Math.min(RETURN_UNCLAIMED_MAX_PASS_CEILING, Math.max(1, Math.floor(maxPasses)))
1101+
: RETURN_UNCLAIMED_MAX_PASSES;
10831102

1084-
let returned = 0;
1085-
let skipped = 0;
1103+
const returnedRunIds = new Set<string>();
1104+
let skippedLastPass = 0;
10861105
let errors = 0;
10871106
let passes = 0;
10881107

1089-
for (let pass = 0; pass < Math.max(1, maxPasses); pass++) {
1108+
for (let pass = 0; pass < totalPasses; pass++) {
10901109
passes++;
10911110

10921111
const passResult = await this.#returnUnclaimedMessagesPass(env, targetBaseQueueKey);
10931112

1094-
returned += passResult.returned;
1113+
for (const runId of passResult.returnedRunIds) {
1114+
returnedRunIds.add(runId);
1115+
}
10951116
errors += passResult.errors;
1096-
skipped = passResult.skipped;
1117+
skippedLastPass = passResult.skipped;
10971118

1098-
if (passResult.skipped === 0 || pass === Math.max(1, maxPasses) - 1) {
1119+
const settled = passResult.skipped === 0 && passResult.errors === 0;
1120+
1121+
if (settled || pass === totalPasses - 1) {
10991122
break;
11001123
}
11011124

11021125
await setTimeout(passDelayMs);
11031126
}
11041127

1128+
const returned = returnedRunIds.size;
1129+
11051130
span.setAttribute("returned_count", returned);
1106-
span.setAttribute("skipped_count", skipped);
1131+
span.setAttribute("skipped_last_pass", skippedLastPass);
11071132
span.setAttribute("error_count", errors);
11081133
span.setAttribute("passes", passes);
11091134

@@ -1112,12 +1137,12 @@ export class RunQueue {
11121137
environmentId: env.id,
11131138
queue,
11141139
returned,
1115-
skipped,
1140+
skippedLastPass,
11161141
errors,
11171142
passes,
11181143
});
11191144

1120-
return { returned, skipped, errors, passes };
1145+
return { returned, skippedLastPass, errors, passes };
11211146
},
11221147
{
11231148
kind: SpanKind.INTERNAL,
@@ -1129,25 +1154,31 @@ export class RunQueue {
11291154
);
11301155
}
11311156

1157+
/**
1158+
* Candidates come from the environment-level sets even when a single queue is targeted.
1159+
* The per-queue sets would be cheaper but `ckIndex` only tracks concurrency key variants
1160+
* that still have pending messages, so a variant whose only runs are already in flight is
1161+
* not discoverable from it.
1162+
*/
11321163
async #returnUnclaimedMessagesPass(
11331164
env: MinimalAuthenticatedEnvironment,
11341165
targetBaseQueueKey: string | undefined
1135-
): Promise<{ returned: number; skipped: number; errors: number }> {
1166+
): Promise<{ returnedRunIds: string[]; skipped: number; errors: number }> {
11361167
const unclaimedRunIds = await this.redis.sdiff(
11371168
this.keys.envCurrentConcurrencyKey(env),
11381169
this.keys.envCurrentDequeuedKey(env)
11391170
);
11401171

11411172
if (unclaimedRunIds.length === 0) {
1142-
return { returned: 0, skipped: 0, errors: 0 };
1173+
return { returnedRunIds: [], skipped: 0, errors: 0 };
11431174
}
11441175

11451176
const messages = (await this.#readMessages(env.organization.id, unclaimedRunIds)).filter(
11461177
(message) =>
11471178
!targetBaseQueueKey || this.keys.baseQueueKeyFromQueue(message.queue) === targetBaseQueueKey
11481179
);
11491180

1150-
let returned = 0;
1181+
const returnedRunIds: string[] = [];
11511182
let skipped = 0;
11521183
let errors = 0;
11531184

@@ -1171,14 +1202,17 @@ export class RunQueue {
11711202
}
11721203

11731204
if (result.value) {
1174-
returned++;
1205+
const runId = batch[index]?.runId;
1206+
if (runId) {
1207+
returnedRunIds.push(runId);
1208+
}
11751209
} else {
11761210
skipped++;
11771211
}
11781212
}
11791213
}
11801214

1181-
return { returned, skipped, errors };
1215+
return { returnedRunIds, skipped, errors };
11821216
}
11831217

11841218
async #readMessages(orgId: string, runIds: string[]): Promise<OutputPayload[]> {

0 commit comments

Comments
 (0)