Skip to content

Commit c60dd3b

Browse files
committed
fix(run-store): bound connectedRuns relation hydrator on the dedicated schema
The dedicated-schema hydrator for a waitpoint's connectedRuns relation fetched an unbounded list of connections and full run rows. Bound it per parent with a window function so a waitpoint with many connections cannot pull a large result set for a display-only field.
1 parent ce00de1 commit c60dd3b

3 files changed

Lines changed: 183 additions & 14 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// RED→GREEN for bounding the DISPLAY `connectedRuns` RELATION hydration (findWaitpoint /
2+
// findManyWaitpoints with `include`/`select: { connectedRuns }`), NOT the id-list helper
3+
// `findWaitpointConnectedRunIds` (covered separately in connectedRunsBounded.test.ts).
4+
//
5+
// The dedicated-schema hydrator `hydrateConnectedRuns` goes through the generic
6+
// `batchHydrateJoinRelation`, which fetched EVERY WaitpointRunConnection link with no per-parent
7+
// cap — so a heavily-fanned-in waitpoint hydrated an unbounded connectedRuns list, bypassing the
8+
// CONNECTED_RUNS_LIMIT the presenter/`findWaitpointConnectedRunIds` already enforce.
9+
//
10+
// The fix bounds it per parent via a window function + existence-JOIN to TaskRun, mirroring the
11+
// bounded helper: a dangling connection row never occupies a LIMIT slot, and the returned relation
12+
// is capped at CONNECTED_RUNS_LIMIT.
13+
14+
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
15+
import { describe, expect } from "vitest";
16+
import { CONNECTED_RUNS_LIMIT, PostgresRunStore } from "./PostgresRunStore.js";
17+
18+
function seedEnvironmentDedicated(suffix: string) {
19+
return {
20+
organization: { id: `org_${suffix}` },
21+
project: { id: `proj_${suffix}` },
22+
environment: { id: `env_${suffix}` },
23+
};
24+
}
25+
26+
function taskRunData(opts: {
27+
id: string;
28+
friendlyId: string;
29+
organizationId: string;
30+
projectId: string;
31+
runtimeEnvironmentId: string;
32+
}) {
33+
return {
34+
id: opts.id,
35+
engine: "V2" as const,
36+
status: "PENDING" as const,
37+
friendlyId: opts.friendlyId,
38+
runtimeEnvironmentId: opts.runtimeEnvironmentId,
39+
environmentType: "DEVELOPMENT" as const,
40+
organizationId: opts.organizationId,
41+
projectId: opts.projectId,
42+
taskIdentifier: "my-task",
43+
payload: "{}",
44+
payloadType: "application/json",
45+
traceContext: {},
46+
traceId: `trace_${opts.id}`,
47+
spanId: `span_${opts.id}`,
48+
queue: "task/my-task",
49+
isTest: false,
50+
taskEventStore: "taskEvent",
51+
depth: 0,
52+
};
53+
}
54+
55+
describe("PostgresRunStore.findWaitpoint — connectedRuns relation is bounded", () => {
56+
heteroRunOpsPostgresTest(
57+
"dedicated schema: include connectedRuns caps at CONNECTED_RUNS_LIMIT and skips dangling connections",
58+
async ({ prisma17 }) => {
59+
const store = new PostgresRunStore({
60+
prisma: prisma17 as never,
61+
readOnlyPrisma: prisma17 as never,
62+
schemaVariant: "dedicated",
63+
});
64+
65+
const env = seedEnvironmentDedicated("cr_incl_new");
66+
const waitpointId = "waitpoint_cr_incl_new";
67+
await prisma17.waitpoint.create({
68+
data: {
69+
id: waitpointId,
70+
friendlyId: "wp_cr_incl_new",
71+
type: "MANUAL",
72+
status: "PENDING",
73+
idempotencyKey: `idem_${waitpointId}`,
74+
userProvidedIdempotencyKey: false,
75+
projectId: env.project.id,
76+
environmentId: env.environment.id,
77+
},
78+
});
79+
80+
// Dangling connections (taskRunId points at runs that were never created). Legal on the
81+
// dedicated schema — WaitpointRunConnection.taskRunId is a scalar with no FK.
82+
const danglingRunIds = Array.from({ length: 20 }, (_, i) => `run_cr_dangling_${i}`);
83+
for (const runId of danglingRunIds) {
84+
await store.blockRunWithWaitpointEdges({
85+
runId,
86+
waitpointIds: [waitpointId],
87+
projectId: env.project.id,
88+
});
89+
}
90+
91+
// More REAL connected runs than the limit.
92+
const realRunIds = Array.from({ length: CONNECTED_RUNS_LIMIT + 3 }, (_, i) => `run_cr_real_${i}`);
93+
for (const [i, id] of realRunIds.entries()) {
94+
await prisma17.taskRun.create({
95+
data: taskRunData({
96+
id,
97+
friendlyId: `run_cr_incl_new_${i}`,
98+
organizationId: env.organization.id,
99+
projectId: env.project.id,
100+
runtimeEnvironmentId: env.environment.id,
101+
}),
102+
});
103+
await store.blockRunWithWaitpointEdges({
104+
runId: id,
105+
waitpointIds: [waitpointId],
106+
projectId: env.project.id,
107+
});
108+
}
109+
110+
const waitpoint = (await store.findWaitpoint({
111+
where: { id: waitpointId },
112+
include: { connectedRuns: true },
113+
})) as unknown as { connectedRuns: { id: string }[] } | null;
114+
115+
expect(waitpoint).not.toBeNull();
116+
const connectedRuns = waitpoint!.connectedRuns;
117+
118+
// Bounded.
119+
expect(connectedRuns.length).toBeLessThanOrEqual(CONNECTED_RUNS_LIMIT);
120+
// Never a dangling id — every returned run must be a REAL run.
121+
for (const run of connectedRuns) {
122+
expect(realRunIds).toContain(run.id);
123+
expect(danglingRunIds).not.toContain(run.id);
124+
}
125+
}
126+
);
127+
});

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ type CallCounts = { findMany: number; findFirst: number };
1919
function countingClient(
2020
real: RunOpsPrismaClient,
2121
delegateNames: string[]
22-
): { client: RunOpsPrismaClient; counts: Record<string, CallCounts> } {
22+
): { client: RunOpsPrismaClient; counts: Record<string, CallCounts>; queryRaw: { count: number } } {
2323
const counts: Record<string, CallCounts> = {};
2424
for (const name of delegateNames) {
2525
counts[name] = { findMany: 0, findFirst: 0 };
2626
}
27+
const queryRaw = { count: 0 };
2728
const wrapped = new Map(
2829
delegateNames.map((name) => [
2930
name,
@@ -42,10 +43,17 @@ function countingClient(
4243
if (typeof prop === "string" && wrapped.has(prop)) {
4344
return wrapped.get(prop);
4445
}
46+
// Tally the grouped raw query the bounded connectedRuns hydrator issues, delegating unchanged.
47+
if (prop === "$queryRaw") {
48+
return (...args: unknown[]) => {
49+
queryRaw.count++;
50+
return (target as any).$queryRaw(...args);
51+
};
52+
}
4553
return (target as any)[prop];
4654
},
4755
}) as RunOpsPrismaClient;
48-
return { client, counts };
56+
return { client, counts, queryRaw };
4957
}
5058

5159
function seedEnvironmentDedicated(suffix: string) {
@@ -219,8 +227,11 @@ describe("PostgresRunStore dedicated relation hydrators — grouped batch reads"
219227
expect(connected[0].id).toBe(runIds[i]);
220228
}
221229

222-
// GROUPED: one join query + one target query for the WHOLE batch, never one pair per parent.
223-
expect(counting.counts.waitpointRunConnection.findMany).toBe(1);
230+
// GROUPED: one bounded join query (raw, ROW_NUMBER-per-parent) + one target query for the
231+
// WHOLE batch, never one pair per parent. The bounded hydrator replaces the delegate
232+
// `waitpointRunConnection.findMany` with a single `$queryRaw`.
233+
expect(counting.queryRaw.count).toBe(1);
234+
expect(counting.counts.waitpointRunConnection.findMany).toBe(0);
224235
expect(counting.counts.taskRun.findMany).toBe(1);
225236
expect(counting.counts.taskRun.findFirst).toBe(0);
226237
}

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

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -336,16 +336,47 @@ const hydrateBlockingTaskRuns: DedicatedRelationHydrator = async (client, parent
336336
return byParent;
337337
};
338338

339-
// Display connections for a waitpoint: WaitpointRunConnection → TaskRun rows.
340-
const hydrateConnectedRuns: DedicatedRelationHydrator = async (client, parents, projection) =>
341-
batchHydrateJoinRelation(
342-
client.waitpointRunConnection,
343-
client.taskRun,
344-
parents.map((p) => p.id as string),
345-
"waitpointId",
346-
"taskRunId",
347-
projection
348-
);
339+
// Display connections for a waitpoint: WaitpointRunConnection → TaskRun rows. Bounded per parent to
340+
// CONNECTED_RUNS_LIMIT via a window function + existence-JOIN to TaskRun, mirroring the id-list
341+
// helper findWaitpointConnectedRunIds: a dangling (run-less) connection row never occupies a LIMIT
342+
// slot, and a heavily-fanned-in waitpoint never hydrates an unbounded connectedRuns list. This is
343+
// the DISPLAY relation only — functional blocking reads (hydrateBlockingTaskRuns) stay uncapped.
344+
const hydrateConnectedRuns: DedicatedRelationHydrator = async (client, parents, projection) => {
345+
const parentIds = parents.map((p) => p.id as string);
346+
const byParent = new Map<string, unknown[]>(parentIds.map((id) => [id, []]));
347+
if (parentIds.length === 0) {
348+
return byParent;
349+
}
350+
// One grouped query for the bounded edges across every parent: ROW_NUMBER partitioned per
351+
// waitpoint keeps at most CONNECTED_RUNS_LIMIT rows per parent (uses @@index([waitpointId])).
352+
const links = (await client.$queryRaw`
353+
SELECT ranked."waitpointId" AS "waitpointId", ranked."taskRunId" AS "taskRunId"
354+
FROM (
355+
SELECT c."waitpointId", c."taskRunId",
356+
ROW_NUMBER() OVER (PARTITION BY c."waitpointId" ORDER BY c."id") AS rn
357+
FROM "WaitpointRunConnection" c
358+
JOIN "TaskRun" t ON t."id" = c."taskRunId"
359+
WHERE c."waitpointId" = ANY(${parentIds}::text[])
360+
) ranked
361+
WHERE ranked.rn <= ${CONNECTED_RUNS_LIMIT}
362+
`) as { waitpointId: string; taskRunId: string }[];
363+
if (links.length === 0) {
364+
return byParent;
365+
}
366+
const targetIds = [...new Set(links.map((l) => l.taskRunId))];
367+
const rows = (await client.taskRun.findMany(
368+
targetFindManyArgs({ id: { in: targetIds } }, projection, ["id"])
369+
)) as Record<string, unknown>[];
370+
const byTargetId = new Map(rows.map((r) => [r.id as string, r]));
371+
for (const link of links) {
372+
const target = byTargetId.get(link.taskRunId);
373+
const bucket = byParent.get(link.waitpointId);
374+
if (target && bucket) {
375+
bucket.push(applyProjection(target, projection));
376+
}
377+
}
378+
return byParent;
379+
};
349380

350381
// Snapshots that completed a waitpoint: CompletedWaitpoint join → TaskRunExecutionSnapshot rows.
351382
const hydrateCompletedExecutionSnapshots: DedicatedRelationHydrator = async (

0 commit comments

Comments
 (0)