From 8d0f693186d4af3d1a771d8fe2d63278bb75fb9e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 12 Aug 2026 23:50:52 +0100 Subject: [PATCH] perf(webapp): drop archived branch environments from project env loads (#4595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Several project pages loaded **every** `RuntimeEnvironment` row for a project, including the archived preview-branch environments that are never shown in the UI. On a project with heavy preview-branch usage that means thousands of rows per load, producing a large result set and a rare multi-second tail on the environment lookup (~30s outlier observed via Insights on `RuntimeEnvironment` projectId lookups, fingerprint `f2b3ecab…`). The tail is dominated by the size of the result being parsed/transferred, not by the query plan (it already used `RuntimeEnvironment_projectId_idx` with no over-read). So the fix is to stop returning archived branch environments. ## Diagnosis correction The ticket framed this as a "large `projectId IN` list" and suggested bounding the IN list / cursor pagination. It's actually a Prisma **nested relation load** on a *single-project* `project.findFirst`, so the `IN (...)` holds one projectId and the trailing `OFFSET $1` is Prisma's relation-subquery artifact. The 4,644 rows in the observed execution were **one project with ~4,644 environments** (accumulated archived branches), not many projects. ## Change Filter the `environments` relation load to `archivedAt: null` (base envs never archive, so only archived preview branches are excluded): - `ProjectPresenter.server.ts` - `orgs.$organizationSlug.projects.$projectParam.{concurrency,apikeys,environment-variables,settings}.ts` (best-env resolvers) And remove an **unused** `environments` select from `DeploymentListPresenter.server.ts` (it was selected but never read). `loadProjectEnvironments` (replay route) already filters `archivedAt: null` + env type; this change follows that existing precedent. ## Evidence (isolated stack, seeded one project with 2,000 archived branch envs + 4 active) `EXPLAIN (ANALYZE)` of the exact presenter sub-select: | | rows returned | index | |---|---|---| | before (unfiltered) | **2004** | `RuntimeEnvironment_projectId_idx` | | after (`archivedAt IS NULL`) | **4** (`Rows Removed by Filter: 2000`) | same index, no plan change | 500x fewer rows to the client, which is what removes the parse-on-load tail. No new index needed. `typecheck --filter webapp` clean. UI verified: project layout, Deploys page, and the concurrency best-env redirect all render with the 2,000 archived branches present in the DB and zero console errors. ## Rollout / rollback Straight deploy, no migration. Rollback is revert-only (read-path filter, no data change). Old and in-flight rows read correctly under both the old and new code. ## Limitation A project with thousands of *active* branches would still load them all; in practice active branches are few (branches are archived when their work merges). Hard-bounding active branches would be a larger change and is out of scope here. --- .../bound-project-environment-loads.md | 6 ++++++ .../app/presenters/ProjectPresenter.server.ts | 1 + .../SelectBestEnvironmentPresenter.server.ts | 2 ++ .../v3/DeploymentListPresenter.server.ts | 18 ------------------ .../route.tsx | 1 + ...ationSlug.projects.$projectParam.apikeys.ts | 1 + ...nSlug.projects.$projectParam.concurrency.ts | 1 + ...ects.$projectParam.environment-variables.ts | 1 + ...tionSlug.projects.$projectParam.settings.ts | 1 + 9 files changed, 14 insertions(+), 18 deletions(-) create mode 100644 .server-changes/bound-project-environment-loads.md diff --git a/.server-changes/bound-project-environment-loads.md b/.server-changes/bound-project-environment-loads.md new file mode 100644 index 000000000..6dfa59bc7 --- /dev/null +++ b/.server-changes/bound-project-environment-loads.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Project pages now load faster for projects with a large number of preview branches, by no longer loading archived branch environments that aren't shown. diff --git a/apps/webapp/app/presenters/ProjectPresenter.server.ts b/apps/webapp/app/presenters/ProjectPresenter.server.ts index 66b51a33b..773b78eda 100644 --- a/apps/webapp/app/presenters/ProjectPresenter.server.ts +++ b/apps/webapp/app/presenters/ProjectPresenter.server.ts @@ -30,6 +30,7 @@ export class ProjectPresenter { version: true, externalRef: true, environments: { + where: { archivedAt: null }, select: { id: true, slug: true, diff --git a/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts b/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts index 13d869119..29559a87a 100644 --- a/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts +++ b/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts @@ -46,6 +46,7 @@ export class SelectBestEnvironmentPresenter { include: { organization: true, environments: { + where: { archivedAt: null }, select: { id: true, type: true, @@ -71,6 +72,7 @@ export class SelectBestEnvironmentPresenter { include: { organization: true, environments: { + where: { archivedAt: null }, select: { id: true, type: true, diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts index b4b236a49..6d758f6b5 100644 --- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts @@ -43,24 +43,6 @@ export class DeploymentListPresenter { const project = await this.#prismaClient.project.findFirstOrThrow({ select: { id: true, - environments: { - select: { - id: true, - type: true, - slug: true, - orgMember: { - select: { - user: { - select: { - id: true, - name: true, - displayName: true, - }, - }, - }, - }, - }, - }, connectedGithubRepository: { select: { branchTracking: true, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx index 91854efbc..0874432fe 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx @@ -17,6 +17,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, include: { environments: { + where: { archivedAt: null }, select: { id: true, type: true, diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.apikeys.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.apikeys.ts index f34873105..51bdb7879 100644 --- a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.apikeys.ts +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.apikeys.ts @@ -16,6 +16,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, include: { environments: { + where: { archivedAt: null }, select: { id: true, type: true, diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts index de31a1373..4bde0ccae 100644 --- a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts @@ -16,6 +16,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, include: { environments: { + where: { archivedAt: null }, select: { id: true, type: true, diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.environment-variables.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.environment-variables.ts index 13a79aaad..23208dda6 100644 --- a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.environment-variables.ts +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.environment-variables.ts @@ -16,6 +16,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, include: { environments: { + where: { archivedAt: null }, select: { id: true, type: true, diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.settings.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.settings.ts index 0ac309bbe..aef178d2e 100644 --- a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.settings.ts +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.settings.ts @@ -16,6 +16,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, include: { environments: { + where: { archivedAt: null }, select: { id: true, type: true,