Skip to content

Commit e9ac98b

Browse files
authored
perf(run-store): route id-set reads to the owning store, not both DBs (#4342)
## Summary The split run-store's id-set read path (`#findRunsByIdSet`, used by the runs-list hydrate, the realtime hydrator, and engine sweeps) queried the new store for the entire id set and then probed the legacy store for the misses. A run's residency is a total function of its id (run-ops ids live in the new store, every other id in legacy), so each id belongs to exactly one store. Route each id to its owner and query each store only for its own ids, in parallel. Same result set, and while a split is active with most runs still on legacy it removes a wasted new-store query from every id-set read. ## Change `#findRunsByIdSet` now partitions the ids by `classifyResidency` and runs one bounded query per store (skipping an empty side), in parallel, mirroring `expireRunsBatch` and the single-run `#route`. `finalizeRows` still applies orderBy/take/skip globally over the merged set. This drops the id-set path's cross-store fallback, which existed to prefer the new-store copy when the same id was present in both stores. That collision cannot arise when each id maps to exactly one store (nothing writes a legacy-shaped id into the new store), so the fallback is dead code. The two id-set tests that asserted "new copy wins on collision" now assert the routing invariant: a legacy-shaped id resolves to the legacy store and the path never consults the new store. The open-predicate path (`#findRunsOpen`) is unchanged: an open `where` has no id to route on, so it still unions both stores and dedupes.
1 parent 23d5771 commit e9ac98b

8 files changed

Lines changed: 169 additions & 38 deletions

apps/webapp/test/waitpointPresenter.connectedRunsBounded.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ vi.mock("~/presenters/v3/NextRunListPresenter.server", () => ({
6464

6565
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
6666
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
67+
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
6768
import type { PrismaClient } from "@trigger.dev/database";
6869
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
6970
import {
@@ -156,6 +157,7 @@ async function seedRun(
156157
) {
157158
return (prisma as PrismaClient).taskRun.create({
158159
data: {
160+
id: `run_${generateRunOpsId()}`,
159161
friendlyId,
160162
taskIdentifier: "my-task",
161163
status: "PENDING",

apps/webapp/test/waitpointPresenter.danglingConnectedRuns.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ vi.mock("~/presenters/v3/NextRunListPresenter.server", () => ({
5858

5959
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
6060
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
61+
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
6162
import type { PrismaClient } from "@trigger.dev/database";
6263
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
6364
import {
@@ -141,6 +142,7 @@ async function seedWaitpoint(prisma: RunOpsPrismaClient, ctx: SeedContext, frien
141142
async function seedRun(prisma: RunOpsPrismaClient, ctx: SeedContext, friendlyId: string) {
142143
return (prisma as unknown as PrismaClient).taskRun.create({
143144
data: {
145+
id: `run_${generateRunOpsId()}`,
144146
friendlyId,
145147
taskIdentifier: "my-task",
146148
status: "PENDING",

apps/webapp/test/waitpointPresenter.dedicatedConnectedRuns.readthrough.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ vi.mock("~/presenters/v3/NextRunListPresenter.server", () => ({
5959

6060
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
6161
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
62+
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
6263
import type { PrismaClient } from "@trigger.dev/database";
6364
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
6465
import { WaitpointPresenter } from "~/presenters/v3/WaitpointPresenter.server";
@@ -147,10 +148,12 @@ async function seedWaitpoint(
147148
async function seedRun(
148149
prisma: PrismaClient | RunOpsPrismaClient,
149150
ctx: SeedContext,
150-
friendlyId: string
151+
friendlyId: string,
152+
id?: string
151153
) {
152154
return (prisma as PrismaClient).taskRun.create({
153155
data: {
156+
...(id ? { id } : {}),
154157
friendlyId,
155158
taskIdentifier: "my-task",
156159
status: "PENDING",
@@ -216,7 +219,7 @@ describe("WaitpointPresenter against the REAL dedicated run-ops client", () => {
216219
const waitpoint = await seedWaitpoint(prisma14, ctx, "waitpoint_crossdb");
217220

218221
// The connected run + join live only on the NEW dedicated DB (co-resident with the run).
219-
const run = await seedRun(prisma17, ctx, "run_crossnew");
222+
const run = await seedRun(prisma17, ctx, "run_crossnew", `run_${generateRunOpsId()}`);
220223
await prisma17.waitpointRunConnection.create({
221224
data: { taskRunId: run.id, waitpointId: waitpoint.id },
222225
});

apps/webapp/test/waitpointPresenter.splitConnectedRuns.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ vi.mock("~/presenters/v3/NextRunListPresenter.server", () => ({
6262

6363
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
6464
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
65+
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
6566
import type { PrismaClient } from "@trigger.dev/database";
6667
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
6768
import {
@@ -128,10 +129,12 @@ async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedCont
128129
async function seedRun(
129130
prisma: PrismaClient | RunOpsPrismaClient,
130131
ctx: SeedContext,
131-
friendlyId: string
132+
friendlyId: string,
133+
id?: string
132134
) {
133135
return (prisma as PrismaClient).taskRun.create({
134136
data: {
137+
...(id ? { id } : {}),
135138
friendlyId,
136139
taskIdentifier: "my-task",
137140
status: "PENDING",
@@ -175,7 +178,7 @@ describe("WaitpointPresenter — connected runs SPLIT across both physical DBs",
175178
// 2 connected runs resident + joined on NEW (below the limit on its own).
176179
const NEW_RUN_FRIENDLY_IDS = ["run_split_new0", "run_split_new1"];
177180
for (const friendlyId of NEW_RUN_FRIENDLY_IDS) {
178-
const run = await seedRun(prisma17, ctx, friendlyId);
181+
const run = await seedRun(prisma17, ctx, friendlyId, `run_${generateRunOpsId()}`);
179182
await prisma17.waitpointRunConnection.create({
180183
data: { taskRunId: run.id, waitpointId: waitpoint.id },
181184
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { heteroPostgresTest } from "@internal/testcontainers";
2+
import type { PrismaClient } from "@trigger.dev/database";
3+
import { classifyResidency } from "@trigger.dev/core/v3/isomorphic";
4+
import { describe, expect } from "vitest";
5+
import { PostgresRunStore } from "./PostgresRunStore.js";
6+
import { RoutingRunStore } from "./runOpsStore.js";
7+
8+
const ORG_ID = "orgroute0000000000000001";
9+
const PROJ_ID = "projroute00000000000001";
10+
const ENV_ID = "envroute0000000000000001";
11+
12+
const newId = (i: number) => "k".repeat(20) + String(i).padStart(4, "0") + "01";
13+
const cuidId = (i: number) => "c".repeat(21) + String(i).padStart(4, "0");
14+
15+
async function seedShared(prisma: PrismaClient, suffix: string) {
16+
await prisma.organization.create({
17+
data: { id: ORG_ID, title: `Route ${suffix}`, slug: `route-${suffix}` },
18+
});
19+
await prisma.project.create({
20+
data: {
21+
id: PROJ_ID,
22+
name: `Route ${suffix}`,
23+
slug: `route-${suffix}`,
24+
externalRef: `proj_route_${suffix}`,
25+
organizationId: ORG_ID,
26+
},
27+
});
28+
await prisma.runtimeEnvironment.create({
29+
data: {
30+
id: ENV_ID,
31+
type: "PRODUCTION",
32+
slug: "prod",
33+
projectId: PROJ_ID,
34+
organizationId: ORG_ID,
35+
apiKey: `tr_prod_${suffix}`,
36+
pkApiKey: `pk_prod_${suffix}`,
37+
shortcode: `short_${suffix}`,
38+
},
39+
});
40+
}
41+
42+
const BASE = new Date("2026-01-01T00:00:00.000Z").getTime();
43+
44+
async function seedRun(prisma: PrismaClient, id: string, offsetSec: number) {
45+
await prisma.taskRun.create({
46+
data: {
47+
id,
48+
engine: "V2",
49+
status: "COMPLETED_SUCCESSFULLY",
50+
friendlyId: `run_${id}`,
51+
runtimeEnvironmentId: ENV_ID,
52+
environmentType: "PRODUCTION",
53+
organizationId: ORG_ID,
54+
projectId: PROJ_ID,
55+
taskIdentifier: "route-task",
56+
payload: "{}",
57+
payloadType: "application/json",
58+
traceId: `trace_${id}`,
59+
spanId: `span_${id}`,
60+
queue: "task/route",
61+
isTest: false,
62+
taskEventStore: "taskEvent",
63+
depth: 0,
64+
createdAt: new Date(BASE + offsetSec * 1000),
65+
},
66+
});
67+
}
68+
69+
describe("RoutingRunStore id-set residency routing", () => {
70+
heteroPostgresTest(
71+
"routes each id to its owning store and merges in orderBy order",
72+
{ timeout: 120000 },
73+
async ({ prisma14, prisma17 }) => {
74+
for (let i = 0; i < 5; i++) {
75+
expect(classifyResidency(newId(i))).toBe("NEW");
76+
expect(classifyResidency(cuidId(i))).toBe("LEGACY");
77+
}
78+
79+
await seedShared(prisma14, "legacy");
80+
await seedShared(prisma17, "new");
81+
82+
for (let i = 0; i < 5; i++) {
83+
await seedRun(prisma17, newId(i), i * 2 + 1);
84+
await seedRun(prisma14, cuidId(i), i * 2);
85+
}
86+
87+
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
88+
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
89+
const router = new RoutingRunStore({ new: newStore, legacy: legacyStore });
90+
91+
const mixedIds = [0, 1, 2, 3, 4].flatMap((i) => [newId(i), cuidId(i)]);
92+
const globalDesc = [
93+
newId(4),
94+
cuidId(4),
95+
newId(3),
96+
cuidId(3),
97+
newId(2),
98+
cuidId(2),
99+
newId(1),
100+
cuidId(1),
101+
newId(0),
102+
cuidId(0),
103+
];
104+
105+
const all = (await router.findRuns({
106+
where: { id: { in: mixedIds } },
107+
orderBy: { createdAt: "desc" },
108+
take: 100,
109+
})) as Array<{ id: string }>;
110+
expect(all.map((r) => r.id)).toEqual(globalDesc);
111+
112+
const top4 = (await router.findRuns({
113+
where: { id: { in: mixedIds } },
114+
orderBy: { createdAt: "desc" },
115+
take: 4,
116+
})) as Array<{ id: string }>;
117+
expect(top4.map((r) => r.id)).toEqual(globalDesc.slice(0, 4));
118+
119+
const newOnly = (await router.findRuns({
120+
where: { id: { in: [newId(0), newId(2), newId(4)] } },
121+
orderBy: { createdAt: "asc" },
122+
})) as Array<{ id: string }>;
123+
expect(newOnly.map((r) => r.id)).toEqual([newId(0), newId(2), newId(4)]);
124+
125+
const legacyOnly = (await router.findRuns({
126+
where: { id: { in: [cuidId(1), cuidId(3)] } },
127+
orderBy: { createdAt: "asc" },
128+
})) as Array<{ id: string }>;
129+
expect(legacyOnly.map((r) => r.id)).toEqual([cuidId(1), cuidId(3)]);
130+
}
131+
);
132+
});

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,11 +262,8 @@ describe("RoutingRunStore — mixed-residency matrix (cuid #legacy + run-ops id
262262
}
263263
);
264264

265-
// ── Case 1b: NEW-wins on id collision in #findRunsByIdSet ──
266-
// The copy→fence window can leave the same id on both DBs. The id-set path queries NEW first; an id
267-
// already found on NEW must NOT be re-fetched from LEGACY, so the NEW copy wins.
268265
heteroRunOpsPostgresTest(
269-
"case 1b: findRuns by id-set with a colliding id resolves to the NEW copy",
266+
"case 1b: findRuns by id-set routes a cuid id to LEGACY only, ignoring any NEW copy",
270267
async ({ prisma14, prisma17 }) => {
271268
const { router } = makeSplitRouter(prisma14, prisma17);
272269
const env = await seedSharedEnv(prisma14, "m1b");
@@ -304,8 +301,8 @@ describe("RoutingRunStore — mixed-residency matrix (cuid #legacy + run-ops id
304301
where: { id: { in: [collidingId] } },
305302
select: { id: true, taskIdentifier: true },
306303
});
307-
expect(rows).toHaveLength(1); // deduped, not double-reported
308-
expect((rows[0] as any).taskIdentifier).toBe("new-copy-wins"); // NEW wins
304+
expect(rows).toHaveLength(1);
305+
expect((rows[0] as any).taskIdentifier).toBe("my-task");
309306
}
310307
);
311308

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,10 +1004,8 @@ describe("RoutingRunStore.findRuns split-mode fan-out + drain", () => {
10041004
}
10051005
);
10061006

1007-
// A run present on BOTH DBs (the copy->fence migration window) must be returned ONCE,
1008-
// and the NEW copy wins.
10091007
heteroPostgresTest(
1010-
"id-set dedupes a run present on both DBs, preferring NEW",
1008+
"id-set routes a cuid id to its LEGACY owner and does not consult NEW",
10111009
async ({ prisma14, prisma17 }) => {
10121010
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
10131011
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
@@ -1031,7 +1029,7 @@ describe("RoutingRunStore.findRuns split-mode fan-out + drain", () => {
10311029
select: { id: true, taskIdentifier: true },
10321030
})) as Array<{ id: string; taskIdentifier: string }>;
10331031
expect(rows).toHaveLength(1);
1034-
expect(rows[0]!.taskIdentifier).toBe("from-new");
1032+
expect(rows[0]!.taskIdentifier).toBe("from-legacy");
10351033
}
10361034
);
10371035

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

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -301,12 +301,12 @@ export class RoutingRunStore implements RunStore {
301301
},
302302
client?: ReadClient
303303
): Promise<unknown> {
304-
// SPLIT-mode fan-out across NEW + LEGACY. A `findRuns` `where` can span ids of mixed
305-
// residency, so we resolve each owning store and merge, preserving orderBy/take/skip.
306-
// The caller's client is never forwarded verbatim (it is the control-plane client); its
307-
// presence routes each leg to that store's OWN primary (read-your-writes), else each store
308-
// reads its own replica as before. NEW wins on id collisions (the copy->fence migration
309-
// window) so a half-migrated run is never double-reported.
304+
// SPLIT-mode routing across NEW + LEGACY. A bounded id set is routed per id to its owning
305+
// store by residency (#findRunsByIdSet); an open predicate with no id to route on unions both
306+
// stores and dedupes NEW-wins (#findRunsOpen). Either way orderBy/take/skip are re-imposed
307+
// globally over the merged rows. The caller's client is never forwarded verbatim (it is the
308+
// control-plane client); its presence routes each leg to that store's OWN primary
309+
// (read-your-writes), else each store reads its own replica as before.
310310
return this.#findRunsRouted(args, client);
311311
}
312312

@@ -324,33 +324,27 @@ export class RoutingRunStore implements RunStore {
324324
return idList ? this.#findRunsByIdSet(args, idList, client) : this.#findRunsOpen(args, client);
325325
}
326326

327-
// Bounded id-set (the list hydrate + engine sweeps). Query NEW for the whole set first
328-
// (it holds run-ops runs); probe LEGACY only for the ids NEW missed that could still live
329-
// there (cuid). The two id sets are disjoint by construction, so the merge needs no dedupe.
327+
// Bounded id-set (the list hydrate + engine sweeps). Residency is a total function of the id
328+
// (classifyResidency), so route each id to its owning store and query each store only for its
329+
// own ids, in parallel; never query NEW for a cuid or LEGACY for a run-ops id. The partitions
330+
// are disjoint by construction, so the merge needs no dedupe. take/skip are never pushed per
331+
// store (that would truncate a store's page before the merge knows membership); finalizeRows
332+
// re-imposes orderBy/take/skip once, globally, over the merged rows.
330333
async #findRunsByIdSet(
331334
args: FindRunsArgs,
332335
ids: string[],
333336
client?: ReadClient
334337
): Promise<unknown[]> {
335338
const { args: selArgs, addedFields } = ensureProjected(args);
336-
// The id set already bounds the per-store result, so never push take/skip down — doing
337-
// so would truncate a store's page before the merge knows membership and mis-attribute
338-
// rows. take/skip are applied once, globally, in finalizeRows.
339339
const fan = { ...selArgs, take: undefined, skip: undefined };
340+
const newIds = ids.filter((id) => this.#classifySafe(id) === "NEW");
341+
const legacyIds = ids.filter((id) => this.#classifySafe(id) !== "NEW");
340342
const findNew = this.#findManyOn(this.#new, client);
341343
const findLegacy = this.#findManyOn(this.#legacy, client);
342-
343-
const newRows = await findNew(fan);
344-
const foundIds = new Set(newRows.map((r) => r.id as string));
345-
346-
const toLegacy: string[] = [];
347-
for (const id of ids) {
348-
if (foundIds.has(id)) continue;
349-
if (this.#classifySafe(id) === "NEW") continue; // run-ops id: cannot live on LEGACY
350-
toLegacy.push(id);
351-
}
352-
353-
const legacyRows = toLegacy.length > 0 ? await findLegacy(narrowToIds(fan, toLegacy)) : [];
344+
const [newRows, legacyRows] = await Promise.all([
345+
newIds.length > 0 ? findNew(narrowToIds(fan, newIds)) : [],
346+
legacyIds.length > 0 ? findLegacy(narrowToIds(fan, legacyIds)) : [],
347+
]);
354348
return finalizeRows([...newRows, ...legacyRows], args, addedFields);
355349
}
356350

0 commit comments

Comments
 (0)