Skip to content

Commit 70bca82

Browse files
authored
feat(run-ops): activation — drop cross-DB FKs, provision run-ops DB, enable split (#4124)
1 parent 1f4369d commit 70bca82

13 files changed

Lines changed: 1109 additions & 19 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Enable the dedicated run-ops database split: run records and their related rows are served from a separate database, with cross-database references resolved in application code instead of database foreign keys.

apps/webapp/app/v3/services/bulk/performBulkAction.server.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ export class PerformBulkActionService extends BaseService {
1010
const item = await this._prisma.bulkActionItem.findFirst({
1111
where: { id: bulkActionItemId },
1212
include: {
13-
group: true,
1413
sourceRun: true,
1514
destinationRun: true,
1615
},
@@ -24,7 +23,7 @@ export class PerformBulkActionService extends BaseService {
2423
return;
2524
}
2625

27-
switch (item.group.type) {
26+
switch (item.type) {
2827
case "REPLAY": {
2928
const service = new ReplayTaskRunService(this._prisma);
3029
const result = await service.call(item.sourceRun, { triggerSource: "dashboard" });
@@ -57,7 +56,7 @@ export class PerformBulkActionService extends BaseService {
5756
break;
5857
}
5958
default: {
60-
assertNever(item.group.type);
59+
assertNever(item.type);
6160
}
6261
}
6362

@@ -94,17 +93,20 @@ export class PerformBulkActionService extends BaseService {
9493

9594
public async call(bulkActionGroupId: string) {
9695
const actionGroup = await this._prisma.bulkActionGroup.findFirst({
97-
include: {
98-
items: true,
99-
},
10096
where: { id: bulkActionGroupId },
97+
select: { id: true },
10198
});
10299

103100
if (!actionGroup) {
104101
return;
105102
}
106103

107-
for (const item of actionGroup.items) {
104+
const items = await this._prisma.bulkActionItem.findMany({
105+
where: { groupId: bulkActionGroupId },
106+
select: { id: true },
107+
});
108+
109+
for (const item of items) {
108110
await this.enqueueBulkActionItem(item.id, bulkActionGroupId);
109111
}
110112
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Proof for dropping the canonical BatchTaskRun -> RuntimeEnvironment FK
2+
// (constraint "BatchTaskRun_runtimeEnvironmentId_fkey", onDelete: Cascade) while keeping the
3+
// runtimeEnvironmentId scalar and its compound @@unique/@@index. BatchTaskRun is run-ops and
4+
// RuntimeEnvironment is control-plane, so the two may live on different servers; create-time
5+
// integrity is preserved app-side via the ControlPlaneResolver's assertEnvExists. Env-delete
6+
// orphan cleanup is handled separately — here the batch row is tolerated.
7+
8+
import { heteroPostgresTest, postgresTest } from "@internal/testcontainers";
9+
import type { PrismaClient } from "@trigger.dev/database";
10+
import { describe, expect, vi } from "vitest";
11+
import { ControlPlaneCache } from "~/v3/runOpsMigration/controlPlaneCache.server";
12+
import {
13+
ControlPlaneReferenceError,
14+
ControlPlaneResolver,
15+
} from "~/v3/runOpsMigration/controlPlaneResolver.server";
16+
17+
// Cross-DB testcontainer spin-up + queries can exceed the 5s default on the first test.
18+
vi.setConfig({ testTimeout: 60_000 });
19+
20+
let seedCounter = 0;
21+
22+
async function seedEnvironment(prisma: PrismaClient) {
23+
const n = seedCounter++;
24+
const organization = await prisma.organization.create({
25+
data: { title: `Org ${n}`, slug: `org-${n}` },
26+
});
27+
const project = await prisma.project.create({
28+
data: {
29+
name: `Project ${n}`,
30+
slug: `project-${n}`,
31+
externalRef: `proj_${n}`,
32+
organizationId: organization.id,
33+
},
34+
});
35+
const environment = await prisma.runtimeEnvironment.create({
36+
data: {
37+
type: "PRODUCTION",
38+
slug: `env-${n}`,
39+
projectId: project.id,
40+
organizationId: organization.id,
41+
apiKey: `tr_prod_${n}`,
42+
pkApiKey: `pk_prod_${n}`,
43+
shortcode: `short_${n}`,
44+
},
45+
});
46+
return { organization, project, environment };
47+
}
48+
49+
let batchCounter = 0;
50+
51+
async function createBatch(prisma: PrismaClient, runtimeEnvironmentId: string) {
52+
const n = batchCounter++;
53+
return prisma.batchTaskRun.create({
54+
data: {
55+
friendlyId: `batch_${n}`,
56+
runtimeEnvironmentId,
57+
runCount: 1,
58+
runIds: [],
59+
batchVersion: "runengine:v2",
60+
},
61+
});
62+
}
63+
64+
// Asserts the post-migration state of BatchTaskRun on a given client: the FK is gone, but the
65+
// scalar and both compound constraints are retained. Shared by the single-version and the
66+
// cross-version suites.
67+
async function assertSchemaState(prisma: PrismaClient) {
68+
const foreignKeys = await prisma.$queryRaw<{ constraint_name: string }[]>`
69+
SELECT constraint_name
70+
FROM information_schema.table_constraints
71+
WHERE table_name = 'BatchTaskRun'
72+
AND constraint_type = 'FOREIGN KEY'
73+
`;
74+
expect(foreignKeys.map((c) => c.constraint_name)).not.toContain(
75+
"BatchTaskRun_runtimeEnvironmentId_fkey"
76+
);
77+
78+
const columns = await prisma.$queryRaw<{ column_name: string }[]>`
79+
SELECT column_name
80+
FROM information_schema.columns
81+
WHERE table_name = 'BatchTaskRun'
82+
AND column_name = 'runtimeEnvironmentId'
83+
`;
84+
expect(columns).toHaveLength(1);
85+
86+
// The @@unique([runtimeEnvironmentId, idempotencyKey]) and
87+
// @@index([runtimeEnvironmentId, id(sort: Desc)]) both survive the FK drop.
88+
const indexes = await prisma.$queryRaw<{ indexdef: string }[]>`
89+
SELECT indexdef FROM pg_indexes WHERE tablename = 'BatchTaskRun'
90+
`;
91+
const defs = indexes.map((i) => i.indexdef);
92+
const hasUnique = defs.some(
93+
(d) => /UNIQUE/i.test(d) && d.includes("runtimeEnvironmentId") && d.includes("idempotencyKey")
94+
);
95+
const hasIndex = defs.some(
96+
(d) => !/UNIQUE/i.test(d) && d.includes("runtimeEnvironmentId") && /\bid\b/.test(d)
97+
);
98+
expect(hasUnique).toBe(true);
99+
expect(hasIndex).toBe(true);
100+
}
101+
102+
// Inserts an env + batch, deletes the env, and asserts the batch survives (cascade gone).
103+
async function assertOrphanTolerated(prisma: PrismaClient) {
104+
const { environment } = await seedEnvironment(prisma);
105+
const batch = await createBatch(prisma, environment.id);
106+
107+
await prisma.runtimeEnvironment.delete({ where: { id: environment.id } });
108+
109+
const survivor = await prisma.batchTaskRun.findFirst({ where: { id: batch.id } });
110+
expect(survivor).not.toBeNull();
111+
expect(survivor?.runtimeEnvironmentId).toBe(environment.id);
112+
}
113+
114+
describe("drop BatchTaskRun -> RuntimeEnvironment FK", () => {
115+
postgresTest("FK constraint absent; scalar + unique + index retained", async ({ prisma }) => {
116+
await assertSchemaState(prisma);
117+
});
118+
119+
postgresTest(
120+
"deleting the env leaves the BatchTaskRun row alive (no cascade; orphan cleanup handled separately)",
121+
async ({ prisma }) => {
122+
await assertOrphanTolerated(prisma);
123+
}
124+
);
125+
126+
postgresTest(
127+
"app-side env validation: assertEnvExists rejects an invalid env and a valid-env create succeeds by scalar",
128+
async ({ prisma }) => {
129+
const { environment } = await seedEnvironment(prisma);
130+
131+
const resolver = new ControlPlaneResolver({
132+
controlPlanePrimary: prisma,
133+
controlPlaneReplica: prisma,
134+
cache: new ControlPlaneCache(),
135+
splitEnabled: () => true,
136+
});
137+
138+
// The exact guard call the create services place before batchTaskRun.create.
139+
await expect(resolver.assertEnvExists("env_does_not_exist")).rejects.toBeInstanceOf(
140+
ControlPlaneReferenceError
141+
);
142+
143+
await expect(resolver.assertEnvExists(environment.id)).resolves.toBeUndefined();
144+
145+
// Once the guard passes, the batch is linked by the runtimeEnvironmentId scalar (no FK).
146+
const batch = await createBatch(prisma, environment.id);
147+
expect(batch.runtimeEnvironmentId).toBe(environment.id);
148+
}
149+
);
150+
});
151+
152+
// Cross-version gate: the migration applies and the post-state is identical across major versions.
153+
describe("drop BatchTaskRun -> RuntimeEnvironment FK — cross-version (legacy + new Postgres)", () => {
154+
heteroPostgresTest(
155+
"migration applies and FK is absent on both the legacy and new databases",
156+
async ({ prisma14, prisma17 }) => {
157+
await assertSchemaState(prisma14);
158+
await assertSchemaState(prisma17);
159+
}
160+
);
161+
162+
heteroPostgresTest(
163+
"env delete leaves the batch orphaned on both the legacy and new databases",
164+
async ({ prisma14, prisma17 }) => {
165+
await assertOrphanTolerated(prisma14);
166+
await assertOrphanTolerated(prisma17);
167+
}
168+
);
169+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Single-version proof for dropping the dead `_TaskRunToTaskRunTag` implicit join.
2+
3+
import { describe, expect } from "vitest";
4+
import { postgresTest } from "@internal/testcontainers";
5+
6+
describe("drop _TaskRunToTaskRunTag implicit join", () => {
7+
postgresTest("runTags scalar round-trips and the join table is gone", async ({ prisma }) => {
8+
const organization = await prisma.organization.create({
9+
data: {
10+
title: "test",
11+
slug: "test",
12+
},
13+
});
14+
15+
const project = await prisma.project.create({
16+
data: {
17+
name: "test",
18+
slug: "test",
19+
organizationId: organization.id,
20+
externalRef: "test",
21+
},
22+
});
23+
24+
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
25+
data: {
26+
slug: "test",
27+
type: "DEVELOPMENT",
28+
projectId: project.id,
29+
organizationId: organization.id,
30+
apiKey: "test",
31+
pkApiKey: "test",
32+
shortcode: "test",
33+
},
34+
});
35+
36+
const taskRun = await prisma.taskRun.create({
37+
data: {
38+
friendlyId: "run_1234",
39+
taskIdentifier: "my-task",
40+
payload: JSON.stringify({ foo: "bar" }),
41+
payloadType: "application/json",
42+
traceId: "1234",
43+
spanId: "1234",
44+
queue: "test",
45+
runtimeEnvironmentId: runtimeEnvironment.id,
46+
projectId: project.id,
47+
organizationId: organization.id,
48+
environmentType: "DEVELOPMENT",
49+
engine: "V2",
50+
runTags: ["alpha", "beta"],
51+
},
52+
});
53+
54+
const readBack = await prisma.taskRun.findFirstOrThrow({
55+
where: { id: taskRun.id },
56+
});
57+
expect(readBack.runTags).toEqual(["alpha", "beta"]);
58+
59+
const result = await prisma.$queryRaw<{ t: string | null }[]>`
60+
SELECT to_regclass('public._TaskRunToTaskRunTag')::text as t
61+
`;
62+
expect(result[0].t).toBeNull();
63+
});
64+
});

0 commit comments

Comments
 (0)