Files
triggerdotdev--trigger.dev/apps/webapp/test/checkSchedule.test.ts
Eric Allam aca234d1c3 perf(webapp): bound checkSchedule environment load to the requested ids (#4598)
## What

`CheckScheduleService.call` loaded **every** environment of a project
(`{ id, type, archivedAt }`, no filter) and then immediately narrowed to
just the requested `environmentIds` via
`resolveProjectScopedEnvironments`. It only ever uses the requested envs
(to reject foreign env ids and reject archived branches). On a
preview-heavy project that meant loading hundreds of archived branch
rows to validate one, on a path called in a per-scheduled-task loop on
the deploy path (`createBackgroundWorker` -> `syncDeclarativeSchedules`)
and from `upsertTaskSchedule`.

The query is index-backed and individually fast (rows_read/returned = 1
per predicate), so this is about result-set width / egress and wasted
work at scale (~580k calls/24h observed via Insights), not a slow plan.

## Change

Bound the `environments` relation load to `boundedIn(environmentIds)`:

```ts
environments: {
  where: { id: { in: boundedIn(environmentIds) } },
  select: { id: true, type: true, archivedAt: true },
}
```

Returns `<=` the number of requested envs (usually 1) instead of the
whole project. Both existing behaviors are preserved:

- **Foreign-id rejection**: the relation is still scoped to the project,
so a requested id belonging to another project never comes back and
`resolveProjectScopedEnvironments` reports it as `foreign` (a missing
requested id is already treated as foreign).
- **Archived-branch rejection**: a requested id that is an archived
branch still comes back with `archivedAt` set, so the downstream `Can't
add or edit a schedule for an archived branch` check still fires.

`archivedAt` is kept in the select deliberately, so this bounds by id
rather than filtering archived rows out.

## Evidence (isolated stack, seeded 1 prod env + 40 archived branch
envs)

Local `EXPLAIN (ANALYZE)` of the exact environments sub-select:

| | rows returned | buffers |
|---|---|---|
| before (unbounded) | **41** | shared hit=12 |
| after (`id IN (requested)`) | **1** (`Rows Removed by Filter: 40`) |
shared hit=4 |

Same `RuntimeEnvironment_projectId_idx`, no plan change. Rows to the
client drop to `len(environmentIds)`, which is the point.

**Unit (vitest, testcontainers, real Postgres):**
`apps/webapp/test/checkSchedule.test.ts` extended to prove, on real
rows, that the bounded load returns only the requested env (1 of 10),
still reports a foreign id as foreign, and still surfaces an archived
branch when it is the requested one. 5/5 pass.

**Full e2e (both execution modes, real stack):** a purpose-built project
with two declarative `schedules.task`s.
- `trigger dev`: dev worker created, both schedules synced through the
edited `checkSchedule` loop, no errors.
- `trigger deploy` (managed deployment): PRODUCTION worker registered,
both schedules synced against the **prod** environment through the same
loop, prod + dev schedule instances active, no errors.

`typecheck --filter webapp` clean.

## Rollout / rollback

Straight deploy, no flag, no migration. Rollback is revert-only
(read-path narrowing, no data change). Old and in-flight rows read
correctly under both the old and new code.

## Out of scope

The two lower-priority sibling reads in the ticket (the Query/metrics
env id->slug map and the env-var repository fan-out) are left for
follow-ups; they need caching / per-method scoping rather than this
single bound.
2026-08-13 07:36:07 +01:00

151 lines
5.3 KiB
TypeScript

import { containerTest } from "@internal/testcontainers";
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 });
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 } });
const project = await prisma.project.create({
data: { name: slug, slug, organizationId: organization.id, externalRef: slug },
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `${slug}-prod`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: slug.slice(0, 6),
},
});
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");
const b = await seedProjectWithEnv(prisma, "orgb");
const result = resolveProjectScopedEnvironments(
[a.environment.id, b.environment.id],
await projectEnvironments(prisma, a.project.id)
);
expect(result.kind).toBe("foreign");
expect(result).toMatchObject({ foreignEnvironmentId: b.environment.id });
});
containerTest("accepts environment ids that belong to the project", async ({ prisma }) => {
const a = await seedProjectWithEnv(prisma, "orga");
const result = resolveProjectScopedEnvironments(
[a.environment.id],
await projectEnvironments(prisma, a.project.id)
);
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);
}
);
});