Skip to content

Commit 036cf8d

Browse files
authored
chore(webapp): admin endpoint to backfill Vercel deployment external ids (#4770)
Skew protection resolves a run's worker by (environmentId, externalId, status=DEPLOYED). A miss parks the run and then expires it, so deployments predating the feature — which already carry the same value in commitSHA — need externalId populated to stay reachable. Vercel instant-rollback is the sharpest case, which is why the scope is the current promotion plus a recent window rather than current alone. Follows the existing backfill shape: admin PAT, keyset cursor over environments, per-environment action results, pMap, dryRun defaulting to true. Reuses normalizeExternalDeploymentId so a backfilled id is byte-identical to what a build writes, and the update re-checks externalId IS NULL so a deploy landing mid-backfill keeps its own id. Refs TRI-13464.
1 parent 11e1cd8 commit 036cf8d

3 files changed

Lines changed: 766 additions & 0 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
2+
import { json } from "@remix-run/server-runtime";
3+
import { tryCatch } from "@trigger.dev/core/v3";
4+
import { z } from "zod";
5+
import { $replica, prisma } from "~/db.server";
6+
import { logger } from "~/services/logger.server";
7+
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
8+
import { backfillVercelExternalIds } from "~/v3/services/vercelExternalIdBackfill.server";
9+
10+
const BodySchema = z.object({
11+
cursor: z.string().optional(),
12+
limit: z.number().int().min(1).max(500).default(50),
13+
recentPerEnvironment: z.number().int().min(0).max(200).default(10),
14+
parallelism: z.number().int().min(1).max(20).default(5),
15+
dryRun: z.boolean().default(true),
16+
});
17+
18+
export async function action({ request }: ActionFunctionArgs) {
19+
await requireAdminApiRequest(request);
20+
21+
if (request.method.toUpperCase() !== "POST") {
22+
return json({ error: "Method Not Allowed" }, { status: 405 });
23+
}
24+
25+
const [bodyError, body] = await tryCatch(request.json());
26+
if (bodyError) {
27+
return json({ error: bodyError.message }, { status: 400 });
28+
}
29+
30+
const parsedBody = BodySchema.safeParse(body);
31+
if (!parsedBody.success) {
32+
return json({ error: parsedBody.error.message }, { status: 400 });
33+
}
34+
35+
const { cursor, limit, recentPerEnvironment, parallelism, dryRun } = parsedBody.data;
36+
37+
logger.info("Vercel external id backfill starting", {
38+
cursor,
39+
limit,
40+
recentPerEnvironment,
41+
parallelism,
42+
dryRun,
43+
});
44+
45+
const [error, result] = await tryCatch(
46+
backfillVercelExternalIds({
47+
prisma,
48+
replica: $replica,
49+
cursor,
50+
limit,
51+
recentPerEnvironment,
52+
parallelism,
53+
dryRun,
54+
})
55+
);
56+
57+
if (error) {
58+
logger.error("Vercel external id backfill failed", { cursor, error });
59+
return json({ error: error.message }, { status: 500 });
60+
}
61+
62+
logger.info("Vercel external id backfill batch complete", {
63+
dryRun,
64+
cursor,
65+
projectCount: result.projects,
66+
environmentCount: result.environments.length,
67+
summary: result.summary,
68+
deployments: result.deployments,
69+
next: result.next,
70+
done: result.done,
71+
});
72+
73+
return json(result);
74+
}
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
2+
import { normalizeExternalDeploymentId, tryCatch } from "@trigger.dev/core/v3";
3+
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
4+
import pMap from "p-map";
5+
import { logger } from "~/services/logger.server";
6+
7+
type BackfillEnvironmentResult = {
8+
/** An environment id, or a project id when `scope` is "project". */
9+
id: string;
10+
/** Only set when the failure happened before any environment was resolved. */
11+
scope?: "project";
12+
action: "updated" | "would_update" | "skipped_nothing_eligible" | "error";
13+
eligible?: number;
14+
written?: number;
15+
error?: string;
16+
};
17+
18+
export type BackfillResult = {
19+
projects: number;
20+
environments: BackfillEnvironmentResult[];
21+
summary: Record<string, number>;
22+
deployments: { eligible: number; written: number };
23+
next?: string;
24+
done?: boolean;
25+
};
26+
27+
export type BackfillOptions = {
28+
prisma: PrismaClientOrTransaction;
29+
replica: PrismaClientOrTransaction;
30+
cursor?: string;
31+
limit: number;
32+
recentPerEnvironment: number;
33+
parallelism: number;
34+
dryRun: boolean;
35+
};
36+
37+
type Candidate = { id: string; externalId: string };
38+
39+
/**
40+
* Copy `commitSHA` into `externalId` for Vercel deployments that predate skew
41+
* protection, one keyset page of connected projects at a time.
42+
*
43+
* Resolution reads (environmentId, externalId, status=DEPLOYED) and a miss parks
44+
* the run rather than falling back, so a deployment that stores a commit SHA but
45+
* no external id is unreachable to an app that sends one.
46+
*
47+
* `cursor` and `limit` are in OrganizationProjectIntegration ids, so a page is N
48+
* connected projects and yields however many environments those hold.
49+
*/
50+
export async function backfillVercelExternalIds(options: BackfillOptions): Promise<BackfillResult> {
51+
const { replica, cursor, limit, parallelism } = options;
52+
53+
// Paginate over the connected projects rather than over environments. Driving
54+
// from RuntimeEnvironment means "is this Vercel-connected" sits two joins away
55+
// from the ordered column, so no index can serve filter and order together and
56+
// every page has to build the whole matching set and sort it. Here the keyset
57+
// runs on this table's primary key and the page is bounded by `take`.
58+
const integrations = await replica.organizationProjectIntegration.findMany({
59+
where: {
60+
deletedAt: null,
61+
organizationIntegration: { service: "VERCEL", deletedAt: null },
62+
id: cursor ? { gt: cursor } : undefined,
63+
},
64+
select: { id: true, projectId: true },
65+
orderBy: { id: "asc" },
66+
take: limit,
67+
});
68+
69+
if (integrations.length === 0) {
70+
return {
71+
projects: 0,
72+
environments: [],
73+
summary: {},
74+
deployments: { eligible: 0, written: 0 },
75+
done: true,
76+
};
77+
}
78+
79+
const next = integrations[integrations.length - 1]?.id;
80+
81+
// A project can hold more than one connection row, and reconnecting leaves the
82+
// old one behind. Deduping keeps a page from walking the same environments twice.
83+
const projectIds = [...new Set(integrations.map((integration) => integration.projectId))];
84+
85+
// One equality lookup per project rather than a single `projectId IN (...)`. A
86+
// wide IN list tips the planner into seq-scanning RuntimeEnvironment, whereas an
87+
// equality always rides projectId's index. These run concurrently anyway.
88+
// Nothing in this mapper may throw. `stopOnError: false` does not isolate a
89+
// rejected mapper: pMap still rejects the whole call with an AggregateError,
90+
// which would cost the page its results and its `next` cursor.
91+
const perProject = await pMap(
92+
projectIds,
93+
async (projectId): Promise<BackfillEnvironmentResult[]> => {
94+
const [lookupError, environments] = await tryCatch(
95+
replica.runtimeEnvironment.findMany({
96+
where: { projectId, type: { not: "DEVELOPMENT" } },
97+
select: { id: true },
98+
orderBy: { id: "asc" },
99+
})
100+
);
101+
102+
if (lookupError) {
103+
logger.error("Vercel external id backfill could not list environments", {
104+
projectId,
105+
error: lookupError,
106+
});
107+
return [{ id: projectId, scope: "project", action: "error", error: lookupError.message }];
108+
}
109+
110+
const results: BackfillEnvironmentResult[] = [];
111+
for (const environment of environments) {
112+
results.push(await backfillEnvironment(environment.id, options));
113+
}
114+
return results;
115+
},
116+
{ concurrency: parallelism, stopOnError: false }
117+
);
118+
119+
const results = perProject.flat();
120+
121+
if (results.length === 0) {
122+
return {
123+
projects: projectIds.length,
124+
environments: [],
125+
summary: {},
126+
deployments: { eligible: 0, written: 0 },
127+
next,
128+
};
129+
}
130+
131+
const summary = results.reduce<Record<string, number>>((acc, result) => {
132+
acc[result.action] = (acc[result.action] ?? 0) + 1;
133+
return acc;
134+
}, {});
135+
136+
const deployments = results.reduce(
137+
(acc, result) => ({
138+
eligible: acc.eligible + (result.eligible ?? 0),
139+
written: acc.written + (result.written ?? 0),
140+
}),
141+
{ eligible: 0, written: 0 }
142+
);
143+
144+
return {
145+
projects: projectIds.length,
146+
environments: results,
147+
summary,
148+
deployments,
149+
next,
150+
};
151+
}
152+
153+
async function backfillEnvironment(
154+
environmentId: string,
155+
options: BackfillOptions
156+
): Promise<BackfillEnvironmentResult> {
157+
const [readError, candidates] = await tryCatch(findCandidates(environmentId, options));
158+
159+
if (readError) {
160+
logger.error("Vercel external id backfill could not read deployments", {
161+
environmentId,
162+
error: readError,
163+
});
164+
return { id: environmentId, action: "error", error: readError.message };
165+
}
166+
167+
if (candidates.length === 0) {
168+
return { id: environmentId, action: "skipped_nothing_eligible", eligible: 0 };
169+
}
170+
171+
if (options.dryRun) {
172+
return { id: environmentId, action: "would_update", eligible: candidates.length };
173+
}
174+
175+
let written = 0;
176+
177+
for (const candidate of candidates) {
178+
const [writeError, result] = await tryCatch(
179+
options.prisma.workerDeployment.updateMany({
180+
// Re-checking externalId lets a deploy landing mid-backfill keep the id it set.
181+
where: { id: candidate.id, externalId: null },
182+
data: { externalId: candidate.externalId },
183+
})
184+
);
185+
186+
if (writeError) {
187+
logger.error("Vercel external id backfill could not write a deployment", {
188+
environmentId,
189+
deploymentId: candidate.id,
190+
error: writeError,
191+
});
192+
return {
193+
id: environmentId,
194+
action: "error",
195+
eligible: candidates.length,
196+
written,
197+
error: writeError.message,
198+
};
199+
}
200+
201+
written += result.count;
202+
}
203+
204+
return { id: environmentId, action: "updated", eligible: candidates.length, written };
205+
}
206+
207+
/**
208+
* The deployment holding the `current` promotion, plus the most recent DEPLOYED
209+
* ones. Only DEPLOYED deployments are ever resolved, and `current` plus a recent
210+
* window is what can still receive traffic. The window is there for Vercel
211+
* instant-rollback, where the live app is an older commit than `current`.
212+
*/
213+
async function findCandidates(
214+
environmentId: string,
215+
{ replica, recentPerEnvironment }: BackfillOptions
216+
): Promise<Candidate[]> {
217+
const select = {
218+
id: true,
219+
externalId: true,
220+
commitSHA: true,
221+
workerId: true,
222+
status: true,
223+
} as const;
224+
225+
const [promotion, recent] = await Promise.all([
226+
replica.workerDeploymentPromotion.findFirst({
227+
where: { environmentId, label: CURRENT_DEPLOYMENT_LABEL },
228+
select: { deployment: { select } },
229+
}),
230+
recentPerEnvironment > 0
231+
? replica.workerDeployment.findMany({
232+
where: { environmentId, status: "DEPLOYED" },
233+
select,
234+
// id DESC, not createdAt: it matches [environmentId, status, id] exactly, so
235+
// status stays in the index condition and the LIMIT bounds the scan. cuids sort
236+
// by creation, and resolveExternalDeployment orders its candidates the same way.
237+
orderBy: { id: "desc" },
238+
take: recentPerEnvironment,
239+
})
240+
: Promise.resolve([]),
241+
]);
242+
243+
const byId = new Map<string, (typeof recent)[number]>();
244+
for (const deployment of recent) {
245+
byId.set(deployment.id, deployment);
246+
}
247+
if (promotion?.deployment) {
248+
byId.set(promotion.deployment.id, promotion.deployment);
249+
}
250+
251+
const candidates: Candidate[] = [];
252+
253+
for (const deployment of byId.values()) {
254+
if (
255+
deployment.externalId !== null ||
256+
deployment.workerId === null ||
257+
deployment.status !== "DEPLOYED"
258+
) {
259+
continue;
260+
}
261+
262+
// Reusing the live normalizer keeps a backfilled id byte-identical to what a
263+
// build would have written.
264+
const externalId = normalizeExternalDeploymentId(deployment.commitSHA ?? undefined);
265+
if (!externalId) {
266+
continue;
267+
}
268+
269+
candidates.push({ id: deployment.id, externalId });
270+
}
271+
272+
return candidates;
273+
}

0 commit comments

Comments
 (0)