perf: optimize getBusyTimesForLimitChecks with batched parallel queries (#27399)

* perf: optimize getBusyTimesForLimitChecks with batched parallel queries and raw SQL

Co-Authored-By: udit@cal.com <udit222001@gmail.com>

* test: add unit tests for getBusyTimesForLimitChecks batched query optimization

Co-Authored-By: udit@cal.com <udit222001@gmail.com>

* refactor: use Prisma findMany instead of raw SQL for better testability

The batching (50 user chunks) and parallel execution (Promise.all) provide
the main performance optimization. Using findMany instead of $queryRaw
maintains the same performance characteristics while being compatible
with prismock for testing.

Co-Authored-By: udit@cal.com <udit222001@gmail.com>

* fix: type error

* refactor: add integration test

* chore: remove log

* chore: remove log

* fix: avoid logging PII (userId) in busyTimes debug log

Replace JSON.stringify(busyTimes) with count to avoid exposing userId
in logs. Addresses Cubic AI review feedback (confidence 9/10).

Co-Authored-By: unknown <>

* chore: undo

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Udit Takkar
2026-02-10 15:56:30 +00:00
committed by GitHub
co-authored by unknown Devin AI
parent db76980644
commit 3908cda372
3 changed files with 534 additions and 30 deletions
@@ -0,0 +1,160 @@
import dayjs from "@calcom/dayjs";
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { afterEach, describe, expect, it } from "vitest";
type CreatedResources = {
users: number[];
eventTypes: number[];
bookings: number[];
};
const createdResources: CreatedResources = {
users: [],
eventTypes: [],
bookings: [],
};
const createTestUser = async (overrides?: { email?: string; username?: string }) => {
const timestamp = `${Date.now()}-${Math.random()}`;
const user = await prisma.user.create({
data: {
email: overrides?.email ?? `busy-times-${timestamp}@example.com`,
username: overrides?.username ?? `busy-times-${timestamp}`,
},
});
createdResources.users.push(user.id);
return user;
};
const createTestEventType = async (userId: number) => {
const timestamp = `${Date.now()}-${Math.random()}`;
const eventType = await prisma.eventType.create({
data: {
title: "Busy Times Test Event",
slug: `busy-times-${timestamp}`,
length: 30,
userId,
users: {
connect: { id: userId },
},
},
});
createdResources.eventTypes.push(eventType.id);
return eventType;
};
const createTestBooking = async (params: {
userId: number;
eventTypeId: number;
uid: string;
startTime: Date;
endTime: Date;
}) => {
const booking = await prisma.booking.create({
data: {
userId: params.userId,
eventTypeId: params.eventTypeId,
uid: params.uid,
status: BookingStatus.ACCEPTED,
startTime: params.startTime,
endTime: params.endTime,
title: "Busy Times Test Booking",
},
});
createdResources.bookings.push(booking.id);
return booking;
};
afterEach(async () => {
if (createdResources.bookings.length > 0) {
await prisma.booking.deleteMany({
where: { id: { in: createdResources.bookings } },
});
createdResources.bookings = [];
}
if (createdResources.eventTypes.length > 0) {
await prisma.eventType.deleteMany({
where: { id: { in: createdResources.eventTypes } },
});
createdResources.eventTypes = [];
}
if (createdResources.users.length > 0) {
await prisma.user.deleteMany({
where: { id: { in: createdResources.users } },
});
createdResources.users = [];
}
});
describe("getBusyTimesForLimitChecks (integration)", () => {
it("returns bookings across batches for large user lists", async () => {
const user1 = await createTestUser();
const user2 = await createTestUser();
const eventType = await createTestEventType(user1.id);
const dayStart = dayjs().add(1, "day").startOf("day");
const dayEnd = dayStart.endOf("day");
await createTestBooking({
userId: user1.id,
eventTypeId: eventType.id,
uid: `busy-times-${Date.now()}-1`,
startTime: dayStart.set("hour", 9).toDate(),
endTime: dayStart.set("hour", 10).toDate(),
});
await createTestBooking({
userId: user2.id,
eventTypeId: eventType.id,
uid: `busy-times-${Date.now()}-2`,
startTime: dayStart.set("hour", 11).toDate(),
endTime: dayStart.set("hour", 12).toDate(),
});
const fillerIds = Array.from({ length: 58 }, (_, index) => 1_000_000 + index);
const userIds = [user1.id, ...fillerIds, user2.id];
const busyTimes = await getBusyTimesService().getBusyTimesForLimitChecks({
userIds,
eventTypeId: eventType.id,
startDate: dayStart.toISOString(),
endDate: dayEnd.toISOString(),
bookingLimits: { PER_DAY: 5 },
});
expect(busyTimes).toHaveLength(2);
expect(busyTimes.map((busyTime) => busyTime.userId).sort()).toEqual([user1.id, user2.id].sort());
});
it("excludes rescheduleUid from results", async () => {
const user = await createTestUser();
const eventType = await createTestEventType(user.id);
const dayStart = dayjs().add(2, "day").startOf("day");
const dayEnd = dayStart.endOf("day");
const rescheduleUid = `busy-times-${Date.now()}-reschedule`;
await createTestBooking({
userId: user.id,
eventTypeId: eventType.id,
uid: rescheduleUid,
startTime: dayStart.set("hour", 9).toDate(),
endTime: dayStart.set("hour", 10).toDate(),
});
const busyTimes = await getBusyTimesService().getBusyTimesForLimitChecks({
userIds: [user.id],
eventTypeId: eventType.id,
startDate: dayStart.toISOString(),
endDate: dayEnd.toISOString(),
rescheduleUid,
bookingLimits: { PER_DAY: 5 },
});
expect(busyTimes).toHaveLength(0);
});
});
@@ -1,11 +1,10 @@
import { prisma } from "@calcom/prisma/__mocks__/prisma";
import { describe, expect, it, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@calcom/prisma", () => ({
default: prisma,
prisma,
}));
@@ -134,3 +133,252 @@ describe("getBusyTimes", () => {
]);
});
});
describe("getBusyTimesForLimitChecks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createMockBookingResult = (
overrides: Partial<{
id: number;
startTime: Date;
endTime: Date;
eventTypeId: number | null;
title: string;
userId: number | null;
}> = {}
) => ({
id: 1,
startTime: startOfTomorrow.set("hour", 10).toDate(),
endTime: startOfTomorrow.set("hour", 11).toDate(),
eventTypeId: 1,
title: "Test Booking",
userId: 1,
...overrides,
});
it("should return empty array when no booking or duration limits are provided", async () => {
const busyTimesService = getBusyTimesService();
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
userIds: [1, 2, 3],
eventTypeId: 1,
startDate: startOfTomorrow.format(),
endDate: startOfTomorrow.endOf("day").format(),
bookingLimits: null,
durationLimits: null,
});
expect(busyTimes).toEqual([]);
expect(prisma.booking.findMany).not.toHaveBeenCalled();
});
it("should return empty array when userIds is empty", async () => {
const busyTimesService = getBusyTimesService();
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
userIds: [],
eventTypeId: 1,
startDate: startOfTomorrow.format(),
endDate: startOfTomorrow.endOf("day").format(),
bookingLimits: { PER_DAY: 5 },
});
expect(busyTimes).toEqual([]);
});
it("should fetch bookings for a single user with booking limits", async () => {
const mockBooking = createMockBookingResult();
vi.mocked(prisma.booking.findMany).mockResolvedValue([mockBooking]);
const busyTimesService = getBusyTimesService();
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
userIds: [1],
eventTypeId: 1,
startDate: startOfTomorrow.format(),
endDate: startOfTomorrow.endOf("day").format(),
bookingLimits: { PER_DAY: 5 },
});
expect(busyTimes).toHaveLength(1);
expect(busyTimes[0]).toEqual(
expect.objectContaining({
start: mockBooking.startTime,
end: mockBooking.endTime,
title: mockBooking.title,
source: `eventType-${mockBooking.eventTypeId}-booking-${mockBooking.id}`,
userId: mockBooking.userId,
})
);
expect(prisma.booking.findMany).toHaveBeenCalledTimes(1);
});
it("should fetch bookings for multiple users with duration limits", async () => {
const mockBookings = [
createMockBookingResult({ id: 1, userId: 1 }),
createMockBookingResult({
id: 2,
userId: 2,
startTime: startOfTomorrow.set("hour", 14).toDate(),
endTime: startOfTomorrow.set("hour", 15).toDate(),
}),
];
vi.mocked(prisma.booking.findMany).mockResolvedValue(mockBookings);
const busyTimesService = getBusyTimesService();
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
userIds: [1, 2],
eventTypeId: 1,
startDate: startOfTomorrow.format(),
endDate: startOfTomorrow.endOf("day").format(),
durationLimits: { PER_DAY: 120 },
});
expect(busyTimes).toHaveLength(2);
expect(busyTimes[0].userId).toBe(1);
expect(busyTimes[1].userId).toBe(2);
});
it("should batch queries when userIds exceeds batch size (50)", async () => {
const userIds = Array.from({ length: 75 }, (_, i) => i + 1);
const mockBookings = [createMockBookingResult()];
vi.mocked(prisma.booking.findMany).mockResolvedValue(mockBookings);
const busyTimesService = getBusyTimesService();
await busyTimesService.getBusyTimesForLimitChecks({
userIds,
eventTypeId: 1,
startDate: startOfTomorrow.format(),
endDate: startOfTomorrow.endOf("day").format(),
bookingLimits: { PER_DAY: 5 },
});
// Should be called twice: once for first 50 users, once for remaining 25
expect(prisma.booking.findMany).toHaveBeenCalledTimes(2);
});
it("should batch queries correctly for exactly 100 users (2 full batches)", async () => {
const userIds = Array.from({ length: 100 }, (_, i) => i + 1);
vi.mocked(prisma.booking.findMany).mockResolvedValue([]);
const busyTimesService = getBusyTimesService();
await busyTimesService.getBusyTimesForLimitChecks({
userIds,
eventTypeId: 1,
startDate: startOfTomorrow.format(),
endDate: startOfTomorrow.endOf("day").format(),
bookingLimits: { PER_WEEK: 10 },
});
expect(prisma.booking.findMany).toHaveBeenCalledTimes(2);
});
it("should batch queries correctly for 150 users (3 batches)", async () => {
const userIds = Array.from({ length: 150 }, (_, i) => i + 1);
vi.mocked(prisma.booking.findMany).mockResolvedValue([]);
const busyTimesService = getBusyTimesService();
await busyTimesService.getBusyTimesForLimitChecks({
userIds,
eventTypeId: 1,
startDate: startOfTomorrow.format(),
endDate: startOfTomorrow.endOf("day").format(),
bookingLimits: { PER_MONTH: 20 },
});
expect(prisma.booking.findMany).toHaveBeenCalledTimes(3);
});
it("should merge results from multiple batches correctly", async () => {
const userIds = Array.from({ length: 75 }, (_, i) => i + 1);
// First batch returns 2 bookings, second batch returns 1 booking
vi.mocked(prisma.booking.findMany)
.mockResolvedValueOnce([
createMockBookingResult({ id: 1, userId: 1 }),
createMockBookingResult({ id: 2, userId: 25 }),
])
.mockResolvedValueOnce([createMockBookingResult({ id: 3, userId: 60 })]);
const busyTimesService = getBusyTimesService();
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
userIds,
eventTypeId: 1,
startDate: startOfTomorrow.format(),
endDate: startOfTomorrow.endOf("day").format(),
bookingLimits: { PER_DAY: 5 },
});
expect(busyTimes).toHaveLength(3);
expect(busyTimes.map((b) => b.userId)).toEqual([1, 25, 60]);
});
it("should exclude rescheduleUid from results", async () => {
const mockBooking = createMockBookingResult();
vi.mocked(prisma.booking.findMany).mockResolvedValue([mockBooking]);
const busyTimesService = getBusyTimesService();
await busyTimesService.getBusyTimesForLimitChecks({
userIds: [1],
eventTypeId: 1,
startDate: startOfTomorrow.format(),
endDate: startOfTomorrow.endOf("day").format(),
rescheduleUid: "existing-booking-uid",
bookingLimits: { PER_DAY: 5 },
});
// Verify the query was called (the actual exclusion happens in the Prisma query)
expect(prisma.booking.findMany).toHaveBeenCalledTimes(1);
});
it("should handle both booking and duration limits together", async () => {
const mockBooking = createMockBookingResult();
vi.mocked(prisma.booking.findMany).mockResolvedValue([mockBooking]);
const busyTimesService = getBusyTimesService();
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
userIds: [1],
eventTypeId: 1,
startDate: startOfTomorrow.format(),
endDate: startOfTomorrow.endOf("day").format(),
bookingLimits: { PER_DAY: 5 },
durationLimits: { PER_DAY: 120 },
});
expect(busyTimes).toHaveLength(1);
expect(prisma.booking.findMany).toHaveBeenCalledTimes(1);
});
it("should handle null eventTypeId in booking results", async () => {
const mockBooking = createMockBookingResult({ eventTypeId: null });
vi.mocked(prisma.booking.findMany).mockResolvedValue([mockBooking]);
const busyTimesService = getBusyTimesService();
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
userIds: [1],
eventTypeId: 1,
startDate: startOfTomorrow.format(),
endDate: startOfTomorrow.endOf("day").format(),
bookingLimits: { PER_DAY: 5 },
});
expect(busyTimes).toHaveLength(1);
expect(busyTimes[0].source).toBe(`eventType-null-booking-${mockBooking.id}`);
});
it("should handle null userId in booking results", async () => {
const mockBooking = createMockBookingResult({ userId: null });
vi.mocked(prisma.booking.findMany).mockResolvedValue([mockBooking]);
const busyTimesService = getBusyTimesService();
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
userIds: [1],
eventTypeId: 1,
startDate: startOfTomorrow.format(),
endDate: startOfTomorrow.endOf("day").format(),
bookingLimits: { PER_DAY: 5 },
});
expect(busyTimes).toHaveLength(1);
expect(busyTimes[0].userId).toBeNull();
});
});
@@ -11,13 +11,14 @@ import { getPiiFreeBooking } from "@calcom/lib/piiFreeData";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { performance } from "@calcom/lib/server/perfObserver";
import prisma from "@calcom/prisma";
import type { Booking, EventType } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { Booking, EventType, Prisma, SelectedCalendar } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import type { CalendarFetchMode, EventBusyDetails } from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
const BATCH_SIZE_FOR_LIMIT_CHECKS = 50;
const MAX_CONCURRENT_LIMIT_CHECK_BATCHES = 5;
export interface IBusyTimesService {
bookingRepo: BookingRepository;
}
@@ -323,7 +324,7 @@ export class BusyTimesService {
performance.mark("getBusyTimesForLimitChecksStart");
let busyTimes: EventBusyDetails[] = [];
const busyTimes: EventBusyDetails[] = [];
if (!bookingLimits && !durationLimits) {
return busyTimes;
@@ -343,6 +344,120 @@ export class BusyTimesService {
})}`
);
const startTimeDate = limitDateFrom.toDate();
const endTimeDate = limitDateTo.toDate();
const bookings = await this.fetchBookingsForLimitChecksBatched({
userIds,
eventTypeId,
startTimeDate,
endTimeDate,
rescheduleUid,
});
for (const booking of bookings) {
busyTimes.push({
start: new Date(booking.startTime),
end: new Date(booking.endTime),
title: booking.title,
source: `eventType-${booking.eventTypeId}-booking-${booking.id}`,
userId: booking.userId,
});
}
logger.silly(`Fetch limit checks bookings for eventId: ${eventTypeId} ${JSON.stringify(busyTimes)}`);
performance.mark("getBusyTimesForLimitChecksEnd");
performance.measure(
`prisma booking get for limits took $1'`,
"getBusyTimesForLimitChecksStart",
"getBusyTimesForLimitChecksEnd"
);
return busyTimes;
}
/**
* Fetches bookings for limit checks using batched parallel queries.
* This optimization improves performance for teams/orgs with many members by:
* 1. Splitting large userIds arrays into smaller batches
* 2. Running a capped number of batch queries in parallel
*/
private async fetchBookingsForLimitChecksBatched(params: {
userIds: number[];
eventTypeId: number;
startTimeDate: Date;
endTimeDate: Date;
rescheduleUid?: string | null;
}): Promise<
Array<{
id: number;
startTime: Date;
endTime: Date;
eventTypeId: number | null;
title: string;
userId: number | null;
}>
> {
const { userIds, eventTypeId, startTimeDate, endTimeDate, rescheduleUid } = params;
if (userIds.length === 0) {
return [];
}
const batches: number[][] = [];
for (let i = 0; i < userIds.length; i += BATCH_SIZE_FOR_LIMIT_CHECKS) {
batches.push(userIds.slice(i, i + BATCH_SIZE_FOR_LIMIT_CHECKS));
}
const results: Array<{
id: number;
startTime: Date;
endTime: Date;
eventTypeId: number | null;
title: string;
userId: number | null;
}> = [];
for (let i = 0; i < batches.length; i += MAX_CONCURRENT_LIMIT_CHECK_BATCHES) {
const currentBatch = batches.slice(i, i + MAX_CONCURRENT_LIMIT_CHECK_BATCHES);
const batchResults = await Promise.all(
currentBatch.map((batchUserIds) =>
this.fetchBookingsForLimitChecksBatch({
userIds: batchUserIds,
eventTypeId,
startTimeDate,
endTimeDate,
rescheduleUid,
})
)
);
results.push(...batchResults.flat());
}
return results;
}
/**
* Fetches bookings for a single batch of userIds using Prisma's findMany.
* Uses batching to improve query planner efficiency for large userIds arrays.
*/
private async fetchBookingsForLimitChecksBatch(params: {
userIds: number[];
eventTypeId: number;
startTimeDate: Date;
endTimeDate: Date;
rescheduleUid?: string | null;
}): Promise<
Array<{
id: number;
startTime: Date;
endTime: Date;
eventTypeId: number | null;
title: string;
userId: number | null;
}>
> {
const { userIds, eventTypeId, startTimeDate, endTimeDate, rescheduleUid } = params;
const where: Prisma.BookingWhereInput = {
userId: {
in: userIds,
@@ -351,10 +466,10 @@ export class BusyTimesService {
status: BookingStatus.ACCEPTED,
// FIXME: bookings that overlap on one side will never be counted
startTime: {
gte: limitDateFrom.toDate(),
gte: startTimeDate,
},
endTime: {
lte: limitDateTo.toDate(),
lte: endTimeDate,
},
};
@@ -370,31 +485,12 @@ export class BusyTimesService {
id: true,
startTime: true,
endTime: true,
eventType: {
select: {
id: true,
},
},
eventTypeId: true,
title: true,
userId: true,
},
});
busyTimes = bookings.map(({ id, startTime, endTime, eventType, title, userId }) => ({
start: dayjs(startTime).toDate(),
end: dayjs(endTime).toDate(),
title,
source: `eventType-${eventType?.id}-booking-${id}`,
userId,
}));
logger.silly(`Fetch limit checks bookings for eventId: ${eventTypeId} ${JSON.stringify(busyTimes)}`);
performance.mark("getBusyTimesForLimitChecksEnd");
performance.measure(
`prisma booking get for limits took $1'`,
"getBusyTimesForLimitChecksStart",
"getBusyTimesForLimitChecksEnd"
);
return busyTimes;
return bookings;
}
}