Delete project (#1913)

* Delete project

- Don’t schedule tasks if the project is deleted
- Delete queues from the master queues
- Add the old delete project UI back in

* Mark the project as deleted last

* Fix for overriding local variable

* Added a todo for deleting env queues

* Remove todo
This commit is contained in:
Matt Aitken
2025-04-16 13:31:22 +01:00
committed by GitHub
parent 2957ee9af9
commit 10f78cbad4
6 changed files with 223 additions and 5 deletions
@@ -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 (
<PageContainer>
<NavBar>
@@ -194,6 +257,47 @@ export default function Page() {
/>
</Fieldset>
</Form>
<div>
<Header2 spacing>Danger zone</Header2>
<Form
method="post"
{...deleteForm.props}
className="w-full rounded-sm border border-rose-500/40"
>
<input type="hidden" name="action" value="delete" />
<Fieldset className="p-4">
<InputGroup>
<Label htmlFor={projectSlug.id}>Delete project</Label>
<Input
{...conform.input(projectSlug, { type: "text" })}
placeholder="Your project slug"
icon="warning"
/>
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
<FormError>{deleteForm.error}</FormError>
<Hint>
This change is irreversible, so please be certain. Type in the Project slug
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
Delete.
</Hint>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant={"danger/small"}
LeadingIcon={isDeleteLoading ? "spinner-white" : "trash-can"}
leadingIconClassName="text-white"
disabled={isDeleteLoading}
>
Delete project
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</MainHorizontallyCenteredContainer>
</PageBody>
@@ -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,29 @@ export class DeleteProjectService {
return;
}
//mark the project as deleted
// 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 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,
organizationId: project.organization.id,
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,
+32
View File
@@ -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<void>((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,
@@ -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;
@@ -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);
}
@@ -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.
@@ -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<void>((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();