perf(webapp): batch declarative schedule cleanup queries (#4522)
## Summary `syncDeclarativeSchedules` runs on every background-worker creation (every deploy, and every file save during `trigger dev`). It issued one instance-delete per declarative schedule the current worker no longer declares, in a loop, and the overwhelming majority of those deletes matched zero rows. This collapses the loop into at most two set-based statements and skips the instance delete entirely when the current environment owns no instance of the schedule. ## Why so many, and mostly no-op The loop runs once per entry in `missingSchedules`, which starts as every DECLARATIVE schedule for the whole project across all its environments (the query filters only by `projectId`). A schedule leaves that set only when a declared task matches it by `taskIdentifier` **and** the schedule already has an instance in the current environment. That last clause is the amplifier. When a task's schedule has no instance in the current environment, the create branch inserts a brand-new `TaskSchedule` row with an instance for this environment rather than adding an instance to the existing row. So the same scheduled task, once it has run in dev and been deployed to prod, exists as two separate schedule rows: one carrying a dev instance, one carrying a prod instance. On a dev worker sync of that project: - the dev-instance row matches the declared task and is removed from the set - the prod-instance row has the same `taskIdentifier` but no dev instance, so it stays in the set and gets `deleteMany(taskScheduleId = prodRow, environmentId = dev)`, which matches zero rows So every declarative task that has been synced in another environment contributes one guaranteed no-op delete per sync, and the count scales with (declarative tasks x environments), plus any leftover rows from renamed or removed tasks. A project does not need to have dropped a schedule to generate these; it just needs the same declarative tasks present in more than one environment, which is the normal develop-in-dev, deploy-to-prod case. ## Fix The candidate schedules are already loaded with their instances, so the branch is decided in memory: - schedules with no instances (or only current-environment instances) are removed in a single `taskSchedule.deleteMany` - schedules that still have another environment's instance have only the current environment's instance detached, in a single `taskScheduleInstance.deleteMany`, and only when such an instance actually exists Behavior is unchanged (cascade delete still removes the instances of a deleted schedule); the difference is statement count. A zero-row delete writes no WAL and creates no dead tuples, so the removed work was pure query and commit overhead. Verified with a testcontainer test (red before, green after) counting the emitted deletes across the no-op, batched-detach, and schedule-delete cases, and end to end through `trigger dev`: three declarative schedules created, surviving a re-sync, then two removed in a single batched delete with the third preserved.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Lower background database load during deployments and dev sessions for projects that use declarative schedules.
|
||||
@@ -775,28 +775,41 @@ export async function syncDeclarativeSchedules(
|
||||
},
|
||||
});
|
||||
|
||||
const scheduleIdsToDelete: string[] = [];
|
||||
const scheduleIdsToDetachFromEnvironment: string[] = [];
|
||||
|
||||
for (const schedule of potentiallyDeletableSchedules) {
|
||||
const canDeleteSchedule =
|
||||
schedule.instances.length === 0 ||
|
||||
schedule.instances.every((instance) => instance.environmentId === environment.id);
|
||||
|
||||
if (canDeleteSchedule) {
|
||||
//we can delete schedules with no instances other than ones for the current environment
|
||||
await prisma.taskSchedule.delete({
|
||||
where: {
|
||||
id: schedule.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
//otherwise we delete the instance (other environments remain untouched)
|
||||
await prisma.taskScheduleInstance.deleteMany({
|
||||
where: {
|
||||
taskScheduleId: schedule.id,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
});
|
||||
scheduleIdsToDelete.push(schedule.id);
|
||||
} else if (schedule.instances.some((instance) => instance.environmentId === environment.id)) {
|
||||
scheduleIdsToDetachFromEnvironment.push(schedule.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduleIdsToDelete.length > 0) {
|
||||
await prisma.taskSchedule.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: scheduleIdsToDelete,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (scheduleIdsToDetachFromEnvironment.length > 0) {
|
||||
await prisma.taskScheduleInstance.deleteMany({
|
||||
where: {
|
||||
taskScheduleId: {
|
||||
in: scheduleIdsToDetachFromEnvironment,
|
||||
},
|
||||
environmentId: environment.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBackgroundFiles(
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { containerTest } from "@internal/testcontainers";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { syncDeclarativeSchedules } from "~/v3/services/createBackgroundWorker.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
type WorkerArg = Parameters<typeof syncDeclarativeSchedules>[1];
|
||||
const noWorker = {} as unknown as WorkerArg;
|
||||
|
||||
async function seedProjectWithEnvs(prisma: PrismaClient) {
|
||||
const slug = `sds_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const organization = await prisma.organization.create({ data: { title: slug, slug } });
|
||||
const project = await prisma.project.create({
|
||||
data: { name: slug, slug, organizationId: organization.id, externalRef: slug },
|
||||
});
|
||||
const mkEnv = (envSlug: string, type: "PRODUCTION" | "DEVELOPMENT") =>
|
||||
prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: envSlug,
|
||||
type,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_${envSlug}_${slug}`,
|
||||
pkApiKey: `pk_${envSlug}_${slug}`,
|
||||
shortcode: `${envSlug[0]}${slug.slice(0, 5)}`,
|
||||
},
|
||||
});
|
||||
const prodEnv = await mkEnv("prod", "PRODUCTION");
|
||||
const devEnv = await mkEnv("dev", "DEVELOPMENT");
|
||||
return { organization, project, prodEnv, devEnv };
|
||||
}
|
||||
|
||||
function makeDeclarativeSchedule(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
environmentIds: string[],
|
||||
taskIdentifier = "my-task"
|
||||
) {
|
||||
return prisma.taskSchedule.create({
|
||||
data: {
|
||||
friendlyId: `sched_${Math.random().toString(36).slice(2, 10)}`,
|
||||
taskIdentifier,
|
||||
projectId,
|
||||
generatorExpression: "0 * * * *",
|
||||
generatorDescription: "every hour",
|
||||
type: "DECLARATIVE",
|
||||
instances: {
|
||||
create: environmentIds.map((environmentId) => ({ environmentId, projectId })),
|
||||
},
|
||||
},
|
||||
include: { instances: true },
|
||||
});
|
||||
}
|
||||
|
||||
function countingPrisma(prisma: PrismaClient) {
|
||||
const counts = { instanceDeleteMany: 0, scheduleDelete: 0, scheduleDeleteMany: 0 };
|
||||
const client = prisma.$extends({
|
||||
query: {
|
||||
taskScheduleInstance: {
|
||||
deleteMany({ args, query }) {
|
||||
counts.instanceDeleteMany++;
|
||||
return query(args);
|
||||
},
|
||||
},
|
||||
taskSchedule: {
|
||||
delete({ args, query }) {
|
||||
counts.scheduleDelete++;
|
||||
return query(args);
|
||||
},
|
||||
deleteMany({ args, query }) {
|
||||
counts.scheduleDeleteMany++;
|
||||
return query(args);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return { client: client as unknown as PrismaClient, counts };
|
||||
}
|
||||
|
||||
const asEnv = (env: { id: string; projectId: string; type: string }) =>
|
||||
env as unknown as AuthenticatedEnvironment;
|
||||
|
||||
describe("syncDeclarativeSchedules deletion path", () => {
|
||||
containerTest(
|
||||
"does not issue any instance delete when the env owns no instance of the missing schedules",
|
||||
async ({ prisma }) => {
|
||||
const { project, prodEnv, devEnv } = await seedProjectWithEnvs(prisma);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await makeDeclarativeSchedule(prisma, project.id, [prodEnv.id], `task-${i}`);
|
||||
}
|
||||
|
||||
const { client, counts } = countingPrisma(prisma);
|
||||
await syncDeclarativeSchedules([], noWorker, asEnv(devEnv), client);
|
||||
|
||||
expect(counts.instanceDeleteMany).toBe(0);
|
||||
expect(counts.scheduleDelete).toBe(0);
|
||||
|
||||
const remaining = await prisma.taskScheduleInstance.count({
|
||||
where: { projectId: project.id },
|
||||
});
|
||||
expect(remaining).toBe(5);
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"collapses N per-schedule instance deletes into a single batched deleteMany",
|
||||
async ({ prisma }) => {
|
||||
const { project, prodEnv, devEnv } = await seedProjectWithEnvs(prisma);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await makeDeclarativeSchedule(prisma, project.id, [prodEnv.id, devEnv.id], `task-${i}`);
|
||||
}
|
||||
|
||||
const { client, counts } = countingPrisma(prisma);
|
||||
await syncDeclarativeSchedules([], noWorker, asEnv(devEnv), client);
|
||||
|
||||
expect(counts.instanceDeleteMany).toBe(1);
|
||||
|
||||
const devInstances = await prisma.taskScheduleInstance.count({
|
||||
where: { projectId: project.id, environmentId: devEnv.id },
|
||||
});
|
||||
expect(devInstances).toBe(0);
|
||||
|
||||
const prodInstances = await prisma.taskScheduleInstance.count({
|
||||
where: { projectId: project.id, environmentId: prodEnv.id },
|
||||
});
|
||||
expect(prodInstances).toBe(5);
|
||||
|
||||
const remainingSchedules = await prisma.taskSchedule.count({
|
||||
where: { projectId: project.id },
|
||||
});
|
||||
expect(remainingSchedules).toBe(5);
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"deletes schedules whose only instance is in the current env",
|
||||
async ({ prisma }) => {
|
||||
const { project, devEnv } = await seedProjectWithEnvs(prisma);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await makeDeclarativeSchedule(prisma, project.id, [devEnv.id], `task-${i}`);
|
||||
}
|
||||
|
||||
const { client } = countingPrisma(prisma);
|
||||
await syncDeclarativeSchedules([], noWorker, asEnv(devEnv), client);
|
||||
|
||||
const schedules = await prisma.taskSchedule.count({ where: { projectId: project.id } });
|
||||
expect(schedules).toBe(0);
|
||||
const instances = await prisma.taskScheduleInstance.count({
|
||||
where: { projectId: project.id },
|
||||
});
|
||||
expect(instances).toBe(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user