From 0873f09f457333f3daa4258e1ad8be13ba232924 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 12 Aug 2026 23:48:46 +0100 Subject: [PATCH] perf(webapp): bound checkSchedule environment load to the requested ids CheckScheduleService.call loaded every environment of a project and then narrowed to the requested environmentIds via resolveProjectScopedEnvironments. On preview-heavy projects that meant loading hundreds of archived branch rows to validate one env, on a path called per-scheduled-task on deploy and from upsertTaskSchedule. Bound the environments relation load to boundedIn(environmentIds) so it returns <= the number of requested envs instead of the whole project. Foreign-id rejection and archived-branch rejection are both preserved: a requested id absent from the bounded result is still treated as foreign, and a requested archived branch still comes back with archivedAt set. --- .../bound-checkschedule-environment-load.md | 6 ++ .../app/v3/services/checkSchedule.server.ts | 5 +- apps/webapp/test/checkSchedule.test.ts | 100 +++++++++++++++++- 3 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 .server-changes/bound-checkschedule-environment-load.md diff --git a/.server-changes/bound-checkschedule-environment-load.md b/.server-changes/bound-checkschedule-environment-load.md new file mode 100644 index 0000000000..5a7b02eccf --- /dev/null +++ b/.server-changes/bound-checkschedule-environment-load.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Validating a schedule when deploying or updating a schedule now does less work on projects with many preview branches, so those operations stay fast as branches accumulate. diff --git a/apps/webapp/app/v3/services/checkSchedule.server.ts b/apps/webapp/app/v3/services/checkSchedule.server.ts index fb365a7824..ea7be4e39e 100644 --- a/apps/webapp/app/v3/services/checkSchedule.server.ts +++ b/apps/webapp/app/v3/services/checkSchedule.server.ts @@ -6,7 +6,7 @@ import { getLimit } from "~/services/platform.v3.server"; import { getTimezones } from "~/utils/timezones.server"; import { env } from "~/env.server"; import type { ScheduleWindow } from "@trigger.dev/core/v3"; -import { type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { boundedIn, type PrismaClientOrTransaction } from "@trigger.dev/database"; import { validateScheduleWindowSyntax } from "../scheduleWindow.server"; type Schedule = { @@ -81,6 +81,9 @@ export class CheckScheduleService extends BaseService { select: { organizationId: true, environments: { + where: { + id: { in: boundedIn(environmentIds) }, + }, select: { id: true, type: true, diff --git a/apps/webapp/test/checkSchedule.test.ts b/apps/webapp/test/checkSchedule.test.ts index bd872bbcec..b8835336df 100644 --- a/apps/webapp/test/checkSchedule.test.ts +++ b/apps/webapp/test/checkSchedule.test.ts @@ -1,14 +1,10 @@ import { containerTest } from "@internal/testcontainers"; -import type { PrismaClient } from "@trigger.dev/database"; +import { boundedIn, type PrismaClient } from "@trigger.dev/database"; import { describe, expect, vi } from "vitest"; import { resolveProjectScopedEnvironments } from "~/v3/services/resolveProjectScopedEnvironments"; vi.setConfig({ testTimeout: 60_000 }); -// Exercises the environment-scoping primitive CheckScheduleService relies on -// (`resolveProjectScopedEnvironments`) with real RuntimeEnvironment rows, -// imported directly to avoid `~/db.server` and its eager global-prisma connect. - async function seedProjectWithEnv(prisma: PrismaClient, slugBase: string) { const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`; const organization = await prisma.organization.create({ data: { title: slug, slug } }); @@ -29,10 +25,47 @@ async function seedProjectWithEnv(prisma: PrismaClient, slugBase: string) { return { organization, project, environment }; } +async function seedBranchEnv( + prisma: PrismaClient, + project: { id: string; organizationId: string }, + slugBase: string, + { archived }: { archived: boolean } +) { + const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`; + return prisma.runtimeEnvironment.create({ + data: { + slug: `${slug}-branch`, + type: "PREVIEW", + branchName: slug, + projectId: project.id, + organizationId: project.organizationId, + apiKey: `tr_preview_${slug}`, + pkApiKey: `pk_preview_${slug}`, + shortcode: Math.random().toString(36).slice(2, 10), + archivedAt: archived ? new Date() : null, + }, + }); +} + function projectEnvironments(prisma: PrismaClient, projectId: string) { return prisma.runtimeEnvironment.findMany({ where: { projectId }, select: { id: true } }); } +function loadScopedEnvironments(prisma: PrismaClient, projectId: string, environmentIds: string[]) { + return prisma.project + .findFirst({ + where: { id: projectId }, + select: { + organizationId: true, + environments: { + where: { id: { in: boundedIn(environmentIds) } }, + select: { id: true, type: true, archivedAt: true }, + }, + }, + }) + .then((project) => project?.environments ?? []); +} + describe("resolveProjectScopedEnvironments (schedule env scoping)", () => { containerTest("rejects an environment id that belongs to another project", async ({ prisma }) => { const a = await seedProjectWithEnv(prisma, "orga"); @@ -58,3 +91,60 @@ describe("resolveProjectScopedEnvironments (schedule env scoping)", () => { expect(result.kind).toBe("ok"); }); }); + +describe("CheckScheduleService bounded environments load", () => { + containerTest( + "loads only the requested environments, not every project environment", + async ({ prisma }) => { + const a = await seedProjectWithEnv(prisma, "orga"); + for (let i = 0; i < 8; i++) { + await seedBranchEnv(prisma, a.project, `branch${i}`, { archived: true }); + } + await seedBranchEnv(prisma, a.project, "active", { archived: false }); + + const all = await projectEnvironments(prisma, a.project.id); + expect(all.length).toBe(10); + + const scoped = await loadScopedEnvironments(prisma, a.project.id, [a.environment.id]); + expect(scoped.length).toBe(1); + expect(scoped[0]?.id).toBe(a.environment.id); + + const result = resolveProjectScopedEnvironments([a.environment.id], scoped); + expect(result.kind).toBe("ok"); + } + ); + + containerTest( + "still rejects a foreign environment id when the load is bounded", + async ({ prisma }) => { + const a = await seedProjectWithEnv(prisma, "orga"); + const b = await seedProjectWithEnv(prisma, "orgb"); + await seedBranchEnv(prisma, a.project, "branch", { archived: true }); + + const scoped = await loadScopedEnvironments(prisma, a.project.id, [ + a.environment.id, + b.environment.id, + ]); + expect(scoped.length).toBe(1); + + const result = resolveProjectScopedEnvironments([a.environment.id, b.environment.id], scoped); + expect(result.kind).toBe("foreign"); + expect(result).toMatchObject({ foreignEnvironmentId: b.environment.id }); + } + ); + + containerTest( + "still surfaces an archived branch env when it is the requested one", + async ({ prisma }) => { + const a = await seedProjectWithEnv(prisma, "orga"); + const archivedBranch = await seedBranchEnv(prisma, a.project, "branch", { archived: true }); + + const scoped = await loadScopedEnvironments(prisma, a.project.id, [archivedBranch.id]); + expect(scoped.length).toBe(1); + + const result = resolveProjectScopedEnvironments([archivedBranch.id], scoped); + expect(result.kind).toBe("ok"); + expect(result.kind === "ok" && result.environments.some((env) => env.archivedAt)).toBe(true); + } + ); +});