Skip to content

Commit ab9b4b2

Browse files
committed
perf(run-store,run-engine): route run-keyed waitpoint and snapshot reads by run id
In split mode these reads fanned out to both run-ops databases on every call. Route them by the run id already in scope so the non-owning database is no longer queried; only genuinely cross-tree waitpoints still need both.
1 parent 4325052 commit ab9b4b2

8 files changed

Lines changed: 656 additions & 80 deletions

File tree

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,13 @@ function enhanceExecutionSnapshotWithWaitpoints(
126126
async function getSnapshotWaitpointIds(
127127
prisma: PrismaClientOrTransaction,
128128
snapshotId: string,
129-
runStore?: RunStore
129+
runStore?: RunStore,
130+
// The owning run id, so the router can route to the run's store (the completed-waitpoint join
131+
// co-locates with the snapshot/run) instead of fanning out to both run-ops DBs.
132+
runId?: string
130133
): Promise<string[]> {
131134
if (runStore) {
132-
return runStore.findSnapshotCompletedWaitpointIds(snapshotId, prisma);
135+
return runStore.findSnapshotCompletedWaitpointIds(snapshotId, prisma, runId);
133136
}
134137

135138
const result = await prisma.$queryRaw<{ B: string }[]>`
@@ -144,10 +147,12 @@ async function getSnapshotWaitpointIds(
144147
async function getSnapshotWaitpointIdsWithPresence(
145148
prisma: PrismaClientOrTransaction,
146149
snapshotId: string,
147-
runStore?: RunStore
150+
runStore?: RunStore,
151+
// The owning run id, so the router can route to the run's store instead of fanning out.
152+
runId?: string
148153
): Promise<{ present: boolean; ids: string[] }> {
149154
if (runStore) {
150-
return runStore.findSnapshotCompletedWaitpointIdsWithPresence(snapshotId, prisma);
155+
return runStore.findSnapshotCompletedWaitpointIdsWithPresence(snapshotId, prisma, runId);
151156
}
152157

153158
const rows = await prisma.$queryRaw<{ id: string; B: string | null }[]>`
@@ -170,15 +175,18 @@ async function getSnapshotWaitpointIdsWithPresence(
170175
async function fetchWaitpointsInChunks(
171176
prisma: PrismaClientOrTransaction,
172177
waitpointIds: string[],
173-
runStore?: RunStore
178+
runStore?: RunStore,
179+
// The owning run id, so the router routes to the run's store and only falls back to the other DB
180+
// for the rare cross-tree token, instead of fanning every chunk out to both run-ops DBs.
181+
runId?: string
174182
): Promise<Waitpoint[]> {
175183
if (waitpointIds.length === 0) return [];
176184

177185
const allWaitpoints: Waitpoint[] = [];
178186
for (let i = 0; i < waitpointIds.length; i += WAITPOINT_CHUNK_SIZE) {
179187
const chunk = waitpointIds.slice(i, i + WAITPOINT_CHUNK_SIZE);
180188
const waitpoints = runStore
181-
? await runStore.findManyWaitpoints({ where: { id: { in: chunk } } }, prisma)
189+
? await runStore.findManyWaitpoints({ where: { id: { in: chunk } } }, prisma, runId)
182190
: await prisma.waitpoint.findMany({
183191
where: { id: { in: chunk } },
184192
});
@@ -347,7 +355,8 @@ export async function getExecutionSnapshotsSince(
347355
const { present, ids } = await getSnapshotWaitpointIdsWithPresence(
348356
prisma,
349357
latestSnapshot.id,
350-
runStore
358+
runStore,
359+
runId
351360
);
352361
let waitpointIds = ids;
353362

@@ -356,7 +365,7 @@ export async function getExecutionSnapshotsSince(
356365
// authoritative - re-read from the primary so the runner is not handed a waitpoint-less continue
357366
// (which it silently drops, hanging the run). Single-reader replicas never hit this (present stays true).
358367
if (repairClient && repairClient !== prisma && !present) {
359-
waitpointIds = await getSnapshotWaitpointIds(repairClient, latestSnapshot.id, runStore);
368+
waitpointIds = await getSnapshotWaitpointIds(repairClient, latestSnapshot.id, runStore, runId);
360369
readClient = repairClient;
361370
}
362371

@@ -369,15 +378,15 @@ export async function getExecutionSnapshotsSince(
369378
// poll even against a caught-up replica.
370379
const expectedCount = new Set(latestSnapshot.completedWaitpointOrder ?? []).size;
371380
if (repairClient && repairClient !== prisma && waitpointIds.length < expectedCount) {
372-
const repaired = await getSnapshotWaitpointIds(repairClient, latestSnapshot.id, runStore);
381+
const repaired = await getSnapshotWaitpointIds(repairClient, latestSnapshot.id, runStore, runId);
373382
if (repaired.length > waitpointIds.length) {
374383
waitpointIds = repaired;
375384
readClient = repairClient;
376385
}
377386
}
378387

379388
// Step 4: Fetch waitpoints in chunks to avoid NAPI string conversion limits
380-
const waitpoints = await fetchWaitpointsInChunks(readClient, waitpointIds, runStore);
389+
const waitpoints = await fetchWaitpointsInChunks(readClient, waitpointIds, runStore, runId);
381390

382391
// Step 5: Build enhanced snapshots - only latest gets waitpoints, others get empty arrays
383392
// The runner only uses completedWaitpoints from the latest snapshot anyway

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,10 @@ export class WaitpointSystem {
5959
runId: string;
6060
tx?: PrismaClientOrTransaction;
6161
}) {
62-
// Route the delete: a run's edges may live on #new and/or #legacy (mid-drain), so it must fan
63-
// across both stores. The caller's `tx` is not forwarded into either leg — each store's delete
64-
// runs on its own client (the router never threads a control-plane tx into a routed write).
62+
// A run's edges co-locate with the run (the edge write routes by runId), so the router routes this
63+
// taskRunId-keyed delete to the run's store rather than fanning out. The caller's `tx` is not
64+
// forwarded — the delete runs on the owning store's own client (the router never threads a
65+
// control-plane tx into a routed write).
6566
const deleted = await this.$.runStore.deleteManyTaskRunWaitpoints(
6667
{ where: { taskRunId: runId } },
6768
tx
@@ -493,7 +494,9 @@ export class WaitpointSystem {
493494

494495
// Check if the run is actually blocked using a separate query (see above). Pass the writer so the
495496
// pending re-read is read-your-writes on the owning PRIMARY (a lagging replica can strand the run).
496-
const pendingCount = await this.$.runStore.countPendingWaitpoints($waitpoints, prisma);
497+
// Route by the blocked run id: its blocking waitpoints co-locate with the run, so the router
498+
// counts on the run's store and only falls back to the other DB for a cross-tree token.
499+
const pendingCount = await this.$.runStore.countPendingWaitpoints($waitpoints, prisma, runId);
497500

498501
const isRunBlocked = pendingCount > 0;
499502

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

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1897,7 +1897,9 @@ export class PostgresRunStore implements RunStore {
18971897

18981898
async findSnapshotCompletedWaitpointIds(
18991899
snapshotId: string,
1900-
client?: ReadClient
1900+
client?: ReadClient,
1901+
// `runId` selects residency at the router; a single store has one client and ignores it.
1902+
_runId?: string
19011903
): Promise<string[]> {
19021904
const prisma = client ?? this.readOnlyPrisma;
19031905

@@ -1924,7 +1926,9 @@ export class PostgresRunStore implements RunStore {
19241926
// return the snapshot (via a separate read) while a different, laggier reader returns 0 links.
19251927
async findSnapshotCompletedWaitpointIdsWithPresence(
19261928
snapshotId: string,
1927-
client?: ReadClient
1929+
client?: ReadClient,
1930+
// `runId` selects residency at the router; a single store has one client and ignores it.
1931+
_runId?: string
19281932
): Promise<{ present: boolean; ids: string[] }> {
19291933
const prisma = client ?? this.readOnlyPrisma;
19301934

@@ -2097,7 +2101,12 @@ export class PostgresRunStore implements RunStore {
20972101
SELECT COUNT(*) FROM inserted`;
20982102
}
20992103

2100-
async countPendingWaitpoints(waitpointIds: string[], client?: ReadClient): Promise<number> {
2104+
async countPendingWaitpoints(
2105+
waitpointIds: string[],
2106+
client?: ReadClient,
2107+
// `runId` selects residency at the router; a single store has one client and ignores it.
2108+
_runId?: string
2109+
): Promise<number> {
21012110
const prisma = client ?? this.readOnlyPrisma;
21022111

21032112
if (waitpointIds.length === 0) {
@@ -2128,6 +2137,35 @@ export class PostgresRunStore implements RunStore {
21282137
return Number(pendingCheck[0]?.pending_count ?? 0);
21292138
}
21302139

2140+
// One `SELECT id, status` returns which ids are PENDING and which exist on this store (any status),
2141+
// so the router can route by run id and only re-count the ids ABSENT here on the other DB — without
2142+
// undercounting pending (which would prematurely unblock a run) or double-counting a drain-mirrored
2143+
// id. Reads only id+status, so it stays cheap for a run's small blocking set.
2144+
async countPendingWaitpointsWithPresence(
2145+
waitpointIds: string[],
2146+
client?: ReadClient
2147+
): Promise<{ pendingIds: string[]; presentIds: string[] }> {
2148+
const prisma = client ?? this.readOnlyPrisma;
2149+
2150+
if (waitpointIds.length === 0) {
2151+
return { pendingIds: [], presentIds: [] };
2152+
}
2153+
2154+
const rows =
2155+
this.schemaVariant === "dedicated"
2156+
? await prisma.$queryRaw<{ id: string; status: string }[]>`
2157+
SELECT id, status FROM "Waitpoint" WHERE id = ANY(${waitpointIds}::text[])
2158+
`
2159+
: await prisma.$queryRaw<{ id: string; status: string }[]>`
2160+
SELECT id, status FROM "Waitpoint" WHERE id IN (${Prisma.join(waitpointIds)})
2161+
`;
2162+
2163+
return {
2164+
pendingIds: rows.filter((r) => r.status === "PENDING").map((r) => r.id),
2165+
presentIds: rows.map((r) => r.id),
2166+
};
2167+
}
2168+
21312169
async createWaitpoint<T extends Prisma.WaitpointCreateArgs>(
21322170
args: Prisma.SelectSubset<T, Prisma.WaitpointCreateArgs>,
21332171
tx?: PrismaClientOrTransaction
@@ -2189,7 +2227,9 @@ export class PostgresRunStore implements RunStore {
21892227

21902228
async findManyWaitpoints<T extends Prisma.WaitpointFindManyArgs>(
21912229
args: Prisma.SelectSubset<T, Prisma.WaitpointFindManyArgs>,
2192-
client?: ReadClient
2230+
client?: ReadClient,
2231+
// `runId` selects residency at the router; a single store has one client and ignores it.
2232+
_runId?: string
21932233
): Promise<Prisma.WaitpointGetPayload<T>[]> {
21942234
const prisma = client ?? this.readOnlyPrisma;
21952235

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,7 @@ describe("RoutingRunStore never threads a caller tx into either sub-store (recor
748748
);
749749

750750
heteroRunOpsPostgresTest(
751-
"deleteManyTaskRunWaitpoints fan-out hands BOTH sub-stores an undefined tx",
751+
"deleteManyTaskRunWaitpoints routes by taskRunId to the owning store with an undefined tx",
752752
async ({ prisma14, prisma17 }) => {
753753
const legacyCalls: RecordedCall[] = [];
754754
const newCalls: RecordedCall[] = [];
@@ -769,14 +769,15 @@ describe("RoutingRunStore never threads a caller tx into either sub-store (recor
769769
);
770770
await seedLegacyBlockingEdge(prisma14, env, runId, "spy_del");
771771

772-
// Keyed by taskRunId → the both-stores fan-out branch. Pass the base control-plane client as tx.
772+
// Keyed by a classifiable taskRunId (a cuid run → #legacy): routes to the owning store, no
773+
// fan-out. Pass the base control-plane client as tx — it must NOT be threaded into the routed leg.
773774
await router.deleteManyTaskRunWaitpoints({ where: { taskRunId: runId } }, prisma14);
774775

775776
const legacyTx = txArgsFor(legacyCalls, "deleteManyTaskRunWaitpoints");
776777
const newTx = txArgsFor(newCalls, "deleteManyTaskRunWaitpoints");
777778
expect(legacyTx.length).toBeGreaterThan(0);
778-
expect(newTx.length).toBeGreaterThan(0);
779-
for (const arg of [...legacyTx, ...newTx]) expect(arg).toBeUndefined();
779+
expect(newTx.length).toBe(0); // routed to #legacy only; the non-owning store is never touched
780+
for (const arg of legacyTx) expect(arg).toBeUndefined();
780781
}
781782
);
782783
});

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -834,18 +834,20 @@ describe("RoutingRunStore — mixed-residency matrix (cuid #legacy + run-ops id
834834
}
835835
);
836836

837-
// ── Case 11b: deleteManyTaskRunWaitpoints by taskRunId fans out to both and sums (:944) ──
838-
// A run's edges can straddle DBs mid-drain; a delete keyed by taskRunId (not waitpointId) must
839-
// delete from BOTH DBs and sum the count.
837+
// ── Case 11b: deleteManyTaskRunWaitpoints by taskRunId routes to the run's store (no fan-out) ──
838+
// An edge always co-locates with its run (blockRunWithWaitpointEdges routes the write by runId), so
839+
// a run's edges only ever live on ONE store — a straddle can't arise via the write path. A delete
840+
// keyed by a classifiable taskRunId therefore routes to the run's store and must NOT touch the other
841+
// DB. To prove the routing (not a fan-out), we manufacture an edge on the non-owning DB too and
842+
// assert it survives the delete.
840843
heteroRunOpsPostgresTest(
841-
"case 11b: deleteManyTaskRunWaitpoints by taskRunId deletes edges on both DBs and sums",
844+
"case 11b: deleteManyTaskRunWaitpoints by taskRunId routes to the run's store and leaves the other DB untouched",
842845
async ({ prisma14, prisma17 }) => {
843846
const { router } = makeSplitRouter(prisma14, prisma17);
844847
const env = await seedSharedEnv(prisma14, "m11b");
845848

846-
// ONE logical run id whose edges happen to exist on BOTH DBs (the straddle the fan-out guards).
847-
// The edge is FK-free on #new (unnest path) and FK-bound on #legacy, so seed a co-resident
848-
// waitpoint + run on #legacy for its edge, and write the #new edge directly.
849+
// A run-ops run (→ #new). Its real edge lives on #new; we also plant an edge on #legacy that the
850+
// write path could never create, purely to prove the routed delete does not fan out to #legacy.
849851
const runId = runOpsNew("m11br");
850852
const legacyToken = cuidLegacy("m11bt");
851853
await router.createRun(buildCreateRunInput({ runId, friendlyId: "run_m11b", ...env }));
@@ -881,7 +883,7 @@ describe("RoutingRunStore — mixed-residency matrix (cuid #legacy + run-ops id
881883
await prisma14.$executeRawUnsafe(
882884
`INSERT INTO "TaskRunWaitpoint" ("id","taskRunId","waitpointId","projectId","createdAt","updatedAt") VALUES (gen_random_uuid(),'${runId}','${legacyToken}','${env.projectId}',NOW(),NOW())`
883885
);
884-
// #new edge (FK-free) pointing at a run-ops token absent locally — drain straddle.
886+
// The run's real edge, on its own DB (#new, FK-free unnest path), pointing at a run-ops token.
885887
const newToken = runOpsNew("m11bn");
886888
await prisma17.$executeRawUnsafe(
887889
`INSERT INTO "TaskRunWaitpoint" ("id","taskRunId","waitpointId","projectId","createdAt","updatedAt") VALUES (gen_random_uuid(),'${runId}','${newToken}','${env.projectId}',NOW(),NOW())`
@@ -891,10 +893,10 @@ describe("RoutingRunStore — mixed-residency matrix (cuid #legacy + run-ops id
891893
expect(await prisma17.taskRunWaitpoint.count({ where: { taskRunId: runId } })).toBe(1);
892894

893895
const { count } = await router.deleteManyTaskRunWaitpoints({ where: { taskRunId: runId } });
894-
expect(count).toBe(2); // one edge deleted on each DB, summed
895-
896-
expect(await prisma14.taskRunWaitpoint.count({ where: { taskRunId: runId } })).toBe(0);
896+
// Routed to #new (the run's store): only its edge is deleted; #legacy is never touched.
897+
expect(count).toBe(1);
897898
expect(await prisma17.taskRunWaitpoint.count({ where: { taskRunId: runId } })).toBe(0);
899+
expect(await prisma14.taskRunWaitpoint.count({ where: { taskRunId: runId } })).toBe(1);
898900
}
899901
);
900902
});

0 commit comments

Comments
 (0)