Files
2026-07-13 13:32:57 +08:00

157 lines
4.9 KiB
TypeScript

import { EnvironmentPauseSource, type PrismaClientOrTransaction } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { getManualPauseEnvironmentResult } from "~/v3/services/billingLimit/manualPauseEnvironmentGuard.server";
import { updateEnvConcurrencyLimits } from "../runQueue.server";
import { WithRunEngine } from "./baseService.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
export type PauseStatus = "paused" | "resumed";
export type PauseEnvironmentResult =
| {
success: true;
state: PauseStatus;
}
| {
success: false;
error: string;
};
export class PauseEnvironmentService extends WithRunEngine {
constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) {
super({ prisma });
}
public async call(
environment: AuthenticatedEnvironment,
action: PauseStatus
): Promise<PauseEnvironmentResult> {
try {
const org = await this._prisma.organization.findFirst({
where: {
id: environment.organizationId,
},
select: {
runsEnabled: true,
},
});
if (!org) {
throw new Error("Organization not found");
}
const previousPauseState = await this._prisma.runtimeEnvironment.findFirst({
where: { id: environment.id },
select: {
paused: true,
pauseSource: true,
},
});
const manualPauseGuard = getManualPauseEnvironmentResult(
action,
previousPauseState?.pauseSource
);
if (!manualPauseGuard.proceed) {
if (manualPauseGuard.success) {
return {
success: true,
state: manualPauseGuard.state,
};
}
// Expected, user-actionable guard result, not an error: return it as a failure
// result so it doesn't reach Sentry via the catch below.
return {
success: false,
error: manualPauseGuard.error,
};
}
if (!org.runsEnabled && action === "resumed") {
throw new Error(
"Runs are disabled for this organization. Your free plan has probably been exceeded. If not please contact support."
);
}
if (action === "resumed") {
const resumed = await this._prisma.runtimeEnvironment.updateMany({
where: {
id: environment.id,
// NOT on a nullable field excludes NULL rows in Prisma, which made
// user-paused envs (pauseSource null) unresumable.
OR: [
{ pauseSource: null },
{ NOT: { pauseSource: EnvironmentPauseSource.BILLING_LIMIT } },
],
},
data: {
paused: false,
pauseSource: null,
},
});
if (resumed.count === 0) {
// Raced into the paused state after the guard read above: expected,
// return as a failure result rather than throwing to Sentry.
return {
success: false,
error:
"This environment is paused because your organization reached its billing limit. Resolve the limit on the billing limits settings page to resume.",
};
}
} else {
await this._prisma.runtimeEnvironment.update({
where: { id: environment.id },
data: { paused: true },
});
}
try {
if (action === "paused") {
logger.debug("PauseEnvironmentService: pausing environment", {
environmentId: environment.id,
});
await updateEnvConcurrencyLimits(environment, 0);
} else {
logger.debug("PauseEnvironmentService: resuming environment", {
environmentId: environment.id,
});
await updateEnvConcurrencyLimits(environment);
}
} catch (error) {
await this._prisma.runtimeEnvironment.update({
where: { id: environment.id },
data: {
paused: previousPauseState?.paused ?? action === "resumed",
pauseSource: previousPauseState?.pauseSource ?? null,
},
});
// Rollback still wrote the env row; drop any cached copy before rethrowing.
controlPlaneResolver.invalidateEnvironment(environment.id);
throw error;
}
// The env's `paused` state changed in the control-plane; drop any cached copy.
controlPlaneResolver.invalidateEnvironment(environment.id);
return {
success: true,
state: action,
};
} catch (error) {
logger.error("PauseEnvironmentService: error pausing environment", {
action,
environmentId: environment.id,
error,
});
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
}