Skip to content

Commit b4554e7

Browse files
d-csclaude
andcommitted
feat(run-ops): read presenters — de-join control-plane relations + read-through hydration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 277ecea commit b4554e7

37 files changed

Lines changed: 8509 additions & 570 deletions
Lines changed: 188 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,216 @@
11
import { BatchTaskRunExecutionResult } from "@trigger.dev/core/v3";
2+
import { $replica, PrismaClientOrTransaction, PrismaReplicaClient, prisma } from "~/db.server";
23
import { executionResultForTaskRun, TaskRunWithAttempts } from "~/models/taskRun.server";
34
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
4-
import { runStore } from "~/v3/runStore.server";
5+
import { isKnownMigrated as defaultIsKnownMigrated } from "~/v3/runOpsMigration/knownMigratedFilter.server";
6+
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
7+
import { runStore as defaultRunStore } from "~/v3/runStore.server";
58
import { BasePresenter } from "./basePresenter.server";
69

10+
/**
11+
* Run-ops read-through wiring. All optional; absent (or `splitEnabled` falsy) collapses `call` to
12+
* passthrough. `legacyReplica` is a READ REPLICA handle only — there is NO legacy-primary field.
13+
*/
14+
type ApiBatchResultsReadThroughDeps = {
15+
splitEnabled?: boolean;
16+
newClient?: PrismaReplicaClient;
17+
legacyReplica?: PrismaReplicaClient;
18+
isKnownMigrated?: (runId: string) => Promise<boolean>;
19+
isPastRetention?: (runId: string) => boolean;
20+
};
21+
22+
// The TaskRun shape `executionResultForTaskRun` consumes. Shared by both read sites.
23+
const memberRunSelect = {
24+
id: true,
25+
friendlyId: true,
26+
status: true,
27+
taskIdentifier: true,
28+
attempts: {
29+
select: {
30+
status: true,
31+
output: true,
32+
outputType: true,
33+
error: true,
34+
},
35+
orderBy: {
36+
createdAt: "desc",
37+
},
38+
},
39+
} as const;
40+
41+
/**
42+
* Split on: the batch row + its item rows resolve new-run-ops first, then the LEGACY RUN-OPS
43+
* READ REPLICA ONLY (never the legacy primary — there is no such handle); each member run is
44+
* hydrated independently via readThroughRun keyed on the member runId, so a batch whose members
45+
* span migrated + abandoned runs returns the complete reachable set (the batch-spanning-the-line
46+
* read; the dangling-reference termination gate is a separate, adjacent unit).
47+
*
48+
* Split off (single-DB / self-host): one passthrough read for the batch row + a single store
49+
* id-set hydrate for the members — no legacy read, no known-migrated probe, no second connection.
50+
*/
751
export class ApiBatchResultsPresenter extends BasePresenter {
52+
constructor(
53+
prismaClient: PrismaClientOrTransaction = prisma,
54+
replicaClient: PrismaClientOrTransaction = $replica,
55+
private readonly readThrough?: ApiBatchResultsReadThroughDeps,
56+
private readonly runStore = defaultRunStore
57+
) {
58+
super(prismaClient, replicaClient);
59+
}
60+
861
public async call(
962
friendlyId: string,
1063
env: AuthenticatedEnvironment
1164
): Promise<BatchTaskRunExecutionResult | undefined> {
1265
return this.traceWithEnv("call", env, async (span) => {
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(
66+
const splitEnabled = this.readThrough?.splitEnabled ?? false;
67+
68+
if (!splitEnabled) {
69+
return this.#callPassthrough(friendlyId, env);
70+
}
71+
72+
return this.#callSplit(friendlyId, env);
73+
});
74+
}
75+
76+
// Passthrough: batch row off the replica, members via the single run store. No legacy read.
77+
async #callPassthrough(
78+
friendlyId: string,
79+
env: AuthenticatedEnvironment
80+
): Promise<BatchTaskRunExecutionResult | undefined> {
81+
const batchRun = await this._replica.batchTaskRun.findFirst({
82+
where: {
1683
friendlyId,
17-
env.id,
18-
{
19-
include: {
20-
items: {
21-
select: {
22-
taskRunId: true,
23-
},
24-
},
84+
runtimeEnvironmentId: env.id,
85+
},
86+
include: {
87+
items: {
88+
select: {
89+
taskRunId: true,
2590
},
2691
},
27-
this._prisma
28-
);
92+
},
93+
});
2994

30-
if (!batchRun) {
31-
return undefined;
32-
}
95+
if (!batchRun) {
96+
return undefined;
97+
}
3398

34-
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
99+
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
35100

36-
if (taskRunIds.length === 0) {
37-
return {
38-
id: batchRun.friendlyId,
39-
items: [],
40-
};
41-
}
101+
if (taskRunIds.length === 0) {
102+
return {
103+
id: batchRun.friendlyId,
104+
items: [],
105+
};
106+
}
42107

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

67-
const runMap = new Map(taskRuns.map((run) => [run.id, run]));
163+
if (!batchRun) {
164+
return undefined;
165+
}
68166

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

0 commit comments

Comments
 (0)