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:
+106
-2
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user