Skip to content

Commit 8e48e78

Browse files
committed
fix(run-engine): route read-your-writes to the owning store's primary in RoutingRunStore
RoutingRunStore dropped the caller's writer/tx client on three read-your-writes reads, defaulting to the owning store's replica (or, on a single DB, an off-transaction connection). Callers that pass the writer to read back state they just wrote (to beat replica lag) got a stale miss — so time-based DATETIME waitpoints (delay / wait.until / reschedule / retry backoff) were never armed or were torn down. Only manifests with the run-ops split active + real read-replicas. Fixed the three methods to route to the owning store's *OnPrimary variant when a writer client is passed (isWriterClient), mirroring the existing findRun/findRunOnPrimary + findWaitpoint/findWaitpointOnPrimary pattern: - findLatestExecutionSnapshot (reschedule, retry-backoff snapshot read) - countPendingWaitpoints (wait blocking check) - findManyTaskRunWaitpoints (continueRunIfUnblocked un-block read) Adds the three *OnPrimary methods to RunStore + PostgresRunStore. Plain reads (no writer) still route to the replica — no offload regression. Test: runOpsStore.snapshotReadAfterWrite.test.ts — real split topology with a lagging-replica proxy, proves red->green for both LEGACY and NEW residencies and guards against read-your-writes escalation on plain reads.
1 parent 962bc48 commit 8e48e78

4 files changed

Lines changed: 444 additions & 5 deletions

File tree

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1418,6 +1418,16 @@ export class PostgresRunStore implements RunStore {
14181418
});
14191419
}
14201420

1421+
// Read-after-write on the OWNING store's PRIMARY: a caller that just wrote a snapshot in this
1422+
// request re-reads it here so replica lag (findLatestExecutionSnapshot's default) can't miss it.
1423+
async findLatestExecutionSnapshotOnPrimary(
1424+
runId: string
1425+
): Promise<Prisma.TaskRunExecutionSnapshotGetPayload<{
1426+
include: { completedWaitpoints: true; checkpoint: true };
1427+
}> | null> {
1428+
return this.findLatestExecutionSnapshot(runId, this.prisma as ReadClient);
1429+
}
1430+
14211431
/**
14221432
* Dedicated-schema replacement for the legacy `include: { completedWaitpoints: true }` snapshot
14231433
* read. The relation doesn't exist on the subset schema, so we resolve the linked waitpoint ids
@@ -1764,6 +1774,12 @@ export class PostgresRunStore implements RunStore {
17641774
return Number(pendingCheck.at(0)?.pending_count ?? 0);
17651775
}
17661776

1777+
// Read-after-write on the OWNING store's PRIMARY: a caller that just wrote/updated a waitpoint
1778+
// in this request re-counts here so replica lag (countPendingWaitpoints's default) can't miss it.
1779+
async countPendingWaitpointsOnPrimary(waitpointIds: string[]): Promise<number> {
1780+
return this.countPendingWaitpoints(waitpointIds, this.prisma as ReadClient);
1781+
}
1782+
17671783
async createWaitpoint<T extends Prisma.WaitpointCreateArgs>(
17681784
args: Prisma.SelectSubset<T, Prisma.WaitpointCreateArgs>,
17691785
tx?: PrismaClientOrTransaction
@@ -1924,6 +1940,15 @@ export class PostgresRunStore implements RunStore {
19241940
return rows as Prisma.TaskRunWaitpointGetPayload<T>[];
19251941
}
19261942

1943+
// Read-after-write on the OWNING store's PRIMARY: a caller that just wrote a taskRunWaitpoint
1944+
// edge in this request re-reads it here so replica lag (findManyTaskRunWaitpoints's default)
1945+
// can't miss it.
1946+
async findManyTaskRunWaitpointsOnPrimary<T extends Prisma.TaskRunWaitpointFindManyArgs>(
1947+
args: Prisma.SelectSubset<T, Prisma.TaskRunWaitpointFindManyArgs>
1948+
): Promise<Prisma.TaskRunWaitpointGetPayload<T>[]> {
1949+
return this.findManyTaskRunWaitpoints(args, this.prisma as ReadClient);
1950+
}
1951+
19271952
async deleteManyTaskRunWaitpoints(
19281953
args: Prisma.TaskRunWaitpointDeleteManyArgs,
19291954
tx?: PrismaClientOrTransaction
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
// RED→GREEN repro for the run-ops split READ-AFTER-WRITE hole:
2+
// RoutingRunStore.findLatestExecutionSnapshot dropped the caller's client and always routed the
3+
// read to the owning store's REPLICA (readOnlyPrisma). Read-after-write callers that just wrote a
4+
// snapshot in this request and pass the WRITER to read it back (to beat replica lag) got a stale
5+
// miss → the engine throws or un-blocks the run → time-based waitpoints (delay/wait.until/
6+
// reschedule/retry-backoff) are never armed or get destroyed.
7+
//
8+
// The fix keys on the passed client's IDENTITY: a WRITER (has `$transaction`) means read-your-writes
9+
// → route to the OWNING store's own writer (findLatestExecutionSnapshotOnPrimary), for BOTH
10+
// residencies, WITHOUT leaking a control-plane client into a NEW-DB query (each store reads its OWN
11+
// writer). A replica / nothing keeps the default (owning store's replica).
12+
//
13+
// `heteroRunOpsPostgresTest` gives a REAL split topology: prisma17 = RunOpsPrismaClient over the
14+
// dedicated subset schema (#new / 5434), prisma14 = full legacy schema on a SEPARATE physical PG
15+
// container (#legacy / control-plane). NEVER mocked. Replica lag is simulated by backing each
16+
// store's `readOnlyPrisma` with a recording proxy whose taskRunExecutionSnapshot reads return EMPTY
17+
// (a lagging replica has not yet seen the fresh row) while recording that it was hit — so a
18+
// replica-routed read MISSES and a writer-routed read FINDS. Seeds/writes always go through the
19+
// real writer (store.createRun).
20+
21+
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
22+
import type { PrismaClient } from "@trigger.dev/database";
23+
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
24+
import { describe, expect } from "vitest";
25+
import { PostgresRunStore } from "./PostgresRunStore.js";
26+
import { RoutingRunStore } from "./runOpsStore.js";
27+
import type { CreateRunInput, RunStoreSchemaVariant } from "./types.js";
28+
29+
type AnyClient = PrismaClient | RunOpsPrismaClient;
30+
31+
// ownerEngine classifies by internal-id LENGTH: 25 chars → cuid → LEGACY, 27 → ksuid → NEW.
32+
const CUID_25 = "c".repeat(25); // → LEGACY (#legacy / prisma14, full schema)
33+
const KSUID_27 = "k".repeat(27); // → NEW (#new / prisma17, dedicated subset schema)
34+
35+
// A recording "replica" that has NOT yet caught up: its taskRunExecutionSnapshot (and, for the
36+
// second guard case, taskRunWaitpoint) reads always come back empty and record that they ran, so a
37+
// replica-routed read misses the just-written row. Everything else forwards to the real client.
38+
// `hit` flips true iff an intercepted read was routed here.
39+
function laggingReplica<C extends AnyClient>(real: C): { client: C; wasHit: () => boolean } {
40+
let hit = false;
41+
function wrapModel(target: any) {
42+
return new Proxy(target, {
43+
get(innerTarget, prop) {
44+
if (prop === "findFirst" || prop === "findMany") {
45+
return async () => {
46+
hit = true;
47+
return prop === "findMany" ? [] : null;
48+
};
49+
}
50+
if (prop === "findFirstOrThrow") {
51+
return async () => {
52+
hit = true;
53+
throw new Error("lagging replica: row not visible");
54+
};
55+
}
56+
return (innerTarget as any)[prop];
57+
},
58+
});
59+
}
60+
const laggingSnapshot = wrapModel((real as any).taskRunExecutionSnapshot);
61+
const laggingTaskRunWaitpoint = wrapModel((real as any).taskRunWaitpoint);
62+
const client = new Proxy(real, {
63+
get(target, prop) {
64+
if (prop === "taskRunExecutionSnapshot") {
65+
return laggingSnapshot;
66+
}
67+
if (prop === "taskRunWaitpoint") {
68+
return laggingTaskRunWaitpoint;
69+
}
70+
return (target as any)[prop];
71+
},
72+
}) as C;
73+
return { client, wasHit: () => hit };
74+
}
75+
76+
// On the dedicated subset there are no Organization/Project/RuntimeEnvironment models (the run-ops
77+
// rows carry FK-free scalar ids), so we mint synthetic owning ids. On legacy we seed the real rows
78+
// the kept FKs require.
79+
async function seedEnvironment(
80+
prisma: AnyClient,
81+
schemaVariant: RunStoreSchemaVariant,
82+
slugSuffix: string
83+
) {
84+
if (schemaVariant === "dedicated") {
85+
return {
86+
organization: { id: `org_${slugSuffix}` },
87+
project: { id: `proj_${slugSuffix}` },
88+
environment: { id: `env_${slugSuffix}` },
89+
};
90+
}
91+
const organization = await (prisma as PrismaClient).organization.create({
92+
data: { title: `Org ${slugSuffix}`, slug: `org-${slugSuffix}` },
93+
});
94+
const project = await (prisma as PrismaClient).project.create({
95+
data: {
96+
name: `Project ${slugSuffix}`,
97+
slug: `project-${slugSuffix}`,
98+
externalRef: `proj_${slugSuffix}`,
99+
organizationId: organization.id,
100+
},
101+
});
102+
const environment = await (prisma as PrismaClient).runtimeEnvironment.create({
103+
data: {
104+
type: "DEVELOPMENT",
105+
slug: "dev",
106+
projectId: project.id,
107+
organizationId: organization.id,
108+
apiKey: `tr_dev_${slugSuffix}`,
109+
pkApiKey: `pk_dev_${slugSuffix}`,
110+
shortcode: `short_${slugSuffix}`,
111+
},
112+
});
113+
return { organization, project, environment };
114+
}
115+
116+
function buildCreateRunInput(params: {
117+
runId: string;
118+
friendlyId: string;
119+
taskIdentifier: string;
120+
organizationId: string;
121+
projectId: string;
122+
runtimeEnvironmentId: string;
123+
}): CreateRunInput {
124+
return {
125+
data: {
126+
id: params.runId,
127+
engine: "V2",
128+
status: "PENDING",
129+
friendlyId: params.friendlyId,
130+
runtimeEnvironmentId: params.runtimeEnvironmentId,
131+
environmentType: "DEVELOPMENT",
132+
organizationId: params.organizationId,
133+
projectId: params.projectId,
134+
taskIdentifier: params.taskIdentifier,
135+
payload: '{"hello":"world"}',
136+
payloadType: "application/json",
137+
context: { foo: "bar" },
138+
traceContext: { trace: "ctx" },
139+
traceId: "trace_1",
140+
spanId: "span_1",
141+
runTags: ["alpha", "beta"],
142+
queue: "task/my-task",
143+
isTest: false,
144+
taskEventStore: "taskEvent",
145+
depth: 0,
146+
createdAt: new Date("2024-01-01T00:00:00.000Z"),
147+
},
148+
snapshot: {
149+
engine: "V2",
150+
executionStatus: "RUN_CREATED",
151+
description: "Run was created",
152+
runStatus: "PENDING",
153+
environmentId: params.runtimeEnvironmentId,
154+
environmentType: "DEVELOPMENT",
155+
projectId: params.projectId,
156+
organizationId: params.organizationId,
157+
},
158+
};
159+
}
160+
161+
describe("run-ops split — snapshot read-after-write reads the OWNING store's WRITER, not its lagging replica", () => {
162+
// (a) LEGACY-resident (cuid) run: the initial snapshot was just committed to the control-plane
163+
// writer via store.createRun; the control-plane replica lags. Passing the control-plane WRITER as
164+
// the read-your-writes client must resolve the snapshot via the owning (legacy) writer, NOT the
165+
// replica.
166+
heteroRunOpsPostgresTest(
167+
"LEGACY cuid: read-after-write via the control-plane WRITER finds the fresh snapshot despite replica lag",
168+
async ({ prisma14, prisma17 }) => {
169+
const legacyReplica = laggingReplica(prisma14);
170+
const legacyStore = new PostgresRunStore({
171+
prisma: prisma14,
172+
readOnlyPrisma: legacyReplica.client,
173+
schemaVariant: "legacy",
174+
});
175+
const newStore = new PostgresRunStore({
176+
prisma: prisma17 as never,
177+
readOnlyPrisma: prisma17 as never,
178+
schemaVariant: "dedicated",
179+
});
180+
const router = new RoutingRunStore({ new: newStore, legacy: legacyStore });
181+
182+
const seed = await seedEnvironment(prisma14, "legacy", "snap_leg");
183+
const runId = `run_${CUID_25}`; // cuid → LEGACY
184+
await legacyStore.createRun(
185+
buildCreateRunInput({
186+
runId,
187+
friendlyId: "run_snap_leg",
188+
taskIdentifier: "my-task",
189+
organizationId: seed.organization.id,
190+
projectId: seed.project.id,
191+
runtimeEnvironmentId: seed.environment.id,
192+
})
193+
);
194+
195+
// FAIL-BEFORE proof: a plain replica read (no client) hits the lagging replica → miss.
196+
const viaReplica = await router.findLatestExecutionSnapshot(runId);
197+
expect(viaReplica).toBeNull();
198+
expect(legacyReplica.wasHit()).toBe(true);
199+
200+
// PASS-AFTER: read-your-writes with the control-plane WRITER resolves the fresh snapshot.
201+
const legacyReplica2 = laggingReplica(prisma14);
202+
const legacyStore2 = new PostgresRunStore({
203+
prisma: prisma14,
204+
readOnlyPrisma: legacyReplica2.client,
205+
schemaVariant: "legacy",
206+
});
207+
const router2 = new RoutingRunStore({ new: newStore, legacy: legacyStore2 });
208+
const viaWriter = await router2.findLatestExecutionSnapshot(
209+
runId,
210+
prisma14 // control-plane WRITER → read-your-writes
211+
);
212+
expect(viaWriter).not.toBeNull();
213+
expect(viaWriter!.runId).toBe(runId);
214+
// The read hit the WRITER, never the replica.
215+
expect(legacyReplica2.wasHit()).toBe(false);
216+
}
217+
);
218+
219+
// (b) NEW-resident (ksuid) run: born on the NEW DB (5434). The NEW replica lags. Passing the NEW
220+
// WRITER as the read-your-writes client must resolve the snapshot via the NEW writer, NOT its
221+
// replica — and (proving the constraint that motivated the original client-drop) the control-plane
222+
// writer is never leaked into the NEW query: each store reads its OWN writer.
223+
heteroRunOpsPostgresTest(
224+
"NEW ksuid: read-after-write via the NEW WRITER finds the fresh snapshot despite NEW replica lag",
225+
async ({ prisma14, prisma17 }) => {
226+
const newReplica = laggingReplica(prisma17);
227+
const newStore = new PostgresRunStore({
228+
prisma: prisma17 as never,
229+
readOnlyPrisma: newReplica.client as never,
230+
schemaVariant: "dedicated",
231+
});
232+
const legacyStore = new PostgresRunStore({
233+
prisma: prisma14,
234+
readOnlyPrisma: prisma14,
235+
schemaVariant: "legacy",
236+
});
237+
const router = new RoutingRunStore({ new: newStore, legacy: legacyStore });
238+
239+
const seed = await seedEnvironment(prisma17, "dedicated", "snap_new");
240+
const runId = `run_${KSUID_27}`; // ksuid → NEW
241+
await newStore.createRun(
242+
buildCreateRunInput({
243+
runId,
244+
friendlyId: "run_snap_new",
245+
taskIdentifier: "my-task",
246+
organizationId: seed.organization.id,
247+
projectId: seed.project.id,
248+
runtimeEnvironmentId: seed.environment.id,
249+
})
250+
);
251+
252+
// FAIL-BEFORE proof: a plain replica read hits the lagging NEW replica → miss.
253+
const viaReplica = await router.findLatestExecutionSnapshot(runId);
254+
expect(viaReplica).toBeNull();
255+
expect(newReplica.wasHit()).toBe(true);
256+
257+
// PASS-AFTER: read-your-writes with the NEW WRITER resolves the fresh snapshot on the NEW DB.
258+
const newReplica2 = laggingReplica(prisma17);
259+
const newStore2 = new PostgresRunStore({
260+
prisma: prisma17 as never,
261+
readOnlyPrisma: newReplica2.client as never,
262+
schemaVariant: "dedicated",
263+
});
264+
const router2 = new RoutingRunStore({ new: newStore2, legacy: legacyStore });
265+
const viaWriter = await router2.findLatestExecutionSnapshot(
266+
runId,
267+
prisma17 as never // NEW WRITER → read-your-writes
268+
);
269+
expect(viaWriter).not.toBeNull();
270+
expect(viaWriter!.runId).toBe(runId);
271+
// The read hit the NEW WRITER, never the NEW replica.
272+
expect(newReplica2.wasHit()).toBe(false);
273+
274+
// Even passing the LEGACY (control-plane) WRITER as the read-your-writes signal resolves the
275+
// ksuid run's snapshot: the router routes by residency to the NEW store's OWN writer, never
276+
// forwarding the control-plane client into the NEW DB. (This is the exact live shape —
277+
// sessions/trigger pass the control-plane `prisma`, and the run may be NEW-resident under
278+
// split-ON.)
279+
const newReplica3 = laggingReplica(prisma17);
280+
const newStore3 = new PostgresRunStore({
281+
prisma: prisma17 as never,
282+
readOnlyPrisma: newReplica3.client as never,
283+
schemaVariant: "dedicated",
284+
});
285+
const router3 = new RoutingRunStore({ new: newStore3, legacy: legacyStore });
286+
const viaControlPlaneWriter = await router3.findLatestExecutionSnapshot(
287+
runId,
288+
prisma14 // control-plane WRITER (writer identity) — router routes to NEW's own writer
289+
);
290+
expect(viaControlPlaneWriter).not.toBeNull();
291+
expect(viaControlPlaneWriter!.runId).toBe(runId);
292+
expect(newReplica3.wasHit()).toBe(false);
293+
}
294+
);
295+
296+
// Guard: a plain replica read (no client, or a replica client) still routes to the replica — the
297+
// fix must not turn every read into a primary read (which would defeat replica offload).
298+
heteroRunOpsPostgresTest(
299+
"plain reads still route to the replica (no read-your-writes escalation)",
300+
async ({ prisma14, prisma17 }) => {
301+
const legacyReplica = laggingReplica(prisma14);
302+
const legacyStore = new PostgresRunStore({
303+
prisma: prisma14,
304+
readOnlyPrisma: legacyReplica.client,
305+
schemaVariant: "legacy",
306+
});
307+
const newStore = new PostgresRunStore({
308+
prisma: prisma17 as never,
309+
readOnlyPrisma: prisma17 as never,
310+
schemaVariant: "dedicated",
311+
});
312+
const router = new RoutingRunStore({ new: newStore, legacy: legacyStore });
313+
314+
const seed = await seedEnvironment(prisma14, "legacy", "snap_plain_leg");
315+
const runId = `run_${CUID_25}`;
316+
await legacyStore.createRun(
317+
buildCreateRunInput({
318+
runId,
319+
friendlyId: "run_snap_plain_leg",
320+
taskIdentifier: "my-task",
321+
organizationId: seed.organization.id,
322+
projectId: seed.project.id,
323+
runtimeEnvironmentId: seed.environment.id,
324+
})
325+
);
326+
327+
await router.findLatestExecutionSnapshot(runId);
328+
// No writer passed → the read went to the replica, exactly as before the fix.
329+
expect(legacyReplica.wasHit()).toBe(true);
330+
}
331+
);
332+
});

0 commit comments

Comments
 (0)