Skip to content

Delete project #1913

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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}`,
});
}
}),
}),
]);
}

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand Down
27 changes: 25 additions & 2 deletions apps/webapp/app/services/deleteProject.server.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions apps/webapp/app/v3/marqs/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions apps/webapp/app/v3/services/triggerScheduledTask.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
18 changes: 17 additions & 1 deletion internal-packages/run-engine/src/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions internal-packages/run-engine/src/run-queue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down