Skip to content

Commit 5282e01

Browse files
committed
fix(webapp): resolve cross-table run parent/root/children in presenters
A v2 run can reference a legacy parent/root, or have legacy children, when a hierarchy straddles a runTableV2 flip. Prisma relation selects are bound to one table, so the run, span, and API-retrieve presenters returned null parent/root and dropped cross-table children. They now resolve parent/root by id (RunStore routes by id format) and children by a both-table predicate, via a shared hydrateParentAndRoot/hydrateChildRuns helper.
1 parent b925f25 commit 5282e01

4 files changed

Lines changed: 117 additions & 44 deletions

File tree

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

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "~/v3/mollifier/readFallback.server";
2424
import { generatePresignedUrl } from "~/v3/objectStore.server";
2525
import { runStore } from "~/v3/runStore.server";
26+
import { hydrateParentAndRoot, hydrateChildRuns } from "~/v3/runHierarchy.server";
2627
import { tracer } from "~/v3/tracer.server";
2728
import { startSpanWithEnv } from "~/v3/tracing.server";
2829

@@ -133,21 +134,28 @@ export class ApiRetrieveRunPresenter {
133134
attemptNumber: true,
134135
engine: true,
135136
taskEventStore: true,
136-
parentTaskRun: {
137-
select: commonRunSelect,
138-
},
139-
rootTaskRun: {
140-
select: commonRunSelect,
141-
},
142-
childRuns: {
143-
select: commonRunSelect,
144-
},
137+
parentTaskRunId: true,
138+
rootTaskRunId: true,
145139
},
146140
},
147141
$replica
148142
);
149143

150-
if (pgRow) return { ...pgRow, isBuffered: false };
144+
if (pgRow) {
145+
// Resolve parent/root/children across both run tables. A single Prisma
146+
// relation select is table-bound, so a v2 run's legacy parent (or a
147+
// legacy run's v2 children), which arise in the mixed window, would come
148+
// back null/empty. Resolve parent/root by id (RunStore routes by format)
149+
// and children by a both-table predicate.
150+
const { parentTaskRun, rootTaskRun } = await hydrateParentAndRoot(
151+
{ parentTaskRunId: pgRow.parentTaskRunId, rootTaskRunId: pgRow.rootTaskRunId },
152+
commonRunSelect,
153+
$replica
154+
);
155+
const childRuns = await hydrateChildRuns(pgRow.id, commonRunSelect, $replica);
156+
157+
return { ...pgRow, parentTaskRun, rootTaskRun, childRuns, isBuffered: false };
158+
}
151159

152160
// Postgres miss → fall back to the mollifier buffer. When the gate
153161
// diverted a trigger, the run lives in Redis until the drainer replays

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

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { isFinalRunStatus } from "~/v3/taskStatus";
99
import { env } from "~/env.server";
1010
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
1111
import { runStore } from "~/v3/runStore.server";
12+
import { hydrateParentAndRoot } from "~/v3/runHierarchy.server";
1213

1314
type Result = Awaited<ReturnType<RunPresenter["call"]>>;
1415
export type Run = Result["run"];
@@ -93,20 +94,8 @@ export class RunPresenter {
9394
completedAt: true,
9495
logsDeletedAt: true,
9596
annotations: true,
96-
rootTaskRun: {
97-
select: {
98-
friendlyId: true,
99-
spanId: true,
100-
createdAt: true,
101-
},
102-
},
103-
parentTaskRun: {
104-
select: {
105-
friendlyId: true,
106-
spanId: true,
107-
createdAt: true,
108-
},
109-
},
97+
rootTaskRunId: true,
98+
parentTaskRunId: true,
11099
runtimeEnvironment: {
111100
select: {
112101
id: true,
@@ -143,6 +132,15 @@ export class RunPresenter {
143132

144133
const showLogs = showDeletedLogs || !run.logsDeletedAt;
145134

135+
// Resolve parent/root across both physical run tables: a v2 run can have a
136+
// legacy parent/root (or vice versa) in the mixed window, which a
137+
// table-bound Prisma relation select would miss.
138+
const { parentTaskRun, rootTaskRun } = await hydrateParentAndRoot(
139+
{ parentTaskRunId: run.parentTaskRunId, rootTaskRunId: run.rootTaskRunId },
140+
{ friendlyId: true, spanId: true, createdAt: true },
141+
this.#prismaClient
142+
);
143+
146144
const runData = {
147145
id: run.id,
148146
number: run.number,
@@ -154,8 +152,8 @@ export class RunPresenter {
154152
startedAt: run.startedAt,
155153
completedAt: run.completedAt,
156154
logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt,
157-
rootTaskRun: run.rootTaskRun,
158-
parentTaskRun: run.parentTaskRun,
155+
rootTaskRun,
156+
parentTaskRun,
159157
environment: {
160158
id: run.runtimeEnvironment.id,
161159
organizationId: run.runtimeEnvironment.organizationId,
@@ -184,7 +182,7 @@ export class RunPresenter {
184182
getTaskEventStoreTableForRun(run),
185183
run.runtimeEnvironment.id,
186184
run.traceId,
187-
run.rootTaskRun?.createdAt ?? run.createdAt,
185+
rootTaskRun?.createdAt ?? run.createdAt,
188186
run.completedAt ?? undefined,
189187
{ includeDebugLogs: showDebug }
190188
);

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

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -587,22 +587,9 @@ export class SpanPresenter extends BasePresenter {
587587
filePath: true,
588588
},
589589
},
590-
//relationships
591-
rootTaskRun: {
592-
select: {
593-
taskIdentifier: true,
594-
friendlyId: true,
595-
spanId: true,
596-
createdAt: true,
597-
},
598-
},
599-
parentTaskRun: {
600-
select: {
601-
taskIdentifier: true,
602-
friendlyId: true,
603-
spanId: true,
604-
},
605-
},
590+
//relationships (resolved across both run tables after the fetch)
591+
rootTaskRunId: true,
592+
parentTaskRunId: true,
606593
batch: {
607594
select: {
608595
friendlyId: true,
@@ -626,7 +613,31 @@ export class SpanPresenter extends BasePresenter {
626613
this._replica
627614
);
628615

629-
return run;
616+
if (!run) {
617+
return run;
618+
}
619+
620+
// Resolve parent/root across both run tables: a v2 run can reference a
621+
// legacy parent/root (or vice versa) in the mixed window, which a
622+
// table-bound Prisma relation select on a single table would miss.
623+
const [parentTaskRun, rootTaskRun] = await Promise.all([
624+
run.parentTaskRunId
625+
? runStore.findRun(
626+
{ id: run.parentTaskRunId },
627+
{ select: { taskIdentifier: true, friendlyId: true, spanId: true } },
628+
this._replica
629+
)
630+
: Promise.resolve(null),
631+
run.rootTaskRunId
632+
? runStore.findRun(
633+
{ id: run.rootTaskRunId },
634+
{ select: { taskIdentifier: true, friendlyId: true, spanId: true, createdAt: true } },
635+
this._replica
636+
)
637+
: Promise.resolve(null),
638+
]);
639+
640+
return { ...run, parentTaskRun, rootTaskRun };
630641
}
631642

632643
async #getSpan({
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { Prisma, PrismaClientOrTransaction, PrismaReplicaClient } from "@trigger.dev/database";
2+
import { runStore } from "~/v3/runStore.server";
3+
4+
type ReadClient = PrismaClientOrTransaction | PrismaReplicaClient;
5+
6+
/**
7+
* Resolve a run's parent and root runs across BOTH physical run tables.
8+
*
9+
* A run's `parentTaskRunId`/`rootTaskRunId` are plain scalar ids whose target
10+
* may live in either `TaskRun` (legacy cuid) or `task_run_v2` (new ksuid) — for
11+
* example a v2 child of a legacy parent, created while the org's `runTableV2`
12+
* flag was mid-flip. A single Prisma relation select (`parentTaskRun { ... }`)
13+
* is bound to one table and silently returns `null` for such a cross-table
14+
* parent/root. Resolving each by id instead lets RunStore route to the correct
15+
* table by id format. Pass the same `select` the caller would have used on the
16+
* relation.
17+
*/
18+
export async function hydrateParentAndRoot<S extends Prisma.TaskRunSelect>(
19+
ids: { parentTaskRunId: string | null; rootTaskRunId: string | null },
20+
select: S,
21+
client?: ReadClient
22+
): Promise<{
23+
parentTaskRun: Prisma.TaskRunGetPayload<{ select: S }> | null;
24+
rootTaskRun: Prisma.TaskRunGetPayload<{ select: S }> | null;
25+
}> {
26+
const [parentTaskRun, rootTaskRun] = await Promise.all([
27+
ids.parentTaskRunId
28+
? runStore.findRun({ id: ids.parentTaskRunId }, { select }, client)
29+
: Promise.resolve(null),
30+
ids.rootTaskRunId
31+
? runStore.findRun({ id: ids.rootTaskRunId }, { select }, client)
32+
: Promise.resolve(null),
33+
]);
34+
35+
return {
36+
parentTaskRun: parentTaskRun as Prisma.TaskRunGetPayload<{ select: S }> | null,
37+
rootTaskRun: rootTaskRun as Prisma.TaskRunGetPayload<{ select: S }> | null,
38+
};
39+
}
40+
41+
/**
42+
* A run's direct child runs across BOTH physical tables. Children reference the
43+
* parent by the scalar `parentTaskRunId`, and a v2 parent can have legacy cuid
44+
* children (or vice versa) in the mixed window, so this is a non-id predicate
45+
* read that `findRuns` resolves against both tables.
46+
*/
47+
export async function hydrateChildRuns<S extends Prisma.TaskRunSelect>(
48+
parentRunId: string,
49+
select: S,
50+
client?: ReadClient
51+
): Promise<Prisma.TaskRunGetPayload<{ select: S }>[]> {
52+
return runStore.findRuns(
53+
{ where: { parentTaskRunId: parentRunId }, select },
54+
client
55+
) as Promise<Prisma.TaskRunGetPayload<{ select: S }>[]>;
56+
}

0 commit comments

Comments
 (0)