Skip to content

Commit 6c6e58e

Browse files
authored
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.
1 parent 04f9c4e commit 6c6e58e

3 files changed

Lines changed: 191 additions & 13 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Lower background database load during deployments and dev sessions for projects that use declarative schedules.

apps/webapp/app/v3/services/createBackgroundWorker.server.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -775,27 +775,40 @@ export async function syncDeclarativeSchedules(
775775
},
776776
});
777777

778+
const scheduleIdsToDelete: string[] = [];
779+
const scheduleIdsToDetachFromEnvironment: string[] = [];
780+
778781
for (const schedule of potentiallyDeletableSchedules) {
779782
const canDeleteSchedule =
780783
schedule.instances.length === 0 ||
781784
schedule.instances.every((instance) => instance.environmentId === environment.id);
782785

783786
if (canDeleteSchedule) {
784-
//we can delete schedules with no instances other than ones for the current environment
785-
await prisma.taskSchedule.delete({
786-
where: {
787-
id: schedule.id,
787+
scheduleIdsToDelete.push(schedule.id);
788+
} else if (schedule.instances.some((instance) => instance.environmentId === environment.id)) {
789+
scheduleIdsToDetachFromEnvironment.push(schedule.id);
790+
}
791+
}
792+
793+
if (scheduleIdsToDelete.length > 0) {
794+
await prisma.taskSchedule.deleteMany({
795+
where: {
796+
id: {
797+
in: scheduleIdsToDelete,
788798
},
789-
});
790-
} else {
791-
//otherwise we delete the instance (other environments remain untouched)
792-
await prisma.taskScheduleInstance.deleteMany({
793-
where: {
794-
taskScheduleId: schedule.id,
795-
environmentId: environment.id,
799+
},
800+
});
801+
}
802+
803+
if (scheduleIdsToDetachFromEnvironment.length > 0) {
804+
await prisma.taskScheduleInstance.deleteMany({
805+
where: {
806+
taskScheduleId: {
807+
in: scheduleIdsToDetachFromEnvironment,
796808
},
797-
});
798-
}
809+
environmentId: environment.id,
810+
},
811+
});
799812
}
800813
}
801814

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { containerTest } from "@internal/testcontainers";
2+
import type { PrismaClient } from "@trigger.dev/database";
3+
import { describe, expect, vi } from "vitest";
4+
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
5+
import { syncDeclarativeSchedules } from "~/v3/services/createBackgroundWorker.server";
6+
7+
vi.setConfig({ testTimeout: 60_000 });
8+
9+
type WorkerArg = Parameters<typeof syncDeclarativeSchedules>[1];
10+
const noWorker = {} as unknown as WorkerArg;
11+
12+
async function seedProjectWithEnvs(prisma: PrismaClient) {
13+
const slug = `sds_${Math.random().toString(36).slice(2, 10)}`;
14+
const organization = await prisma.organization.create({ data: { title: slug, slug } });
15+
const project = await prisma.project.create({
16+
data: { name: slug, slug, organizationId: organization.id, externalRef: slug },
17+
});
18+
const mkEnv = (envSlug: string, type: "PRODUCTION" | "DEVELOPMENT") =>
19+
prisma.runtimeEnvironment.create({
20+
data: {
21+
slug: envSlug,
22+
type,
23+
projectId: project.id,
24+
organizationId: organization.id,
25+
apiKey: `tr_${envSlug}_${slug}`,
26+
pkApiKey: `pk_${envSlug}_${slug}`,
27+
shortcode: `${envSlug[0]}${slug.slice(0, 5)}`,
28+
},
29+
});
30+
const prodEnv = await mkEnv("prod", "PRODUCTION");
31+
const devEnv = await mkEnv("dev", "DEVELOPMENT");
32+
return { organization, project, prodEnv, devEnv };
33+
}
34+
35+
function makeDeclarativeSchedule(
36+
prisma: PrismaClient,
37+
projectId: string,
38+
environmentIds: string[],
39+
taskIdentifier = "my-task"
40+
) {
41+
return prisma.taskSchedule.create({
42+
data: {
43+
friendlyId: `sched_${Math.random().toString(36).slice(2, 10)}`,
44+
taskIdentifier,
45+
projectId,
46+
generatorExpression: "0 * * * *",
47+
generatorDescription: "every hour",
48+
type: "DECLARATIVE",
49+
instances: {
50+
create: environmentIds.map((environmentId) => ({ environmentId, projectId })),
51+
},
52+
},
53+
include: { instances: true },
54+
});
55+
}
56+
57+
function countingPrisma(prisma: PrismaClient) {
58+
const counts = { instanceDeleteMany: 0, scheduleDelete: 0, scheduleDeleteMany: 0 };
59+
const client = prisma.$extends({
60+
query: {
61+
taskScheduleInstance: {
62+
deleteMany({ args, query }) {
63+
counts.instanceDeleteMany++;
64+
return query(args);
65+
},
66+
},
67+
taskSchedule: {
68+
delete({ args, query }) {
69+
counts.scheduleDelete++;
70+
return query(args);
71+
},
72+
deleteMany({ args, query }) {
73+
counts.scheduleDeleteMany++;
74+
return query(args);
75+
},
76+
},
77+
},
78+
});
79+
return { client: client as unknown as PrismaClient, counts };
80+
}
81+
82+
const asEnv = (env: { id: string; projectId: string; type: string }) =>
83+
env as unknown as AuthenticatedEnvironment;
84+
85+
describe("syncDeclarativeSchedules deletion path", () => {
86+
containerTest(
87+
"does not issue any instance delete when the env owns no instance of the missing schedules",
88+
async ({ prisma }) => {
89+
const { project, prodEnv, devEnv } = await seedProjectWithEnvs(prisma);
90+
91+
for (let i = 0; i < 5; i++) {
92+
await makeDeclarativeSchedule(prisma, project.id, [prodEnv.id], `task-${i}`);
93+
}
94+
95+
const { client, counts } = countingPrisma(prisma);
96+
await syncDeclarativeSchedules([], noWorker, asEnv(devEnv), client);
97+
98+
expect(counts.instanceDeleteMany).toBe(0);
99+
expect(counts.scheduleDelete).toBe(0);
100+
101+
const remaining = await prisma.taskScheduleInstance.count({
102+
where: { projectId: project.id },
103+
});
104+
expect(remaining).toBe(5);
105+
}
106+
);
107+
108+
containerTest(
109+
"collapses N per-schedule instance deletes into a single batched deleteMany",
110+
async ({ prisma }) => {
111+
const { project, prodEnv, devEnv } = await seedProjectWithEnvs(prisma);
112+
113+
for (let i = 0; i < 5; i++) {
114+
await makeDeclarativeSchedule(prisma, project.id, [prodEnv.id, devEnv.id], `task-${i}`);
115+
}
116+
117+
const { client, counts } = countingPrisma(prisma);
118+
await syncDeclarativeSchedules([], noWorker, asEnv(devEnv), client);
119+
120+
expect(counts.instanceDeleteMany).toBe(1);
121+
122+
const devInstances = await prisma.taskScheduleInstance.count({
123+
where: { projectId: project.id, environmentId: devEnv.id },
124+
});
125+
expect(devInstances).toBe(0);
126+
127+
const prodInstances = await prisma.taskScheduleInstance.count({
128+
where: { projectId: project.id, environmentId: prodEnv.id },
129+
});
130+
expect(prodInstances).toBe(5);
131+
132+
const remainingSchedules = await prisma.taskSchedule.count({
133+
where: { projectId: project.id },
134+
});
135+
expect(remainingSchedules).toBe(5);
136+
}
137+
);
138+
139+
containerTest(
140+
"deletes schedules whose only instance is in the current env",
141+
async ({ prisma }) => {
142+
const { project, devEnv } = await seedProjectWithEnvs(prisma);
143+
144+
for (let i = 0; i < 3; i++) {
145+
await makeDeclarativeSchedule(prisma, project.id, [devEnv.id], `task-${i}`);
146+
}
147+
148+
const { client } = countingPrisma(prisma);
149+
await syncDeclarativeSchedules([], noWorker, asEnv(devEnv), client);
150+
151+
const schedules = await prisma.taskSchedule.count({ where: { projectId: project.id } });
152+
expect(schedules).toBe(0);
153+
const instances = await prisma.taskScheduleInstance.count({
154+
where: { projectId: project.id },
155+
});
156+
expect(instances).toBe(0);
157+
}
158+
);
159+
});

0 commit comments

Comments
 (0)