Skip to content

Commit 7db8e46

Browse files
committed
refactor(webapp): read waitpoint connected runs via ORM instead of raw SQL
Replaces the raw connected-runs query, which JOINed the waitpoint connection table onto the large TaskRun table, with two indexed ORM reads joined in memory: read the connection rows, then resolve the runs by id. The id lookup can only plan as a primary-key lookup, so the query planner never scans TaskRun, and Prisma qualifies the schema per client so the previous manual schema handling is gone.
1 parent 9c10e14 commit 7db8e46

4 files changed

Lines changed: 107 additions & 318 deletions

File tree

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

Lines changed: 56 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v3";
2-
import {
3-
DATABASE_SCHEMA,
4-
type PrismaClientOrTransaction,
5-
type PrismaReplicaClient,
6-
} from "~/db.server";
2+
import { type PrismaClientOrTransaction, type PrismaReplicaClient } from "~/db.server";
73
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
84
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
95
import { logger } from "~/services/logger.server";
@@ -15,10 +11,12 @@ import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
1511

1612
export type WaitpointDetail = NonNullable<Awaited<ReturnType<WaitpointPresenter["call"]>>>;
1713

18-
// Single-sourced bound for connected run friendlyIds: applied at the FETCH in #connectedRunIdsOn,
19-
// not just at display time.
14+
// Single-sourced display bound for a waitpoint's connected run friendlyIds.
2015
export const CONNECTED_RUNS_DISPLAY_LIMIT = 5;
2116

17+
// Over-read connection rows on the FK-free dedicated join so danglers don't cost a display slot.
18+
export const CONNECTED_RUNS_CONNECTION_SCAN_LIMIT = CONNECTED_RUNS_DISPLAY_LIMIT * 5;
19+
2220
export class WaitpointPresenter extends BasePresenter {
2321
constructor(
2422
prisma?: PrismaClientOrTransaction,
@@ -84,8 +82,7 @@ export class WaitpointPresenter extends BasePresenter {
8482

8583
// Connected-run friendlyIds gathered across BOTH stores. The run<->waitpoint join co-locates with
8684
// the RUN (written on the run's DB), so the waitpoint's own store misses a cross-DB connection; we
87-
// read the join on each client and resolve the run's friendlyId on that same client, then union.
88-
// We never relation-select `connectedRuns`: it is not a field on the dedicated subset `Waitpoint`.
85+
// read the join on each client, resolve the run's friendlyId on that same client, and union.
8986
async #connectedRunFriendlyIds(waitpointId: string): Promise<string[]> {
9087
const replica = this._replica as unknown as PrismaReplicaClient;
9188
const rawClients: PrismaReplicaClient[] =
@@ -99,17 +96,8 @@ export class WaitpointPresenter extends BasePresenter {
9996

10097
const friendlyIds = new Set<string>();
10198
for (const client of clients) {
102-
const runIds = await this.#connectedRunIdsOn(client, waitpointId);
103-
if (runIds.length === 0) {
104-
continue;
105-
}
106-
const runs = await client.taskRun.findMany({
107-
where: { id: { in: runIds } },
108-
select: { friendlyId: true },
109-
take: CONNECTED_RUNS_DISPLAY_LIMIT,
110-
});
111-
for (const run of runs) {
112-
friendlyIds.add(run.friendlyId);
99+
for (const friendlyId of await this.#connectedRunFriendlyIdsOn(client, waitpointId)) {
100+
friendlyIds.add(friendlyId);
113101
}
114102
if (friendlyIds.size >= CONNECTED_RUNS_DISPLAY_LIMIT) {
115103
break;
@@ -118,44 +106,56 @@ export class WaitpointPresenter extends BasePresenter {
118106
return Array.from(friendlyIds).slice(0, CONNECTED_RUNS_DISPLAY_LIMIT);
119107
}
120108

121-
// Schema-aware read of the run ids linked to a waitpoint: the dedicated subset uses the explicit
122-
// `WaitpointRunConnection` model (scalar `taskRunId`, no FK -- a row can dangle after its run is
123-
// deleted), the control-plane full schema the implicit `_WaitpointRunConnections` M2M
124-
// (A = TaskRun.id, B = Waitpoint.id). Both branches existence-filter AT THE QUERY via a JOIN to
125-
// TaskRun, so a dangling connection row can never occupy a LIMIT slot ahead of a real one.
126-
// Tables are schema-qualified with DATABASE_SCHEMA (trusted boot constant) so a non-`public`
127-
// schema= deployment resolves the right tables instead of leaning on search_path. $queryRawUnsafe,
128-
// not a `sqlDatabaseSchema` Prisma.Sql fragment: `client` may be the dedicated run-ops client (a
129-
// different Prisma runtime) which would bind a foreign runtime's Sql fragment as a param instead
130-
// of inlining it. waitpointId and the constant limit stay bound params ($1/$2).
131-
async #connectedRunIdsOn(client: PrismaReplicaClient, waitpointId: string): Promise<string[]> {
132-
const isDedicated = Boolean(
133-
(client as unknown as { waitpointRunConnection?: unknown }).waitpointRunConnection
134-
);
135-
136-
if (isDedicated) {
137-
const rows = await client.$queryRawUnsafe<{ taskRunId: string }[]>(
138-
`SELECT c."taskRunId" AS "taskRunId"
139-
FROM ${DATABASE_SCHEMA}."WaitpointRunConnection" c
140-
JOIN ${DATABASE_SCHEMA}."TaskRun" t ON t."id" = c."taskRunId"
141-
WHERE c."waitpointId" = $1
142-
LIMIT $2`,
143-
waitpointId,
144-
CONNECTED_RUNS_DISPLAY_LIMIT
145-
);
146-
return rows.map((row) => row.taskRunId);
109+
// Connected-run friendlyIds for one store, via the ORM. Two indexed reads joined in memory instead
110+
// of a SQL JOIN onto the (very large) TaskRun table: `id IN (...)` can only plan as a PK lookup, so
111+
// the planner can never scan TaskRun.
112+
//
113+
// Dedicated subset: the explicit `WaitpointRunConnection` is scalar (`taskRunId`, no FK), so a
114+
// connection can outlive a deleted run. We over-read connection ids, resolve runs by id (a missing
115+
// id just drops out -- danglers cost no display slot), and cap at the display limit.
116+
//
117+
// Control-plane full schema: no queryable join delegate (implicit M2M), so we traverse the
118+
// `connectedRuns` relation; it cascade-deletes with the run, so no dangler can exist.
119+
async #connectedRunFriendlyIdsOn(
120+
client: PrismaReplicaClient,
121+
waitpointId: string
122+
): Promise<string[]> {
123+
const dedicated = (
124+
client as unknown as {
125+
waitpointRunConnection?: {
126+
findMany: (args: unknown) => Promise<{ taskRunId: string }[]>;
127+
};
128+
}
129+
).waitpointRunConnection;
130+
131+
if (dedicated) {
132+
const connections = await dedicated.findMany({
133+
where: { waitpointId },
134+
select: { taskRunId: true },
135+
take: CONNECTED_RUNS_CONNECTION_SCAN_LIMIT,
136+
});
137+
if (connections.length === 0) {
138+
return [];
139+
}
140+
const runs = await client.taskRun.findMany({
141+
where: { id: { in: connections.map((connection) => connection.taskRunId) } },
142+
select: { friendlyId: true },
143+
take: CONNECTED_RUNS_DISPLAY_LIMIT,
144+
});
145+
return runs.map((run) => run.friendlyId);
147146
}
148147

149-
const rows = await client.$queryRawUnsafe<{ A: string }[]>(
150-
`SELECT c."A" AS "A"
151-
FROM ${DATABASE_SCHEMA}."_WaitpointRunConnections" c
152-
JOIN ${DATABASE_SCHEMA}."TaskRun" t ON t."id" = c."A"
153-
WHERE c."B" = $1
154-
LIMIT $2`,
155-
waitpointId,
156-
CONNECTED_RUNS_DISPLAY_LIMIT
157-
);
158-
return rows.map((row) => row.A);
148+
const waitpoint = (await (
149+
client.waitpoint.findFirst as (
150+
args: unknown
151+
) => Promise<{ connectedRuns: { friendlyId: string }[] } | null>
152+
)({
153+
where: { id: waitpointId },
154+
select: {
155+
connectedRuns: { select: { friendlyId: true }, take: CONNECTED_RUNS_DISPLAY_LIMIT },
156+
},
157+
})) as { connectedRuns: { friendlyId: string }[] } | null;
158+
return (waitpoint?.connectedRuns ?? []).map((run) => run.friendlyId);
159159
}
160160

161161
public async call({

apps/webapp/test/waitpointPresenter.connectedRunsBounded.test.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
// RED->GREEN guard: WaitpointPresenter#connectedRunIdsOn must BOUND the fetch of a waitpoint's
1+
// RED->GREEN guard: WaitpointPresenter connected-run gather must BOUND the fetch of a waitpoint's
22
// connected-run ids, not just the number displayed. A "displayed count <= 5" assertion would
3-
// false-green on today's buggy code (the existing take:5 already bounds the DISPLAY). Instead
4-
// this captures the real `taskRun.findMany` call args via a Proxy over a REAL testcontainer
5-
// Postgres client (no mocks) and asserts the IN-list itself is bounded.
3+
// false-green (the take:5 on the run resolve already bounds the DISPLAY). Instead this captures the
4+
// real `taskRun.findMany` call args via a Proxy over a REAL testcontainer Postgres client (no mocks)
5+
// and asserts the IN-list is capped at the scan limit (danglers over-read), never unbounded.
66
//
7-
// Exercises the dedicated-schema (Prisma `waitpointRunConnection`) branch of #connectedRunIdsOn:
8-
// waitpoint + 8 connected runs seeded on the NEW dedicated run-ops client (RunOpsPrismaClient,
9-
// prisma17), which uses the delegate directly (no raw SQL fallback).
7+
// Exercises the dedicated-schema (Prisma `waitpointRunConnection`) branch: waitpoint + more than the
8+
// scan-limit connected runs seeded on the NEW dedicated run-ops client (RunOpsPrismaClient, prisma17).
109
import { describe, expect, vi } from "vitest";
1110

1211
const legacyReplicaHolder = vi.hoisted(() => ({ client: undefined as any }));
@@ -66,7 +65,7 @@ import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
6665
import type { PrismaClient } from "@trigger.dev/database";
6766
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
6867
import {
69-
CONNECTED_RUNS_DISPLAY_LIMIT,
68+
CONNECTED_RUNS_CONNECTION_SCAN_LIMIT,
7069
WaitpointPresenter,
7170
} from "~/presenters/v3/WaitpointPresenter.server";
7271

@@ -184,11 +183,13 @@ function capturingTaskRunFindMany(real: RunOpsPrismaClient): {
184183
return { client, calls };
185184
}
186185

187-
const CONNECTED_RUN_COUNT = 8; // > CONNECTED_RUNS_DISPLAY_LIMIT (5), proves the fetch isn't bounded
186+
// Seed MORE than the scan limit so the assertion bites: an unbounded gather IN-lists all of them,
187+
// a correctly bounded one caps at CONNECTED_RUNS_CONNECTION_SCAN_LIMIT.
188+
const CONNECTED_RUN_COUNT = CONNECTED_RUNS_CONNECTION_SCAN_LIMIT + 5;
188189

189-
describe("WaitpointPresenter#connectedRunIdsOn bounds the connected-run-id FETCH", () => {
190+
describe("WaitpointPresenter bounds the connected-run-id FETCH", () => {
190191
heteroRunOpsPostgresTest(
191-
"reading a waitpoint with 8 connected runs never IN-lists more than the display limit",
192+
"a waitpoint with more connections than the scan limit caps the IN-list at the scan limit",
192193
async ({ prisma14, prisma17 }) => {
193194
const ctx = await seedParents(prisma14, "bounded");
194195
const waitpoint = await seedWaitpoint(prisma17, ctx, "waitpoint_bounded");
@@ -220,13 +221,15 @@ describe("WaitpointPresenter#connectedRunIdsOn bounds the connected-run-id FETCH
220221
});
221222

222223
// The guard: assert the FETCH (the IN-list built from the connected-run-id gather) is
223-
// bounded, not just the eventual displayed count. On today's buggy code, the Prisma
224-
// `waitpointRunConnection.findMany` branch of #connectedRunIdsOn returns all 8 connected
225-
// run ids with no take/LIMIT, so this IN-list is 8. After the fix it must be
226-
// <= CONNECTED_RUNS_DISPLAY_LIMIT.
224+
// bounded, not just the eventual displayed count. An unbounded gather would IN-list every
225+
// connection row; the dedicated branch over-reads up to CONNECTED_RUNS_CONNECTION_SCAN_LIMIT
226+
// (25) so a display slot is never lost to a dangler, so the IN-list must be capped at the
227+
// scan limit -- above the display limit (5), but never unbounded.
227228
expect(calls.length).toBeGreaterThan(0);
228229
for (const call of calls) {
229-
expect(call.where?.id?.in?.length ?? 0).toBeLessThanOrEqual(CONNECTED_RUNS_DISPLAY_LIMIT);
230+
expect(call.where?.id?.in?.length ?? 0).toBeLessThanOrEqual(
231+
CONNECTED_RUNS_CONNECTION_SCAN_LIMIT
232+
);
230233
}
231234
}
232235
);

apps/webapp/test/waitpointPresenter.readthrough.test.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,17 @@ import { setupClickhouseReplication } from "./utils/replicationUtils";
6868

6969
vi.setConfig({ testTimeout: 90_000 });
7070

71-
// A read client whose waitpoint.findFirst is recorded; throws if used after being marked
72-
// forbidden, so we can prove a store was NEVER read. Every other access forwards to the real
73-
// client (so the inlined `environment` join + connectedRuns relation still resolve).
71+
// A read client whose waitpoint.findFirst calls are recorded, split by kind. The presenter issues
72+
// two shapes of waitpoint.findFirst: a HYDRATE (loads the waitpoint scalar row -- no `connectedRuns`
73+
// in its select) and a connectedRuns-RELATION read (control-plane branch of the connected-runs
74+
// gather -- select is exactly `{ connectedRuns }`). The `forbidden` guard throws ONLY on a HYDRATE,
75+
// so a store can be proven to never be HYDRATED while still allowing the connectedRuns cross-DB
76+
// union to legitimately read it (that union always touched both stores; it was invisible when the
77+
// old code did it via raw SQL). Every other access forwards to the real client.
7478
function recording(client: PrismaClient, opts: { forbidden?: boolean } = {}) {
7579
const calls: unknown[] = [];
80+
const hydrateCalls: unknown[] = [];
81+
const connectedRunsCalls: unknown[] = [];
7682
return {
7783
handle: new Proxy(client, {
7884
get(target, prop) {
@@ -82,8 +88,16 @@ function recording(client: PrismaClient, opts: { forbidden?: boolean } = {}) {
8288
if (wpProp === "findFirst") {
8389
return (args: unknown) => {
8490
calls.push(args);
85-
if (opts.forbidden) {
86-
throw new Error("this store must never be read");
91+
const select = (args as { select?: Record<string, unknown> })?.select ?? {};
92+
const keys = Object.keys(select);
93+
const isConnectedRunsRead = keys.length === 1 && keys[0] === "connectedRuns";
94+
if (isConnectedRunsRead) {
95+
connectedRunsCalls.push(args);
96+
} else {
97+
hydrateCalls.push(args);
98+
if (opts.forbidden) {
99+
throw new Error("this store must never be hydrated");
100+
}
87101
}
88102
return (wpTarget as any).findFirst(args);
89103
};
@@ -96,6 +110,8 @@ function recording(client: PrismaClient, opts: { forbidden?: boolean } = {}) {
96110
},
97111
}) as unknown as PrismaReplicaClient,
98112
calls,
113+
hydrateCalls,
114+
connectedRunsCalls,
99115
};
100116
}
101117

@@ -255,9 +271,12 @@ describe("WaitpointPresenter dual-DB read-through (hetero PG14 + PG17, no connec
255271
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
256272

257273
expect(result?.id).toBe(seeded.friendlyId);
258-
// New-first short-circuit: legacy never probed (the throwing handle proves it).
259-
expect(newClient.calls.length).toBe(1);
260-
expect(legacy.calls.length).toBe(0);
274+
// New-first short-circuit: the HYDRATE answered from new and never fell through to a legacy
275+
// hydrate (the throwing handle proves it). The connectedRuns cross-DB union still reads legacy
276+
// legitimately -- that read is allowed and must NOT count as a hydrate.
277+
expect(newClient.hydrateCalls.length).toBe(1);
278+
expect(legacy.hydrateCalls.length).toBe(0);
279+
expect(legacy.connectedRunsCalls.length).toBeGreaterThan(0);
261280
}
262281
);
263282

@@ -286,8 +305,11 @@ describe("WaitpointPresenter dual-DB read-through (hetero PG14 + PG17, no connec
286305

287306
expect(result?.id).toBe(seeded.friendlyId);
288307
expect(result?.tags).toEqual(["one"]);
289-
// Exactly one read on the single client; the second handle is never touched.
290-
expect(single.calls.length).toBe(1);
308+
// Two reads on the single client, both on the one handle: the first findFirst hydrates the
309+
// waitpoint, the second loads the `connectedRuns` relation (the implicit M2M has no queryable
310+
// join delegate, so the ORM must traverse the relation with a second findFirst). The second
311+
// handle is never touched -- passthrough is structural, the split branch never fires.
312+
expect(single.calls.length).toBe(2);
291313
expect(second.calls.length).toBe(0);
292314
}
293315
);

0 commit comments

Comments
 (0)