Skip to content

Commit 31a632a

Browse files
committed
fix(run-store): record cross-DB completed waitpoints via an FK-free insert
createExecutionSnapshot and lockRunToWorker recorded completed waitpoints with a Prisma connect on the implicit _completedWaitpoints M2M, which ORM-validates the Waitpoint exists locally and rejects a cross-DB (NEW-resident) token. A LEGACY parent that triggerAndWaits a NEW child then hangs when the resume snapshot connects the NEW token. Insert the join rows FK-free after create, mirroring the dedicated schema, and drop the _completedWaitpoints to Waitpoint FK by migration.
1 parent 2821b05 commit 31a632a

3 files changed

Lines changed: 199 additions & 23 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Run-ops split: drop the _completedWaitpoints -> Waitpoint FK so a LEGACY snapshot can record a
2+
-- cross-DB completed token (NEW-resident). Third of the split's waitpoint-FK drops; the A (snapshot)
3+
-- side stays same-DB. Referential integrity is app-enforced, matching the sibling FK-removals.
4+
ALTER TABLE "_completedWaitpoints" DROP CONSTRAINT IF EXISTS "_completedWaitpoints_B_fkey";

internal-packages/run-store/src/PostgresRunStore.ts

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,26 @@ export class PostgresRunStore implements RunStore {
929929
});
930930
}
931931

932+
// Legacy implicit M2M equivalent of #connectCompletedWaitpoints: raw-insert the join rows FK-free.
933+
// Prisma `connect` ORM-validates the Waitpoint exists locally, which fails for a cross-DB
934+
// (NEW-resident) token; the raw insert + dropped _completedWaitpoints_B_fkey records it. A =
935+
// TaskRunExecutionSnapshot.id, B = Waitpoint.id (implicit M2M alphabetical order).
936+
async #connectCompletedWaitpointsLegacy(
937+
client: PrismaClientOrTransaction,
938+
snapshotId: string,
939+
waitpointIds: string[]
940+
): Promise<void> {
941+
if (waitpointIds.length === 0) {
942+
return;
943+
}
944+
945+
await client.$executeRaw`
946+
INSERT INTO "_completedWaitpoints" ("A", "B")
947+
SELECT ${snapshotId}, w.id
948+
FROM unnest(${waitpointIds}::text[]) AS w(id)
949+
ON CONFLICT DO NOTHING`;
950+
}
951+
932952
async lockRunToWorker(
933953
runId: string,
934954
data: LockRunData,
@@ -970,15 +990,7 @@ export class PostgresRunStore implements RunStore {
970990
organizationId: data.snapshot.organizationId,
971991
checkpointId: data.snapshot.checkpointId ?? undefined,
972992
batchId: data.snapshot.batchId ?? undefined,
973-
// Legacy: connect the implicit M2M. Dedicated: links inserted below into the
974-
// CompletedWaitpoint join model (no such relation field exists on that schema).
975-
...(dedicated
976-
? {}
977-
: {
978-
completedWaitpoints: {
979-
connect: data.snapshot.completedWaitpointIds.map((id) => ({ id })),
980-
},
981-
}),
993+
// Completed-waitpoint links are inserted FK-free after create (below) for BOTH schemas.
982994
completedWaitpointOrder: data.snapshot.completedWaitpointOrder,
983995
workerId: data.snapshot.workerId ?? undefined,
984996
runnerId: data.snapshot.runnerId ?? undefined,
@@ -993,6 +1005,12 @@ export class PostgresRunStore implements RunStore {
9931005
data.snapshot.id,
9941006
data.snapshot.completedWaitpointIds
9951007
);
1008+
} else {
1009+
await this.#connectCompletedWaitpointsLegacy(
1010+
prisma,
1011+
data.snapshot.id,
1012+
data.snapshot.completedWaitpointIds
1013+
);
9961014
}
9971015

9981016
return result;
@@ -1554,15 +1572,8 @@ export class PostgresRunStore implements RunStore {
15541572
workerId,
15551573
runnerId,
15561574
metadata: snapshot.metadata ?? undefined,
1557-
// Legacy: connect the implicit M2M. Dedicated: links inserted below into the
1558-
// CompletedWaitpoint join model (no such relation field exists on that schema).
1559-
...(dedicated
1560-
? {}
1561-
: {
1562-
completedWaitpoints: {
1563-
connect: completedWaitpoints?.map((w) => ({ id: w.id })),
1564-
},
1565-
}),
1575+
// Completed-waitpoint links are inserted FK-free after create (below) for BOTH schemas, so a
1576+
// cross-DB (NEW-resident) token can be recorded without a Prisma `connect` existence check.
15661577
completedWaitpointOrder: completedWaitpoints
15671578
?.filter((c) => c.index !== undefined)
15681579
.sort((a, b) => a.index! - b.index!)
@@ -1573,12 +1584,11 @@ export class PostgresRunStore implements RunStore {
15731584
include: { checkpoint: true },
15741585
});
15751586

1587+
const completedWaitpointIds = completedWaitpoints?.map((w) => w.id) ?? [];
15761588
if (dedicated) {
1577-
await this.#connectCompletedWaitpoints(
1578-
prisma,
1579-
newSnapshot.id,
1580-
completedWaitpoints?.map((w) => w.id) ?? []
1581-
);
1589+
await this.#connectCompletedWaitpoints(prisma, newSnapshot.id, completedWaitpointIds);
1590+
} else {
1591+
await this.#connectCompletedWaitpointsLegacy(prisma, newSnapshot.id, completedWaitpointIds);
15821592
}
15831593

15841594
return newSnapshot;
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// createExecutionSnapshot's legacy branch records completed waitpoints via Prisma `connect` on the
2+
// implicit _completedWaitpoints M2M, whose FK to Waitpoint rejects a cross-DB (NEW-resident) token.
3+
// A LEGACY parent doing triggerAndWait on a NEW child completes on the NEW token; the resume snapshot
4+
// (routed to #legacy) then connects that NEW token -> FK violation -> the legacy parent hangs forever.
5+
// Fix: drop _completedWaitpoints_B_fkey (migration); with the FK gone the connect records the cross-DB
6+
// link (app-enforced integrity). Real two-DB topology; never mocked.
7+
8+
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
9+
import type { PrismaClient } from "@trigger.dev/database";
10+
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
11+
import { expect } from "vitest";
12+
import { PostgresRunStore } from "./PostgresRunStore.js";
13+
import { RoutingRunStore } from "./runOpsStore.js";
14+
import type { CreateRunInput } from "./types.js";
15+
16+
const CUID_25 = "c".repeat(25); // LEGACY run -> #legacy (prisma14)
17+
18+
function makeRouter(prisma14: PrismaClient, prisma17: RunOpsPrismaClient) {
19+
const newStore = new PostgresRunStore({
20+
prisma: prisma17 as never,
21+
readOnlyPrisma: prisma17 as never,
22+
schemaVariant: "dedicated",
23+
});
24+
const legacyStore = new PostgresRunStore({
25+
prisma: prisma14,
26+
readOnlyPrisma: prisma14,
27+
schemaVariant: "legacy",
28+
});
29+
return new RoutingRunStore({ new: newStore, legacy: legacyStore });
30+
}
31+
32+
async function seedEnvironmentLegacy(prisma: PrismaClient, suffix: string) {
33+
const organization = await prisma.organization.create({
34+
data: { title: `Org ${suffix}`, slug: `org-${suffix}` },
35+
});
36+
const project = await prisma.project.create({
37+
data: {
38+
name: `Project ${suffix}`,
39+
slug: `project-${suffix}`,
40+
externalRef: `proj_${suffix}`,
41+
organizationId: organization.id,
42+
},
43+
});
44+
const environment = await prisma.runtimeEnvironment.create({
45+
data: {
46+
type: "PRODUCTION",
47+
slug: `prod-${suffix}`,
48+
projectId: project.id,
49+
organizationId: organization.id,
50+
apiKey: `tr_prod_${suffix}`,
51+
pkApiKey: `pk_prod_${suffix}`,
52+
shortcode: `short_${suffix}`,
53+
maximumConcurrencyLimit: 10,
54+
},
55+
});
56+
return { organization, project, environment };
57+
}
58+
59+
function buildCreateRunInput(p: {
60+
runId: string;
61+
organizationId: string;
62+
projectId: string;
63+
runtimeEnvironmentId: string;
64+
}): CreateRunInput {
65+
return {
66+
data: {
67+
id: p.runId,
68+
engine: "V2",
69+
status: "EXECUTING",
70+
friendlyId: "run_cwc",
71+
runtimeEnvironmentId: p.runtimeEnvironmentId,
72+
environmentType: "PRODUCTION",
73+
organizationId: p.organizationId,
74+
projectId: p.projectId,
75+
taskIdentifier: "cwc-task",
76+
payload: "{}",
77+
payloadType: "application/json",
78+
context: {},
79+
traceContext: {},
80+
traceId: `trace_${p.runId}`,
81+
spanId: `span_${p.runId}`,
82+
runTags: [],
83+
queue: "task/cwc-task",
84+
isTest: false,
85+
taskEventStore: "taskEvent",
86+
depth: 0,
87+
createdAt: new Date("2024-01-01T00:00:00.000Z"),
88+
},
89+
snapshot: {
90+
engine: "V2",
91+
executionStatus: "RUN_CREATED",
92+
description: "Run was created",
93+
runStatus: "PENDING",
94+
environmentId: p.runtimeEnvironmentId,
95+
environmentType: "PRODUCTION",
96+
projectId: p.projectId,
97+
organizationId: p.organizationId,
98+
},
99+
};
100+
}
101+
102+
describe("run-ops split — a LEGACY snapshot can record a cross-DB completed waitpoint (NEW-resident token)", () => {
103+
heteroRunOpsPostgresTest(
104+
"createExecutionSnapshot connects a NEW-resident completed token to a LEGACY run's snapshot",
105+
async ({ prisma14, prisma17 }: { prisma14: PrismaClient; prisma17: RunOpsPrismaClient }) => {
106+
const router = makeRouter(prisma14, prisma17);
107+
// The harness builds the schema with `prisma db push`, which re-creates the FK that migration
108+
// 20260705230000 drops in prod. Mirror the drop on this clone so the connect exercises prod state.
109+
await prisma14.$executeRawUnsafe(
110+
`ALTER TABLE "_completedWaitpoints" DROP CONSTRAINT IF EXISTS "_completedWaitpoints_B_fkey"`
111+
);
112+
const env = await seedEnvironmentLegacy(prisma14, "cwc");
113+
const runId = `run_${CUID_25}`; // LEGACY run -> #legacy
114+
await router.createRun(
115+
buildCreateRunInput({
116+
runId,
117+
organizationId: env.organization.id,
118+
projectId: env.project.id,
119+
runtimeEnvironmentId: env.environment.id,
120+
})
121+
);
122+
const created = await router.findLatestExecutionSnapshot(runId);
123+
124+
// A completed token minted co-located with a NEW child -> its Waitpoint row lives on #new.
125+
const tokenId = "waitpoint_" + "y".repeat(20);
126+
await prisma17.waitpoint.create({
127+
data: {
128+
id: tokenId,
129+
friendlyId: "wp_cwc",
130+
type: "MANUAL",
131+
status: "COMPLETED",
132+
completedAt: new Date(),
133+
idempotencyKey: `idem_${tokenId}`,
134+
userProvidedIdempotencyKey: false,
135+
projectId: env.project.id,
136+
environmentId: env.environment.id,
137+
},
138+
});
139+
140+
const snap = await router.createExecutionSnapshot(
141+
{
142+
run: { id: runId, status: "EXECUTING", attemptNumber: 1 },
143+
snapshot: { executionStatus: "EXECUTING_WITH_WAITPOINTS", description: "resumed" },
144+
previousSnapshotId: created!.id,
145+
completedWaitpoints: [{ id: tokenId, index: 0 }],
146+
environmentId: env.environment.id,
147+
environmentType: "PRODUCTION",
148+
projectId: env.project.id,
149+
organizationId: env.organization.id,
150+
},
151+
prisma14
152+
);
153+
154+
// RED: the connect hits _completedWaitpoints_B_fkey (token not on #legacy) -> throws -> parent hangs.
155+
// GREEN (FK dropped): the cross-DB completed-waitpoint link is recorded.
156+
const link = (await prisma14.$queryRaw`
157+
SELECT "B" FROM "_completedWaitpoints" WHERE "A" = ${snap.id}
158+
`) as { B: string }[];
159+
expect(link.map((r) => r.B)).toEqual([tokenId]);
160+
}
161+
);
162+
});

0 commit comments

Comments
 (0)