chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,231 @@
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 };
}
}
@@ -0,0 +1,8 @@
import { BillingLimitBulkCancelService } from "./BillingLimitBulkCancelService.server";
export async function runBillingLimitCancelInProgressRuns(
organizationId: string,
hitAt: string
): Promise<{ bulkActionIds: string[] }> {
return BillingLimitBulkCancelService.cancelInProgressRuns(organizationId, { hitAt });
}
@@ -0,0 +1,41 @@
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
export const BILLABLE_ENVIRONMENT_TYPES = [
"PRODUCTION",
"STAGING",
"PREVIEW",
] as const satisfies RuntimeEnvironmentType[];
export type BillableEnvironmentType = (typeof BILLABLE_ENVIRONMENT_TYPES)[number];
export const BILLING_LIMIT_CONVERGE_BATCH_SIZE = 50;
/** Max concurrent per-org billing limit lookups during reconciliation. */
export const BILLING_LIMIT_RECONCILE_LOOKUP_CONCURRENCY = 10;
/** Inline bulk-cancel budget for billing limit resolve (worker visibility is 10 min). */
export const BILLING_LIMIT_RESOLVE_BULK_CANCEL_BUDGET_MS = 8 * 60_000;
export type BillingLimitConvergeTargetState = "grace" | "rejected" | "ok";
export function isBillableEnvironmentType(type: RuntimeEnvironmentType): boolean {
return (BILLABLE_ENVIRONMENT_TYPES as readonly RuntimeEnvironmentType[]).includes(type);
}
export function buildBillingLimitResolveDedupeKey(
organizationId: string,
resolvedAt: string
): string {
return `billing-limit-resolve:${organizationId}:${resolvedAt}`;
}
export function buildBillingLimitResolveJobId(organizationId: string, resolvedAt: string): string {
return `billingLimit.resolve:${organizationId}:${resolvedAt}`;
}
export function buildBillingLimitInProgressCancelJobId(
organizationId: string,
hitAt: string
): string {
return `billingLimit.cancelInProgress:${organizationId}:${hitAt}`;
}
@@ -0,0 +1,219 @@
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<void>;
export async function convergeBillingLimitEnvironmentsForOrg(
organizationId: string,
targetState: BillingLimitConvergeTargetState,
options?: {
batchSize?: number;
prismaClient?: PrismaClient;
updateConcurrency?: UpdateEnvConcurrency;
}
): Promise<ConvergeOrgResult> {
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<ConvergeOrgResult> {
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<ConvergeOrgResult> {
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);
}
}
@@ -0,0 +1,24 @@
import { z } from "zod";
import { convergeBillingLimitEnvironmentsForOrg } from "./billingLimitConvergeEnvironments.server";
import { runBillingLimitReconcileTick } from "./runBillingLimitReconcileTick.server";
import { seedBillingLimitReconcileQueue } from "./billingLimitReconcileQueue.server";
const ConvergePayloadSchema = z.object({
organizationId: z.string(),
targetState: z.enum(["grace", "rejected", "ok"]),
});
export class BillingLimitConvergeEnvironmentsService {
static async seedReconcileQueue(organizationId: string) {
await seedBillingLimitReconcileQueue(organizationId);
}
static async runConverge(payload: z.infer<typeof ConvergePayloadSchema>) {
const parsed = ConvergePayloadSchema.parse(payload);
return convergeBillingLimitEnvironmentsForOrg(parsed.organizationId, parsed.targetState);
}
static async runReconcileTick() {
await runBillingLimitReconcileTick();
}
}
@@ -0,0 +1,33 @@
import { bustBillingLimitCaches } from "~/services/platform.v3.server";
import { logger } from "~/services/logger.server";
import { BillingLimitBulkCancelService } from "./BillingLimitBulkCancelService.server";
import { buildBillingLimitResolveDedupeKey } from "./billingLimitConstants";
import { convergeBillingLimitEnvironmentsForOrg } from "./billingLimitConvergeEnvironments.server";
import type { PendingBillingLimitResolve } from "./billingLimitPendingResolve.types";
export type { PendingBillingLimitResolve } from "./billingLimitPendingResolve.types";
export async function convergeBillingLimitResolve(
pending: PendingBillingLimitResolve
): Promise<void> {
const { organizationId, resumeMode, resolvedAt } = pending;
bustBillingLimitCaches(organizationId);
if (resumeMode === "new_only") {
await BillingLimitBulkCancelService.cancelQueuedRuns(organizationId, {
dedupeKey: buildBillingLimitResolveDedupeKey(organizationId, resolvedAt),
waitForCompletion: true,
});
}
await convergeBillingLimitEnvironmentsForOrg(organizationId, "ok");
logger.info("Converged billing limit resolve", {
organizationId,
resumeMode,
resolvedAt,
});
}
export { runPendingBillingLimitResolves } from "./billingLimitPendingResolveCoordinator.server";
@@ -0,0 +1,26 @@
export type BillingLimitHitPayload = {
organizationId: string;
hitAt: string;
cancelInProgressRuns: boolean;
};
export type BillingLimitHitDeps = {
bustCaches: (organizationId: string) => void;
seedReconcileQueue: (organizationId: string) => Promise<void>;
enqueueConverge: (organizationId: string, targetState: "grace") => Promise<unknown>;
enqueueCancelInProgressRuns: (organizationId: string, hitAt: string) => Promise<unknown>;
};
/** Process billing limit grace hit from the billing platform webhook. */
export async function processBillingLimitHit(
payload: BillingLimitHitPayload,
deps: BillingLimitHitDeps
): Promise<void> {
deps.bustCaches(payload.organizationId);
await deps.seedReconcileQueue(payload.organizationId);
await deps.enqueueConverge(payload.organizationId, "grace");
if (payload.cancelInProgressRuns) {
await deps.enqueueCancelInProgressRuns(payload.organizationId, payload.hitAt);
}
}
@@ -0,0 +1,5 @@
export type PendingBillingLimitResolve = {
organizationId: string;
resumeMode: "queue" | "new_only";
resolvedAt: string;
};
@@ -0,0 +1,55 @@
import { logger } from "~/services/logger.server";
import { classifyPendingBillingLimitResolveConvergeFailure } from "./billingLimitPendingResolveFailure.server";
import type { PendingBillingLimitResolve } from "./billingLimitPendingResolve.types";
export type RunPendingBillingLimitResolveDeps = {
converge?: (pending: PendingBillingLimitResolve) => Promise<void>;
complete?: (organizationId: string) => Promise<{ completed: boolean } | undefined>;
};
export async function runPendingBillingLimitResolves(
pendingResolves: PendingBillingLimitResolve[],
deps: RunPendingBillingLimitResolveDeps = {}
): Promise<Set<string>> {
const converge =
deps.converge ??
(await import("./billingLimitConvergeResolve.server")).convergeBillingLimitResolve;
const complete =
deps.complete ?? (await import("~/services/platform.v3.server")).completeBillingLimitResolve;
const stillPendingOrgIds = new Set<string>();
for (const pending of pendingResolves) {
try {
await converge(pending);
} catch (error) {
logger.error("Failed to converge pending billing limit resolve", {
failureClass: classifyPendingBillingLimitResolveConvergeFailure(pending.resumeMode),
error,
organizationId: pending.organizationId,
resumeMode: pending.resumeMode,
resolvedAt: pending.resolvedAt,
});
stillPendingOrgIds.add(pending.organizationId);
continue;
}
try {
const completion = await complete(pending.organizationId);
if (!completion || completion.completed !== true) {
throw new Error("Billing platform client unavailable");
}
} catch (error) {
logger.error("Failed to ack pending billing limit resolve", {
failureClass: "ack-only",
error,
organizationId: pending.organizationId,
resumeMode: pending.resumeMode,
resolvedAt: pending.resolvedAt,
});
stillPendingOrgIds.add(pending.organizationId);
}
}
return stillPendingOrgIds;
}
@@ -0,0 +1,11 @@
export type PendingBillingLimitResolveFailureClass =
| "cancel-failing"
| "converge-failing"
| "ack-only";
/** Used in converge logs to classify stuck pending resolves. */
export function classifyPendingBillingLimitResolveConvergeFailure(
resumeMode: "queue" | "new_only"
): Exclude<PendingBillingLimitResolveFailureClass, "ack-only"> {
return resumeMode === "new_only" ? "cancel-failing" : "converge-failing";
}
@@ -0,0 +1,98 @@
import type { PrismaClient, TaskRunStatus } from "@trigger.dev/database";
import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { prisma } from "~/db.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { BILLABLE_ENVIRONMENT_TYPES } from "./billingLimitConstants";
export type BillableEnvironmentRef = {
id: string;
projectId: string;
};
export async function getBillableEnvironmentsForBillingLimit(
organizationId: string,
prismaClient: PrismaClient = prisma
): Promise<BillableEnvironmentRef[]> {
return prismaClient.runtimeEnvironment.findMany({
where: {
organizationId,
type: { in: [...BILLABLE_ENVIRONMENT_TYPES] },
},
select: {
id: true,
projectId: true,
},
});
}
export async function createBillingLimitRunsRepository(organizationId: string) {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"standard"
);
return new RunsRepository({
clickhouse,
prisma: prisma as PrismaClient,
});
}
export async function countQueuedRunsForBillableEnvironment(
runsRepository: RunsRepository,
organizationId: string,
environment: BillableEnvironmentRef
): Promise<number> {
return countRunsForBillableEnvironment(runsRepository, organizationId, environment, [
...QUEUED_STATUSES,
]);
}
export async function countInProgressRunsForBillableEnvironment(
runsRepository: RunsRepository,
organizationId: string,
environment: BillableEnvironmentRef
): Promise<number> {
return countRunsForBillableEnvironment(runsRepository, organizationId, environment, [
...RUNNING_STATUSES,
]);
}
async function countRunsForBillableEnvironment(
runsRepository: RunsRepository,
organizationId: string,
environment: BillableEnvironmentRef,
statuses: TaskRunStatus[]
): Promise<number> {
return runsRepository.countRuns({
organizationId,
projectId: environment.projectId,
environmentId: environment.id,
statuses,
});
}
/** Same source as BillingLimitBulkCancelService — ClickHouse countRuns(QUEUED_STATUSES). */
export async function countBillableQueuedRunsForOrganization(
organizationId: string
): Promise<number> {
const environments = await getBillableEnvironmentsForBillingLimit(organizationId);
if (environments.length === 0) {
return 0;
}
const runsRepository = await createBillingLimitRunsRepository(organizationId);
let total = 0;
for (const environment of environments) {
total += await countQueuedRunsForBillableEnvironment(
runsRepository,
organizationId,
environment
);
}
return total;
}
@@ -0,0 +1,35 @@
import { env } from "~/env.server";
import { createRedisClient } from "~/redis.server";
import { singleton } from "~/utils/singleton";
const RECONCILE_QUEUE_KEY = "billing-limit:reconcile-queue";
function createQueueRedis() {
return createRedisClient("billing-limit:reconcile", {
keyPrefix: "",
host: env.BILLING_LIMIT_WORKER_REDIS_HOST,
port: env.BILLING_LIMIT_WORKER_REDIS_PORT,
username: env.BILLING_LIMIT_WORKER_REDIS_USERNAME,
password: env.BILLING_LIMIT_WORKER_REDIS_PASSWORD,
tlsDisabled: env.BILLING_LIMIT_WORKER_REDIS_TLS_DISABLED === "true",
});
}
const queueRedis = singleton("billingLimitReconcileQueueRedis", createQueueRedis);
export async function seedBillingLimitReconcileQueue(organizationId: string): Promise<void> {
await queueRedis.sadd(RECONCILE_QUEUE_KEY, organizationId);
}
export async function readBillingLimitReconcileQueue(): Promise<string[]> {
return queueRedis.smembers(RECONCILE_QUEUE_KEY);
}
export async function removeFromBillingLimitReconcileQueue(
organizationIds: string[]
): Promise<void> {
if (organizationIds.length === 0) {
return;
}
await queueRedis.srem(RECONCILE_QUEUE_KEY, ...organizationIds);
}
@@ -0,0 +1,20 @@
import type { BillingLimitConvergeTargetState } from "./billingLimitConstants";
import type { OrgReconcileTarget } from "./billingLimitReconciliation.server";
export async function reconcileBillingLimitTarget(
target: OrgReconcileTarget,
deps: {
bustCaches: (organizationId: string) => void;
enqueueConverge: (
organizationId: string,
targetState: BillingLimitConvergeTargetState
) => Promise<unknown>;
}
) {
// Safety net when webhooks are lost: bust stale entitlement after reject or resolve.
if (target.targetState === "rejected" || target.targetState === "ok") {
deps.bustCaches(target.organizationId);
}
await deps.enqueueConverge(target.organizationId, target.targetState);
}
@@ -0,0 +1,171 @@
import { EnvironmentPauseSource } from "@trigger.dev/database";
import pMap from "p-map";
import { prisma } from "~/db.server";
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
import { logger } from "~/services/logger.server";
import { getActiveBillingLimits, getBillingLimit } from "~/services/platform.v3.server";
import {
BILLING_LIMIT_RECONCILE_LOOKUP_CONCURRENCY,
type BillingLimitConvergeTargetState,
} from "./billingLimitConstants";
import {
readBillingLimitReconcileQueue,
removeFromBillingLimitReconcileQueue,
} from "./billingLimitReconcileQueue.server";
export type OrgReconcileTarget = {
organizationId: string;
targetState: BillingLimitConvergeTargetState;
};
export function resolveConvergeTargetFromBillingLimit(
billingLimit: BillingLimitResult | undefined
): BillingLimitConvergeTargetState {
if (!billingLimit?.isConfigured) {
return "ok";
}
if (billingLimit.limitState.status === "grace") {
return "grace";
}
if (billingLimit.limitState.status === "rejected") {
return "rejected";
}
return "ok";
}
/** Reconcile path only — skip org when the platform lookup failed (undefined ≠ unconfigured). */
export function resolveReconcileTargetFromBillingLimit(
billingLimit: BillingLimitResult | undefined
): BillingLimitConvergeTargetState | undefined {
if (billingLimit === undefined) {
return undefined;
}
return resolveConvergeTargetFromBillingLimit(billingLimit);
}
export async function getOrgIdsWithBillingPauseSource(): Promise<string[]> {
const rows = await prisma.runtimeEnvironment.findMany({
where: {
pauseSource: EnvironmentPauseSource.BILLING_LIMIT,
},
select: {
organizationId: true,
},
distinct: ["organizationId"],
});
return rows.map((row) => row.organizationId);
}
export function collectOrgIdsNeedingBillingLimitLookup(options: {
staleOrgIds: string[];
queuedOrgIds: string[];
excludeOrgIds: Set<string>;
coveredOrgIds: Set<string>;
}): string[] {
const orgIds = new Set<string>();
for (const organizationId of [...options.staleOrgIds, ...options.queuedOrgIds]) {
if (options.excludeOrgIds.has(organizationId) || options.coveredOrgIds.has(organizationId)) {
continue;
}
orgIds.add(organizationId);
}
return [...orgIds];
}
export async function resolveReconcileTargetsForOrgLookups(
organizationIds: string[],
options?: {
getBillingLimit?: (organizationId: string) => Promise<BillingLimitResult | undefined>;
concurrency?: number;
}
): Promise<Map<string, BillingLimitConvergeTargetState>> {
const lookupBillingLimit = options?.getBillingLimit ?? getBillingLimit;
const concurrency = options?.concurrency ?? BILLING_LIMIT_RECONCILE_LOOKUP_CONCURRENCY;
const targets = new Map<string, BillingLimitConvergeTargetState>();
await pMap(
organizationIds,
async (organizationId) => {
try {
const billingLimit = await lookupBillingLimit(organizationId);
const targetState = resolveReconcileTargetFromBillingLimit(billingLimit);
if (targetState === undefined) {
logger.warn("Skipping billing limit reconcile — platform lookup unavailable", {
organizationId,
});
return;
}
targets.set(organizationId, targetState);
} catch (error) {
logger.error("Failed billing limit lookup for reconcile", {
organizationId,
error,
});
}
},
{ concurrency }
);
return targets;
}
export async function collectOrgsToReconcile(options?: { excludeOrgIds?: Set<string> }): Promise<{
targets: OrgReconcileTarget[];
queuedOrgIds: string[];
}> {
const excludeOrgIds = options?.excludeOrgIds ?? new Set<string>();
const targetByOrgId = new Map<string, BillingLimitConvergeTargetState>();
const activeLimits = await getActiveBillingLimits();
if (activeLimits) {
for (const org of activeLimits.orgs) {
if (excludeOrgIds.has(org.orgId)) {
continue;
}
targetByOrgId.set(org.orgId, org.limitState);
}
}
const [staleOrgIds, queuedOrgIds] = await Promise.all([
getOrgIdsWithBillingPauseSource(),
readBillingLimitReconcileQueue(),
]);
const orgIdsNeedingLookup = collectOrgIdsNeedingBillingLimitLookup({
staleOrgIds,
queuedOrgIds,
excludeOrgIds,
coveredOrgIds: new Set(targetByOrgId.keys()),
});
const lookedUpTargets = await resolveReconcileTargetsForOrgLookups(orgIdsNeedingLookup);
for (const [organizationId, targetState] of lookedUpTargets) {
targetByOrgId.set(organizationId, targetState);
}
return {
targets: Array.from(targetByOrgId.entries()).map(([organizationId, targetState]) => ({
organizationId,
targetState,
})),
queuedOrgIds,
};
}
export async function clearProcessedReconcileQueueEntries(
queuedOrgIds: string[],
processedOrgIds: string[]
): Promise<void> {
const processed = new Set(processedOrgIds);
const toRemove = queuedOrgIds.filter((orgId) => processed.has(orgId));
await removeFromBillingLimitReconcileQueue(toRemove);
}
@@ -0,0 +1,15 @@
import type { PendingBillingLimitResolve } from "./billingLimitPendingResolve.types";
export type BillingLimitResolveDeps = {
bustCaches: (organizationId: string) => void;
enqueueResolve: (pending: PendingBillingLimitResolve) => Promise<unknown>;
};
/** Process billing limit resolve from the billing platform webhook. */
export async function processBillingLimitResolve(
pending: PendingBillingLimitResolve,
deps: BillingLimitResolveDeps
): Promise<void> {
deps.bustCaches(pending.organizationId);
await deps.enqueueResolve(pending);
}
@@ -0,0 +1,16 @@
import { EnvironmentPauseSource } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { countBillableQueuedRunsForOrganization } from "./billingLimitQueuedRuns.server";
export async function getBillingLimitQueuedRunCount(organizationId: string): Promise<number> {
return countBillableQueuedRunsForOrganization(organizationId);
}
export async function countBillingLimitPausedEnvironments(organizationId: string): Promise<number> {
return prisma.runtimeEnvironment.count({
where: {
organizationId,
pauseSource: EnvironmentPauseSource.BILLING_LIMIT,
},
});
}
@@ -0,0 +1,77 @@
import {
EnvironmentPauseSource,
type Organization,
type Project,
type RuntimeEnvironment,
type RuntimeEnvironmentType,
} from "@trigger.dev/database";
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
import { logger } from "~/services/logger.server";
import { isBillableEnvironmentType } from "./billingLimitConstants";
import { resolveConvergeTargetFromBillingLimit } from "./billingLimitReconciliation.server";
export type InitialEnvPauseState = {
paused: boolean;
pauseSource: typeof EnvironmentPauseSource.BILLING_LIMIT | null;
};
export type GetInitialEnvPauseStateDeps = {
getBillingLimit?: (organizationId: string) => Promise<BillingLimitResult | undefined>;
};
export async function getInitialEnvPauseStateForBillingLimit(
organizationId: string,
type: RuntimeEnvironmentType,
deps: GetInitialEnvPauseStateDeps = {}
): Promise<InitialEnvPauseState> {
if (!isBillableEnvironmentType(type)) {
return { paused: false, pauseSource: null };
}
let billingLimit: BillingLimitResult | undefined;
try {
billingLimit = deps.getBillingLimit
? await deps.getBillingLimit(organizationId)
: await (await import("~/services/platform.v3.server")).getBillingLimit(organizationId);
} catch (error) {
logger.error("Failed to fetch billing limit for initial env pause state", {
organizationId,
error,
});
return { paused: false, pauseSource: null };
}
const targetState = resolveConvergeTargetFromBillingLimit(billingLimit);
if (targetState === "grace" || targetState === "rejected") {
return {
paused: true,
pauseSource: EnvironmentPauseSource.BILLING_LIMIT,
};
}
return { paused: false, pauseSource: null };
}
export async function applyBillingLimitPauseAfterEnvCreate(
environment: RuntimeEnvironment & { organization: Organization; project: Project }
): Promise<void> {
if (!environment.paused || environment.pauseSource !== EnvironmentPauseSource.BILLING_LIMIT) {
return;
}
try {
// Imported dynamically so this module (pulled in at module load by
// upsertBranch.server.ts) 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 { updateEnvConcurrencyLimits } = await import("~/v3/runQueue.server");
await updateEnvConcurrencyLimits(environment, 0);
} catch (error) {
logger.error("Failed to apply billing-limit pause after env create", {
environmentId: environment.id,
organizationId: environment.organizationId,
error,
});
}
}
@@ -0,0 +1,44 @@
import { EnvironmentPauseSource } from "@trigger.dev/database";
import type { PauseStatus } from "~/v3/services/pauseEnvironment.server";
/**
* Guards manual pause/resume API calls while an environment is billing-paused.
*
* Design trade-off: billing-limit converge unpauses every environment with
* `pauseSource=BILLING_LIMIT` on resolve. We therefore do not record a
* separate manual pause on top of billing enforcement — a manual pause attempt
* while already billing-paused is a silent no-op (`success: true`, still
* paused). If the limit is later resolved, that environment is unpaused with
* the rest, even if the caller intended to keep it paused.
*
* The queues UI hides pause/resume while `pauseSource=BILLING_LIMIT`; API
* callers can still hit this path and should treat the no-op as idempotent.
*/
export function getManualPauseEnvironmentResult(
action: PauseStatus,
pauseSource: EnvironmentPauseSource | null | undefined
):
| { proceed: true }
| { proceed: false; success: true; state: PauseStatus }
| { proceed: false; success: false; error: string } {
if (action === "resumed" && pauseSource === EnvironmentPauseSource.BILLING_LIMIT) {
return {
proceed: false,
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.",
};
}
if (action === "paused" && pauseSource === EnvironmentPauseSource.BILLING_LIMIT) {
// Already billing-paused; do not overwrite pauseSource so resolve converge
// can still find and unpause this environment.
return {
proceed: false,
success: true,
state: "paused",
};
}
return { proceed: true };
}
@@ -0,0 +1,75 @@
import type { BillingLimitsPendingResolvesResult } from "~/services/billingLimit.schemas";
import { logger } from "~/services/logger.server";
import { runPendingBillingLimitResolves } from "./billingLimitPendingResolveCoordinator.server";
import type { PendingBillingLimitResolve } from "./billingLimitPendingResolve.types";
import type { OrgReconcileTarget } from "./billingLimitReconciliation.server";
import type { reconcileBillingLimitTarget } from "./billingLimitReconcileTarget.server";
export type RunBillingLimitReconcileTickDeps = {
getPendingResolves?: () => Promise<BillingLimitsPendingResolvesResult | undefined>;
runPendingResolves?: (pendingResolves: PendingBillingLimitResolve[]) => Promise<Set<string>>;
collectOrgs?: (options?: { excludeOrgIds?: Set<string> }) => Promise<{
targets: OrgReconcileTarget[];
queuedOrgIds: string[];
}>;
reconcileTarget?: typeof reconcileBillingLimitTarget;
clearProcessedQueue?: (queuedOrgIds: string[], processedOrgIds: string[]) => Promise<void>;
bustCaches?: (organizationId: string) => void;
enqueueConverge?: (
organizationId: string,
targetState: OrgReconcileTarget["targetState"]
) => Promise<void>;
};
export async function runBillingLimitReconcileTick(
deps: RunBillingLimitReconcileTickDeps = {}
): Promise<void> {
const getPendingResolves =
deps.getPendingResolves ??
(await import("~/services/platform.v3.server")).getPendingBillingLimitResolves;
const runPendingResolves = deps.runPendingResolves ?? runPendingBillingLimitResolves;
const collectOrgs =
deps.collectOrgs ??
(await import("./billingLimitReconciliation.server")).collectOrgsToReconcile;
const reconcileTarget =
deps.reconcileTarget ??
(await import("./billingLimitReconcileTarget.server")).reconcileBillingLimitTarget;
const clearProcessedQueue =
deps.clearProcessedQueue ??
(await import("./billingLimitReconciliation.server")).clearProcessedReconcileQueueEntries;
const bustCaches =
deps.bustCaches ?? (await import("~/services/platform.v3.server")).bustBillingLimitCaches;
const pendingResolves = (await getPendingResolves())?.orgs ?? [];
const stillPendingOrgIds = await runPendingResolves(pendingResolves);
const { targets, queuedOrgIds } = await collectOrgs({
excludeOrgIds: stillPendingOrgIds,
});
const enqueueConverge =
deps.enqueueConverge ??
(async (organizationId, targetState) => {
const { enqueueBillingLimitConverge } = await import("~/v3/billingLimitWorker.server");
await enqueueBillingLimitConverge(organizationId, targetState);
});
const processedOrgIds: string[] = [];
for (const target of targets) {
try {
await reconcileTarget(target, {
bustCaches,
enqueueConverge,
});
processedOrgIds.push(target.organizationId);
} catch (error) {
logger.error("Failed to reconcile billing limit target", {
organizationId: target.organizationId,
targetState: target.targetState,
error,
});
}
}
await clearProcessedQueue(queuedOrgIds, processedOrgIds);
}