Skip to content

Commit 77236dd

Browse files
d-csclaude
andcommitted
test(run-ops split): route-level regression for batch-results 404 on NEW-resident batch
Adds the route-level sibling of the other readroute presenter tests. Seeds a NEW-resident (ksuid) batch + member on the NEW store and asserts the presenter wired as the route wires it (splitEnabled + newClient + legacyReplica) resolves it, while a passthrough-only build of the same presenter returns undefined -- the exact 404 the route produced before the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1ae68a0 commit 77236dd

1 file changed

Lines changed: 229 additions & 0 deletions

File tree

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// Route-level regression for ApiBatchResultsPresenter: the /batches/:id/results route used to build
2+
// the presenter with no read-through deps, collapsing to a passthrough read off the control-plane
3+
// replica only, which 404s a NEW-resident (ksuid) batch that lives on the dedicated run-ops DB.
4+
import { heteroPostgresTest } from "@internal/testcontainers";
5+
import type { PrismaClient } from "@trigger.dev/database";
6+
import { describe, expect, vi } from "vitest";
7+
import type { PrismaReplicaClient } from "~/db.server";
8+
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
9+
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
10+
11+
vi.setConfig({ testTimeout: 60_000 });
12+
13+
// 27-char body → NEW residency (ksuid analog). 25-char body → LEGACY residency (cuid analog).
14+
function newRunId(c: string) {
15+
return c.repeat(27);
16+
}
17+
18+
// A prisma handle that throws on any access — proves the split path never reads the passthrough
19+
// handles when the batch resolves off the NEW client.
20+
const throwingPrisma = new Proxy(
21+
{},
22+
{
23+
get(_t, prop) {
24+
throw new Error(
25+
`passthrough handle must not be touched on the split path (got .${String(prop)})`
26+
);
27+
},
28+
}
29+
) as unknown as PrismaReplicaClient;
30+
31+
let seedCounter = 0;
32+
33+
async function seedEnv(prisma: PrismaClient, slug: string) {
34+
const n = seedCounter++;
35+
const organization = await prisma.organization.create({
36+
data: { title: `Org ${slug}`, slug: `org-${slug}-${n}` },
37+
});
38+
const project = await prisma.project.create({
39+
data: {
40+
name: `Proj ${slug}`,
41+
slug: `proj-${slug}-${n}`,
42+
organizationId: organization.id,
43+
externalRef: `ext-${slug}-${n}`,
44+
},
45+
});
46+
const environment = await prisma.runtimeEnvironment.create({
47+
data: {
48+
slug: `env-${slug}-${n}`,
49+
type: "PRODUCTION",
50+
projectId: project.id,
51+
organizationId: organization.id,
52+
apiKey: `api-${slug}-${n}`,
53+
pkApiKey: `pk-${slug}-${n}`,
54+
shortcode: `sc-${slug}-${n}`,
55+
},
56+
});
57+
return { organization, project, environment };
58+
}
59+
60+
type SeedCtx = Awaited<ReturnType<typeof seedEnv>>;
61+
62+
// Mirror the same org/project/env ids onto the second DB so a passthrough read against the
63+
// control-plane replica has the environment row to filter on (but NOT the batch row).
64+
async function mirrorEnv(prisma: PrismaClient, ctx: SeedCtx, slug: string) {
65+
const n = seedCounter++;
66+
await prisma.organization.create({
67+
data: { id: ctx.organization.id, title: `Org ${slug}`, slug: `org-${slug}-m-${n}` },
68+
});
69+
await prisma.project.create({
70+
data: {
71+
id: ctx.project.id,
72+
name: `Proj ${slug}`,
73+
slug: `proj-${slug}-m-${n}`,
74+
organizationId: ctx.organization.id,
75+
externalRef: `ext-${slug}-m-${n}`,
76+
},
77+
});
78+
await prisma.runtimeEnvironment.create({
79+
data: {
80+
id: ctx.environment.id,
81+
slug: `env-${slug}-m-${n}`,
82+
type: "PRODUCTION",
83+
projectId: ctx.project.id,
84+
organizationId: ctx.organization.id,
85+
apiKey: `api-${slug}-m-${n}`,
86+
pkApiKey: `pk-${slug}-m-${n}`,
87+
shortcode: `sc-${slug}-m-${n}`,
88+
},
89+
});
90+
}
91+
92+
// Drop the per-DB TaskRunAttempt worker/queue FKs so we can seed an attempt (its output is what the
93+
// execution-result carries) without standing up BackgroundWorker/TaskQueue parents.
94+
async function relaxFks(prisma: PrismaClient) {
95+
for (const sql of [
96+
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerId_fkey"`,
97+
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerTaskId_fkey"`,
98+
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_queueId_fkey"`,
99+
]) {
100+
await prisma.$executeRawUnsafe(sql);
101+
}
102+
}
103+
104+
async function seedMember(
105+
prisma: PrismaClient,
106+
ctx: SeedCtx,
107+
m: { id: string; friendlyId: string; output: string }
108+
) {
109+
const run = await prisma.taskRun.create({
110+
data: {
111+
id: m.id,
112+
friendlyId: m.friendlyId,
113+
taskIdentifier: "my-task",
114+
status: "COMPLETED_SUCCESSFULLY",
115+
payload: JSON.stringify({}),
116+
payloadType: "application/json",
117+
traceId: m.id,
118+
spanId: m.id,
119+
queue: "main",
120+
runtimeEnvironmentId: ctx.environment.id,
121+
projectId: ctx.project.id,
122+
organizationId: ctx.organization.id,
123+
environmentType: "PRODUCTION",
124+
engine: "V2",
125+
},
126+
});
127+
128+
await prisma.taskRunAttempt.create({
129+
data: {
130+
friendlyId: `attempt_${m.id}`,
131+
number: 1,
132+
taskRunId: run.id,
133+
backgroundWorkerId: "bw",
134+
backgroundWorkerTaskId: "bwt",
135+
runtimeEnvironmentId: ctx.environment.id,
136+
queueId: "q",
137+
status: "COMPLETED",
138+
output: m.output,
139+
outputType: "application/json",
140+
},
141+
});
142+
143+
return run;
144+
}
145+
146+
async function seedBatch(
147+
prisma: PrismaClient,
148+
ctx: SeedCtx,
149+
friendlyId: string,
150+
memberIds: string[]
151+
) {
152+
const batch = await prisma.batchTaskRun.create({
153+
data: {
154+
friendlyId,
155+
runtimeEnvironmentId: ctx.environment.id,
156+
runCount: memberIds.length,
157+
runIds: [],
158+
batchVersion: "runengine:v2",
159+
},
160+
});
161+
for (const taskRunId of memberIds) {
162+
await prisma.batchTaskRunItem.create({
163+
data: { batchTaskRunId: batch.id, taskRunId, status: "COMPLETED" },
164+
});
165+
}
166+
return batch;
167+
}
168+
169+
const env = (ctx: SeedCtx) =>
170+
({
171+
id: ctx.environment.id,
172+
type: ctx.environment.type,
173+
slug: ctx.environment.slug,
174+
organizationId: ctx.organization.id,
175+
organization: { slug: ctx.organization.slug, title: ctx.organization.title },
176+
projectId: ctx.project.id,
177+
project: { name: ctx.project.name },
178+
}) as unknown as AuthenticatedEnvironment;
179+
180+
describe("ApiBatchResultsPresenter route wiring (the /batches/:id/results 404 regression)", () => {
181+
heteroPostgresTest(
182+
"a NEW-resident batch resolves with split deps but 404s (undefined) when built passthrough-only",
183+
async ({ prisma14, prisma17 }) => {
184+
const newDb = prisma17 as unknown as PrismaClient; // dedicated run-ops (NEW) DB analog
185+
const legacyDb = prisma14 as unknown as PrismaClient; // control-plane / legacy replica analog
186+
187+
// Batch + members live ONLY on the NEW DB. The env is mirrored onto the legacy DB so the
188+
// passthrough read has an environment to filter on — but never the batch row.
189+
const ctx = await seedEnv(newDb, "route-new");
190+
await mirrorEnv(legacyDb, ctx, "route-legacy");
191+
await relaxFks(newDb);
192+
193+
const memberId = newRunId("a");
194+
await seedMember(newDb, ctx, {
195+
id: memberId,
196+
friendlyId: "run_route_a",
197+
output: JSON.stringify({ from: "new" }),
198+
});
199+
await seedBatch(newDb, ctx, "batch_route_new", [memberId]);
200+
201+
// Route wiring: splitEnabled + newClient + legacyReplica; passthrough handles throw.
202+
const splitPresenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
203+
splitEnabled: true,
204+
newClient: prisma17 as unknown as PrismaReplicaClient,
205+
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
206+
});
207+
208+
const resolved = await splitPresenter.call("batch_route_new", env(ctx));
209+
210+
expect(resolved).toBeDefined();
211+
expect(resolved!.id).toBe("batch_route_new");
212+
expect(resolved!.items).toHaveLength(1);
213+
expect(resolved!.items[0]).toEqual({
214+
ok: true,
215+
id: "run_route_a",
216+
taskIdentifier: "my-task",
217+
output: JSON.stringify({ from: "new" }),
218+
outputType: "application/json",
219+
});
220+
221+
// Pre-fix route: no read-through deps => passthrough off the control-plane replica (the legacy
222+
// DB, which never received the batch) => undefined, i.e. the 404.
223+
const passthroughPresenter = new ApiBatchResultsPresenter(legacyDb, legacyDb);
224+
225+
const missed = await passthroughPresenter.call("batch_route_new", env(ctx));
226+
expect(missed).toBeUndefined();
227+
}
228+
);
229+
});

0 commit comments

Comments
 (0)