Skip to content

Commit 40fd064

Browse files
committed
fix(run-store): route caller-passed read clients to the owning store primary
RoutingRunStore accepted `client` on every routed read but dropped it, so read-your-writes reads (execution snapshots, waitpoints, task run attempts, batches) silently fell back to the read replica. Under replica lag the dequeue re-read of a just-written snapshot could return stale or missing data and fail the run. A caller-passed client is never forwarded verbatim (it is bound to the control-plane database, the wrong one for a NEW-resident run); its presence now routes the read to the owning sub-store own primary via the new `primaryReadClient` handle. Reads without a client keep using the replica.
1 parent 119189f commit 40fd064

5 files changed

Lines changed: 611 additions & 121 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Fix run store routing dropping the caller's read client, which downgraded read-your-writes reads (execution snapshots, waitpoints, batches) to the read replica and could fail dequeues under replica lag

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,13 @@ export class PostgresRunStore implements RunStore {
507507
this.schemaVariant = options.schemaVariant ?? "legacy";
508508
}
509509

510+
// The writer handle in read-client form, so the routing layer can honor a caller-passed client
511+
// (read-your-writes) with THIS store's own primary instead of leaking the caller's client across
512+
// DBs. Cast mirrors runInTransaction: the generated clients differ only in delegates reads use.
513+
get primaryReadClient(): ReadClient {
514+
return this.prisma as unknown as ReadClient;
515+
}
516+
510517
// Open ONE interactive transaction on this store's OWN writer client and run `fn` against THIS store
511518
// (so subclass overrides survive) with the tx as the client to thread into the inner writes. `runId`
512519
// is ignored here — a single store has one connection — but is in the contract so the router can
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// RED→GREEN repro for the routed-read CLIENT DROP: RoutingRunStore's runId-routed / fan-out reads
2+
// accept `client?: ReadClient` but dropped it, so the sub-store fell back to its REPLICA. The
3+
// run-engine passes its writer (`tx ?? this.$.prisma`) into these reads for read-your-writes
4+
// consistency (dequeue re-reads the just-written QUEUED snapshot), so the drop surfaces in cloud as
5+
// TASK_DEQUEUED_INVALID_STATE / "No execution snapshot found for TaskRun ...". The fix routes a
6+
// caller-passed client to the OWNING store's OWN primary (never forwarded verbatim — it is bound to
7+
// the control-plane DB); no client keeps the replica default.
8+
//
9+
// Deterministic harness: `heteroPostgresTest` hands two PHYSICALLY separate postgres containers
10+
// over the same full schema. The owning store WRITES to one and its `readOnlyPrisma` points at the
11+
// other, which stays EMPTY (a replica with unbounded lag) — so a replica-routed read MISSES and a
12+
// primary-routed read finds the row, with no replica==primary aliasing to mask the drop.
13+
14+
import { heteroPostgresTest } from "@internal/testcontainers";
15+
import type { PrismaClient } from "@trigger.dev/database";
16+
import { describe, expect } from "vitest";
17+
import { PostgresRunStore } from "./PostgresRunStore.js";
18+
import { RoutingRunStore } from "./runOpsStore.js";
19+
import type { CreateRunInput } from "./types.js";
20+
21+
// ownerEngine classifies by internal-id LENGTH: 25 chars → cuid → LEGACY, 27 → ksuid → NEW.
22+
const CUID_25 = "c".repeat(25);
23+
const KSUID_27 = "k".repeat(27);
24+
25+
// Router topology where the OWNING store (the one the test's run ids route to) writes to `writer`
26+
// but reads by default from `lagging` — a physically separate, never-written DB. The other store
27+
// lives entirely on the lagging DB so fan-out legs can't accidentally see rows. Both DBs carry the
28+
// full schema (the forwarding under test is residency-agnostic; dedicated-subset parity is covered
29+
// by the sibling suites), so both stores use the "legacy" variant.
30+
function splitTopology(
31+
residency: "LEGACY" | "NEW",
32+
writer: PrismaClient,
33+
lagging: PrismaClient
34+
): { owningStore: PostgresRunStore; router: RoutingRunStore } {
35+
const owningStore = new PostgresRunStore({
36+
prisma: writer,
37+
readOnlyPrisma: lagging,
38+
schemaVariant: "legacy",
39+
});
40+
const otherStore = new PostgresRunStore({
41+
prisma: lagging,
42+
readOnlyPrisma: lagging,
43+
schemaVariant: "legacy",
44+
});
45+
const router = new RoutingRunStore(
46+
residency === "LEGACY"
47+
? { new: otherStore, legacy: owningStore }
48+
: { new: owningStore, legacy: otherStore }
49+
);
50+
return { owningStore, router };
51+
}
52+
53+
async function seedEnvironment(prisma: PrismaClient, suffix: string) {
54+
const organization = await prisma.organization.create({
55+
data: { title: `Org ${suffix}`, slug: `org-${suffix}` },
56+
});
57+
const project = await prisma.project.create({
58+
data: {
59+
name: `Project ${suffix}`,
60+
slug: `project-${suffix}`,
61+
externalRef: `proj_${suffix}`,
62+
organizationId: organization.id,
63+
},
64+
});
65+
const environment = await prisma.runtimeEnvironment.create({
66+
data: {
67+
type: "DEVELOPMENT",
68+
slug: "dev",
69+
projectId: project.id,
70+
organizationId: organization.id,
71+
apiKey: `tr_dev_${suffix}`,
72+
pkApiKey: `pk_dev_${suffix}`,
73+
shortcode: `short_${suffix}`,
74+
},
75+
});
76+
return { organization, project, environment };
77+
}
78+
79+
function buildCreateRunInput(params: {
80+
runId: string;
81+
friendlyId: string;
82+
organizationId: string;
83+
projectId: string;
84+
runtimeEnvironmentId: string;
85+
}): CreateRunInput {
86+
return {
87+
data: {
88+
id: params.runId,
89+
engine: "V2",
90+
status: "PENDING",
91+
friendlyId: params.friendlyId,
92+
runtimeEnvironmentId: params.runtimeEnvironmentId,
93+
environmentType: "DEVELOPMENT",
94+
organizationId: params.organizationId,
95+
projectId: params.projectId,
96+
taskIdentifier: "my-task",
97+
payload: "{}",
98+
payloadType: "application/json",
99+
traceContext: {},
100+
traceId: `trace_${params.runId}`,
101+
spanId: `span_${params.runId}`,
102+
queue: "task/my-task",
103+
isTest: false,
104+
taskEventStore: "taskEvent",
105+
depth: 0,
106+
},
107+
snapshot: {
108+
engine: "V2",
109+
executionStatus: "RUN_CREATED",
110+
description: "Run was created",
111+
runStatus: "PENDING",
112+
environmentId: params.runtimeEnvironmentId,
113+
environmentType: "DEVELOPMENT",
114+
projectId: params.projectId,
115+
organizationId: params.organizationId,
116+
},
117+
};
118+
}
119+
120+
describe("run-ops split — routed reads honor a caller-passed client via the owning store's PRIMARY", () => {
121+
// The outage path: dequeue writes the QUEUED snapshot then re-reads it via
122+
// `getLatestExecutionSnapshot(this.$.prisma, ...)`. The router must not downgrade that read to
123+
// the replica. Covers the whole snapshot read family on the LEGACY (cuid) routing arm.
124+
heteroPostgresTest(
125+
"LEGACY cuid: snapshot reads with a client resolve on the owning primary; without, on the replica",
126+
async ({ prisma14, prisma17 }) => {
127+
const { router } = splitTopology("LEGACY", prisma14, prisma17);
128+
const seed = await seedEnvironment(prisma14, "snap_leg");
129+
const runId = `run_${CUID_25}`;
130+
await router.createRun(
131+
buildCreateRunInput({
132+
runId,
133+
friendlyId: "run_snap_leg",
134+
organizationId: seed.organization.id,
135+
projectId: seed.project.id,
136+
runtimeEnvironmentId: seed.environment.id,
137+
})
138+
);
139+
140+
// findLatestExecutionSnapshot — client passed → owning primary finds the fresh snapshot.
141+
const latest = await router.findLatestExecutionSnapshot(runId, prisma14);
142+
expect(latest).not.toBeNull();
143+
expect(latest?.executionStatus).toBe("RUN_CREATED");
144+
// No client → the (empty) replica, unchanged behavior.
145+
expect(await router.findLatestExecutionSnapshot(runId)).toBeNull();
146+
147+
const snapshotId = latest!.id;
148+
149+
// findExecutionSnapshot (runId-routed, the warm-restart shape).
150+
const one = await router.findExecutionSnapshot(
151+
{ where: { runId, isValid: true }, select: { id: true } },
152+
prisma14
153+
);
154+
expect(one?.id).toBe(snapshotId);
155+
expect(await router.findExecutionSnapshot({ where: { runId, isValid: true } })).toBeNull();
156+
157+
// findManyExecutionSnapshots (runId-routed).
158+
const many = await router.findManyExecutionSnapshots(
159+
{ where: { runId }, select: { id: true } },
160+
prisma14
161+
);
162+
expect(many.map((s) => s.id)).toEqual([snapshotId]);
163+
expect(await router.findManyExecutionSnapshots({ where: { runId } })).toEqual([]);
164+
165+
// findSnapshotCompletedWaitpointIds (the resume-payload join read). Seed a completed
166+
// waitpoint + its `_completedWaitpoints` join on the writer only.
167+
const waitpoint = await prisma14.waitpoint.create({
168+
data: {
169+
friendlyId: "waitpoint_snap_leg",
170+
type: "MANUAL",
171+
status: "COMPLETED",
172+
idempotencyKey: "idem_snap_leg",
173+
userProvidedIdempotencyKey: false,
174+
projectId: seed.project.id,
175+
environmentId: seed.environment.id,
176+
},
177+
});
178+
await prisma14.$executeRaw`
179+
INSERT INTO "_completedWaitpoints" ("A", "B") VALUES (${snapshotId}, ${waitpoint.id})
180+
`;
181+
expect(await router.findSnapshotCompletedWaitpointIds(snapshotId, prisma14)).toEqual([
182+
waitpoint.id,
183+
]);
184+
expect(await router.findSnapshotCompletedWaitpointIds(snapshotId)).toEqual([]);
185+
}
186+
);
187+
188+
// NEW (ksuid) routing arm. The caller's client here is the CONTROL-PLANE writer — the wrong
189+
// physical DB for a NEW-resident run — so this also pins that the client is never forwarded
190+
// verbatim: the read must resolve on the owning NEW store's OWN primary.
191+
heteroPostgresTest(
192+
"NEW ksuid: a control-plane client routes the snapshot read to the NEW store's OWN primary",
193+
async ({ prisma14, prisma17 }) => {
194+
// Owning (NEW) store writes to prisma14; the control-plane/other store is prisma17.
195+
const { router } = splitTopology("NEW", prisma14, prisma17);
196+
const seed = await seedEnvironment(prisma14, "snap_new");
197+
const runId = `run_${KSUID_27}`;
198+
await router.createRun(
199+
buildCreateRunInput({
200+
runId,
201+
friendlyId: "run_snap_new",
202+
organizationId: seed.organization.id,
203+
projectId: seed.project.id,
204+
runtimeEnvironmentId: seed.environment.id,
205+
})
206+
);
207+
208+
// Control-plane writer (prisma17 side of this topology) passed as the client: the row can
209+
// only be found on the NEW store's own primary (prisma14) — verbatim forwarding would miss.
210+
const latest = await router.findLatestExecutionSnapshot(runId, prisma17);
211+
expect(latest).not.toBeNull();
212+
expect(latest?.executionStatus).toBe("RUN_CREATED");
213+
// No client → the NEW store's (empty) replica.
214+
expect(await router.findLatestExecutionSnapshot(runId)).toBeNull();
215+
}
216+
);
217+
218+
heteroPostgresTest(
219+
"LEGACY cuid: findRuns fan-out and batch friendlyId probe honor a caller client",
220+
async ({ prisma14, prisma17 }) => {
221+
const { router } = splitTopology("LEGACY", prisma14, prisma17);
222+
const seed = await seedEnvironment(prisma14, "runs_leg");
223+
const runId = `run_${CUID_25}`;
224+
await router.createRun(
225+
buildCreateRunInput({
226+
runId,
227+
friendlyId: "run_runs_leg",
228+
organizationId: seed.organization.id,
229+
projectId: seed.project.id,
230+
runtimeEnvironmentId: seed.environment.id,
231+
})
232+
);
233+
234+
// findRuns (bounded id-set fan-out).
235+
const rows = await router.findRuns(
236+
{ where: { id: { in: [runId] } }, select: { id: true } },
237+
prisma14
238+
);
239+
expect(rows.map((r) => r.id)).toEqual([runId]);
240+
expect(
241+
await router.findRuns({ where: { id: { in: [runId] } }, select: { id: true } })
242+
).toEqual([]);
243+
244+
// findBatchTaskRunByFriendlyId (env-scoped fan-out probe; the one batch read that defaults
245+
// to the replica).
246+
const batch = await prisma14.batchTaskRun.create({
247+
data: {
248+
id: `batch_${CUID_25}`,
249+
friendlyId: "batch_runs_leg",
250+
runtimeEnvironmentId: seed.environment.id,
251+
},
252+
});
253+
const viaPrimary = await router.findBatchTaskRunByFriendlyId(
254+
batch.friendlyId,
255+
seed.environment.id,
256+
undefined,
257+
prisma14
258+
);
259+
expect(viaPrimary?.id).toBe(batch.id);
260+
expect(
261+
await router.findBatchTaskRunByFriendlyId(batch.friendlyId, seed.environment.id)
262+
).toBeNull();
263+
}
264+
);
265+
266+
heteroPostgresTest(
267+
"LEGACY cuid: waitpoint reads honor a caller client",
268+
async ({ prisma14, prisma17 }) => {
269+
const { router } = splitTopology("LEGACY", prisma14, prisma17);
270+
const seed = await seedEnvironment(prisma14, "wp_leg");
271+
const runId = `run_${CUID_25}`;
272+
await router.createRun(
273+
buildCreateRunInput({
274+
runId,
275+
friendlyId: "run_wp_leg",
276+
organizationId: seed.organization.id,
277+
projectId: seed.project.id,
278+
runtimeEnvironmentId: seed.environment.id,
279+
})
280+
);
281+
const waitpoint = await prisma14.waitpoint.create({
282+
data: {
283+
id: `waitpoint_${CUID_25}`,
284+
friendlyId: "waitpoint_wp_leg",
285+
type: "MANUAL",
286+
status: "PENDING",
287+
idempotencyKey: "idem_wp_leg",
288+
userProvidedIdempotencyKey: false,
289+
projectId: seed.project.id,
290+
environmentId: seed.environment.id,
291+
},
292+
});
293+
await prisma14.taskRunWaitpoint.create({
294+
data: { taskRunId: runId, waitpointId: waitpoint.id, projectId: seed.project.id },
295+
});
296+
297+
// findWaitpoint (by id, resolve + scalar read).
298+
const found = await router.findWaitpoint({ where: { id: waitpoint.id } }, prisma14);
299+
expect(found?.id).toBe(waitpoint.id);
300+
expect(await router.findWaitpoint({ where: { id: waitpoint.id } })).toBeNull();
301+
302+
// findManyWaitpoints (both-store fan-out).
303+
const manyOnPrimary = await router.findManyWaitpoints(
304+
{ where: { id: { in: [waitpoint.id] } } },
305+
prisma14
306+
);
307+
expect(manyOnPrimary.map((w) => w.id)).toEqual([waitpoint.id]);
308+
expect(await router.findManyWaitpoints({ where: { id: { in: [waitpoint.id] } } })).toEqual(
309+
[]
310+
);
311+
312+
// countPendingWaitpoints (both-store fan-out sum).
313+
expect(await router.countPendingWaitpoints([waitpoint.id], prisma14)).toBe(1);
314+
expect(await router.countPendingWaitpoints([waitpoint.id])).toBe(0);
315+
316+
// findManyTaskRunWaitpoints (the blocked-run edge fan-out).
317+
const edges = await router.findManyTaskRunWaitpoints(
318+
{ where: { taskRunId: runId } },
319+
prisma14
320+
);
321+
expect(edges).toHaveLength(1);
322+
expect(edges[0]?.waitpointId).toBe(waitpoint.id);
323+
expect(await router.findManyTaskRunWaitpoints({ where: { taskRunId: runId } })).toEqual([]);
324+
}
325+
);
326+
});

0 commit comments

Comments
 (0)