Skip to content

Commit 0a51341

Browse files
authored
feat(run-ops): read presenters — de-join control-plane relations + read-through hydration (#4122)
1 parent 5be6a4f commit 0a51341

40 files changed

Lines changed: 8522 additions & 668 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Route dashboard and API run/batch/waitpoint presenter reads through the run store so they can be served from a separate backing store without changing call sites.
Lines changed: 190 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,219 @@
11
import type { BatchTaskRunExecutionResult } from "@trigger.dev/core/v3";
2+
import {
3+
$replica,
4+
type PrismaClientOrTransaction,
5+
type PrismaReplicaClient,
6+
prisma,
7+
} from "~/db.server";
28
import type { TaskRunWithAttempts } from "~/models/taskRun.server";
39
import { executionResultForTaskRun } from "~/models/taskRun.server";
410
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
5-
import { runStore } from "~/v3/runStore.server";
11+
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
12+
import { runStore as defaultRunStore } from "~/v3/runStore.server";
613
import { BasePresenter } from "./basePresenter.server";
714

15+
/**
16+
* Run-ops read-through wiring. All optional; absent (or `splitEnabled` falsy) collapses `call` to
17+
* passthrough. `legacyReplica` is a READ REPLICA handle only — there is NO legacy-primary field.
18+
*/
19+
type ApiBatchResultsReadThroughDeps = {
20+
splitEnabled?: boolean;
21+
newClient?: PrismaReplicaClient;
22+
legacyReplica?: PrismaReplicaClient;
23+
isPastRetention?: (runId: string) => boolean;
24+
};
25+
26+
// The TaskRun shape `executionResultForTaskRun` consumes. Shared by both read sites.
27+
const memberRunSelect = {
28+
id: true,
29+
friendlyId: true,
30+
status: true,
31+
taskIdentifier: true,
32+
attempts: {
33+
select: {
34+
status: true,
35+
output: true,
36+
outputType: true,
37+
error: true,
38+
},
39+
orderBy: {
40+
createdAt: "desc",
41+
},
42+
},
43+
} as const;
44+
45+
/**
46+
* Split on: the batch row + its item rows resolve new-run-ops first, then the LEGACY RUN-OPS
47+
* READ REPLICA ONLY (never the legacy primary — there is no such handle); each member run is
48+
* hydrated independently via readThroughRun keyed on the member runId, so a batch whose members
49+
* span migrated + abandoned runs returns the complete reachable set (the batch-spanning-the-line
50+
* read; the dangling-reference termination gate is a separate, adjacent unit).
51+
*
52+
* Split off (single-DB / self-host): one passthrough read for the batch row + a single store
53+
* id-set hydrate for the members — no legacy read, no known-migrated probe, no second connection.
54+
*/
855
export class ApiBatchResultsPresenter extends BasePresenter {
56+
constructor(
57+
prismaClient: PrismaClientOrTransaction = prisma,
58+
replicaClient: PrismaClientOrTransaction = $replica,
59+
private readonly readThrough?: ApiBatchResultsReadThroughDeps,
60+
private readonly runStore = defaultRunStore
61+
) {
62+
super(prismaClient, replicaClient);
63+
}
64+
965
public async call(
1066
friendlyId: string,
1167
env: AuthenticatedEnvironment
1268
): Promise<BatchTaskRunExecutionResult | undefined> {
1369
return this.traceWithEnv("call", env, async (span) => {
14-
// Route through the store so a NEW-resident batch resolves under the run-ops split (the
15-
// router probes NEW→LEGACY and drops this client hint) instead of 404ing on a control-plane read.
16-
const batchRun = await runStore.findBatchTaskRunByFriendlyId(
70+
const splitEnabled = this.readThrough?.splitEnabled ?? false;
71+
72+
if (!splitEnabled) {
73+
return this.#callPassthrough(friendlyId, env);
74+
}
75+
76+
return this.#callSplit(friendlyId, env);
77+
});
78+
}
79+
80+
// Passthrough: batch row off the replica, members via the single run store. No legacy read.
81+
async #callPassthrough(
82+
friendlyId: string,
83+
env: AuthenticatedEnvironment
84+
): Promise<BatchTaskRunExecutionResult | undefined> {
85+
const batchRun = await this._replica.batchTaskRun.findFirst({
86+
where: {
1787
friendlyId,
18-
env.id,
19-
{
20-
include: {
21-
items: {
22-
select: {
23-
taskRunId: true,
24-
},
25-
},
88+
runtimeEnvironmentId: env.id,
89+
},
90+
include: {
91+
items: {
92+
select: {
93+
taskRunId: true,
2694
},
2795
},
28-
this._prisma
29-
);
96+
},
97+
});
3098

31-
if (!batchRun) {
32-
return undefined;
33-
}
99+
if (!batchRun) {
100+
return undefined;
101+
}
34102

35-
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
103+
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
36104

37-
if (taskRunIds.length === 0) {
38-
return {
39-
id: batchRun.friendlyId,
40-
items: [],
41-
};
42-
}
105+
if (taskRunIds.length === 0) {
106+
return {
107+
id: batchRun.friendlyId,
108+
items: [],
109+
};
110+
}
43111

44-
const taskRuns = await runStore.findRuns(
45-
{
46-
where: { id: { in: taskRunIds } },
47-
select: {
48-
id: true,
49-
friendlyId: true,
50-
status: true,
51-
taskIdentifier: true,
52-
attempts: {
53-
select: {
54-
status: true,
55-
output: true,
56-
outputType: true,
57-
error: true,
58-
},
59-
orderBy: {
60-
createdAt: "desc",
61-
},
112+
const taskRuns = await this.runStore.findRuns(
113+
{
114+
where: { id: { in: taskRunIds } },
115+
select: memberRunSelect,
116+
},
117+
this._prisma
118+
);
119+
120+
const runMap = new Map(taskRuns.map((run) => [run.id, run]));
121+
122+
return {
123+
id: batchRun.friendlyId,
124+
items: batchRun.items
125+
.map((item) => {
126+
const run = runMap.get(item.taskRunId);
127+
return run ? executionResultForTaskRun(run as TaskRunWithAttempts) : undefined;
128+
})
129+
.filter(Boolean),
130+
};
131+
}
132+
133+
// Split: resolve the batch row new-first then off the legacy READ REPLICA only (a batch id may
134+
// be cuid or ksuid, and a cuid-shaped id can still have been backfilled onto NEW, so id-shape
135+
// residency is not authoritative for the row — the new-first-then-legacy probe is), then
136+
// hydrate every member run independently via the per-run read-through primitive.
137+
async #callSplit(
138+
friendlyId: string,
139+
env: AuthenticatedEnvironment
140+
): Promise<BatchTaskRunExecutionResult | undefined> {
141+
// Resolve both handles ONCE so the batch row and its members never read from different DBs.
142+
const newClient = (this.readThrough?.newClient ?? this._replica) as PrismaReplicaClient;
143+
const legacyReplica = (this.readThrough?.legacyReplica ?? this._replica) as PrismaReplicaClient;
144+
145+
const readBatch = (client: PrismaClientOrTransaction) =>
146+
client.batchTaskRun.findFirst({
147+
where: {
148+
friendlyId,
149+
runtimeEnvironmentId: env.id,
150+
},
151+
include: {
152+
items: {
153+
select: {
154+
taskRunId: true,
62155
},
63156
},
64157
},
65-
this._prisma
66-
);
158+
});
159+
160+
let batchRun = await readBatch(newClient);
161+
162+
// Legacy READ REPLICA probe, only on a new-probe miss; skipped when past retention.
163+
if (!batchRun && !this.readThrough?.isPastRetention?.(friendlyId)) {
164+
batchRun = await readBatch(legacyReplica);
165+
}
67166

68-
const runMap = new Map(taskRuns.map((run) => [run.id, run]));
167+
if (!batchRun) {
168+
return undefined;
169+
}
69170

171+
if (batchRun.items.length === 0) {
70172
return {
71173
id: batchRun.friendlyId,
72-
items: batchRun.items
73-
.map((item) => {
74-
const run = runMap.get(item.taskRunId);
75-
return run ? executionResultForTaskRun(run as TaskRunWithAttempts) : undefined;
76-
})
77-
.filter(Boolean),
174+
items: [],
78175
};
79-
});
176+
}
177+
178+
const readMemberRun = (client: PrismaClientOrTransaction, taskRunId: string) =>
179+
client.taskRun.findFirst({
180+
where: { id: taskRunId },
181+
select: memberRunSelect,
182+
}) as Promise<TaskRunWithAttempts | null>;
183+
184+
// Per-member fan-out: each member may live on a different DB, so a single nested include cannot
185+
// cross the seam. Promise.all preserves batchRun.items order, unchanged from today.
186+
const memberResults = await Promise.all(
187+
batchRun.items.map(async (item) => {
188+
const result = await readThroughRun<TaskRunWithAttempts>({
189+
runId: item.taskRunId,
190+
environmentId: env.id,
191+
readNew: (client) => readMemberRun(client, item.taskRunId),
192+
readLegacy: (replica) => readMemberRun(replica, item.taskRunId),
193+
deps: {
194+
splitEnabled: true,
195+
// Pass the SAME resolved handles the batch row used, so the batch row and its members
196+
// never resolve against different DBs. (Letting these fall through to readThroughRun's
197+
// own module-level defaults would diverge from the batch read's `?? this._replica`.)
198+
newClient,
199+
legacyReplica,
200+
isPastRetention: this.readThrough?.isPastRetention,
201+
},
202+
});
203+
204+
// not-found / past-retention members are omitted (matches today's drop-undefined behavior);
205+
// the dangling-reference termination gate (separate unit) governs whether that's permitted.
206+
if (result.source === "not-found" || result.source === "past-retention") {
207+
return undefined;
208+
}
209+
210+
return executionResultForTaskRun(result.value);
211+
})
212+
);
213+
214+
return {
215+
id: batchRun.friendlyId,
216+
items: memberResults.filter(Boolean),
217+
};
80218
}
81219
}

0 commit comments

Comments
 (0)