Skip to content

Commit 69aab8a

Browse files
committed
Merge remote-tracking branch 'origin/main' into runops-track3-deforward-fks
# Conflicts: # apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts # apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts # apps/webapp/test/waitpointPresenter.readthrough.test.ts
2 parents f3c9d90 + c0f7c80 commit 69aab8a

43 files changed

Lines changed: 5613 additions & 337 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Make the native build server the default in project build settings. It's now opt-out, stored as a new `disableNativeBuildServer` key. Also clarifies in the UI that build settings apply to GitHub-triggered and native build server deployments.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Return a clear client error when SSO form submissions use an unsupported content type

apps/webapp/app/env.server.ts

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

17851789
// Session replication (Postgres → ClickHouse sessions_v1). Shares Redis
17861790
// 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 & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
22
import type { BatchTaskRunExecutionResult } from "@trigger.dev/core/v3";
3+
import { ownerEngine } from "@trigger.dev/core/v3/isomorphic";
34
import {
45
$replica,
56
type PrismaClientOrTransaction,
@@ -10,7 +11,6 @@ import {
1011
import type { TaskRunWithAttempts } from "~/models/taskRun.server";
1112
import { executionResultForTaskRun } from "~/models/taskRun.server";
1213
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
13-
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
1414
import { runStore as defaultRunStore } from "~/v3/runStore.server";
1515
import { BasePresenter } from "./basePresenter.server";
1616

@@ -45,11 +45,14 @@ const memberRunSelect = {
4545
} as const;
4646

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

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) =>
192-
client.taskRun.findFirst({
193-
// runops-routed-ok: per-member readThrough fan-out
194-
where: { id: item.taskRunId },
195-
select: memberRunSelect,
196-
}) as Promise<TaskRunWithAttempts | null>,
197-
readLegacy: (replica) =>
198-
replica.taskRun.findFirst({
199-
// runops-routed-ok: per-member readThrough fan-out
200-
where: { id: item.taskRunId },
201-
select: memberRunSelect,
202-
}) as Promise<TaskRunWithAttempts | null>,
203-
deps: {
204-
splitEnabled: true,
205-
// Pass the SAME resolved handles the batch row used, so the batch row and its members
206-
// never resolve against different DBs. (Letting these fall through to readThroughRun's
207-
// own module-level defaults would diverge from the batch read's resolved handles.)
208-
newClient,
209-
legacyReplica,
210-
isPastRetention: this.readThrough?.isPastRetention,
211-
},
212-
});
187+
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
213188

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

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

224217
return {
225218
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: 9 additions & 3 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,
@@ -56,8 +62,8 @@ export class WaitpointPresenter extends BasePresenter {
5662

5763
// Connected-run friendlyIds gathered across BOTH stores. The run<->waitpoint join co-locates with
5864
// the RUN (written on the run's DB), so the waitpoint's own store misses a cross-DB connection.
59-
// The run-store fans the connection lookup out to both DBs and resolves each run id on its owning
60-
// DB (by id-shape residency), so we get the union without joining across the seam.
65+
// The run-store fans the connection lookup out to both DBs (bounded there) and resolves each run
66+
// id on its owning DB (by id-shape residency), so we get the union without joining across the seam.
6167
async #connectedRunFriendlyIds(waitpointId: string): Promise<string[]> {
6268
const runIds = await this.runStore.findWaitpointConnectedRunIds(waitpointId);
6369
if (runIds.length === 0) {
@@ -66,7 +72,7 @@ export class WaitpointPresenter extends BasePresenter {
6672
const runs = await this.runStore.findRuns({
6773
where: { id: { in: runIds } },
6874
select: { friendlyId: true },
69-
take: 5,
75+
take: CONNECTED_RUNS_DISPLAY_LIMIT,
7076
});
7177
return runs.map((run) => run.friendlyId);
7278
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { json } from "@remix-run/server-runtime";
55
import React, { useCallback, useEffect, useRef, useState } from "react";
66
import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson";
77
import { z } from "zod";
8+
import { InlineCode } from "~/components/code/InlineCode";
89
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
910
import { Button } from "~/components/primitives/Buttons";
1011
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
@@ -130,6 +131,8 @@ const UpdateBuildSettingsFormSchema = z.object({
130131
.refine((val) => !val || val.length <= 500, {
131132
message: "Pre-build command must not exceed 500 characters",
132133
}),
134+
// Positive checkbox in the UI ("Use native build server"). It is checked by
135+
// default; we store the inverse as `disableNativeBuildServer`.
133136
useNativeBuildServer: z
134137
.string()
135138
.optional()
@@ -177,7 +180,8 @@ export const action = dashboardAction(
177180
installCommand: installCommand || undefined,
178181
preBuildCommand: preBuildCommand || undefined,
179182
triggerConfigFilePath: triggerConfigFilePath || undefined,
180-
useNativeBuildServer: useNativeBuildServer,
183+
// Native build server is the default, so we only persist the opt-out.
184+
disableNativeBuildServer: useNativeBuildServer ? undefined : true,
181185
});
182186

183187
if (resultOrFail.isErr()) {
@@ -379,6 +383,11 @@ export default function IntegrationsSettingsPage() {
379383

380384
<div>
381385
<Header2 spacing>Build settings</Header2>
386+
<Hint className="mb-2">
387+
These settings apply to deployments triggered from GitHub and to CLI deployments
388+
run with the <InlineCode variant="extra-small">--native-build-server</InlineCode>{" "}
389+
flag.
390+
</Hint>
382391
<div className="w-full rounded-sm border border-grid-dimmed p-4">
383392
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
384393
</div>
@@ -426,21 +435,24 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
426435
const navigation = useNavigation();
427436

428437
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
438+
// The native build server is enabled by default; it's only off when the
439+
// project has explicitly opted out via `disableNativeBuildServer`.
440+
const nativeBuildServerEnabled = buildSettings?.disableNativeBuildServer !== true;
429441
const [buildSettingsValues, setBuildSettingsValues] = useState({
430442
preBuildCommand: buildSettings?.preBuildCommand || "",
431443
installCommand: buildSettings?.installCommand || "",
432444
triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "",
433-
useNativeBuildServer: buildSettings?.useNativeBuildServer || false,
445+
useNativeBuildServer: nativeBuildServerEnabled,
434446
});
435447

436448
useEffect(() => {
437449
const hasChanges =
438450
buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") ||
439451
buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") ||
440452
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") ||
441-
buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false);
453+
buildSettingsValues.useNativeBuildServer !== nativeBuildServerEnabled;
442454
setHasBuildSettingsChanges(hasChanges);
443-
}, [buildSettingsValues, buildSettings]);
455+
}, [buildSettingsValues, buildSettings, nativeBuildServerEnabled]);
444456

445457
const [buildSettingsForm, fields] = useForm({
446458
id: "update-build-settings",
@@ -529,7 +541,7 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
529541
{...getInputProps(fields.useNativeBuildServer, { type: "checkbox" })}
530542
label="Use native build server"
531543
variant="simple/small"
532-
defaultChecked={buildSettings?.useNativeBuildServer || false}
544+
defaultChecked={nativeBuildServerEnabled}
533545
onChange={(isChecked) => {
534546
setBuildSettingsValues((prev) => ({
535547
...prev,
@@ -538,8 +550,8 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
538550
}}
539551
/>
540552
<Hint>
541-
Native build server builds do not rely on external build providers and will become the
542-
default in the future. Version 4.2.0 or newer is required.
553+
Native build server builds don't rely on external build providers and are used by
554+
default. Requires version 4.2.0 or newer.
543555
</Hint>
544556
<FormError id={fields.useNativeBuildServer.errorId}>
545557
{fields.useNativeBuildServer.errors}

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)