Skip to content

Commit c601739

Browse files
authored
perf(webapp,run-store): grouped run-ops reads + mint-kind flip grace (#4227)
## Summary Two threads on the run-ops split path. Read path: per-item run reads are batched into grouped queries, a waitpoint's connected-run reads are bounded, and the dedicated-schema relation hydrators fetch only the requested columns instead of whole rows. Retrieve also falls back to the other database when a routed read misses, so a run whose physical residency diverges from its id shape is still found rather than returning a spurious not-found. Fewer and lighter queries on the run read path, with no change to results. Mint-kind flip safety: flipping which database new runs mint to is now a deterministic wall-clock cutover, for both per-org and global flips. For a grace window every process resolves the same database, so a flip cannot route two concurrent triggers that share an idempotency key to different databases (which would bypass the per-database unique constraint and create a duplicate run). Supersedes the earlier #4205 and #4208. Draft: validation in progress.
1 parent 5f2541d commit c601739

38 files changed

Lines changed: 5597 additions & 352 deletions

File tree

apps/webapp/app/env.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1755,6 +1755,10 @@ const EnvironmentSchema = z
17551755
RUN_OPS_MINT_ENABLED: BoolEnv.default(false),
17561756
RUN_OPS_MINT_FLAG_CACHE_TTL_MS: z.coerce.number().int().default(30_000),
17571757
RUN_OPS_MINT_FLAG_CACHE_MAX_ENTRIES: z.coerce.number().int().default(10_000),
1758+
// Deterministic wall-clock cutover after a runOpsMintKind flip. Must exceed the sum
1759+
// of RUN_OPS_MINT_FLAG_CACHE_TTL_MS and the control-plane cache TTL so every process
1760+
// (stale or fresh) resolves to the same kind for the whole window. See mintFlipGrace.ts.
1761+
RUN_OPS_MINT_FLIP_GRACE_MS: z.coerce.number().int().default(90_000),
17581762

17591763
// Session replication (Postgres → ClickHouse sessions_v1). Shares Redis
17601764
// with the runs replicator for leader locking but has its own slot and

apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts

Lines changed: 36 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { BatchTaskRunExecutionResult } from "@trigger.dev/core/v3";
2+
import { ownerEngine } from "@trigger.dev/core/v3/isomorphic";
23
import {
34
$replica,
45
type PrismaClientOrTransaction,
@@ -8,7 +9,6 @@ import {
89
import type { TaskRunWithAttempts } from "~/models/taskRun.server";
910
import { executionResultForTaskRun } from "~/models/taskRun.server";
1011
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
11-
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
1212
import { runStore as defaultRunStore } from "~/v3/runStore.server";
1313
import { BasePresenter } from "./basePresenter.server";
1414

@@ -43,11 +43,14 @@ const memberRunSelect = {
4343
} as const;
4444

4545
/**
46-
* Split on: the batch row + its item rows resolve new-run-ops first, then the LEGACY RUN-OPS
47-
* READ REPLICA ONLY (never the legacy primary — there is no such handle); each member run is
48-
* hydrated independently via readThroughRun keyed on the member runId, so a batch whose members
49-
* span migrated + abandoned runs returns the complete reachable set (the batch-spanning-the-line
50-
* read; the dangling-reference termination gate is a separate, adjacent unit).
46+
* Split on: the batch row + its members resolve new-run-ops first, then the LEGACY RUN-OPS READ
47+
* REPLICA ONLY (never the legacy primary — there is no such handle). Members hydrate via ONE
48+
* grouped read against `newClient` for the whole id set, then ONE grouped read against
49+
* `legacyReplica` for just the misses that could still be legacy-resident — the same
50+
* residency-partitioned shape as the old per-member read-through, but batched instead of fanned
51+
* out one query per member. A batch whose members span migrated + abandoned runs returns the
52+
* complete reachable set (the batch-spanning-the-line read; the dangling-reference termination
53+
* gate is a separate, adjacent unit).
5154
*
5255
* Split off (single-DB / self-host): one passthrough read for the batch row + a single store
5356
* id-set hydrate for the members — no legacy read, no known-migrated probe, no second connection.
@@ -133,7 +136,7 @@ export class ApiBatchResultsPresenter extends BasePresenter {
133136
// Split: resolve the batch row new-first then off the legacy READ REPLICA only (a batch id may
134137
// be cuid or run-ops id, and a cuid-shaped id can still have been backfilled onto NEW, so id-shape
135138
// residency is not authoritative for the row — the new-first-then-legacy probe is), then
136-
// hydrate every member run independently via the per-run read-through primitive.
139+
// hydrate every member run in ONE grouped new-then-legacy read.
137140
async #callSplit(
138141
friendlyId: string,
139142
env: AuthenticatedEnvironment
@@ -175,41 +178,35 @@ export class ApiBatchResultsPresenter extends BasePresenter {
175178
};
176179
}
177180

178-
const readMemberRun = (client: PrismaClientOrTransaction, taskRunId: string) =>
179-
client.taskRun.findFirst({
180-
where: { id: taskRunId },
181-
select: memberRunSelect,
182-
}) as Promise<TaskRunWithAttempts | null>;
183-
184-
// Per-member fan-out: each member may live on a different DB, so a single nested include cannot
185-
// cross the seam. Promise.all preserves batchRun.items order, unchanged from today.
186-
const memberResults = await Promise.all(
187-
batchRun.items.map(async (item) => {
188-
const result = await readThroughRun<TaskRunWithAttempts>({
189-
runId: item.taskRunId,
190-
environmentId: env.id,
191-
readNew: (client) => readMemberRun(client, item.taskRunId),
192-
readLegacy: (replica) => readMemberRun(replica, item.taskRunId),
193-
deps: {
194-
splitEnabled: true,
195-
// Pass the SAME resolved handles the batch row used, so the batch row and its members
196-
// never resolve against different DBs. (Letting these fall through to readThroughRun's
197-
// own module-level defaults would diverge from the batch read's `?? this._replica`.)
198-
newClient,
199-
legacyReplica,
200-
isPastRetention: this.readThrough?.isPastRetention,
201-
},
202-
});
181+
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
203182

204-
// not-found / past-retention members are omitted (matches today's drop-undefined behavior);
205-
// the dangling-reference termination gate (separate unit) governs whether that's permitted.
206-
if (result.source === "not-found" || result.source === "past-retention") {
207-
return undefined;
208-
}
183+
const newRows = (await newClient.taskRun.findMany({
184+
where: { id: { in: taskRunIds } },
185+
select: memberRunSelect,
186+
})) as TaskRunWithAttempts[];
187+
const runsById = new Map(newRows.map((run) => [run.id, run]));
209188

210-
return executionResultForTaskRun(result.value);
211-
})
189+
// A run-ops id can only live on NEW, so only misses that AREN'T run-ops-shaped are candidates
190+
// for the legacy probe — mirrors readThroughRun's per-id "NEW residency skips legacy" rule.
191+
const legacyCandidateIds = taskRunIds.filter(
192+
(id) => !runsById.has(id) && ownerEngine(id) !== "NEW"
212193
);
194+
if (legacyCandidateIds.length > 0) {
195+
const legacyRows = (await legacyReplica.taskRun.findMany({
196+
where: { id: { in: legacyCandidateIds } },
197+
select: memberRunSelect,
198+
})) as TaskRunWithAttempts[];
199+
for (const run of legacyRows) {
200+
runsById.set(run.id, run);
201+
}
202+
}
203+
204+
// not-found members are omitted (matches today's drop-undefined behavior); the
205+
// dangling-reference termination gate (separate unit) governs whether that's permitted.
206+
const memberResults = batchRun.items.map((item) => {
207+
const run = runsById.get(item.taskRunId);
208+
return run ? executionResultForTaskRun(run) : undefined;
209+
});
213210

214211
return {
215212
id: batchRun.friendlyId,

apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -164,17 +164,13 @@ export class ApiRetrieveRunPresenter {
164164
collect(child.lockedToVersionId);
165165
}
166166

167+
const lockedWorkersByVersionId =
168+
await controlPlaneResolver.resolveRunLockedWorkersByVersionIds([...distinctVersionIds]);
167169
const versionById = new Map<string, string | null>(
168-
await Promise.all(
169-
[...distinctVersionIds].map(
170-
async (id) =>
171-
[
172-
id,
173-
(await controlPlaneResolver.resolveRunLockedWorker({ lockedToVersionId: id }))
174-
?.lockedToVersion?.version ?? null,
175-
] as const
176-
)
177-
)
170+
[...distinctVersionIds].map((id) => [
171+
id,
172+
lockedWorkersByVersionId.get(id)?.lockedToVersion?.version ?? null,
173+
])
178174
);
179175

180176
const resolveVersion = (id: string | null): { version: string } | null => {

apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
1111

1212
export type WaitpointDetail = NonNullable<Awaited<ReturnType<WaitpointPresenter["call"]>>>;
1313

14+
// Single-sourced display bound for a waitpoint's connected run friendlyIds.
15+
export const CONNECTED_RUNS_DISPLAY_LIMIT = 5;
16+
17+
// Over-read connection rows on the FK-free dedicated join so danglers don't cost a display slot.
18+
export const CONNECTED_RUNS_CONNECTION_SCAN_LIMIT = CONNECTED_RUNS_DISPLAY_LIMIT * 5;
19+
1420
export class WaitpointPresenter extends BasePresenter {
1521
constructor(
1622
prisma?: PrismaClientOrTransaction,
@@ -76,8 +82,7 @@ export class WaitpointPresenter extends BasePresenter {
7682

7783
// Connected-run friendlyIds gathered across BOTH stores. The run<->waitpoint join co-locates with
7884
// the RUN (written on the run's DB), so the waitpoint's own store misses a cross-DB connection; we
79-
// read the join on each client and resolve the run's friendlyId on that same client, then union.
80-
// We never relation-select `connectedRuns`: it is not a field on the dedicated subset `Waitpoint`.
85+
// read the join on each client, resolve the run's friendlyId on that same client, and union.
8186
async #connectedRunFriendlyIds(waitpointId: string): Promise<string[]> {
8287
const replica = this._replica as unknown as PrismaReplicaClient;
8388
const rawClients: PrismaReplicaClient[] =
@@ -91,47 +96,66 @@ export class WaitpointPresenter extends BasePresenter {
9196

9297
const friendlyIds = new Set<string>();
9398
for (const client of clients) {
94-
const runIds = await this.#connectedRunIdsOn(client, waitpointId);
95-
if (runIds.length === 0) {
96-
continue;
99+
for (const friendlyId of await this.#connectedRunFriendlyIdsOn(client, waitpointId)) {
100+
friendlyIds.add(friendlyId);
97101
}
98-
const runs = await client.taskRun.findMany({
99-
where: { id: { in: runIds } },
100-
select: { friendlyId: true },
101-
take: 5,
102-
});
103-
for (const run of runs) {
104-
friendlyIds.add(run.friendlyId);
105-
}
106-
if (friendlyIds.size >= 5) {
102+
if (friendlyIds.size >= CONNECTED_RUNS_DISPLAY_LIMIT) {
107103
break;
108104
}
109105
}
110-
return Array.from(friendlyIds).slice(0, 5);
106+
return Array.from(friendlyIds).slice(0, CONNECTED_RUNS_DISPLAY_LIMIT);
111107
}
112108

113-
// Schema-aware read of the run ids linked to a waitpoint: the dedicated subset uses the explicit
114-
// `WaitpointRunConnection` model, the control-plane full schema the implicit `_WaitpointRunConnections`
115-
// M2M (A = TaskRun.id, B = Waitpoint.id). The dedicated join delegate is absent on the full client.
116-
async #connectedRunIdsOn(client: PrismaReplicaClient, waitpointId: string): Promise<string[]> {
117-
const joinDelegate = (
109+
// Connected-run friendlyIds for one store, via the ORM. Two indexed reads joined in memory instead
110+
// of a SQL JOIN onto the (very large) TaskRun table: `id IN (...)` can only plan as a PK lookup, so
111+
// the planner can never scan TaskRun.
112+
//
113+
// Dedicated subset: the explicit `WaitpointRunConnection` is scalar (`taskRunId`, no FK), so a
114+
// connection can outlive a deleted run. We over-read connection ids, resolve runs by id (a missing
115+
// id just drops out -- danglers cost no display slot), and cap at the display limit.
116+
//
117+
// Control-plane full schema: no queryable join delegate (implicit M2M), so we traverse the
118+
// `connectedRuns` relation; it cascade-deletes with the run, so no dangler can exist.
119+
async #connectedRunFriendlyIdsOn(
120+
client: PrismaReplicaClient,
121+
waitpointId: string
122+
): Promise<string[]> {
123+
const dedicated = (
118124
client as unknown as {
119125
waitpointRunConnection?: {
120126
findMany: (args: unknown) => Promise<{ taskRunId: string }[]>;
121127
};
122128
}
123129
).waitpointRunConnection;
124-
if (joinDelegate && typeof joinDelegate.findMany === "function") {
125-
const links = await joinDelegate.findMany({
130+
131+
if (dedicated) {
132+
const connections = await dedicated.findMany({
126133
where: { waitpointId },
127134
select: { taskRunId: true },
135+
take: CONNECTED_RUNS_CONNECTION_SCAN_LIMIT,
128136
});
129-
return links.map((link) => link.taskRunId);
137+
if (connections.length === 0) {
138+
return [];
139+
}
140+
const runs = await client.taskRun.findMany({
141+
where: { id: { in: connections.map((connection) => connection.taskRunId) } },
142+
select: { friendlyId: true },
143+
take: CONNECTED_RUNS_DISPLAY_LIMIT,
144+
});
145+
return runs.map((run) => run.friendlyId);
130146
}
131-
const rows = await client.$queryRaw<{ A: string }[]>`
132-
SELECT "A" FROM "_WaitpointRunConnections" WHERE "B" = ${waitpointId}
133-
`;
134-
return rows.map((row) => row.A);
147+
148+
const waitpoint = (await (
149+
client.waitpoint.findFirst as (
150+
args: unknown
151+
) => Promise<{ connectedRuns: { friendlyId: string }[] } | null>
152+
)({
153+
where: { id: waitpointId },
154+
select: {
155+
connectedRuns: { select: { friendlyId: true }, take: CONNECTED_RUNS_DISPLAY_LIMIT },
156+
},
157+
})) as { connectedRuns: { friendlyId: string }[] } | null;
158+
return (waitpoint?.connectedRuns ?? []).map((run) => run.friendlyId);
135159
}
136160

137161
public async call({

apps/webapp/app/routes/admin.api.v1.feature-flags.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { prisma } from "~/db.server";
4+
import { env } from "~/env.server";
45
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
5-
import { makeSetMultipleFlags } from "~/v3/featureFlags.server";
6+
import { applyGlobalMintKindFlip, makeSetMultipleFlags } from "~/v3/featureFlags.server";
67
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
78

89
export async function action({ request }: ActionFunctionArgs) {
@@ -24,9 +25,19 @@ export async function action({ request }: ActionFunctionArgs) {
2425
);
2526
}
2627

27-
const featureFlags = validationResult.data;
28-
const setMultipleFlags = makeSetMultipleFlags(prisma);
29-
const updatedFlags = await setMultipleFlags(featureFlags);
28+
// Derived grace-stamp fields are computed server-side; never trust them from the body.
29+
const {
30+
runOpsMintKindPrev: _ignoredPrev,
31+
runOpsMintKindFlippedAt: _ignoredFlippedAt,
32+
...requestedFlags
33+
} = validationResult.data;
34+
35+
// A global mint-kind flip stamps its grace window under a lock (applyGlobalMintKindFlip);
36+
// any other flag save writes directly.
37+
const updatedFlags =
38+
requestedFlags.runOpsMintKind !== undefined
39+
? await applyGlobalMintKindFlip(prisma, requestedFlags, env.RUN_OPS_MINT_FLIP_GRACE_MS)
40+
: await makeSetMultipleFlags(prisma)(requestedFlags);
3041

3142
return json({
3243
success: true,

0 commit comments

Comments
 (0)