From f32720a4db3c42a3905096edb81f8da07eaf46a7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Apr 2025 14:56:18 +0100 Subject: [PATCH 1/5] Delete project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Don’t schedule tasks if the project is deleted - Delete queues from the master queues - Add the old delete project UI back in --- .../route.tsx | 108 +++++++++++++++++- .../app/services/deleteProject.server.ts | 28 ++++- apps/webapp/app/v3/marqs/index.server.ts | 32 ++++++ .../services/triggerScheduledTask.server.ts | 10 ++ .../run-engine/src/engine/index.ts | 16 +++ .../run-engine/src/run-queue/index.ts | 33 ++++++ 6 files changed, 223 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index d078b8b3e4..24d9cbe670 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -27,9 +27,11 @@ import * as Property from "~/components/primitives/PropertyTable"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { prisma } from "~/db.server"; import { useProject } from "~/hooks/useProject"; -import { redirectWithSuccessMessage } from "~/models/message.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { DeleteProjectService } from "~/services/deleteProject.server"; +import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { v3ProjectPath } from "~/utils/pathBuilder"; +import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { return [ @@ -49,6 +51,27 @@ export function createSchema( action: z.literal("rename"), projectName: z.string().min(3, "Project name must have at least 3 characters").max(50), }), + z.object({ + action: z.literal("delete"), + projectSlug: z.string().superRefine((slug, ctx) => { + if (constraints.getSlugMatch === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: conform.VALIDATION_UNDEFINED, + }); + } else { + const { isMatch, projectSlug } = constraints.getSlugMatch(slug); + if (isMatch) { + return; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The slug must match ${projectSlug}`, + }); + } + }), + }), ]); } @@ -97,6 +120,27 @@ export const action: ActionFunction = async ({ request, params }) => { `Project renamed to ${submission.value.projectName}` ); } + case "delete": { + const deleteProjectService = new DeleteProjectService(); + try { + await deleteProjectService.call({ projectSlug: projectParam, userId }); + + return redirectWithSuccessMessage( + organizationPath({ slug: organizationSlug }), + request, + "Project deleted" + ); + } catch (error: unknown) { + logger.error("Project could not be deleted", { + error: error instanceof Error ? error.message : JSON.stringify(error), + }); + return redirectWithErrorMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project ${projectParam} could not be deleted` + ); + } + } } } catch (error: any) { return json({ errors: { body: error.message } }, { status: 400 }); @@ -124,6 +168,25 @@ export default function Page() { navigation.formData?.get("action") === "rename" && (navigation.state === "submitting" || navigation.state === "loading"); + const [deleteForm, { projectSlug }] = useForm({ + id: "delete-project", + // TODO: type this + lastSubmission: lastSubmission as any, + shouldValidate: "onInput", + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: createSchema({ + getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }), + }), + }); + }, + }); + + const isDeleteLoading = + navigation.formData?.get("action") === "delete" && + (navigation.state === "submitting" || navigation.state === "loading"); + return ( @@ -194,6 +257,47 @@ export default function Page() { /> + +
+ Danger zone +
+ +
+ + + + {projectSlug.error} + {deleteForm.error} + + This change is irreversible, so please be certain. Type in the Project slug + {project.slug} and then press + Delete. + + + + Delete project + + } + /> +
+
+
diff --git a/apps/webapp/app/services/deleteProject.server.ts b/apps/webapp/app/services/deleteProject.server.ts index 9cfc71674f..89cee4aad9 100644 --- a/apps/webapp/app/services/deleteProject.server.ts +++ b/apps/webapp/app/services/deleteProject.server.ts @@ -1,6 +1,7 @@ import { PrismaClient } from "@trigger.dev/database"; import { prisma } from "~/db.server"; -import { logger } from "./logger.server"; +import { marqs } from "~/v3/marqs/index.server"; +import { engine } from "~/v3/runEngine.server"; type Options = ({ projectId: string } | { projectSlug: string }) & { userId: string; @@ -34,7 +35,9 @@ export class DeleteProjectService { return; } - //mark the project as deleted + // Mark the project as deleted + // - This disables all API keys + // - This disables all schedules from being scheduled await this.#prismaClient.project.update({ where: { id: project.id, @@ -43,6 +46,27 @@ export class DeleteProjectService { deletedAt: new Date(), }, }); + + // Remove queues from MARQS + for (const environment of project.environments) { + await marqs?.removeEnvironmentQueuesFromMasterQueue(project.organization.id, environment.id); + } + + // Delete all queues from the RunEngine 2 master queues + const workerGroups = await this.#prismaClient.workerInstanceGroup.findMany({ + select: { + masterQueue: true, + }, + }); + const engineMasterQueues = workerGroups.map((group) => group.masterQueue); + + for (const masterQueue of engineMasterQueues) { + await engine.removeEnvironmentQueuesFromMasterQueue({ + masterQueue, + organizationId: project.organization.id, + projectId: project.id, + }); + } } async #getProjectId(options: Options) { diff --git a/apps/webapp/app/v3/marqs/index.server.ts b/apps/webapp/app/v3/marqs/index.server.ts index b85fef788b..98e1996484 100644 --- a/apps/webapp/app/v3/marqs/index.server.ts +++ b/apps/webapp/app/v3/marqs/index.server.ts @@ -193,6 +193,38 @@ export class MarQS { return this.redis.scard(this.keys.envReserveConcurrencyKey(env.id)); } + public async removeEnvironmentQueuesFromMasterQueue(orgId: string, environmentId: string) { + const sharedQueue = this.keys.sharedQueueKey(); + const queuePattern = this.keys.queueKey(orgId, environmentId, "*"); + + // Use scanStream to find all matching members + const stream = this.redis.zscanStream(sharedQueue, { + match: queuePattern, + count: 100, + }); + + return new Promise((resolve, reject) => { + const matchingQueues: string[] = []; + + stream.on("data", (resultKeys) => { + // zscanStream returns [member1, score1, member2, score2, ...] + // We only want the members (even indices) + for (let i = 0; i < resultKeys.length; i += 2) { + matchingQueues.push(resultKeys[i]); + } + }); + + stream.on("end", async () => { + if (matchingQueues.length > 0) { + await this.redis.zrem(sharedQueue, matchingQueues); + } + resolve(); + }); + + stream.on("error", (err) => reject(err)); + }); + } + public async enqueueMessage( env: AuthenticatedEnvironment, queue: string, diff --git a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts index b2a56f78cc..665a1bafce 100644 --- a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts +++ b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts @@ -43,6 +43,16 @@ export class TriggerScheduledTaskService extends BaseService { return; } + if (instance.environment.project.deletedAt) { + logger.debug("Project is deleted, disabling schedule", { + instanceId, + scheduleId: instance.taskSchedule.friendlyId, + projectId: instance.environment.project.id, + }); + + return; + } + try { let shouldTrigger = true; diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 21993d809e..f0822f10b4 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -775,6 +775,22 @@ export class RunEngine { return this.runQueue.currentConcurrencyOfQueues(environment, queues); } + async removeEnvironmentQueuesFromMasterQueue({ + masterQueue, + organizationId, + projectId, + }: { + masterQueue: string; + organizationId: string; + projectId: string; + }) { + return this.runQueue.removeEnvironmentQueuesFromMasterQueue( + masterQueue, + organizationId, + projectId + ); + } + /** * This creates a DATETIME waitpoint, that will be completed automatically when the specified date is reached. * If you pass an `idempotencyKey`, the waitpoint will be created only if it doesn't already exist. diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index a5aacd957f..6dc4a3b76e 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -683,6 +683,39 @@ export class RunQueue { ); } + public async removeEnvironmentQueuesFromMasterQueue( + masterQueue: string, + organizationId: string, + projectId: string + ) { + // Use scanStream to find all matching members + const stream = this.redis.zscanStream(masterQueue, { + match: this.keys.queueKey(organizationId, projectId, "*", "*"), + count: 100, + }); + + return new Promise((resolve, reject) => { + const matchingQueues: string[] = []; + + stream.on("data", (resultKeys) => { + // zscanStream returns [member1, score1, member2, score2, ...] + // We only want the members (even indices) + for (let i = 0; i < resultKeys.length; i += 2) { + matchingQueues.push(resultKeys[i]); + } + }); + + stream.on("end", async () => { + if (matchingQueues.length > 0) { + await this.redis.zrem(masterQueue, matchingQueues); + } + resolve(); + }); + + stream.on("error", (err) => reject(err)); + }); + } + async quit() { await this.subscriber.unsubscribe(); await this.subscriber.quit(); From a7fa8e0a00287a9b145ee2453803c5db2a16e4f5 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Apr 2025 15:00:45 +0100 Subject: [PATCH 2/5] Mark the project as deleted last --- .../app/services/deleteProject.server.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/services/deleteProject.server.ts b/apps/webapp/app/services/deleteProject.server.ts index 89cee4aad9..f57438234d 100644 --- a/apps/webapp/app/services/deleteProject.server.ts +++ b/apps/webapp/app/services/deleteProject.server.ts @@ -35,18 +35,6 @@ export class DeleteProjectService { return; } - // Mark the project as deleted - // - This disables all API keys - // - This disables all schedules from being scheduled - await this.#prismaClient.project.update({ - where: { - id: project.id, - }, - data: { - deletedAt: new Date(), - }, - }); - // Remove queues from MARQS for (const environment of project.environments) { await marqs?.removeEnvironmentQueuesFromMasterQueue(project.organization.id, environment.id); @@ -67,6 +55,18 @@ export class DeleteProjectService { projectId: project.id, }); } + + // Mark the project as deleted (do this last because it makes it impossible to try again) + // - This disables all API keys + // - This disables all schedules from being scheduled + await this.#prismaClient.project.update({ + where: { + id: project.id, + }, + data: { + deletedAt: new Date(), + }, + }); } async #getProjectId(options: Options) { From c4089cd34f85e1afe72848da3794e23f6f447dae Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Apr 2025 16:01:43 +0100 Subject: [PATCH 3/5] Fix for overriding local variable --- internal-packages/run-engine/src/engine/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index f0822f10b4..b4b4cb3564 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -378,7 +378,7 @@ export class RunEngine { } } else { // For deployed runs, we add the env/worker id as the secondary master queue - let secondaryMasterQueue = this.#environmentMasterQueueKey(environment.id); + secondaryMasterQueue = this.#environmentMasterQueueKey(environment.id); if (lockedToVersionId) { secondaryMasterQueue = this.#backgroundWorkerQueueKey(lockedToVersionId); } From aac294a6547d50490a77490dc19d63caaae7ec9a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Apr 2025 16:02:44 +0100 Subject: [PATCH 4/5] Added a todo for deleting env queues --- apps/webapp/app/services/deleteProject.server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/services/deleteProject.server.ts b/apps/webapp/app/services/deleteProject.server.ts index f57438234d..7644686c28 100644 --- a/apps/webapp/app/services/deleteProject.server.ts +++ b/apps/webapp/app/services/deleteProject.server.ts @@ -40,14 +40,13 @@ export class DeleteProjectService { await marqs?.removeEnvironmentQueuesFromMasterQueue(project.organization.id, environment.id); } - // Delete all queues from the RunEngine 2 master queues + // Delete all queues from the RunEngine 2 prod master queues const workerGroups = await this.#prismaClient.workerInstanceGroup.findMany({ select: { masterQueue: true, }, }); const engineMasterQueues = workerGroups.map((group) => group.masterQueue); - for (const masterQueue of engineMasterQueues) { await engine.removeEnvironmentQueuesFromMasterQueue({ masterQueue, @@ -56,6 +55,8 @@ export class DeleteProjectService { }); } + // todo Delete all queues from the RunEngine 2 dev master queues + // Mark the project as deleted (do this last because it makes it impossible to try again) // - This disables all API keys // - This disables all schedules from being scheduled From 5e4aee346456bd677424c0367ba63b3d4840d1c1 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 16 Apr 2025 13:30:56 +0100 Subject: [PATCH 5/5] Remove todo --- apps/webapp/app/services/deleteProject.server.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/webapp/app/services/deleteProject.server.ts b/apps/webapp/app/services/deleteProject.server.ts index 7644686c28..8af67330a2 100644 --- a/apps/webapp/app/services/deleteProject.server.ts +++ b/apps/webapp/app/services/deleteProject.server.ts @@ -55,8 +55,6 @@ export class DeleteProjectService { }); } - // todo Delete all queues from the RunEngine 2 dev master queues - // Mark the project as deleted (do this last because it makes it impossible to try again) // - This disables all API keys // - This disables all schedules from being scheduled