Skip to content

Commit 5a42f06

Browse files
d-csclaude
andcommitted
fix(run-engine): resume a child that returned no output, and read its output on the writer
Two defects in the deferred-output path, both found in review. A task that returns nothing completes its waitpoint with no output at all. The record build still marked it derivable, so the resolver read a null TaskRun.output and refused the resume as a lost output -- for every triggerAndWait on a void task. The hydration this replaces resumes cleanly with no output. A record now defers only when the waitpoint actually carried an output; one that did, whose run row has since gone, still refuses, which is what the refusal is for. An empty string stays derivable, because empty is a value. A test asserted the broken behaviour was correct, which is why nothing caught it. It now pins the case it meant to cover: a record that defers to a run whose output is gone. The run-output read also needs read-your-writes, and an earlier revision removed its client to keep the read off the writer. The ordering argument for that was wrong: the child does commit its output before completing the waitpoint, but the reader can observe the completion by another route while the run-output replica still trails, and the output then reads null and the resume is refused. A hard refusal is worse than a read on the writer, and this read is bounded and projected to one column. The client is required rather than optional, because the router turns a missing client into a replica read and an optional parameter would reintroduce the window whenever a caller omitted it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 482e86e commit 5a42f06

6 files changed

Lines changed: 89 additions & 18 deletions

File tree

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ async function bothPaths(
8989
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
9090

9191
const actual = await createCompletedWaitpointResolver({
92-
readRunOutputs: createRunOutputsReader(runStore),
92+
readRunOutputs: createRunOutputsReader(runStore, prisma),
9393
})({
9494
runId: RUN_ID,
9595
...(batchId ? { batchId } : {}),
@@ -232,6 +232,29 @@ describe("the resolver reproduces the existing hydration", () => {
232232
expect(actual.find((w) => w.id === "wp_run_b")?.output).toBe('{"child":"b"}');
233233
});
234234

235+
// A child that returned nothing. The oracle emits `output: w.output ?? undefined`, i.e. resumes
236+
// with no output; an earlier revision marked this derivable, read a null TaskRun.output and
237+
// refused the resume outright. Comparing against the oracle is what makes that a failure rather
238+
// than a design choice, so the case belongs here and not only in the unit suite.
239+
postgresTest("for a RUN waitpoint whose child returned no output", async ({ prisma }) => {
240+
const childRunId = await seedChildRunWithOutput(prisma, null);
241+
const { expected, actual } = await bothPaths(
242+
prisma,
243+
[
244+
pair({
245+
id: "wp_run_void",
246+
type: "RUN",
247+
output: null,
248+
completedByTaskRunId: childRunId,
249+
}),
250+
],
251+
["wp_run_void"]
252+
);
253+
254+
expect(actual).toEqual(expected);
255+
expect(actual[0]?.output).toBeUndefined();
256+
});
257+
235258
postgresTest("for a RUN waitpoint read under a batch", async ({ prisma }) => {
236259
const childRunId = await seedChildRunWithOutput(prisma, '{"ok":true}');
237260
const { expected, actual } = await bothPaths(

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,27 @@ describe("buildCompletedWaitpointRecords", () => {
107107
expect(record?.output).toEqual({ deriveFromRun: true });
108108
});
109109

110+
// A task that returns nothing completes its waitpoint with NO output, so there is nothing to
111+
// derive. Marking it derivable made the resolver read a null TaskRun.output and refuse the
112+
// resume as a lost output -- for every triggerAndWait on a void task. The hydration this
113+
// replaces resumes cleanly with no output, so the record must carry none, not a marker.
114+
it("does not defer a RUN whose output is absent", () => {
115+
const [record] = buildCompletedWaitpointRecords([
116+
source({ type: "RUN", completedByTaskRunId: "run_1", output: undefined }),
117+
]);
118+
119+
expect(record?.output).toBeNull();
120+
});
121+
122+
// An empty string is a value, so it IS derivable: TaskRun.output holds it verbatim.
123+
it("defers a RUN whose output is an empty string", () => {
124+
const [record] = buildCompletedWaitpointRecords([
125+
source({ type: "RUN", completedByTaskRunId: "run_1", output: "" }),
126+
]);
127+
128+
expect(record?.output).toEqual({ deriveFromRun: true });
129+
});
130+
110131
// TaskRun.error is jsonb and does not round-trip to the same string, so a RUN error can
111132
// never be re-read from the run row.
112133
it("keeps a RUN error inline", () => {

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,21 @@ function chooseOutput(source: CompletionEnvelopeSource): CompletedWaitpointRecor
4949
return { ref: source.outputRef };
5050
}
5151

52-
// A plain RUN output is re-readable from TaskRun.output verbatim. Two RUN cases are not,
53-
// and both must stay inline: an ERROR, because TaskRun.error is jsonb and does not
54-
// round-trip to the same string, and an ORPHAN, because the back-reference is
55-
// onDelete: SetNull so the completing row may be gone.
56-
if (source.type === "RUN" && !source.outputIsError && source.completedByTaskRunId) {
52+
// A plain RUN output is re-readable from TaskRun.output verbatim. Three RUN cases are not.
53+
// An ERROR, because TaskRun.error is jsonb and does not round-trip to the same string. An
54+
// ORPHAN, because the back-reference is onDelete: SetNull so the completing row may be gone.
55+
// And an ABSENT output, which is the case that has to be checked here rather than left to the
56+
// read: a task that returns nothing completes its waitpoint with no output at all, and there
57+
// is then nothing to derive. Marking it derivable makes the resolver read a null TaskRun.output
58+
// and refuse the resume as a lost output, where the hydration this replaces resumes cleanly
59+
// with no output (`output: w.output ?? undefined`). A waitpoint that DID carry an output whose
60+
// run row has since gone still refuses, which is what the refusal is for.
61+
if (
62+
source.type === "RUN" &&
63+
!source.outputIsError &&
64+
source.output !== undefined &&
65+
source.completedByTaskRunId
66+
) {
5767
return { deriveFromRun: true };
5868
}
5969

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ function deriveRecord(completedByTaskRunId: string): CompletedWaitpointRecord {
3030

3131
function resolverFor(prisma: PrismaClient) {
3232
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
33-
return createCompletedWaitpointResolver({ readRunOutputs: createRunOutputsReader(runStore) });
33+
return createCompletedWaitpointResolver({
34+
readRunOutputs: createRunOutputsReader(runStore, prisma),
35+
});
3436
}
3537

3638
/**
@@ -42,7 +44,7 @@ function resolverFor(prisma: PrismaClient) {
4244
*/
4345
function countingResolver(prisma: PrismaClient) {
4446
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
45-
const read = createRunOutputsReader(runStore);
47+
const read = createRunOutputsReader(runStore, prisma);
4648
const batches: string[][] = [];
4749

4850
return {
@@ -106,7 +108,14 @@ describe("the deriveFromRun branch", () => {
106108
expect(failure.reason).toBe("lost-run-output");
107109
});
108110

109-
postgresTest("refuses when the run exists with no output", async ({ prisma }) => {
111+
// A record that DEFERS to a run whose output is gone still refuses -- that is the case the
112+
// refusal exists for, and the record only defers when the waitpoint carried an output.
113+
//
114+
// This is deliberately reached by hand-building a derive record for an output-less run, a shape
115+
// chooseOutput no longer produces. An earlier revision produced it for every non-error RUN
116+
// waitpoint, which refused the resume of any task that returns nothing; the test below pins
117+
// that case, and this one keeps the refusal itself honest.
118+
postgresTest("refuses when a derive record's run output is gone", async ({ prisma }) => {
110119
const runId = await seedChildRunWithOutput(prisma, null);
111120

112121
const failure = await resolverFor(prisma)({

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

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
CompletedWaitpointRecord,
3+
ReadClient,
34
ResolveCompletedWaitpointsArgs,
45
RunStore,
56
} from "@internal/run-store";
@@ -64,22 +65,29 @@ const RUN_OUTPUT_CHUNK_SIZE = 100;
6465
* and it forces `id` into the projection so the map keys correctly even though this select
6566
* names only `output`.
6667
*
67-
* Takes no read client on purpose. The router reads the owning store's REPLICA when no client is
68-
* passed, and forces its PRIMARY for any client that is not replica-branded -- so accepting one
69-
* would let a caller put a wide `TaskRun.output` read on the writer by reflex. This read does not
70-
* need read-your-writes: the child run committed its output before it completed the waitpoint that
71-
* unblocked this parent, so replica lag cannot hide it. That is the opposite of the envelope read
72-
* in the legacy arm, which reads a waitpoint completed moments earlier and must use the writer.
68+
* The client is REQUIRED, and must be the writer, because this read needs read-your-writes.
69+
*
70+
* It is tempting to argue the replica is safe here: the child commits its output before it
71+
* completes the waitpoint that unblocks this parent, so the write happens first. That ordering is
72+
* real but it does not help, because the READER can observe the completion by another route --
73+
* from Redis, or from the primary -- while the run-output replica still trails. The output then
74+
* reads null and the resolver refuses the resume as a lost output. A hard refusal is a far worse
75+
* outcome than a read on the writer, and this read is bounded and projected to one column.
76+
*
77+
* The router turns any client that is not replica-branded into the owning store's primary, and
78+
* 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.
7380
*/
7481
export function createRunOutputsReader(
75-
runStore: Pick<RunStore, "findRunsByIds">
82+
runStore: Pick<RunStore, "findRunsByIds">,
83+
client: ReadClient
7684
): (taskRunIds: string[]) => Promise<Map<string, string>> {
7785
return async (taskRunIds) => {
7886
const outputs = new Map<string, string>();
7987

8088
for (let i = 0; i < taskRunIds.length; i += RUN_OUTPUT_CHUNK_SIZE) {
8189
const chunk = taskRunIds.slice(i, i + RUN_OUTPUT_CHUNK_SIZE);
82-
const rows = await runStore.findRunsByIds(chunk, { select: { output: true } });
90+
const rows = await runStore.findRunsByIds(chunk, { select: { output: true } }, client);
8391

8492
for (const [id, row] of rows) {
8593
// A row present with a null output is the same absence as a missing row: either way the

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ function harness(prisma: PrismaClient, redisOptions: never): Harness {
126126
logger: new Logger("roundtrip", "error"),
127127
}),
128128
resolve: createCompletedWaitpointResolver({
129-
readRunOutputs: createRunOutputsReader(runStore),
129+
readRunOutputs: createRunOutputsReader(runStore, prisma),
130130
}),
131131
runStore,
132132
};

0 commit comments

Comments
 (0)