Skip to content

Commit b1640ff

Browse files
committed
perf(webapp): batch declarative schedule cleanup queries
syncDeclarativeSchedules issued one instance-delete per missing schedule on every worker creation, and the overwhelming majority matched zero rows. Collapse the loop into at most two set-based deletes and skip the instance delete entirely when the environment owns no instance of the schedule.
1 parent 337dda1 commit b1640ff

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+
Reduce database load when syncing declarative schedules by batching schedule cleanup into a single query instead of one query per schedule.

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)