Skip to content

Commit 88d1290

Browse files
d-csclaude
andcommitted
fix(run-ops split): resolve NEW-resident batches in ApiBatchResultsPresenter
The batch-results presenter read the batch row via `this._prisma.batchTaskRun.findFirst`, which only hits the control-plane DB. Under the run-ops split a batch (and its items) can live in the NEW run-ops DB, so a bare-constructed presenter (as the results route builds it) missed a NEW-resident batch and returned 404. Route the batch-row lookup through `runStore.findBatchTaskRunByFriendlyId`, whose router probes NEW then LEGACY (dropping the passed client hint under the split, while the non-split single-store path keeps reading the injected client). Mirrors ApiRunResultPresenter's use of `runStore.findRun`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 05c912e commit 88d1290

2 files changed

Lines changed: 110 additions & 10 deletions

File tree

apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,22 @@ export class ApiBatchResultsPresenter extends BasePresenter {
1010
env: AuthenticatedEnvironment
1111
): Promise<BatchTaskRunExecutionResult | undefined> {
1212
return this.traceWithEnv("call", env, async (span) => {
13-
const batchRun = await this._prisma.batchTaskRun.findFirst({
14-
where: {
15-
friendlyId,
16-
runtimeEnvironmentId: env.id,
17-
},
18-
include: {
19-
items: {
20-
select: {
21-
taskRunId: true,
13+
// Route through the store so a NEW-resident batch resolves under the run-ops split (the
14+
// router probes NEW→LEGACY and drops this client hint) instead of 404ing on a control-plane read.
15+
const batchRun = await runStore.findBatchTaskRunByFriendlyId(
16+
friendlyId,
17+
env.id,
18+
{
19+
include: {
20+
items: {
21+
select: {
22+
taskRunId: true,
23+
},
2224
},
2325
},
2426
},
25-
});
27+
this._prisma
28+
);
2629

2730
if (!batchRun) {
2831
return undefined;
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Run-ops split resolution LOCK for ApiBatchResultsPresenter.
2+
//
3+
// GET /api/v1/batches/:id/results constructs the presenter BARE (no injected client), so it must
4+
// resolve a batch that lives in the NEW run-ops DB on its own. The presenter routes the batch-row
5+
// lookup through the `runStore` singleton, whose split router probes NEW→LEGACY. This drives a
6+
// NEW-resident (ksuid) batch through a REAL two-physical-DB split router and asserts the bare
7+
// presenter finds it. Fails before the fix (the presenter read the control-plane DB directly and
8+
// 404'd on a NEW-resident batch).
9+
10+
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
11+
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
12+
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
13+
import type { Organization, PrismaClient, Project } from "@trigger.dev/database";
14+
import { generateKsuidId } from "@trigger.dev/core/v3/isomorphic";
15+
import { expect, vi } from "vitest";
16+
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
17+
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
18+
19+
// The split router built over the two testcontainer DBs; injected in place of the db.server-backed
20+
// singleton the presenter imports. Populated per-test before the presenter is constructed.
21+
let testRunStore: RoutingRunStore;
22+
23+
// Presenter reads the batch row via `runStore`; child-run reads also go through it. Neutralize the
24+
// real db.server singleton (no env DB) and the runStore singleton (use the split router below).
25+
// The getter defers to `testRunStore` so each test can set its own router before constructing.
26+
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
27+
vi.mock("~/v3/runStore.server", () => ({
28+
get runStore() {
29+
return testRunStore;
30+
},
31+
}));
32+
33+
vi.setConfig({ testTimeout: 60_000 });
34+
35+
function makeSplitRouter(prisma14: PrismaClient, prisma17: RunOpsPrismaClient) {
36+
const legacyStore = new PostgresRunStore({
37+
prisma: prisma14,
38+
readOnlyPrisma: prisma14,
39+
schemaVariant: "legacy",
40+
});
41+
const newStore = new PostgresRunStore({
42+
prisma: prisma17 as never,
43+
readOnlyPrisma: prisma17 as never,
44+
schemaVariant: "dedicated",
45+
});
46+
return new RoutingRunStore({ new: newStore, legacy: legacyStore });
47+
}
48+
49+
function authEnv(environmentId: string): AuthenticatedEnvironment {
50+
return {
51+
id: environmentId,
52+
type: "DEVELOPMENT",
53+
project: { id: "proj_split" } as Project,
54+
organization: { id: "org_split" } as Organization,
55+
orgMember: null,
56+
} as unknown as AuthenticatedEnvironment;
57+
}
58+
59+
heteroRunOpsPostgresTest(
60+
"a bare ApiBatchResultsPresenter resolves a NEW-resident (ksuid) batch under the split",
61+
async ({ prisma14, prisma17 }) => {
62+
testRunStore = makeSplitRouter(prisma14, prisma17);
63+
64+
const environmentId = "env_split_res";
65+
// ksuid internal id → classifies to the NEW store, seeded in the NEW (prisma17) DB. The
66+
// friendlyId probe fans out NEW→LEGACY regardless of id shape, so the NEW seed is what matters.
67+
const batchInternalId = generateKsuidId();
68+
const batchFriendlyId = `batch_${generateKsuidId()}`;
69+
70+
await prisma17.batchTaskRun.create({
71+
data: {
72+
id: batchInternalId,
73+
friendlyId: batchFriendlyId,
74+
runtimeEnvironmentId: environmentId,
75+
},
76+
});
77+
78+
// Bare construction — exactly how the results route builds it.
79+
const presenter = new ApiBatchResultsPresenter();
80+
const result = await presenter.call(batchFriendlyId, authEnv(environmentId));
81+
82+
// Before the fix this 404s (undefined) because a control-plane read misses the NEW-resident batch.
83+
expect(result).toEqual({ id: batchFriendlyId, items: [] });
84+
}
85+
);
86+
87+
heteroRunOpsPostgresTest(
88+
"a bare ApiBatchResultsPresenter still returns undefined for a genuinely missing batch",
89+
async ({ prisma14, prisma17 }) => {
90+
testRunStore = makeSplitRouter(prisma14, prisma17);
91+
92+
const presenter = new ApiBatchResultsPresenter();
93+
const result = await presenter.call("batch_does_not_exist", authEnv("env_none"));
94+
95+
expect(result).toBeUndefined();
96+
}
97+
);

0 commit comments

Comments
 (0)