import { EnvironmentPauseSource, type Organization, type PrismaClient, type Project, type RuntimeEnvironment, } from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; import { BILLABLE_ENVIRONMENT_TYPES, BILLING_LIMIT_CONVERGE_BATCH_SIZE, type BillingLimitConvergeTargetState, } from "./billingLimitConstants"; export type ConvergeOrgResult = { paused: number; unpaused: number; }; type EnvironmentWithRelations = RuntimeEnvironment & { organization: Organization; project: Project; }; type UpdateEnvConcurrency = ( environment: EnvironmentWithRelations, maximumConcurrencyLimit?: number ) => Promise; export async function convergeBillingLimitEnvironmentsForOrg( organizationId: string, targetState: BillingLimitConvergeTargetState, options?: { batchSize?: number; prismaClient?: PrismaClient; updateConcurrency?: UpdateEnvConcurrency; } ): Promise { const db = options?.prismaClient ?? prisma; const batchSize = options?.batchSize ?? BILLING_LIMIT_CONVERGE_BATCH_SIZE; // Imported dynamically so this module (reachable from upsertBranch.server.ts at // module load) doesn't eagerly load runQueue.server -> marqs -> triggerTaskV1 -> // the autoIncrementCounter singleton, which throws when REDIS_HOST/REDIS_PORT are // unset (e.g. the webapp unit-test CI job). const updateConcurrency = options?.updateConcurrency ?? (async (environment, maximumConcurrencyLimit) => { const { updateEnvConcurrencyLimits } = await import("~/v3/runQueue.server"); return updateEnvConcurrencyLimits(environment, maximumConcurrencyLimit); }); if (targetState === "ok") { return unpauseBillingLimitEnvironments(organizationId, db, batchSize, updateConcurrency); } return pauseBillingLimitEnvironments(organizationId, db, batchSize, updateConcurrency); } async function pauseBillingLimitEnvironments( organizationId: string, db: PrismaClient, batchSize: number, updateConcurrency: UpdateEnvConcurrency ): Promise { let paused = 0; let cursor: string | undefined; while (true) { const environments = await db.runtimeEnvironment.findMany({ where: { organizationId, type: { in: [...BILLABLE_ENVIRONMENT_TYPES] }, paused: false, }, take: batchSize, ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), orderBy: { id: "asc" }, include: { organization: true, project: true, }, }); if (environments.length === 0) { break; } for (const environment of environments) { await pauseEnvironmentForBillingLimit(environment, db, updateConcurrency); paused++; } cursor = environments[environments.length - 1]?.id; if (environments.length < batchSize) { break; } } logger.info("Billing limit converge paused environments", { organizationId, paused, }); return { paused, unpaused: 0 }; } async function unpauseBillingLimitEnvironments( organizationId: string, db: PrismaClient, batchSize: number, updateConcurrency: UpdateEnvConcurrency ): Promise { let unpaused = 0; let cursor: string | undefined; while (true) { const environments = await db.runtimeEnvironment.findMany({ where: { organizationId, pauseSource: EnvironmentPauseSource.BILLING_LIMIT, }, take: batchSize, ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), orderBy: { id: "asc" }, include: { organization: true, project: true, }, }); if (environments.length === 0) { break; } for (const environment of environments) { await resumeEnvironmentFromBillingLimit(environment, db, updateConcurrency); unpaused++; } cursor = environments[environments.length - 1]?.id; if (environments.length < batchSize) { break; } } logger.info("Billing limit converge unpaused environments", { organizationId, unpaused, }); return { paused: 0, unpaused }; } async function pauseEnvironmentForBillingLimit( environment: EnvironmentWithRelations, db: PrismaClient, updateConcurrency: UpdateEnvConcurrency ) { const updated = await db.runtimeEnvironment.update({ where: { id: environment.id }, data: { paused: true, pauseSource: EnvironmentPauseSource.BILLING_LIMIT, }, include: { organization: true, project: true, }, }); try { await updateConcurrency(updated, 0); } catch (error) { await db.runtimeEnvironment.update({ where: { id: environment.id }, data: { paused: false, pauseSource: null }, }); throw error; } finally { // The env's paused state changed (or was rolled back); drop any cached copy either way. controlPlaneResolver.invalidateEnvironment(environment.id); } } async function resumeEnvironmentFromBillingLimit( environment: EnvironmentWithRelations, db: PrismaClient, updateConcurrency: UpdateEnvConcurrency ) { const updated = await db.runtimeEnvironment.update({ where: { id: environment.id }, data: { paused: false, pauseSource: null, }, include: { organization: true, project: true, }, }); try { await updateConcurrency(updated); } catch (error) { await db.runtimeEnvironment.update({ where: { id: environment.id }, data: { paused: true, pauseSource: EnvironmentPauseSource.BILLING_LIMIT, }, }); throw error; } finally { // The env's paused state changed (or was rolled back); drop any cached copy either way. controlPlaneResolver.invalidateEnvironment(environment.id); } }