Skip to content

Commit 4fd7cc0

Browse files
authored
perf(webapp,database): index RuntimeEnvironment.pauseSource for the billing-limit reconcile tick (#4590)
## What The `billingLimit.reconcileTick` worker calls `getOrgIdsWithBillingPauseSource()` on `BILLING_LIMIT_RECONCILE_INTERVAL_MS` (~every 90s) to find which orgs currently have billing-limit-paused environments. Two problems: 1. `RuntimeEnvironment.pauseSource` had no index, so `WHERE pauseSource = 'BILLING_LIMIT'` was a **sequential scan of the whole table** on the control-plane primary, every tick. 2. Prisma `distinct` dedups **after** fetching, so it read every paused row (thousands) to produce a handful of distinct org ids. This PR: - Adds a **partial index** on `RuntimeEnvironment (pauseSource, organizationId) WHERE pauseSource IS NOT NULL`. Nearly all rows have `pauseSource = null`, so the index stays tiny. Second column lets the DB satisfy the distinct-org lookup from the index. Defined in SQL (Prisma can't express partial indexes), matching the existing partial-unique indexes on this model. - Switches the query from `findMany({ distinct })` to `groupBy(["organizationId"])`, pushing DISTINCT into the DB so it returns only the distinct orgs. ## Evidence **Correctness** — colocated `postgresTest` (testcontainers, no mocks): multiple `BILLING_LIMIT` envs in one org collapse to one org id, `pauseSource = null` envs are excluded, each org id returned once. 5/5 tests in `billingLimitReconciliation.test.ts` pass. **Plan change** — `EXPLAIN ANALYZE` on a synthetic table (200k rows, 5,250 `BILLING_LIMIT` across ~40 orgs, mirroring the test-side numbers from the investigation): | | Before (no index) | After (partial index) | |---|---|---| | Plan | Seq Scan (194,750 rows removed by filter) | Bitmap Index Scan on partial index | | Buffers | 1355 | 51 (index 6 + heap 45) | | Exec time | 6.06 ms | 0.59 ms | Index size 56 kB vs table 11 MB. The key win: cost now scales with the paused-env count, not total table size, which matters most on prod where the table is far larger. ## Rollout & rollback - **Index**: `CREATE INDEX CONCURRENTLY IF NOT EXISTS`, in its own migration file. Pre-apply the index manually on the control-plane primary before deploying the migration (the migration is a no-op if the index already exists). - **Query change** is behavior-equivalent (same distinct org set), so no flag needed. - **Rollback**: revert the deploy and drop the index. No data migration either direction. ## Notes / limitations - The planner uses a Bitmap Heap Scan, so `organizationId` is still read from the heap (45 blocks for the matched rows only, not the whole table). A pure index-only scan isn't chosen for the bitmap path; the second index column keeps that open for the index-scan path at negligible cost. refs TRI-13169
1 parent 4658cd0 commit 4fd7cc0

4 files changed

Lines changed: 85 additions & 6 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Reduced recurring background database load from the billing-limit recovery check, so paused environments are reconciled with less overhead.

apps/webapp/app/v3/services/billingLimit/billingLimitReconciliation.server.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { EnvironmentPauseSource } from "@trigger.dev/database";
2+
import type { PrismaClient } from "@trigger.dev/database";
23
import pMap from "p-map";
34
import { prisma } from "~/db.server";
45
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
@@ -47,15 +48,14 @@ export function resolveReconcileTargetFromBillingLimit(
4748
return resolveConvergeTargetFromBillingLimit(billingLimit);
4849
}
4950

50-
export async function getOrgIdsWithBillingPauseSource(): Promise<string[]> {
51-
const rows = await prisma.runtimeEnvironment.findMany({
51+
export async function getOrgIdsWithBillingPauseSource(
52+
db: PrismaClient = prisma
53+
): Promise<string[]> {
54+
const rows = await db.runtimeEnvironment.groupBy({
55+
by: ["organizationId"],
5256
where: {
5357
pauseSource: EnvironmentPauseSource.BILLING_LIMIT,
5458
},
55-
select: {
56-
organizationId: true,
57-
},
58-
distinct: ["organizationId"],
5959
});
6060

6161
return rows.map((row) => row.organizationId);

apps/webapp/test/billingLimitReconciliation.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { postgresTest } from "@internal/testcontainers";
2+
import type { PrismaClient } from "@trigger.dev/database";
13
import { describe, expect, it } from "vitest";
24
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
35
import {
46
collectOrgIdsNeedingBillingLimitLookup,
7+
getOrgIdsWithBillingPauseSource,
58
resolveConvergeTargetFromBillingLimit,
69
resolveReconcileTargetFromBillingLimit,
710
resolveReconcileTargetsForOrgLookups,
@@ -97,3 +100,70 @@ describe("billingLimitReconciliation", () => {
97100
expect(new Set(lookedUpOrgIds)).toEqual(new Set(["org_ok", "org_fail", "org_grace"]));
98101
});
99102
});
103+
104+
let envSeedCounter = 0;
105+
106+
async function seedEnvironment(
107+
prisma: PrismaClient,
108+
opts: { organizationId: string; projectId: string; pauseSource: "BILLING_LIMIT" | null }
109+
) {
110+
const n = envSeedCounter++;
111+
return prisma.runtimeEnvironment.create({
112+
data: {
113+
slug: `env-${n}`,
114+
type: "PRODUCTION",
115+
projectId: opts.projectId,
116+
organizationId: opts.organizationId,
117+
apiKey: `api-${n}`,
118+
pkApiKey: `pk-${n}`,
119+
shortcode: `sc-${n}`,
120+
pauseSource: opts.pauseSource,
121+
},
122+
});
123+
}
124+
125+
describe("getOrgIdsWithBillingPauseSource", () => {
126+
postgresTest(
127+
"returns each org once and ignores envs without the billing-limit pause source",
128+
async ({ prisma }) => {
129+
const seed: Record<string, Array<"BILLING_LIMIT" | null>> = {
130+
org_a: ["BILLING_LIMIT", "BILLING_LIMIT"],
131+
org_b: ["BILLING_LIMIT"],
132+
org_c: [null],
133+
};
134+
135+
const orgIdBySlug = new Map<string, string>();
136+
137+
for (const [slug, pauseSources] of Object.entries(seed)) {
138+
const organization = await prisma.organization.create({
139+
data: { title: slug, slug: `${slug}-${envSeedCounter}` },
140+
});
141+
const project = await prisma.project.create({
142+
data: {
143+
name: slug,
144+
slug: `proj-${slug}-${envSeedCounter}`,
145+
organizationId: organization.id,
146+
externalRef: `ext-${slug}-${envSeedCounter}`,
147+
},
148+
});
149+
orgIdBySlug.set(slug, organization.id);
150+
151+
for (const pauseSource of pauseSources) {
152+
await seedEnvironment(prisma, {
153+
organizationId: organization.id,
154+
projectId: project.id,
155+
pauseSource,
156+
});
157+
}
158+
}
159+
160+
const orgIds = await getOrgIdsWithBillingPauseSource(prisma);
161+
162+
expect(orgIds.length).toBe(new Set(orgIds).size);
163+
expect([...orgIds].sort()).toEqual(
164+
[orgIdBySlug.get("org_a")!, orgIdBySlug.get("org_b")!].sort()
165+
);
166+
},
167+
30_000
168+
);
169+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_pauseSource_organizationId_idx"
2+
ON "RuntimeEnvironment" ("pauseSource", "organizationId")
3+
WHERE "pauseSource" IS NOT NULL;

0 commit comments

Comments
 (0)