Skip to content

Commit 409b057

Browse files
committed
fix(run-engine,run-store): close run-ops split read and routing gaps on the resume path
Repair a resume snapshot's completed-waitpoints from the owning primary when a multi-reader replica serves the snapshot without its join rows. This is the single-triggerAndWait case the order-based repair could not see (empty completedWaitpointOrder), where the runner consumes an empty resume and the run hangs. The presence-aware read co-reads snapshot visibility and its ids in one statement, and only reads the primary when the reader lacks the snapshot, so a single-reader replica never pays. Also route batch item creation by batchTaskRunId, so an item stays visible to the batch-completion count even if child and batch residency ever diverge, and reject control-plane-only relation includes on the dedicated store with a clear error instead of an opaque Prisma failure.
1 parent ff7f461 commit 409b057

8 files changed

Lines changed: 333 additions & 4 deletions

internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,30 @@ async function getSnapshotWaitpointIds(
138138
return result.map((r) => r.B);
139139
}
140140

141+
// Reads the snapshot's completed-waitpoint ids AND whether the snapshot is visible on this reader, in
142+
// one query, so a multi-reader replica can't return the ids from a different point-in-time than the
143+
// snapshot. `present=false` -> this reader lacks the snapshot; its empty id list is not authoritative.
144+
async function getSnapshotWaitpointIdsWithPresence(
145+
prisma: PrismaClientOrTransaction,
146+
snapshotId: string,
147+
runStore?: RunStore
148+
): Promise<{ present: boolean; ids: string[] }> {
149+
if (runStore) {
150+
return runStore.findSnapshotCompletedWaitpointIdsWithPresence(snapshotId, prisma);
151+
}
152+
153+
const rows = await prisma.$queryRaw<{ id: string; B: string | null }[]>`
154+
SELECT s."id", cw."B"
155+
FROM "TaskRunExecutionSnapshot" s
156+
LEFT JOIN "_completedWaitpoints" cw ON cw."A" = s."id"
157+
WHERE s."id" = ${snapshotId}
158+
`;
159+
return {
160+
present: rows.length > 0,
161+
ids: rows.filter((r) => r.B !== null).map((r) => r.B as string),
162+
};
163+
}
164+
141165
/**
142166
* Fetches waitpoints in chunks to avoid NAPI string conversion limits.
143167
* This is necessary because waitpoints can have large outputs (100KB+),
@@ -320,9 +344,23 @@ export async function getExecutionSnapshotsSince(
320344
// Step 3: Get waitpoint IDs for the LATEST snapshot only (first in desc order)
321345
const latestSnapshot = snapshots[0];
322346
let readClient = prisma;
323-
let waitpointIds = await getSnapshotWaitpointIds(prisma, latestSnapshot.id, runStore);
347+
const { present, ids } = await getSnapshotWaitpointIdsWithPresence(
348+
prisma,
349+
latestSnapshot.id,
350+
runStore
351+
);
352+
let waitpointIds = ids;
353+
354+
// Read-repair (multi-reader): Step 2 saw this snapshot, but on a multi-reader replica the join read
355+
// can hit a laggier reader that does not have it yet. present=false means its empty id list is not
356+
// authoritative - re-read from the primary so the runner is not handed a waitpoint-less continue
357+
// (which it silently drops, hanging the run). Single-reader replicas never hit this (present stays true).
358+
if (repairClient && repairClient !== prisma && !present) {
359+
waitpointIds = await getSnapshotWaitpointIds(repairClient, latestSnapshot.id, runStore);
360+
readClient = repairClient;
361+
}
324362

325-
// Read-repair: completedWaitpointOrder is written on the snapshot row in the same statement as the
363+
// Read-repair (batch): completedWaitpointOrder is written on the snapshot row in the same statement as the
326364
// snapshot, so it is authoritative. If it lists more waitpoints than the join read returned, the
327365
// _completedWaitpoints rows are stale on this client (a lagging read replica) - re-read them from the
328366
// primary so the runner is not handed a waitpoint-less continue, which it drops and the run hangs.

internal-packages/run-engine/src/engine/tests/getSnapshotsSince.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,4 +1527,56 @@ describe("RunEngine getSnapshotsSince", () => {
15271527
}
15281528
}
15291529
);
1530+
1531+
// A single triggerAndWait resume has an EMPTY completedWaitpointOrder (only batch-indexed waitpoints
1532+
// populate it) but a real join row. On a multi-reader replica the join read can hit a laggier reader
1533+
// that does not yet have the snapshot, returning 0 - and the order-based repair can't see it (order is
1534+
// empty). The presence check must detect the reader lacks the snapshot and repair from the primary,
1535+
// or the runner is handed a waitpoint-less continue and the run hangs.
1536+
containerTest(
1537+
"repairs a single-wait resume from the primary when the replica reader lacks the snapshot",
1538+
async ({ prisma }) => {
1539+
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
1540+
const scenario = await setupTestScenario(prisma, authenticatedEnvironment, {
1541+
totalWaitpoints: 1,
1542+
outputSizeKB: 1,
1543+
snapshotConfigs: [
1544+
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
1545+
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 0 },
1546+
{ status: "EXECUTING", completedWaitpointCount: 1 },
1547+
],
1548+
});
1549+
const waitpointId = scenario.waitpoints[0].id;
1550+
const latestSnapshot = scenario.snapshots[scenario.snapshots.length - 1];
1551+
// Single-wait: keep the join row, clear the order so it's the non-batch case.
1552+
await prisma.taskRunExecutionSnapshot.update({
1553+
where: { id: latestSnapshot.id },
1554+
data: { completedWaitpointOrder: [] },
1555+
});
1556+
1557+
// A multi-reader replica reader that has NOT yet applied the snapshot: any raw read (the
1558+
// presence+join query) returns empty, while Step 2's findMany still returns the snapshot.
1559+
const laggyReader = new Proxy(prisma, {
1560+
get(target, prop) {
1561+
if (prop === "$queryRaw") {
1562+
return async () => [];
1563+
}
1564+
const value = (target as Record<string | symbol, unknown>)[prop];
1565+
return typeof value === "function" ? value.bind(target) : value;
1566+
},
1567+
}) as unknown as PrismaClient;
1568+
1569+
const result = await getExecutionSnapshotsSince(
1570+
laggyReader,
1571+
scenario.run.id,
1572+
scenario.snapshots[0].id,
1573+
undefined,
1574+
prisma
1575+
);
1576+
1577+
const latest = result[result.length - 1];
1578+
// The runner must receive the completed waitpoint (repaired from the primary), not an empty set.
1579+
expect(latest.completedWaitpoints.map((w) => w.id)).toEqual([waitpointId]);
1580+
}
1581+
);
15301582
});

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,4 +405,36 @@ describe("PostgresRunStore dedicated caller-select adapter (P2-store-bodies-2)",
405405
expect(blocking[0].taskRun.friendlyId).toBe(r.friendlyId);
406406
}
407407
);
408+
409+
// The dedicated finders forward include/select to a subset client typed as the full schema, so a
410+
// control-plane-only relation would throw an opaque Prisma 500 for NEW data (invisible to tsc). The
411+
// boundary guard rejects the known keys with a clear message; the legacy (full-schema) store is a no-op.
412+
heteroRunOpsPostgresTest(
413+
"dedicated batch/attempt finders reject control-plane-only includes with a clear error",
414+
async ({ prisma14, prisma17 }) => {
415+
const dedicated = makeStore(prisma17, "dedicated");
416+
const legacy = makeStore(prisma14, "legacy");
417+
418+
await expect(
419+
dedicated.findBatchTaskRunById("batch_x", { include: { runsBlocked: true } as never })
420+
).rejects.toThrow(/not available on the dedicated run-ops subset/);
421+
await expect(
422+
dedicated.findTaskRunAttempt({
423+
where: { id: "att_x" },
424+
include: { backgroundWorker: true },
425+
} as never)
426+
).rejects.toThrow(/not available on the dedicated run-ops subset/);
427+
428+
// A subset-present include passes the guard and resolves normally (null for a missing batch).
429+
expect(
430+
await dedicated.findBatchTaskRunById("batch_missing", { include: { items: true } as never })
431+
).toBeNull();
432+
// Legacy store: guard is a no-op; the full schema has the relation, so no guard throw.
433+
expect(
434+
await legacy.findBatchTaskRunById("batch_missing", {
435+
include: { runsBlocked: true } as never,
436+
})
437+
).toBeNull();
438+
}
439+
);
408440
});

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1662,6 +1662,41 @@ export class PostgresRunStore implements RunStore {
16621662
return result.map((r) => r.B);
16631663
}
16641664

1665+
// One query: LEFT JOIN the snapshot to its completed-waitpoint links so `present` (snapshot visible
1666+
// on this reader) and `ids` come from the SAME point-in-time. A multi-reader replica can otherwise
1667+
// return the snapshot (via a separate read) while a different, laggier reader returns 0 links.
1668+
async findSnapshotCompletedWaitpointIdsWithPresence(
1669+
snapshotId: string,
1670+
client?: ReadClient
1671+
): Promise<{ present: boolean; ids: string[] }> {
1672+
const prisma = client ?? this.readOnlyPrisma;
1673+
1674+
const joinDelegate = (prisma as RunOpsCapableClient).completedWaitpoint;
1675+
if (this.schemaVariant === "dedicated" && joinDelegate) {
1676+
const rows = await prisma.$queryRaw<{ id: string; waitpointId: string | null }[]>`
1677+
SELECT s."id", cw."waitpointId"
1678+
FROM "TaskRunExecutionSnapshot" s
1679+
LEFT JOIN "CompletedWaitpoint" cw ON cw."snapshotId" = s."id"
1680+
WHERE s."id" = ${snapshotId}
1681+
`;
1682+
return {
1683+
present: rows.length > 0,
1684+
ids: rows.filter((r) => r.waitpointId !== null).map((r) => r.waitpointId as string),
1685+
};
1686+
}
1687+
1688+
const rows = await prisma.$queryRaw<{ id: string; B: string | null }[]>`
1689+
SELECT s."id", cw."B"
1690+
FROM "TaskRunExecutionSnapshot" s
1691+
LEFT JOIN "_completedWaitpoints" cw ON cw."A" = s."id"
1692+
WHERE s."id" = ${snapshotId}
1693+
`;
1694+
return {
1695+
present: rows.length > 0,
1696+
ids: rows.filter((r) => r.B !== null).map((r) => r.B as string),
1697+
};
1698+
}
1699+
16651700
// Reverse of `connectedRuns`: the run ids linked to a waitpoint. Co-resident with the RUN (the join
16661701
// is written on the run's DB in blockRunWithWaitpointEdges), so the waitpoint's own store can MISS a
16671702
// cross-DB run — the router fans this across BOTH DBs.
@@ -1994,12 +2029,44 @@ export class PostgresRunStore implements RunStore {
19942029
return prisma.taskRunWaitpoint.deleteMany(args);
19952030
}
19962031

2032+
// The dedicated subset schema lacks control-plane relations; a pass-through include/select of one
2033+
// throws an opaque Prisma "Unknown field" 500 for NEW-resident data - invisible to tsc, since the
2034+
// run-ops client is typed as the full schema. Reject the known control-plane-only keys with a clear
2035+
// message at the boundary. No-op on the legacy (full-schema) store.
2036+
#assertSubsetSelectable(
2037+
fields: Record<string, unknown> | null | undefined,
2038+
forbidden: readonly string[],
2039+
method: string
2040+
): void {
2041+
if (this.schemaVariant !== "dedicated" || !fields) return;
2042+
for (const key of forbidden) {
2043+
if (fields[key]) {
2044+
throw new Error(
2045+
`${method}: "${key}" is not available on the dedicated run-ops subset schema ` +
2046+
`(control-plane-only); resolve it via the control-plane instead of selecting it here.`
2047+
);
2048+
}
2049+
}
2050+
}
2051+
19972052
async findTaskRunAttempt<T extends Prisma.TaskRunAttemptFindFirstArgs>(
19982053
args: Prisma.SelectSubset<T, Prisma.TaskRunAttemptFindFirstArgs>,
19992054
client?: ReadClient
20002055
): Promise<Prisma.TaskRunAttemptGetPayload<T> | null> {
20012056
const prisma = client ?? this.readOnlyPrisma;
20022057

2058+
const forbidden = ["backgroundWorker", "backgroundWorkerTask", "runtimeEnvironment", "queue"];
2059+
this.#assertSubsetSelectable(
2060+
(args as { include?: Record<string, unknown> }).include,
2061+
forbidden,
2062+
"findTaskRunAttempt"
2063+
);
2064+
this.#assertSubsetSelectable(
2065+
(args as { select?: Record<string, unknown> }).select,
2066+
forbidden,
2067+
"findTaskRunAttempt"
2068+
);
2069+
20032070
return prisma.taskRunAttempt.findFirst(
20042071
args
20052072
) as Promise<Prisma.TaskRunAttemptGetPayload<T> | null>;
@@ -2051,6 +2118,12 @@ export class PostgresRunStore implements RunStore {
20512118
): Promise<Prisma.BatchTaskRunGetPayload<{ include: T }> | null> {
20522119
const prisma = client ?? this.prisma;
20532120

2121+
this.#assertSubsetSelectable(
2122+
args?.include as Record<string, unknown> | undefined,
2123+
["runsBlocked", "waitpoints", "runtimeEnvironment"],
2124+
"findBatchTaskRunById"
2125+
);
2126+
20542127
return prisma.batchTaskRun.findFirst({
20552128
where: { id },
20562129
...(args?.include ? { include: args.include } : {}),
@@ -2065,6 +2138,12 @@ export class PostgresRunStore implements RunStore {
20652138
): Promise<Prisma.BatchTaskRunGetPayload<{ include: T }> | null> {
20662139
const prisma = client ?? this.readOnlyPrisma;
20672140

2141+
this.#assertSubsetSelectable(
2142+
args?.include as Record<string, unknown> | undefined,
2143+
["runsBlocked", "waitpoints", "runtimeEnvironment"],
2144+
"findBatchTaskRunByFriendlyId"
2145+
);
2146+
20682147
return prisma.batchTaskRun.findFirst({
20692148
where: { friendlyId, runtimeEnvironmentId: environmentId },
20702149
...(args?.include ? { include: args.include } : {}),
@@ -2081,6 +2160,12 @@ export class PostgresRunStore implements RunStore {
20812160
): Promise<Prisma.BatchTaskRunGetPayload<{ include: T }> | null> {
20822161
const prisma = client ?? this.prisma;
20832162

2163+
this.#assertSubsetSelectable(
2164+
args?.include as Record<string, unknown> | undefined,
2165+
["runsBlocked", "waitpoints", "runtimeEnvironment"],
2166+
"findBatchTaskRunByIdempotencyKey"
2167+
);
2168+
20842169
return prisma.batchTaskRun.findFirst({
20852170
where: { runtimeEnvironmentId: environmentId, idempotencyKey },
20862171
...(args?.include ? { include: args.include } : {}),

internal-packages/run-store/src/runOpsStore.batchItemMisroute.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,40 @@ describe("run-ops split — completing a batch item routes by the batch id, not
9292
expect(await prisma14.batchTaskRunItem.count({ where: { batchTaskRunId: batchId } })).toBe(0);
9393
}
9494
);
95+
96+
// createBatchTaskRunItem must co-locate the item with its BATCH (so the completion count, routed by
97+
// batchTaskRunId, finds it), not with the child run. Routing by taskRunId would place a divergent-
98+
// residency item on the child's DB -> invisible to the count -> the batch never completes -> parent hangs.
99+
heteroRunOpsPostgresTest(
100+
"createBatchTaskRunItem places the item on the batch's DB, routing by batchTaskRunId not taskRunId",
101+
async ({ prisma14, prisma17 }: { prisma14: PrismaClient; prisma17: RunOpsPrismaClient }) => {
102+
const router = makeSplitRouter(prisma14, prisma17);
103+
const envId = "env_batchitem";
104+
const batchId = `batch_${NEW_ID_26}`; // run-ops id -> #new
105+
const runId = "c".repeat(25); // cuid -> classifies LEGACY (the divergent routing key)
106+
107+
await prisma17.batchTaskRun.create({
108+
data: {
109+
id: batchId,
110+
friendlyId: "batch_create_new",
111+
runtimeEnvironmentId: envId,
112+
runCount: 1,
113+
status: "PROCESSING",
114+
},
115+
});
116+
// The run physically lives on #new (the batch's DB) so the item's FKs resolve there.
117+
await seedDedicatedRun(prisma17, envId, runId);
118+
119+
await router.createBatchTaskRunItem({
120+
batchTaskRunId: batchId,
121+
taskRunId: runId,
122+
status: "PENDING",
123+
});
124+
125+
// GREEN: routed by batchTaskRunId (NEW) -> item on #new, visible to the batch-completion count.
126+
// RED: routed by the cuid taskRunId -> #legacy -> the create FK-fails / the count never sees it.
127+
expect(await prisma17.batchTaskRunItem.count({ where: { batchTaskRunId: batchId } })).toBe(1);
128+
expect(await prisma14.batchTaskRunItem.count({ where: { batchTaskRunId: batchId } })).toBe(0);
129+
}
130+
);
95131
});

internal-packages/run-store/src/runOpsStore.snapshotCompletedWaitpoints.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,60 @@ describe("run-ops split — completed waitpoints for a cuid snapshot are found o
4444
expect(ids).toEqual([WAITPOINT_ID]);
4545
}
4646
);
47+
48+
// WithPresence reports, in one read, whether the snapshot is visible on the reader (so a multi-reader
49+
// replica can distinguish "no waitpoints" from "reader has not applied the snapshot yet").
50+
heteroRunOpsPostgresTest(
51+
"findSnapshotCompletedWaitpointIdsWithPresence reports present+ids for a dedicated snapshot, absent otherwise",
52+
async ({ prisma17 }: { prisma17: RunOpsPrismaClient }) => {
53+
const newStore = new PostgresRunStore({
54+
prisma: prisma17 as never,
55+
readOnlyPrisma: prisma17 as never,
56+
schemaVariant: "dedicated",
57+
});
58+
const runId = "run_" + "k".repeat(24) + "01";
59+
await prisma17.taskRun.create({
60+
data: {
61+
id: runId,
62+
friendlyId: "run_pres",
63+
engine: "V2",
64+
status: "PENDING",
65+
taskIdentifier: "t",
66+
payload: "{}",
67+
payloadType: "application/json",
68+
traceId: "tr",
69+
spanId: "sp",
70+
queue: "q",
71+
runtimeEnvironmentId: "env_pres",
72+
projectId: "proj_pres",
73+
},
74+
});
75+
const snap = await prisma17.taskRunExecutionSnapshot.create({
76+
data: {
77+
id: "c".repeat(25),
78+
engine: "V2",
79+
executionStatus: "EXECUTING",
80+
description: "continue",
81+
runStatus: "PENDING",
82+
runId,
83+
environmentId: "env_pres",
84+
environmentType: "DEVELOPMENT",
85+
projectId: "proj_pres",
86+
organizationId: "org_pres",
87+
},
88+
});
89+
await prisma17.completedWaitpoint.create({
90+
data: { snapshotId: snap.id, waitpointId: WAITPOINT_ID },
91+
});
92+
93+
expect(await newStore.findSnapshotCompletedWaitpointIdsWithPresence(snap.id)).toEqual({
94+
present: true,
95+
ids: [WAITPOINT_ID],
96+
});
97+
// A snapshot the reader does not have -> present:false, so its empty ids are not trusted as authoritative.
98+
expect(
99+
await newStore.findSnapshotCompletedWaitpointIdsWithPresence("c".repeat(24) + "zz")
100+
).toEqual({ present: false, ids: [] });
101+
}
102+
);
47103
});

0 commit comments

Comments
 (0)