232 lines
7.8 KiB
TypeScript
232 lines
7.8 KiB
TypeScript
import { BulkActionId } from "@trigger.dev/core/v3/isomorphic";
|
|
import {
|
|
type Prisma,
|
|
BulkActionNotificationType,
|
|
BulkActionStatus,
|
|
BulkActionType,
|
|
type PrismaClient,
|
|
type TaskRunStatus,
|
|
} from "@trigger.dev/database";
|
|
import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
|
import { prisma } from "~/db.server";
|
|
import type { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
|
import {
|
|
countInProgressRunsForBillableEnvironment,
|
|
countQueuedRunsForBillableEnvironment,
|
|
createBillingLimitRunsRepository,
|
|
getBillableEnvironmentsForBillingLimit,
|
|
} from "./billingLimitQueuedRuns.server";
|
|
import { BILLING_LIMIT_RESOLVE_BULK_CANCEL_BUDGET_MS } from "./billingLimitConstants";
|
|
|
|
export const BILLING_LIMIT_RESOLVE_CANCEL_SOURCE = "billing_limit_resolve_new_only";
|
|
export const BILLING_LIMIT_IN_PROGRESS_CANCEL_SOURCE = "billing_limit_in_progress";
|
|
|
|
export class BillingLimitBulkCancelIncompleteError extends Error {
|
|
constructor(readonly bulkActionId: string) {
|
|
super(`Billing limit bulk cancel did not complete within time budget: ${bulkActionId}`);
|
|
this.name = "BillingLimitBulkCancelIncompleteError";
|
|
}
|
|
}
|
|
|
|
type BulkCancelSource =
|
|
| typeof BILLING_LIMIT_RESOLVE_CANCEL_SOURCE
|
|
| typeof BILLING_LIMIT_IN_PROGRESS_CANCEL_SOURCE;
|
|
|
|
export type BillingLimitBulkCancelDeps = {
|
|
prismaClient?: PrismaClient;
|
|
createRunsRepository?: (organizationId: string) => Promise<RunsRepository>;
|
|
enqueueProcessBulkAction?: (bulkActionId: string) => Promise<unknown>;
|
|
processBulkActionToCompletion?: (
|
|
bulkActionId: string,
|
|
options?: { deadline?: number }
|
|
) => Promise<{ completed: boolean }>;
|
|
};
|
|
|
|
function resolveBulkCancelDeps(deps?: BillingLimitBulkCancelDeps) {
|
|
return {
|
|
prismaClient: deps?.prismaClient ?? prisma,
|
|
createRunsRepository: deps?.createRunsRepository ?? createBillingLimitRunsRepository,
|
|
enqueueProcessBulkAction:
|
|
deps?.enqueueProcessBulkAction ??
|
|
(async (bulkActionId: string) => {
|
|
// Imported dynamically for the same reason as BulkActionService below:
|
|
// commonWorker.server transitively loads marqs -> the
|
|
// TaskRunConcurrencyTracker singleton, which throws when REDIS_HOST/
|
|
// REDIS_PORT are unset (e.g. the webapp unit-test CI job).
|
|
const { commonWorker } = await import("~/v3/commonWorker.server");
|
|
await commonWorker.enqueue({
|
|
id: `processBulkAction-${bulkActionId}`,
|
|
job: "processBulkAction",
|
|
payload: { bulkActionId },
|
|
});
|
|
}),
|
|
processBulkActionToCompletion:
|
|
deps?.processBulkActionToCompletion ??
|
|
(async (bulkActionId: string, options?: { deadline?: number }) => {
|
|
// Imported dynamically so this module doesn't eagerly load BulkActionV2 ->
|
|
// CancelTaskRunService -> marqs -> the TaskRunConcurrencyTracker singleton,
|
|
// which throws when REDIS_HOST/REDIS_PORT are unset (e.g. the webapp
|
|
// unit-test CI job).
|
|
const { BulkActionService } = await import("~/v3/services/bulk/BulkActionV2.server");
|
|
const service = new BulkActionService();
|
|
return service.processToCompletion(bulkActionId, { deadline: options?.deadline });
|
|
}),
|
|
};
|
|
}
|
|
|
|
export class BillingLimitBulkCancelService {
|
|
static async cancelQueuedRuns(
|
|
organizationId: string,
|
|
options?: {
|
|
dedupeKey?: string;
|
|
waitForCompletion?: boolean;
|
|
bulkCancelDeadline?: number;
|
|
},
|
|
deps?: BillingLimitBulkCancelDeps
|
|
): Promise<{ bulkActionIds: string[] }> {
|
|
return this.cancelRunsForBillableEnvironments(
|
|
organizationId,
|
|
{
|
|
source: BILLING_LIMIT_RESOLVE_CANCEL_SOURCE,
|
|
statuses: [...QUEUED_STATUSES],
|
|
name: "Billing limit resolve — cancel queued runs",
|
|
countRuns: countQueuedRunsForBillableEnvironment,
|
|
dedupeKey: options?.dedupeKey,
|
|
waitForCompletion: options?.waitForCompletion,
|
|
bulkCancelDeadline: options?.bulkCancelDeadline,
|
|
},
|
|
deps
|
|
);
|
|
}
|
|
|
|
static async cancelInProgressRuns(
|
|
organizationId: string,
|
|
options: { hitAt: string },
|
|
deps?: BillingLimitBulkCancelDeps
|
|
): Promise<{ bulkActionIds: string[] }> {
|
|
return this.cancelRunsForBillableEnvironments(
|
|
organizationId,
|
|
{
|
|
source: BILLING_LIMIT_IN_PROGRESS_CANCEL_SOURCE,
|
|
statuses: [...RUNNING_STATUSES],
|
|
name: "Billing limit hit — cancel in-progress runs",
|
|
countRuns: countInProgressRunsForBillableEnvironment,
|
|
dedupeKey: options.hitAt,
|
|
},
|
|
deps
|
|
);
|
|
}
|
|
|
|
private static async cancelRunsForBillableEnvironments(
|
|
organizationId: string,
|
|
options: {
|
|
source: BulkCancelSource;
|
|
statuses: TaskRunStatus[];
|
|
name: string;
|
|
countRuns: typeof countQueuedRunsForBillableEnvironment;
|
|
dedupeKey?: string;
|
|
waitForCompletion?: boolean;
|
|
bulkCancelDeadline?: number;
|
|
},
|
|
deps?: BillingLimitBulkCancelDeps
|
|
): Promise<{ bulkActionIds: string[] }> {
|
|
const {
|
|
prismaClient,
|
|
createRunsRepository,
|
|
enqueueProcessBulkAction,
|
|
processBulkActionToCompletion,
|
|
} = resolveBulkCancelDeps(deps);
|
|
|
|
const environments = await getBillableEnvironmentsForBillingLimit(organizationId, prismaClient);
|
|
|
|
if (environments.length === 0) {
|
|
return { bulkActionIds: [] };
|
|
}
|
|
|
|
const runsRepository = await createRunsRepository(organizationId);
|
|
const bulkActionIds: string[] = [];
|
|
const bulkActionInternalIds: string[] = [];
|
|
|
|
for (const environment of environments) {
|
|
if (options.dedupeKey) {
|
|
const existing = await prismaClient.bulkActionGroup.findFirst({
|
|
where: {
|
|
environmentId: environment.id,
|
|
type: BulkActionType.CANCEL,
|
|
dedupeKey: options.dedupeKey,
|
|
status: { not: BulkActionStatus.ABORTED },
|
|
},
|
|
select: { id: true, friendlyId: true, status: true },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
|
|
if (existing) {
|
|
bulkActionIds.push(existing.friendlyId);
|
|
|
|
if (existing.status === BulkActionStatus.COMPLETED) {
|
|
continue;
|
|
}
|
|
|
|
if (options.waitForCompletion) {
|
|
bulkActionInternalIds.push(existing.id);
|
|
} else {
|
|
await enqueueProcessBulkAction(existing.id);
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const count = await options.countRuns(runsRepository, organizationId, environment);
|
|
|
|
if (count === 0) {
|
|
continue;
|
|
}
|
|
|
|
const { id, friendlyId } = BulkActionId.generate();
|
|
|
|
await prismaClient.bulkActionGroup.create({
|
|
data: {
|
|
id,
|
|
friendlyId,
|
|
projectId: environment.projectId,
|
|
environmentId: environment.id,
|
|
name: options.name,
|
|
type: BulkActionType.CANCEL,
|
|
dedupeKey: options.dedupeKey,
|
|
params: {
|
|
statuses: options.statuses,
|
|
finalizeRun: true,
|
|
source: options.source,
|
|
...(options.dedupeKey ? { dedupeKey: options.dedupeKey } : {}),
|
|
} as Prisma.InputJsonValue,
|
|
queryName: "bulk_action_v1",
|
|
totalCount: count,
|
|
completionNotification: BulkActionNotificationType.NONE,
|
|
},
|
|
});
|
|
|
|
if (options.waitForCompletion) {
|
|
bulkActionInternalIds.push(id);
|
|
} else {
|
|
await enqueueProcessBulkAction(id);
|
|
}
|
|
|
|
bulkActionIds.push(friendlyId);
|
|
}
|
|
|
|
if (options.waitForCompletion) {
|
|
const deadline =
|
|
options.bulkCancelDeadline ?? Date.now() + BILLING_LIMIT_RESOLVE_BULK_CANCEL_BUDGET_MS;
|
|
|
|
for (const bulkActionId of bulkActionInternalIds) {
|
|
const result = await processBulkActionToCompletion(bulkActionId, { deadline });
|
|
if (!result.completed) {
|
|
throw new BillingLimitBulkCancelIncompleteError(bulkActionId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { bulkActionIds };
|
|
}
|
|
}
|