Skip to content

Commit d166ded

Browse files
committed
chore(webapp): admin endpoint to backfill Vercel deployment external ids
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 f866210 commit d166ded

3 files changed

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

0 commit comments

Comments
 (0)