Skip to content

Commit 9bf3749

Browse files
committed
fix(run-engine): clear a NEW run's blocking waitpoints on the owning store
clearBlockingWaitpoints deleted edges via the caller's control-plane tx, so a NEW run's TaskRunWaitpoint edges (on the run-ops database) were orphaned and re-blocked the run after a retry. Route the delete through the store, which fans across both databases and applies the tx only to the legacy leg.
1 parent b7936c5 commit 9bf3749

2 files changed

Lines changed: 189 additions & 12 deletions

File tree

internal-packages/run-engine/src/engine/systems/waitpointSystem.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,13 @@ export class WaitpointSystem {
5959
runId: string;
6060
tx?: PrismaClientOrTransaction;
6161
}) {
62-
// A tx pins a specific client and must not be re-routed through the store.
63-
const deleted = tx
64-
? await tx.taskRunWaitpoint.deleteMany({
65-
where: {
66-
taskRunId: runId,
67-
},
68-
})
69-
: await this.$.runStore.deleteManyTaskRunWaitpoints({
70-
where: {
71-
taskRunId: runId,
72-
},
73-
});
62+
// Route the delete: a run's edges may live on #new and/or #legacy (mid-drain), so it must fan
63+
// across both stores. The caller's control-plane tx would only clear #legacy, orphaning #new
64+
// edges that then re-block the run after a retry. The router applies tx to the #legacy leg only.
65+
const deleted = await this.$.runStore.deleteManyTaskRunWaitpoints(
66+
{ where: { taskRunId: runId } },
67+
tx
68+
);
7469

7570
return deleted.count;
7671
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// clearBlockingWaitpoints (called by attemptFailed) deleted edges via the caller's control-plane tx.
2+
// A NEW run's TaskRunWaitpoint edges live on #new, so the control-plane deleteMany matched 0 rows and
3+
// left them orphaned; on a retry+re-block those stale PENDING edges re-block the run forever. The fix
4+
// routes the delete through the store (which fans across both DBs, dropping the tx for the cross-DB
5+
// path). Real two-DB topology; never mocked.
6+
7+
import {
8+
heteroRunOpsPostgresTest,
9+
network,
10+
redisContainer,
11+
redisOptions,
12+
} from "@internal/testcontainers";
13+
import { trace } from "@internal/tracing";
14+
import { PostgresRunStore, RoutingRunStore, type CreateRunInput } from "@internal/run-store";
15+
import type { PrismaClient } from "@trigger.dev/database";
16+
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
17+
import { expect } from "vitest";
18+
import { RunEngine } from "../index.js";
19+
20+
const twoDbEngineTest = heteroRunOpsPostgresTest.extend<{
21+
redisContainer: any;
22+
redisOptions: any;
23+
}>({
24+
network,
25+
redisContainer,
26+
redisOptions,
27+
});
28+
29+
const RUN_OPS_A = "n".repeat(24) + "01"; // run-ops id -> NEW (#new / prisma17)
30+
31+
function baseEngineOptions(redisOptions: any, prisma: any) {
32+
return {
33+
prisma,
34+
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
35+
queue: {
36+
redis: redisOptions,
37+
masterQueueConsumersDisabled: true,
38+
processWorkerQueueDebounceMs: 50,
39+
},
40+
runLock: { redis: redisOptions },
41+
machines: {
42+
defaultMachine: "small-1x" as const,
43+
machines: {
44+
"small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
45+
},
46+
baseCostInCents: 0.0001,
47+
},
48+
tracer: trace.getTracer("test", "0.0.0"),
49+
};
50+
}
51+
52+
function makeRouter(prisma14: PrismaClient, prisma17: RunOpsPrismaClient) {
53+
const newStore = new PostgresRunStore({
54+
prisma: prisma17 as never,
55+
readOnlyPrisma: prisma17 as never,
56+
schemaVariant: "dedicated",
57+
});
58+
const legacyStore = new PostgresRunStore({
59+
prisma: prisma14,
60+
readOnlyPrisma: prisma14,
61+
schemaVariant: "legacy",
62+
});
63+
return new RoutingRunStore({ new: newStore, legacy: legacyStore });
64+
}
65+
66+
async function seedControlPlaneEnv(prisma: PrismaClient, suffix: string) {
67+
const organization = await prisma.organization.create({
68+
data: { title: `Org ${suffix}`, slug: `org-${suffix}` },
69+
});
70+
const project = await prisma.project.create({
71+
data: {
72+
name: `Project ${suffix}`,
73+
slug: `project-${suffix}`,
74+
externalRef: `proj_${suffix}`,
75+
organizationId: organization.id,
76+
},
77+
});
78+
const environment = await prisma.runtimeEnvironment.create({
79+
data: {
80+
type: "PRODUCTION",
81+
slug: `prod-${suffix}`,
82+
projectId: project.id,
83+
organizationId: organization.id,
84+
apiKey: `tr_prod_${suffix}`,
85+
pkApiKey: `pk_prod_${suffix}`,
86+
shortcode: `short_${suffix}`,
87+
maximumConcurrencyLimit: 10,
88+
},
89+
});
90+
return { organization, project, environment };
91+
}
92+
93+
function buildCreateRunInput(p: {
94+
runId: string;
95+
organizationId: string;
96+
projectId: string;
97+
runtimeEnvironmentId: string;
98+
}): CreateRunInput {
99+
return {
100+
data: {
101+
id: p.runId,
102+
engine: "V2",
103+
status: "EXECUTING",
104+
friendlyId: "run_clearwp",
105+
runtimeEnvironmentId: p.runtimeEnvironmentId,
106+
environmentType: "PRODUCTION",
107+
organizationId: p.organizationId,
108+
projectId: p.projectId,
109+
taskIdentifier: "clearwp-task",
110+
payload: "{}",
111+
payloadType: "application/json",
112+
context: {},
113+
traceContext: {},
114+
traceId: `trace_${p.runId}`,
115+
spanId: `span_${p.runId}`,
116+
runTags: [],
117+
queue: "task/clearwp-task",
118+
isTest: false,
119+
taskEventStore: "taskEvent",
120+
depth: 0,
121+
createdAt: new Date("2024-01-01T00:00:00.000Z"),
122+
},
123+
snapshot: {
124+
engine: "V2",
125+
executionStatus: "RUN_CREATED",
126+
description: "Run was created",
127+
runStatus: "PENDING",
128+
environmentId: p.runtimeEnvironmentId,
129+
environmentType: "PRODUCTION",
130+
projectId: p.projectId,
131+
organizationId: p.organizationId,
132+
},
133+
};
134+
}
135+
136+
describe("RunEngine clearBlockingWaitpoints — clears a NEW run's edges even with a control-plane tx", () => {
137+
twoDbEngineTest(
138+
"a control-plane tx does not leave a NEW run's #new-resident blocking edge orphaned",
139+
async ({ prisma14, prisma17, redisOptions }) => {
140+
const router = makeRouter(prisma14 as unknown as PrismaClient, prisma17);
141+
const engine = new RunEngine({
142+
store: router,
143+
...baseEngineOptions(redisOptions, prisma14),
144+
});
145+
146+
try {
147+
const runId = `run_${RUN_OPS_A}`;
148+
const env = await seedControlPlaneEnv(prisma14 as unknown as PrismaClient, "clearwp");
149+
await router.createRun(
150+
buildCreateRunInput({
151+
runId,
152+
organizationId: env.organization.id,
153+
projectId: env.project.id,
154+
runtimeEnvironmentId: env.environment.id,
155+
})
156+
);
157+
// A NEW run's blocking edge lives on #new.
158+
await prisma17.taskRunWaitpoint.create({
159+
data: {
160+
id: "trw_clearwp_0000000000001",
161+
taskRunId: runId,
162+
waitpointId: "waitpoint_clearwp_00000001",
163+
projectId: env.project.id,
164+
},
165+
});
166+
167+
// attemptFailed passes the control-plane tx; the #new edge must still be cleared.
168+
const count = await engine.waitpointSystem.clearBlockingWaitpoints({
169+
runId,
170+
tx: prisma14 as unknown as PrismaClient,
171+
});
172+
173+
// RED: the tx deletes on #legacy -> 0; the #new edge is orphaned.
174+
// GREEN: the routed delete fans out -> the #new edge is cleared.
175+
expect(count).toBe(1);
176+
expect(await prisma17.taskRunWaitpoint.count({ where: { taskRunId: runId } })).toBe(0);
177+
} finally {
178+
await engine.quit();
179+
}
180+
}
181+
);
182+
});

0 commit comments

Comments
 (0)