188 lines
5.3 KiB
TypeScript
188 lines
5.3 KiB
TypeScript
import { json } from "@remix-run/server-runtime";
|
|
import { Ratelimit } from "@upstash/ratelimit";
|
|
import { tryCatch } from "@trigger.dev/core";
|
|
import { DevDisconnectRequestBody } from "@trigger.dev/core/v3";
|
|
import { BulkActionId, RunId } from "@trigger.dev/core/v3/isomorphic";
|
|
import { BulkActionNotificationType, BulkActionType } from "@trigger.dev/database";
|
|
import { prisma } from "~/db.server";
|
|
import { runStore } from "~/v3/runStore.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { RateLimiter } from "~/services/rateLimiter.server";
|
|
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
|
import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server";
|
|
import { commonWorker } from "~/v3/commonWorker.server";
|
|
import pMap from "p-map";
|
|
|
|
const CANCEL_REASON = "Dev session ended (CLI exited)";
|
|
|
|
// Below this threshold, cancel runs inline with pMap.
|
|
// Above it, create a bulk action and process asynchronously.
|
|
const BULK_ACTION_THRESHOLD = 25;
|
|
|
|
// Maximum number of runs that can be cancelled in a single disconnect call.
|
|
const MAX_RUNS = 500;
|
|
|
|
// Rate limit: 5 calls per minute per environment
|
|
const disconnectRateLimiter = new RateLimiter({
|
|
keyPrefix: "dev-disconnect",
|
|
limiter: Ratelimit.fixedWindow(5, "1 m"),
|
|
logFailure: true,
|
|
});
|
|
|
|
const { action } = createActionApiRoute(
|
|
{
|
|
body: DevDisconnectRequestBody,
|
|
maxContentLength: 1024 * 256, // 256KB
|
|
method: "POST",
|
|
},
|
|
async ({ authentication, body }) => {
|
|
// Only allow dev environments — this endpoint uses finalizeRun which
|
|
// skips PENDING_CANCEL and immediately finalizes executing runs.
|
|
if (authentication.environment.type !== "DEVELOPMENT") {
|
|
return json(
|
|
{ error: "This endpoint is only available for dev environments" },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const environmentId = authentication.environment.id;
|
|
|
|
// Rate limit per environment
|
|
const rateLimitResult = await disconnectRateLimiter.limit(environmentId);
|
|
if (!rateLimitResult.success) {
|
|
return json(
|
|
{
|
|
error: "Rate limit exceeded",
|
|
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
|
|
},
|
|
{ status: 429 }
|
|
);
|
|
}
|
|
|
|
if (body.runFriendlyIds.length > MAX_RUNS) {
|
|
return json(
|
|
{ error: `A maximum of ${MAX_RUNS} runs can be cancelled per request` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const { runFriendlyIds } = body;
|
|
|
|
if (runFriendlyIds.length === 0) {
|
|
return json({ cancelled: 0 }, { status: 200 });
|
|
}
|
|
|
|
logger.info("Dev disconnect: cancelling runs", {
|
|
environmentId,
|
|
runCount: runFriendlyIds.length,
|
|
});
|
|
|
|
// For small numbers of runs, cancel inline
|
|
if (runFriendlyIds.length <= BULK_ACTION_THRESHOLD) {
|
|
const cancelled = await cancelRunsInline(runFriendlyIds, environmentId);
|
|
return json({ cancelled }, { status: 200 });
|
|
}
|
|
|
|
// For large numbers, create a bulk action to process asynchronously
|
|
const bulkActionId = await createBulkCancelAction(
|
|
runFriendlyIds,
|
|
authentication.environment.project.id,
|
|
environmentId
|
|
);
|
|
|
|
logger.info("Dev disconnect: created bulk action for large run set", {
|
|
environmentId,
|
|
bulkActionId,
|
|
runCount: runFriendlyIds.length,
|
|
});
|
|
|
|
return json({ cancelled: 0, bulkActionId }, { status: 200 });
|
|
}
|
|
);
|
|
|
|
async function cancelRunsInline(runFriendlyIds: string[], environmentId: string): Promise<number> {
|
|
const runIds = runFriendlyIds.map((fid) => RunId.toId(fid));
|
|
|
|
const runs = await runStore.findRuns(
|
|
{
|
|
where: {
|
|
id: { in: runIds },
|
|
runtimeEnvironmentId: environmentId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
engine: true,
|
|
friendlyId: true,
|
|
status: true,
|
|
createdAt: true,
|
|
completedAt: true,
|
|
taskEventStore: true,
|
|
},
|
|
},
|
|
prisma
|
|
);
|
|
|
|
let cancelled = 0;
|
|
const cancelService = new CancelTaskRunService(prisma);
|
|
|
|
await pMap(
|
|
runs,
|
|
async (run) => {
|
|
const [error, result] = await tryCatch(
|
|
cancelService.call(run, { reason: CANCEL_REASON, finalizeRun: true })
|
|
);
|
|
|
|
if (error) {
|
|
logger.error("Dev disconnect: failed to cancel run", {
|
|
runId: run.id,
|
|
error,
|
|
});
|
|
} else if (result && !result.alreadyFinished) {
|
|
cancelled++;
|
|
}
|
|
},
|
|
{ concurrency: 10 }
|
|
);
|
|
|
|
logger.info("Dev disconnect: completed inline cancellation", {
|
|
environmentId,
|
|
cancelled,
|
|
total: runFriendlyIds.length,
|
|
});
|
|
|
|
return cancelled;
|
|
}
|
|
|
|
async function createBulkCancelAction(
|
|
runFriendlyIds: string[],
|
|
projectId: string,
|
|
environmentId: string
|
|
): Promise<string> {
|
|
const { id, friendlyId } = BulkActionId.generate();
|
|
|
|
await prisma.bulkActionGroup.create({
|
|
data: {
|
|
id,
|
|
friendlyId,
|
|
projectId,
|
|
environmentId,
|
|
name: "Dev session disconnect",
|
|
type: BulkActionType.CANCEL,
|
|
params: { runId: runFriendlyIds, finalizeRun: true },
|
|
queryName: "bulk_action_v1",
|
|
totalCount: runFriendlyIds.length,
|
|
completionNotification: BulkActionNotificationType.NONE,
|
|
},
|
|
});
|
|
|
|
await commonWorker.enqueue({
|
|
id: `processBulkAction-${id}`,
|
|
job: "processBulkAction",
|
|
payload: { bulkActionId: id },
|
|
});
|
|
|
|
return friendlyId;
|
|
}
|
|
|
|
export { action };
|