Skip to content

Commit 5bc039e

Browse files
d-csclaude
andcommitted
fix(run-engine): enforce the writer-client rule, and close the deferred-output fail-open
An exhaustive walk of the deferred-output state space found the mechanism sound, and asked for one change plus two cheap ones. The required-writer rule could not be enforced by the type system. ReadClient admits both a writer and a replica, and the two are structurally identical -- separable only by a runtime brand -- so a caller passing readOnlyPrisma would type-check and quietly reinstate the replica-lag window that turns a committed child output into a refused resume. The reader now asserts the brand. It is built once at wiring time, so the check costs nothing per read. A record marked as deferring its output while carrying no run id was the one place this design failed OPEN rather than loud: it resolved the waitpoint with no output and no error. Unreachable from the current record build, which requires the run id before it marks anything derivable, but a reordering of those conditions is all it would take. It now throws, like a record arriving with no reader wired. Also pins the orphan case against the oracle: a RUN waitpoint whose completing run is gone keeps its own output inline and must omit the run sub-object, which only the differential comparison catches. Records the premise the deferred read rests on: the completing run's output still holds what the waitpoint was completed with. Nothing overwrites it today, and if that changes the divergence would be silent rather than loud. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 5a42f06 commit 5bc039e

3 files changed

Lines changed: 79 additions & 2 deletions

File tree

internal-packages/run-engine/src/engine/waitpointCoordinator/completedWaitpointEquivalence.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,29 @@ describe("the resolver reproduces the existing hydration", () => {
236236
// with no output; an earlier revision marked this derivable, read a null TaskRun.output and
237237
// refused the resume outright. Comparing against the oracle is what makes that a failure rather
238238
// than a design choice, so the case belongs here and not only in the unit suite.
239+
// An orphan: the completing run is gone, so onDelete: SetNull cleared the back-reference while
240+
// the waitpoint kept its output. The record must stay inline -- derivable would send the
241+
// resolver to a run that no longer exists -- and the entry must omit completedByTaskRun, which
242+
// only the oracle comparison pins.
243+
postgresTest("for an orphaned RUN waitpoint that kept its output", async ({ prisma }) => {
244+
const { expected, actual } = await bothPaths(
245+
prisma,
246+
[
247+
pair({
248+
id: "wp_run_orphan",
249+
type: "RUN",
250+
output: '{"orphan":true}',
251+
completedByTaskRunId: null,
252+
}),
253+
],
254+
["wp_run_orphan"]
255+
);
256+
257+
expect(actual).toEqual(expected);
258+
expect(actual[0]?.output).toBe('{"orphan":true}');
259+
expect(actual[0]?.completedByTaskRun).toBeUndefined();
260+
});
261+
239262
postgresTest("for a RUN waitpoint whose child returned no output", async ({ prisma }) => {
240263
const childRunId = await seedChildRunWithOutput(prisma, null);
241264
const { expected, actual } = await bothPaths(

internal-packages/run-engine/src/engine/waitpointCoordinator/completedWaitpointResolver.runOutput.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// by a pure test: the claim is that TaskRun.output holds the same string the waitpoint carried,
55
// and only a real row can settle that. The pure suite covers everything that does not read.
66
import { postgresTest } from "@internal/testcontainers";
7-
import { PostgresRunStore } from "@internal/run-store";
7+
import { markReadReplicaClient, PostgresRunStore } from "@internal/run-store";
88
import type { CompletedWaitpointRecord } from "@internal/run-store";
99
import type { PrismaClient } from "@trigger.dev/database";
1010
import { describe, expect } from "vitest";
@@ -248,6 +248,31 @@ describe("the deriveFromRun branch", () => {
248248
expect(batches).toEqual([]);
249249
});
250250

251+
// The required-writer rule is a runtime check because it cannot be a type one: ReadClient admits
252+
// a writer and a replica, and they are structurally identical apart from the brand.
253+
postgresTest("refuses to be built with a replica client", async ({ prisma }) => {
254+
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
255+
const replica = markReadReplicaClient({ ...prisma });
256+
257+
expect(() => createRunOutputsReader(runStore, replica as never)).toThrow(/needs a writer/);
258+
});
259+
260+
// The one place the design could fail OPEN instead of loud: a derive marker with no run to
261+
// derive from would otherwise resolve the waitpoint with no output and no error.
262+
postgresTest("throws on a derive record carrying no run id", async ({ prisma }) => {
263+
const { id: _drop, ...rest } = deriveRecord("run_unused");
264+
265+
await expect(
266+
resolverFor(prisma)({
267+
runId: "run_parent",
268+
pointer: { cycleSeq: 1, count: 0 },
269+
order: [],
270+
distinctIds: ["wp_run"],
271+
records: [{ ...rest, id: "wp_run", completedByTaskRunId: undefined }],
272+
})
273+
).rejects.toThrow(/carries no run id/);
274+
});
275+
251276
postgresTest("throws when a derive record arrives with no reader wired", async ({ prisma }) => {
252277
const runId = await seedChildRunWithOutput(prisma, '{"value":42}');
253278

internal-packages/run-engine/src/engine/waitpointCoordinator/completedWaitpointResolver.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isReadReplicaClient } from "@internal/run-store";
12
import type {
23
CompletedWaitpointRecord,
34
ReadClient,
@@ -76,12 +77,29 @@ const RUN_OUTPUT_CHUNK_SIZE = 100;
7677
*
7778
* The router turns any client that is not replica-branded into the owning store's primary, and
7879
* turns NO client into its replica -- so an optional parameter would silently reintroduce the lag
79-
* window every time a caller omitted it. Hence required.
80+
* window every time a caller omitted it. Hence required, and hence checked: `ReadClient` admits
81+
* both a writer and a replica, and the two are structurally identical, separable only by the
82+
* runtime brand. A caller passing `readOnlyPrisma` would type-check and quietly reinstate the
83+
* window, so the constraint has to be asserted rather than documented. Constructed once at wiring
84+
* time, so the assertion costs nothing per read.
85+
*
86+
* The premise this read rests on: `TaskRun.output` still holds what the waitpoint was completed
87+
* with. `completeAttemptSuccess` writes the run's output and the waitpoint's completion from the
88+
* same values, and nothing overwrites a completed run's output afterwards. If that ever stops
89+
* being true, a deferred record resolves the NEWER value while the legacy path would emit the
90+
* frozen one -- and unlike a lost output, that divergence is silent.
8091
*/
8192
export function createRunOutputsReader(
8293
runStore: Pick<RunStore, "findRunsByIds">,
8394
client: ReadClient
8495
): (taskRunIds: string[]) => Promise<Map<string, string>> {
96+
if (isReadReplicaClient(client)) {
97+
throw new Error(
98+
"createRunOutputsReader needs a writer: a replica can trail the child's committed output " +
99+
"and the resolver would refuse the resume as a lost output."
100+
);
101+
}
102+
85103
return async (taskRunIds) => {
86104
const outputs = new Map<string, string>();
87105

@@ -295,6 +313,17 @@ function hydrateOutput(
295313
return record.output.ref;
296314
}
297315

316+
// A record marked deriveFromRun with no run to derive from. Unreachable from today's
317+
// chooseOutput, which requires the id before it marks anything derivable -- but this is the one
318+
// spot where the design could fail OPEN rather than loud, resolving the waitpoint with no output
319+
// at all, and a reordering of those conjuncts is all it would take. Treated as the build fault
320+
// it would be, like a record arriving with no reader wired.
321+
if (record.output !== null && "deriveFromRun" in record.output && !record.completedByTaskRunId) {
322+
throw new Error(
323+
`Waitpoint ${record.id} defers its output to its completing run, but carries no run id.`
324+
);
325+
}
326+
298327
const runId = deferredRunIdOf(record);
299328
if (runId === undefined) {
300329
return undefined;

0 commit comments

Comments
 (0)