Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c642427
perf(webapp,run-store): group per-item run reads into batched queries
d-cs Jul 9, 2026
e7fb6bd
fix(run-store): drop force-injected id from findRunsByIds results whe…
d-cs Jul 9, 2026
fc9588f
chore(webapp): drop cancelDevSessionRuns changes from this PR
d-cs Jul 9, 2026
a32f169
fix(webapp): filter deleted runs out of a waitpoint's connected-runs …
d-cs Jul 9, 2026
119c1f6
fix(run-store): give each parent its own hydrated relation object
d-cs Jul 9, 2026
3e8faa5
fix(run-store): bound waitpoint connected-run reads to the display limit
d-cs Jul 9, 2026
e205b80
perf(run-store): narrow dedicated hydrator reads to requested columns
d-cs Jul 10, 2026
b9bf974
fix(webapp): schedule run-ops mint-kind flips to avoid duplicate runs
d-cs Jul 9, 2026
54c79b6
test(webapp): assert malformed mint-flip stamp is inert when resolved
d-cs Jul 9, 2026
98996c9
fix(webapp): stamp the mint-kind flip time from the control-plane DB …
d-cs Jul 9, 2026
2e839a9
docs(webapp): document the mint-kind flip grace clock-domain assumption
d-cs Jul 10, 2026
32e46e9
fix(webapp): extend mint-kind flip grace to global flips
d-cs Jul 10, 2026
8891394
fix(run-store): fall back to the other store when a routed run read m…
d-cs Jul 10, 2026
16df23f
chore(webapp,run-store): apply oxfmt to new files
d-cs Jul 10, 2026
bfb9ddf
fix(webapp): stop unrelated org flag saves from pinning the run-ops m…
d-cs Jul 10, 2026
4660a0a
fix(webapp): schema-qualify waitpoint connected-run queries
d-cs Jul 10, 2026
ce00de1
fix(webapp): correct mint-flag grace baseline and lock down derived f…
d-cs Jul 10, 2026
c60dd3b
fix(run-store): bound connectedRuns relation hydrator on the dedicate…
d-cs Jul 10, 2026
bb008c9
chore(webapp,run-store): apply formatter
d-cs Jul 10, 2026
9c10e14
test(run-store): assert exact connectedRuns cap in bound test
d-cs Jul 10, 2026
7db8e46
refactor(webapp): read waitpoint connected runs via ORM instead of ra…
d-cs Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1746,6 +1746,10 @@ const EnvironmentSchema = z
RUN_OPS_MINT_ENABLED: BoolEnv.default(false),
RUN_OPS_MINT_FLAG_CACHE_TTL_MS: z.coerce.number().int().default(30_000),
RUN_OPS_MINT_FLAG_CACHE_MAX_ENTRIES: z.coerce.number().int().default(10_000),
// Deterministic wall-clock cutover after a runOpsMintKind flip. Must exceed the sum
// of RUN_OPS_MINT_FLAG_CACHE_TTL_MS and the control-plane cache TTL so every process
// (stale or fresh) resolves to the same kind for the whole window. See mintFlipGrace.ts.
RUN_OPS_MINT_FLIP_GRACE_MS: z.coerce.number().int().default(90_000),

// Session replication (Postgres → ClickHouse sessions_v1). Shares Redis
// with the runs replicator for leader locking but has its own slot and
Expand Down
75 changes: 36 additions & 39 deletions apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { BatchTaskRunExecutionResult } from "@trigger.dev/core/v3";
import { ownerEngine } from "@trigger.dev/core/v3/isomorphic";
import {
$replica,
type PrismaClientOrTransaction,
Expand All @@ -8,7 +9,6 @@ import {
import type { TaskRunWithAttempts } from "~/models/taskRun.server";
import { executionResultForTaskRun } from "~/models/taskRun.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
import { runStore as defaultRunStore } from "~/v3/runStore.server";
import { BasePresenter } from "./basePresenter.server";

Expand Down Expand Up @@ -43,11 +43,14 @@ const memberRunSelect = {
} as const;

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

const readMemberRun = (client: PrismaClientOrTransaction, taskRunId: string) =>
client.taskRun.findFirst({
where: { id: taskRunId },
select: memberRunSelect,
}) as Promise<TaskRunWithAttempts | null>;

// Per-member fan-out: each member may live on a different DB, so a single nested include cannot
// cross the seam. Promise.all preserves batchRun.items order, unchanged from today.
const memberResults = await Promise.all(
batchRun.items.map(async (item) => {
const result = await readThroughRun<TaskRunWithAttempts>({
runId: item.taskRunId,
environmentId: env.id,
readNew: (client) => readMemberRun(client, item.taskRunId),
readLegacy: (replica) => readMemberRun(replica, item.taskRunId),
deps: {
splitEnabled: true,
// Pass the SAME resolved handles the batch row used, so the batch row and its members
// never resolve against different DBs. (Letting these fall through to readThroughRun's
// own module-level defaults would diverge from the batch read's `?? this._replica`.)
newClient,
legacyReplica,
isPastRetention: this.readThrough?.isPastRetention,
},
});
const taskRunIds = batchRun.items.map((item) => item.taskRunId);

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

return executionResultForTaskRun(result.value);
})
// A run-ops id can only live on NEW, so only misses that AREN'T run-ops-shaped are candidates
// for the legacy probe — mirrors readThroughRun's per-id "NEW residency skips legacy" rule.
const legacyCandidateIds = taskRunIds.filter(
(id) => !runsById.has(id) && ownerEngine(id) !== "NEW"
);
if (legacyCandidateIds.length > 0) {
const legacyRows = (await legacyReplica.taskRun.findMany({
where: { id: { in: legacyCandidateIds } },
select: memberRunSelect,
})) as TaskRunWithAttempts[];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for (const run of legacyRows) {
runsById.set(run.id, run);
}
}

// not-found members are omitted (matches today's drop-undefined behavior); the
// dangling-reference termination gate (separate unit) governs whether that's permitted.
const memberResults = batchRun.items.map((item) => {
const run = runsById.get(item.taskRunId);
return run ? executionResultForTaskRun(run) : undefined;
});

return {
id: batchRun.friendlyId,
Expand Down
16 changes: 6 additions & 10 deletions apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,13 @@ export class ApiRetrieveRunPresenter {
collect(child.lockedToVersionId);
}

const lockedWorkersByVersionId =
await controlPlaneResolver.resolveRunLockedWorkersByVersionIds([...distinctVersionIds]);
const versionById = new Map<string, string | null>(
await Promise.all(
[...distinctVersionIds].map(
async (id) =>
[
id,
(await controlPlaneResolver.resolveRunLockedWorker({ lockedToVersionId: id }))
?.lockedToVersion?.version ?? null,
] as const
)
)
[...distinctVersionIds].map((id) => [
id,
lockedWorkersByVersionId.get(id)?.lockedToVersion?.version ?? null,
])
);

const resolveVersion = (id: string | null): { version: string } | null => {
Expand Down
78 changes: 51 additions & 27 deletions apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";

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

// Single-sourced display bound for a waitpoint's connected run friendlyIds.
export const CONNECTED_RUNS_DISPLAY_LIMIT = 5;

// Over-read connection rows on the FK-free dedicated join so danglers don't cost a display slot.
export const CONNECTED_RUNS_CONNECTION_SCAN_LIMIT = CONNECTED_RUNS_DISPLAY_LIMIT * 5;

export class WaitpointPresenter extends BasePresenter {
constructor(
prisma?: PrismaClientOrTransaction,
Expand Down Expand Up @@ -76,8 +82,7 @@ export class WaitpointPresenter extends BasePresenter {

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

const friendlyIds = new Set<string>();
for (const client of clients) {
const runIds = await this.#connectedRunIdsOn(client, waitpointId);
if (runIds.length === 0) {
continue;
for (const friendlyId of await this.#connectedRunFriendlyIdsOn(client, waitpointId)) {
friendlyIds.add(friendlyId);
}
const runs = await client.taskRun.findMany({
where: { id: { in: runIds } },
select: { friendlyId: true },
take: 5,
});
for (const run of runs) {
friendlyIds.add(run.friendlyId);
}
if (friendlyIds.size >= 5) {
if (friendlyIds.size >= CONNECTED_RUNS_DISPLAY_LIMIT) {
break;
}
}
return Array.from(friendlyIds).slice(0, 5);
return Array.from(friendlyIds).slice(0, CONNECTED_RUNS_DISPLAY_LIMIT);
}

// Schema-aware read of the run ids linked to a waitpoint: the dedicated subset uses the explicit
// `WaitpointRunConnection` model, the control-plane full schema the implicit `_WaitpointRunConnections`
// M2M (A = TaskRun.id, B = Waitpoint.id). The dedicated join delegate is absent on the full client.
async #connectedRunIdsOn(client: PrismaReplicaClient, waitpointId: string): Promise<string[]> {
const joinDelegate = (
// Connected-run friendlyIds for one store, via the ORM. Two indexed reads joined in memory instead
// of a SQL JOIN onto the (very large) TaskRun table: `id IN (...)` can only plan as a PK lookup, so
// the planner can never scan TaskRun.
//
// Dedicated subset: the explicit `WaitpointRunConnection` is scalar (`taskRunId`, no FK), so a
// connection can outlive a deleted run. We over-read connection ids, resolve runs by id (a missing
// id just drops out -- danglers cost no display slot), and cap at the display limit.
//
// Control-plane full schema: no queryable join delegate (implicit M2M), so we traverse the
// `connectedRuns` relation; it cascade-deletes with the run, so no dangler can exist.
async #connectedRunFriendlyIdsOn(
client: PrismaReplicaClient,
waitpointId: string
): Promise<string[]> {
const dedicated = (
client as unknown as {
waitpointRunConnection?: {
findMany: (args: unknown) => Promise<{ taskRunId: string }[]>;
};
}
).waitpointRunConnection;
if (joinDelegate && typeof joinDelegate.findMany === "function") {
const links = await joinDelegate.findMany({

if (dedicated) {
const connections = await dedicated.findMany({
where: { waitpointId },
select: { taskRunId: true },
take: CONNECTED_RUNS_CONNECTION_SCAN_LIMIT,
});
return links.map((link) => link.taskRunId);
if (connections.length === 0) {
return [];
}
const runs = await client.taskRun.findMany({
where: { id: { in: connections.map((connection) => connection.taskRunId) } },
select: { friendlyId: true },
take: CONNECTED_RUNS_DISPLAY_LIMIT,
});
return runs.map((run) => run.friendlyId);
}
const rows = await client.$queryRaw<{ A: string }[]>`
SELECT "A" FROM "_WaitpointRunConnections" WHERE "B" = ${waitpointId}
`;
return rows.map((row) => row.A);

const waitpoint = (await (
client.waitpoint.findFirst as (
args: unknown
) => Promise<{ connectedRuns: { friendlyId: string }[] } | null>
)({
where: { id: waitpointId },
select: {
connectedRuns: { select: { friendlyId: true }, take: CONNECTED_RUNS_DISPLAY_LIMIT },
},
})) as { connectedRuns: { friendlyId: string }[] } | null;
return (waitpoint?.connectedRuns ?? []).map((run) => run.friendlyId);
}

public async call({
Expand Down
54 changes: 51 additions & 3 deletions apps/webapp/app/routes/admin.api.v1.feature-flags.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
import { makeSetMultipleFlags } from "~/v3/featureFlags.server";
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
import {
FEATURE_FLAG,
type FeatureFlagCatalog,
validatePartialFeatureFlags,
} from "~/v3/featureFlags";
import { stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace";

export async function action({ request }: ActionFunctionArgs) {
await requireAdminApiRequest(request);
Expand All @@ -24,9 +30,51 @@ export async function action({ request }: ActionFunctionArgs) {
);
}

const featureFlags = validationResult.data;
// Derived grace-stamp fields are computed server-side; never trust them from the body.
const {
runOpsMintKindPrev: _ignoredPrev,
runOpsMintKindFlippedAt: _ignoredFlippedAt,
...requestedFlags
} = validationResult.data;

let flagsToWrite: Partial<FeatureFlagCatalog> = requestedFlags;

if (requestedFlags.runOpsMintKind !== undefined) {
// Read the current GLOBAL mint flags so the stamp is computed against the authoritative
// stored state, mirroring the per-org route. stampMintKindFlip writes prev/flippedAt only
// on a genuine global flip, and carries an in-flight stamp forward on a same-target save.
const existingRows = await prisma.featureFlag.findMany({
where: {
key: {
in: [
FEATURE_FLAG.runOpsMintKind,
FEATURE_FLAG.runOpsMintKindPrev,
FEATURE_FLAG.runOpsMintKindFlippedAt,
],
},
},
select: { key: true, value: true },
});
const existingGlobal: Record<string, unknown> = {};
for (const row of existingRows) {
existingGlobal[row.key] = row.value;
}

// Anchor the cutover to the control-plane DB clock, not this process's wall clock.
const [{ now: controlPlaneNow }] = await prisma.$queryRaw<
{ now: Date }[]
>`SELECT now() AS now`;

flagsToWrite = stampMintKindFlip(
existingGlobal,
{ ...requestedFlags },
controlPlaneNow.getTime(),
env.RUN_OPS_MINT_FLIP_GRACE_MS
) as Partial<FeatureFlagCatalog>;
}

const setMultipleFlags = makeSetMultipleFlags(prisma);
const updatedFlags = await setMultipleFlags(featureFlags);
const updatedFlags = await setMultipleFlags(flagsToWrite);
Comment on lines +46 to +77

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concurrent global mint-kind flag flips can clobber each other's grace-window metadata

The global feature-flag save reads existing flags and writes the stamped result without a transaction or row lock (admin.api.v1.feature-flags.ts:46-77), so two concurrent requests can both read the same state and one silently overwrites the other's grace stamp.

Impact: A lost grace stamp means the deterministic cutover window doesn't fire, briefly reopening the cross-DB duplicate window the grace mechanism was designed to close.

Race condition: read-then-write without locking on the global flags route

The per-org routes at apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts:83-124 and apps/webapp/app/routes/admin.api.v2.orgs.$organizationId.feature-flags.ts:150-176 both use prisma.$transaction(async (tx) => { ... SELECT ... FOR UPDATE ... }) to atomically read, stamp, and write the grace metadata.

The global flags route at apps/webapp/app/routes/admin.api.v1.feature-flags.ts:46-77 does the same read→stamp→write sequence but without any transaction or lock:

  1. Line 46-61: reads existing global flags via prisma.featureFlag.findMany
  2. Line 68-73: computes the stamp via stampMintKindFlip
  3. Line 77: writes via setMultipleFlags (individual upserts)

If two requests race, both read the same existingGlobal, both compute a stamp against it, and the second write silently overwrites the first's runOpsMintKindPrev / runOpsMintKindFlippedAt values. The per-org routes explicitly document this as the "read-then-write race" they lock against.

Prompt for agents
The global feature-flags route (admin.api.v1.feature-flags.ts) performs a read-then-write of the mint-kind grace stamp (lines 46-77) without any transaction or row-level lock, unlike the per-org routes which both use prisma.$transaction with SELECT ... FOR UPDATE to prevent concurrent clobbering.

To fix: wrap the existing-flags read (featureFlag.findMany), the DB-clock read (SELECT now()), the stampMintKindFlip computation, and the setMultipleFlags write in a single prisma.$transaction. Inside the transaction, use a SELECT ... FOR UPDATE on the three mint-flag FeatureFlag rows to serialize concurrent flips. This mirrors the pattern already used in admin.api.v1.orgs.$organizationId.feature-flags.ts lines 83-124 and admin.api.v2.orgs.$organizationId.feature-flags.ts lines 150-176.

The FeatureFlag table uses individual rows keyed by `key`, so the FOR UPDATE should lock the three rows with keys FEATURE_FLAG.runOpsMintKind, FEATURE_FLAG.runOpsMintKindPrev, and FEATURE_FLAG.runOpsMintKindFlippedAt.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


return json({
success: true,
Expand Down
Loading