Skip to content

Commit 355e6f4

Browse files
committed
feat(webapp): gen-2 shard arms in read-through
Route read-through by `resolveShard` instead of the binary residency classifier. A gen-2 id takes one read on its own shard's replica and probes no other store; a gen-1 v1 id still reads new only. Callers now declare `idKind`, because a cuid gives no way to tell a run id from a waitpoint id and the two must route differently. A legacy run id reads the legacy replica only, since there is no cuid run migration and the new-store probe cannot find it. A cuid waitpoint keeps the new-first pair probe, which is load-bearing because a cuid waitpoint can be co-located with its run on the new store. `ReadThroughResult` now carries `found` structurally. `source` is an open-ended union once shards exist, so a consumer testing found-ness by listing hit sources would read a gen-2 hit as a miss; discriminating on `found` turns that class of bug into a compile error. An id resolving to an unconfigured shard key logs an error and returns not-found rather than throwing. These ids arrive from callers (a URL param on the waitpoint route) and any base32hex core plus `[a-z0-9]` plus "2" parses as gen-2, so a throw would be a 500 any client could induce by guessing a shard char. Throwing stays correct on the router path, where ids are minted rather than received. The read-your-writes fallback for a gen-2 waitpoint now reads that shard's own writer. The gen-1 new writer is a different database, so reading it would miss and silently disable read-your-writes for the freshly minted token that fallback exists to serve. Inert while RUN_OPS_SHARDS is unset: the shard maps are empty, so every gen-2 arm is unreachable.
1 parent aa2bb68 commit 355e6f4

8 files changed

Lines changed: 459 additions & 71 deletions

apps/webapp/app/runEngine/concerns/resolveWaitpointThroughReadThrough.server.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
1+
import { resolveShard, type ShardKey } from "@trigger.dev/core/v3/isomorphic";
12
import type { PrismaReplicaClient } from "~/db.server";
23
import {
34
runOpsLegacyReplica as defaultLegacyReplica,
45
runOpsNewPrisma as defaultNewPrimary,
56
runOpsNewReplica as defaultNewClient,
67
runOpsSplitReadEnabled as defaultSplitReadEnabled,
78
} from "~/db.server";
9+
import {
10+
runOpsShardReplicas as defaultShardReplicas,
11+
runOpsShardWriters as defaultShardWriters,
12+
} from "~/v3/runOpsMigration/shardHandles.server";
813
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
914

1015
type ResolveWaitpointDeps = {
1116
newClient?: PrismaReplicaClient;
1217
legacyReplica?: PrismaReplicaClient;
1318
newPrimary?: PrismaReplicaClient;
19+
shardReplicas?: ReadonlyMap<ShardKey, PrismaReplicaClient>;
20+
shardWriters?: ReadonlyMap<ShardKey, PrismaReplicaClient>;
1421
splitEnabled?: boolean;
1522
isPastRetention?: (id: string) => boolean;
1623
};
@@ -21,13 +28,17 @@ export type ResolveWaitpointReadThroughDefaults = {
2128
newClient: PrismaReplicaClient;
2229
legacyReplica: PrismaReplicaClient;
2330
newPrimary: PrismaReplicaClient;
31+
shardReplicas: ReadonlyMap<ShardKey, PrismaReplicaClient>;
32+
shardWriters: ReadonlyMap<ShardKey, PrismaReplicaClient>;
2433
splitEnabled: boolean;
2534
};
2635

2736
const productionDefaults: ResolveWaitpointReadThroughDefaults = {
2837
newClient: defaultNewClient,
2938
legacyReplica: defaultLegacyReplica,
3039
newPrimary: defaultNewPrimary as unknown as PrismaReplicaClient,
40+
shardReplicas: defaultShardReplicas,
41+
shardWriters: defaultShardWriters as unknown as ReadonlyMap<ShardKey, PrismaReplicaClient>,
3142
splitEnabled: defaultSplitReadEnabled,
3243
};
3344

@@ -43,30 +54,40 @@ export async function resolveWaitpointThroughReadThrough<T>(opts: {
4354
const splitEnabled = opts.deps?.splitEnabled ?? defaults.splitEnabled;
4455

4556
const result = await readThroughRun({
46-
runId: opts.waitpointId,
57+
id: opts.waitpointId,
58+
idKind: "waitpoint",
4759
environmentId: opts.environmentId,
4860
readNew: (client) => opts.read(client),
4961
readLegacy: (replica) => opts.read(replica),
5062
deps: {
5163
splitEnabled,
5264
newClient: opts.deps?.newClient ?? defaults.newClient,
5365
legacyReplica: opts.deps?.legacyReplica ?? defaults.legacyReplica,
66+
shardReplicas: opts.deps?.shardReplicas ?? defaults.shardReplicas,
5467
isPastRetention: opts.deps?.isPastRetention,
5568
},
5669
});
5770

58-
if (result.source === "new" || result.source === "legacy-replica") {
71+
if (result.found) {
5972
return result.value;
6073
}
6174
// past-retention is an intentional not-found: the token is gone.
62-
if (result.source === "past-retention") {
75+
if (result.reason === "past-retention") {
6376
return null;
6477
}
6578

6679
// Read-your-writes fallback for a token completed immediately after mint, before it replicated:
67-
// re-read from the run-ops PRIMARY only. We deliberately never read the control-plane/legacy
80+
// re-read from the owning store's PRIMARY only. We deliberately never read the control-plane/legacy
6881
// primary here (that is the load the replica-only read-through exists to shed), so a legacy-resident
6982
// token that misses its replica stays a miss and the caller retries, rather than adding primary load.
83+
const shardKey = resolveShard(opts.waitpointId);
84+
if (shardKey !== "new" && shardKey !== "legacy") {
85+
// A gen-2 token's primary is its OWN shard's writer. The gen-1 new writer is a different
86+
// database, so reading it would miss and silently disable read-your-writes here.
87+
const shardWriter = (opts.deps?.shardWriters ?? defaults.shardWriters).get(shardKey);
88+
return shardWriter ? await opts.read(shardWriter) : null;
89+
}
90+
7091
const fromNewPrimary = await opts.read(opts.deps?.newPrimary ?? defaults.newPrimary);
7192
if (fromNewPrimary != null) {
7293
return fromNewPrimary;

apps/webapp/app/v3/runEngineHandlersShared.server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export async function readRunForEvent<S extends Prisma.TaskRunSelect>(
3535
deps: EventReadDeps
3636
): Promise<Prisma.TaskRunGetPayload<{ select: S }> | null> {
3737
const result = await readThroughRun<Prisma.TaskRunGetPayload<{ select: S }>>({
38-
runId,
38+
id: runId,
39+
idKind: "run",
3940
environmentId,
4041
readNew: (client) => deps.store.findRun({ id: runId }, { select }, client),
4142
readLegacy: (replica) => deps.store.findRun({ id: runId }, { select }, replica),
@@ -47,7 +48,7 @@ export async function readRunForEvent<S extends Prisma.TaskRunSelect>(
4748
},
4849
});
4950

50-
return result.source === "not-found" || result.source === "past-retention" ? null : result.value;
51+
return result.found ? result.value : null;
5152
}
5253

5354
/**

apps/webapp/app/v3/runOpsMigration/readThrough.server.test.ts

Lines changed: 175 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ vi.setConfig({ testTimeout: 60_000 });
1313
// 25-char cuid body → LEGACY residency. 26-char v1 body (version "1" at index 25) → NEW residency.
1414
const LEGACY_RUN_ID = "run_" + "a".repeat(25);
1515
const NEW_RUN_ID = "run_" + "b".repeat(24) + "01";
16+
// 26-char gen-2 body: shard char at index 24, version "2" at index 25.
17+
const SHARD_A_RUN_ID = "run_" + "c".repeat(24) + "a2";
18+
const SHARD_Z_RUN_ID = "run_" + "c".repeat(24) + "z2";
19+
const LEGACY_WAITPOINT_ID = "waitpoint_" + "d".repeat(25);
20+
21+
function throwingClient(label: string) {
22+
return vi.fn(async (): Promise<{ marker: number } | null> => {
23+
throw new Error(`${label} must never be read`);
24+
});
25+
}
26+
27+
function collectingLogger() {
28+
const errors: { message: string; meta?: unknown }[] = [];
29+
return { errors, error: (message: string, meta?: unknown) => errors.push({ message, meta }) };
30+
}
1631

1732
// Lightweight real read: a trivial `$queryRaw` that genuinely hits the given container.
1833
// `hit` controls whether the read "finds" the run, so we exercise routing without
@@ -28,14 +43,7 @@ async function realRead(
2843
// A presenter-shaped mapping: both "not-found" and "past-retention" collapse to the
2944
// same 404-ish surface, so an old run after termination yields the normal response.
3045
function toHttpish<T>(result: ReadThroughResult<T>): { status: number; value?: T } {
31-
switch (result.source) {
32-
case "new":
33-
case "legacy-replica":
34-
return { status: 200, value: result.value };
35-
case "not-found":
36-
case "past-retention":
37-
return { status: 404 };
38-
}
46+
return result.found ? { status: 200, value: result.value } : { status: 404 };
3947
}
4048

4149
describe("readThroughRun (legacy replica + new DB)", () => {
@@ -46,7 +54,8 @@ describe("readThroughRun (legacy replica + new DB)", () => {
4654
// read resolving through `legacyReplica` (prisma14) IS the structural guarantee
4755
// that the primary is never touched.
4856
const result = await readThroughRun({
49-
runId: LEGACY_RUN_ID,
57+
id: LEGACY_RUN_ID,
58+
idKind: "run",
5059
environmentId: "env_1",
5160
readNew: (c) => realRead(c, false),
5261
readLegacy: (c) => realRead(c, true),
@@ -57,7 +66,7 @@ describe("readThroughRun (legacy replica + new DB)", () => {
5766
},
5867
});
5968

60-
expect(result.source).toBe("legacy-replica");
69+
expect(result.found && result.source).toBe("legacy-replica");
6170
expect(toHttpish(result).status).toBe(200);
6271
}
6372
);
@@ -66,7 +75,8 @@ describe("readThroughRun (legacy replica + new DB)", () => {
6675
"post-termination past-retention returns the normal not-found surface",
6776
async ({ prisma14, prisma17 }) => {
6877
const pastRetentionResult = await readThroughRun({
69-
runId: LEGACY_RUN_ID,
78+
id: LEGACY_RUN_ID,
79+
idKind: "run",
7080
environmentId: "env_1",
7181
readNew: (c) => realRead(c, false),
7282
readLegacy: (c) => realRead(c, false), // legacy gone / retention elapsed
@@ -78,11 +88,12 @@ describe("readThroughRun (legacy replica + new DB)", () => {
7888
},
7989
});
8090

81-
expect(pastRetentionResult.source).toBe("past-retention");
91+
expect(pastRetentionResult.found === false && pastRetentionResult.reason).toBe("past-retention");
8292

8393
// A run that is simply absent (not past retention) yields not-found.
8494
const notFoundResult = await readThroughRun({
85-
runId: LEGACY_RUN_ID,
95+
id: LEGACY_RUN_ID,
96+
idKind: "run",
8697
environmentId: "env_1",
8798
readNew: (c) => realRead(c, false),
8899
readLegacy: (c) => realRead(c, false),
@@ -94,7 +105,7 @@ describe("readThroughRun (legacy replica + new DB)", () => {
94105
},
95106
});
96107

97-
expect(notFoundResult.source).toBe("not-found");
108+
expect(notFoundResult.found === false && notFoundResult.reason).toBe("not-found");
98109
// Both collapse to the same 404-ish surface.
99110
expect(toHttpish(pastRetentionResult).status).toBe(toHttpish(notFoundResult).status);
100111
expect(toHttpish(pastRetentionResult).status).toBe(404);
@@ -110,7 +121,8 @@ describe("readThroughRun (legacy replica + new DB)", () => {
110121
const newRead = vi.fn((c: PrismaReplicaClient) => realRead(c, true));
111122

112123
const result = await readThroughRun({
113-
runId: LEGACY_RUN_ID,
124+
id: LEGACY_RUN_ID,
125+
idKind: "run",
114126
environmentId: "env_1",
115127
readNew: newRead,
116128
readLegacy: throwingLegacy,
@@ -121,7 +133,7 @@ describe("readThroughRun (legacy replica + new DB)", () => {
121133
},
122134
});
123135

124-
expect(result.source).toBe("new");
136+
expect(result.found && result.source).toBe("new");
125137
expect(newRead).toHaveBeenCalledTimes(1);
126138
expect(throwingLegacy).not.toHaveBeenCalled();
127139
}
@@ -135,7 +147,152 @@ describe("readThroughRun (legacy replica + new DB)", () => {
135147
});
136148

137149
const result = await readThroughRun({
138-
runId: NEW_RUN_ID,
150+
id: NEW_RUN_ID,
151+
idKind: "run",
152+
environmentId: "env_1",
153+
readNew: (c) => realRead(c, true),
154+
readLegacy: throwingLegacy,
155+
deps: {
156+
splitEnabled: true,
157+
newClient: prisma17 as unknown as PrismaReplicaClient,
158+
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
159+
},
160+
});
161+
162+
expect(result.found && result.source).toBe("new");
163+
expect(throwingLegacy).not.toHaveBeenCalled();
164+
}
165+
);
166+
167+
heteroPostgresTest(
168+
"gen-2 id reads its OWN shard replica once and probes no other store",
169+
async ({ prisma14, prisma17 }) => {
170+
const throwingNew = throwingClient("the gen-1 new store");
171+
const throwingLegacy = throwingClient("the legacy replica");
172+
const shardRead = vi.fn((c: PrismaReplicaClient) => realRead(c, true));
173+
174+
const result = await readThroughRun({
175+
id: SHARD_A_RUN_ID,
176+
idKind: "run",
177+
environmentId: "env_1",
178+
// One closure serves both the gen-1 new store and a shard: a shard is the same
179+
// dedicated schema. The throwing clients prove WHICH client it was handed.
180+
readNew: (c) => shardRead(c),
181+
readLegacy: throwingLegacy,
182+
deps: {
183+
splitEnabled: true,
184+
newClient: throwingNew as unknown as PrismaReplicaClient,
185+
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
186+
shardReplicas: new Map([["a", prisma17 as unknown as PrismaReplicaClient]]),
187+
},
188+
});
189+
190+
expect(result.found && result.source).toBe("shard:a");
191+
expect(shardRead).toHaveBeenCalledTimes(1);
192+
// Identity, not deep equality: a Prisma client is too large to deep-compare.
193+
expect(shardRead.mock.calls[0][0]).toBe(prisma17);
194+
expect(throwingLegacy).not.toHaveBeenCalled();
195+
}
196+
);
197+
198+
heteroPostgresTest(
199+
"gen-2 id on an UNCONFIGURED shard key logs an error and returns not-found, never throws",
200+
async ({ prisma14, prisma17 }) => {
201+
const logger = collectingLogger();
202+
const throwingLegacy = throwingClient("the legacy replica");
203+
const newRead = vi.fn((c: PrismaReplicaClient) => realRead(c, true));
204+
205+
// Shard "z" is not configured. A 500 here would be inducible by any caller that
206+
// guesses a shard char, so the layer must degrade rather than throw.
207+
const result = await readThroughRun({
208+
id: SHARD_Z_RUN_ID,
209+
idKind: "run",
210+
environmentId: "env_1",
211+
readNew: newRead,
212+
readLegacy: throwingLegacy,
213+
deps: {
214+
splitEnabled: true,
215+
newClient: prisma17 as unknown as PrismaReplicaClient,
216+
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
217+
shardReplicas: new Map([["a", prisma17 as unknown as PrismaReplicaClient]]),
218+
logger,
219+
},
220+
});
221+
222+
expect(result.found).toBe(false);
223+
expect(result.found === false && result.reason).toBe("not-found");
224+
expect(logger.errors).toHaveLength(1);
225+
expect(logger.errors[0].meta).toMatchObject({ shardKey: "z", configured: ["a"] });
226+
// It must not silently fall back onto a gen-1 store.
227+
expect(newRead).not.toHaveBeenCalled();
228+
expect(throwingLegacy).not.toHaveBeenCalled();
229+
}
230+
);
231+
232+
heteroPostgresTest(
233+
"gen-1 RUN id reads the legacy replica only and never probes the new store",
234+
async ({ prisma14 }) => {
235+
const throwingNew = throwingClient("the new store");
236+
const legacyRead = vi.fn((c: PrismaReplicaClient) => realRead(c, true));
237+
238+
const result = await readThroughRun({
239+
id: LEGACY_RUN_ID,
240+
idKind: "run",
241+
environmentId: "env_1",
242+
readNew: throwingNew,
243+
readLegacy: legacyRead,
244+
deps: {
245+
splitEnabled: true,
246+
newClient: prisma14 as unknown as PrismaReplicaClient,
247+
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
248+
},
249+
});
250+
251+
expect(result.found && result.source).toBe("legacy-replica");
252+
expect(throwingNew).not.toHaveBeenCalled();
253+
expect(legacyRead).toHaveBeenCalledTimes(1);
254+
}
255+
);
256+
257+
heteroPostgresTest(
258+
"cuid WAITPOINT id keeps the new-FIRST pair probe (frozen: cuid waitpoints co-locate on new)",
259+
async ({ prisma14, prisma17 }) => {
260+
const calls: string[] = [];
261+
const newRead = vi.fn(async (c: PrismaReplicaClient) => {
262+
calls.push("new");
263+
return realRead(c, false);
264+
});
265+
const legacyRead = vi.fn(async (c: PrismaReplicaClient) => {
266+
calls.push("legacy");
267+
return realRead(c, true);
268+
});
269+
270+
const result = await readThroughRun({
271+
id: LEGACY_WAITPOINT_ID,
272+
idKind: "waitpoint",
273+
environmentId: "env_1",
274+
readNew: newRead,
275+
readLegacy: legacyRead,
276+
deps: {
277+
splitEnabled: true,
278+
newClient: prisma17 as unknown as PrismaReplicaClient,
279+
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
280+
},
281+
});
282+
283+
expect(result.found && result.source).toBe("legacy-replica");
284+
expect(calls).toEqual(["new", "legacy"]);
285+
}
286+
);
287+
288+
heteroPostgresTest(
289+
"a cuid waitpoint found on the new store returns it without touching legacy",
290+
async ({ prisma14, prisma17 }) => {
291+
const throwingLegacy = throwingClient("the legacy replica");
292+
293+
const result = await readThroughRun({
294+
id: LEGACY_WAITPOINT_ID,
295+
idKind: "waitpoint",
139296
environmentId: "env_1",
140297
readNew: (c) => realRead(c, true),
141298
readLegacy: throwingLegacy,
@@ -146,7 +303,7 @@ describe("readThroughRun (legacy replica + new DB)", () => {
146303
},
147304
});
148305

149-
expect(result.source).toBe("new");
306+
expect(result.found && result.source).toBe("new");
150307
expect(throwingLegacy).not.toHaveBeenCalled();
151308
}
152309
);

0 commit comments

Comments
 (0)