Skip to content

Commit 152f826

Browse files
d-csclaude
andcommitted
perf(run-store,run-engine): take the records read off the copy-forward path
The append script now sources a refused carry-forward's record set from the cycle it replaces, rather than the caller pre-reading it and passing it in. The pre-read cost one HGET plus a parse of the whole record blob on every copy-forward append -- attempt start, dequeue, checkpoint -- to serve a branch that needs partial eviction to reach. Reading it in the branch that uses it is also atomic with the mint, so no reader can observe a replacement cycle whose ids have no records. Projects the legacy arm's envelope read to the columns the envelope is built from, so it stops reading tags and the ownership and timestamp columns it does not use on a read that lands on the writer inside the run lock. Corrects a docstring that described the extra read as happening on the resume path: the resume path supplies the records and was the one path that never read. Records the rollout-ordering constraint that keeps the format gate cheap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 0468fc4 commit 152f826

8 files changed

Lines changed: 267 additions & 210 deletions

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

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ async function getSnapshotWaitpointIdsWithPresence(
173173
* This is necessary because waitpoints can have large outputs (100KB+),
174174
* and fetching many at once can exceed Node.js string limits.
175175
*/
176-
export async function fetchWaitpointsInChunks(
176+
async function fetchWaitpointsInChunks(
177177
prisma: PrismaClientOrTransaction,
178178
waitpointIds: string[],
179179
runStore?: RunStore,
@@ -200,6 +200,67 @@ export async function fetchWaitpointsInChunks(
200200
return allWaitpoints;
201201
}
202202

203+
/**
204+
* The columns the completion envelope is built from, and only those.
205+
*
206+
* `fetchWaitpointsInChunks` reads whole rows because the snapshot hydration builds a full
207+
* executor waitpoint from them. The envelope needs fewer, so projecting drops `tags`,
208+
* `projectId`, `environmentId`, `createdAt`, `updatedAt` and `idempotencyKeyExpiresAt` from a
209+
* read that happens on the writer inside the run lock. It cannot drop `output`, which is the
210+
* 100KB+ column and also the payload the envelope exists to carry.
211+
*
212+
* `status` is here for the arm's COMPLETED filter, not for the mapper.
213+
*/
214+
const WAITPOINT_ENVELOPE_SELECT = {
215+
id: true,
216+
friendlyId: true,
217+
type: true,
218+
status: true,
219+
completedAt: true,
220+
output: true,
221+
outputType: true,
222+
outputIsError: true,
223+
completedByTaskRunId: true,
224+
completedByBatchId: true,
225+
completedAfter: true,
226+
idempotencyKey: true,
227+
userProvidedIdempotencyKey: true,
228+
inactiveIdempotencyKey: true,
229+
} satisfies Prisma.WaitpointSelect;
230+
231+
export type WaitpointEnvelopeRow = Pick<Waitpoint, keyof typeof WAITPOINT_ENVELOPE_SELECT>;
232+
233+
/**
234+
* The projected sibling of `fetchWaitpointsInChunks`, for the envelope read.
235+
*
236+
* Chunked identically, and for the same reason: a waitpoint output can be 100KB+, so a large
237+
* fan-in read whole can exceed Node's string limits. `boundedIn` pads for plan-cache stability,
238+
* it does not bound the set. `runId` is the routing hint the router needs to read the run's own
239+
* store instead of fanning every chunk across both run-ops databases.
240+
*/
241+
export async function fetchWaitpointEnvelopeRowsInChunks(
242+
prisma: PrismaClientOrTransaction,
243+
waitpointIds: string[],
244+
runStore?: RunStore,
245+
runId?: string
246+
): Promise<WaitpointEnvelopeRow[]> {
247+
if (waitpointIds.length === 0) return [];
248+
249+
const rows: WaitpointEnvelopeRow[] = [];
250+
for (let i = 0; i < waitpointIds.length; i += WAITPOINT_CHUNK_SIZE) {
251+
const chunk = waitpointIds.slice(i, i + WAITPOINT_CHUNK_SIZE);
252+
const args = {
253+
where: { id: { in: boundedIn(chunk) } },
254+
select: WAITPOINT_ENVELOPE_SELECT,
255+
};
256+
const found = runStore
257+
? await runStore.findManyWaitpoints(args, prisma, runId)
258+
: await prisma.waitpoint.findMany(args);
259+
rows.push(...found);
260+
}
261+
return rows;
262+
}
263+
203264
/**
204265
* Gets the most recent valid snapshot for a run. When `environmentId` is provided the read is scoped
205266
* to that environment (tenant boundary): a run in another environment reads as not-found and rejects

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,13 @@ export class WaitpointSystem {
766766
* That gate is what keeps this inert. `parseWaitpointId` reports legacy for every id minted
767767
* today, so no live resume reads an envelope or writes a record until a waitpoint mints in
768768
* store format.
769+
*
770+
* ROLLOUT ORDER. Because the gate reads the id and not the organisation, the first store-format
771+
* mint is what starts the cost, for every organisation that then holds one -- not the first
772+
* snapshot-store flip. The arm that answers here is the Postgres one until a store arm is
773+
* wired, so a mint enabled ahead of the store means a projected `Waitpoint` read on the
774+
* WRITER, inside the run lock, once per resume. That is bounded and correct, but it is not
775+
* free, so store-format minting should follow the snapshot store rather than lead it.
769776
*/
770777
async #completedWaitpointRecordsFor(
771778
runId: string,

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { PrismaClient, Waitpoint } from "@trigger.dev/database";
66
import { boundedIn, Prisma } from "@trigger.dev/database";
77
import { nanoid } from "nanoid";
88
import { UnclassifiableWaitpointId } from "../errors.js";
9-
import { fetchWaitpointsInChunks } from "../systems/executionSnapshotSystem.js";
9+
import { fetchWaitpointEnvelopeRowsInChunks } from "../systems/executionSnapshotSystem.js";
1010
import { envelopeSourceFromWaitpointRow } from "./completionEnvelopeSource.js";
1111
import type {
1212
AssociatedWaitpointData,
@@ -96,6 +96,10 @@ export class LegacyPostgresWaitpointCoordinator implements WaitpointCoordinator
9696
* Chunked for the same reason the snapshot hydration chunks: a waitpoint output can be
9797
* 100KB+, and a large fan-in read whole can exceed Node's string limits. `boundedIn` pads
9898
* for plan-cache stability, it does not bound the set.
99+
*
100+
* Projected to the columns the envelope is built from. This read lands on the writer, inside
101+
* the run lock, and the hydration reads the same rows again afterwards for a store-format
102+
* resume that Postgres still serves -- so it takes no more than it uses.
99103
*/
100104
async readCompletionEnvelopes({
101105
runId,
@@ -105,7 +109,12 @@ export class LegacyPostgresWaitpointCoordinator implements WaitpointCoordinator
105109
return [];
106110
}
107111

108-
const rows = await fetchWaitpointsInChunks(this.prisma, waitpointIds, this.runStore, runId);
112+
const rows = await fetchWaitpointEnvelopeRowsInChunks(
113+
this.prisma,
114+
waitpointIds,
115+
this.runStore,
116+
runId
117+
);
109118

110119
// COMPLETED only, so both arms honour one omission contract. The store arm cannot return a
111120
// pending waitpoint because a pending one has no completion to read; this arm reads rows by

internal-packages/run-store/src/redisSnapshotStore.recordsOnCarryRefusal.test.ts

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,9 @@ describe("a refused carry-forward", () => {
9797
});
9898

9999
// The reachable production shape. Every copy-forward append (dequeue, checkpoint, attempt)
100-
// re-passes the same refs and carries no records of its own, so this is the case a refusal
101-
// actually meets. Before the decorator read the surviving cycle's records, this minted a
102-
// replacement holding ids with no records, permanently.
100+
// re-passes the same refs and carries NO records of its own, and no longer pre-reads them: the
101+
// append script sources the record set from the cycle it is replacing. So a refusal preserves
102+
// the records without the caller having paid a read on every copy-forward that did not refuse.
103103
redisTest("keeps the records when the caller carried refs but none", async ({ redisOptions }) => {
104104
const store = new RedisSnapshotStore({ redisOptions, completedTtlMs: 60_000 });
105105
const raw = createRedisClient(redisOptions, { onError: () => {} });
@@ -115,35 +115,85 @@ describe("a refused carry-forward", () => {
115115
},
116116
});
117117

118-
// What the decorator now does for a records-less carry: read the surviving cycle's
119-
// records and carry those into the refusal branch.
120-
const carried = await store.getCycleRecords("run_1", 1);
121-
expect(carried).toHaveLength(1);
122-
118+
// Lose the four core keys, keeping the cycle key. This is the shape that makes the store
119+
// refuse the pointer: the seq counter is behind cycleSeqIn while wp:1 still lives.
123120
await raw.del("snap:{run_1}:e", "snap:{run_1}:idx", "snap:{run_1}:cur", "snap:{run_1}:seq");
124121

125-
await store.append({
122+
const carried = await store.append({
126123
entry: entry({ id: "snap_2" }),
127124
kind: "birth",
128125
isTerminal: false,
126+
// No `records`, exactly as a copy-forward append passes it.
129127
cycle: {
130128
kind: "carryForward",
131129
cycleSeq: 1,
132130
completedWaitpoints: [{ id: "w_a", index: 0 }],
133-
records: carried,
134131
},
135132
});
136133

134+
expect(carried).toMatchObject({ outcome: "written", cycleMismatch: true });
135+
136+
// The replacement holds the records the refused cycle held, copied inside the script.
137137
const read = await store.getLatest("run_1");
138138
const records = await recordsAt(raw, read!.cycle!.cycleSeq);
139139

140140
expect(records).toHaveLength(1);
141141
expect(records?.[0]?.id).toBe("w_a");
142+
expect(records?.[0]?.output).toEqual({ inline: "first" });
142143
} finally {
143144
await Promise.all([store.quit(), raw.quit().catch(() => {})]);
144145
}
145146
});
146147

148+
// The other half of the refusal: the cycle key itself is gone, so there are no records anywhere
149+
// and the replacement legitimately holds none. It must not inherit a stale set from a re-minted
150+
// cycleSeq whose key survived.
151+
redisTest(
152+
"mints a records-less replacement when the cycle key is gone",
153+
async ({ redisOptions }) => {
154+
const store = new RedisSnapshotStore({ redisOptions, completedTtlMs: 60_000 });
155+
const raw = createRedisClient(redisOptions, { onError: () => {} });
156+
try {
157+
await store.append({
158+
entry: entry({ id: "snap_1" }),
159+
kind: "birth",
160+
isTerminal: false,
161+
cycle: {
162+
kind: "new",
163+
completedWaitpoints: [{ id: "w_a", index: 0 }],
164+
records: [record("w_a", "first")],
165+
},
166+
});
167+
168+
await raw.del(
169+
"snap:{run_1}:e",
170+
"snap:{run_1}:idx",
171+
"snap:{run_1}:cur",
172+
"snap:{run_1}:seq",
173+
"snap:{run_1}:wp:1"
174+
);
175+
176+
const carried = await store.append({
177+
entry: entry({ id: "snap_2" }),
178+
kind: "birth",
179+
isTerminal: false,
180+
cycle: {
181+
kind: "carryForward",
182+
cycleSeq: 1,
183+
completedWaitpoints: [{ id: "w_b", index: 0 }],
184+
},
185+
});
186+
187+
expect(carried).toMatchObject({ outcome: "written", cycleMismatch: true });
188+
189+
const read = await store.getLatest("run_1");
190+
expect(await recordsAt(raw, read!.cycle!.cycleSeq)).toBeUndefined();
191+
} finally {
192+
await Promise.all([store.quit(), raw.quit().catch(() => {})]);
193+
}
194+
}
195+
);
196+
147197
// Without refs there is nothing to mint from, so the entry is written with no pointer. That is
148198
// the older behaviour and it stays: no pointer is safe, a pointer with no records is not.
149199
redisTest("writes no pointer when the caller carried no refs", async ({ redisOptions }) => {

internal-packages/run-store/src/redisSnapshotStore.test.ts

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@ import { expect, describe, vi } from "vitest";
44
import { redisTest, slotOf } from "@internal/testcontainers";
55
import { createRedisClient } from "@internal/redis";
66
import { Logger } from "@trigger.dev/core/logger";
7-
import { generateWaitpointId } from "@trigger.dev/core/v3/isomorphic";
87
import {
9-
mayHoldRecords,
108
snapshotKeys,
119
deriveOrder,
1210
isValidFor,
@@ -26,46 +24,6 @@ describe("snapshotKeys", () => {
2624
});
2725
});
2826

29-
describe("mayHoldRecords", () => {
30-
it("is false for an empty set", () => {
31-
expect(mayHoldRecords([])).toBe(false);
32-
});
33-
34-
it("is false when every id is legacy", () => {
35-
expect(mayHoldRecords([{ id: "w_cuid_a", index: 0 }, { id: "w_cuid_b" }])).toBe(false);
36-
});
37-
38-
it("is true when one id is store-format", () => {
39-
expect(mayHoldRecords([{ id: generateWaitpointId("MANUAL"), index: 0 }])).toBe(true);
40-
});
41-
42-
// The mixed case is the one a per-organisation rollout produces: a run holding waitpoints
43-
// minted either side of the flip. One store-format id is enough to require the read.
44-
it("is true when a store-format id sits among legacy ones", () => {
45-
expect(
46-
mayHoldRecords([
47-
{ id: "w_cuid_a", index: 0 },
48-
{ id: generateWaitpointId("MANUAL"), index: 1 },
49-
{ id: "w_cuid_c" },
50-
])
51-
).toBe(true);
52-
});
53-
54-
it("reads the id, not the index, so an index-less store id still counts", () => {
55-
expect(mayHoldRecords([{ id: generateWaitpointId("DATETIME") }])).toBe(true);
56-
});
57-
58-
it("counts every store waitpoint type", () => {
59-
for (const t of ["RUN", "BATCH", "DATETIME", "MANUAL"] as const) {
60-
expect(mayHoldRecords([{ id: generateWaitpointId(t) }])).toBe(true);
61-
}
62-
});
63-
64-
it("is false for a foreign prefix that is not a waitpoint id", () => {
65-
expect(mayHoldRecords([{ id: "run_0123456789abcdefghijklm" }])).toBe(false);
66-
});
67-
});
68-
6927
describe("deriveOrder", () => {
7028
it("drops entries with no index, sorts by index, and maps to id", () => {
7129
expect(

0 commit comments

Comments
 (0)