diff --git a/packages/features/busyTimes/services/getBusyTimes.integration-test.ts b/packages/features/busyTimes/services/getBusyTimes.integration-test.ts new file mode 100644 index 0000000000..86f00b0ad0 --- /dev/null +++ b/packages/features/busyTimes/services/getBusyTimes.integration-test.ts @@ -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); + }); +}); diff --git a/packages/features/busyTimes/services/getBusyTimes.test.ts b/packages/features/busyTimes/services/getBusyTimes.test.ts index 2692fb7286..91fdfc76bd 100644 --- a/packages/features/busyTimes/services/getBusyTimes.test.ts +++ b/packages/features/busyTimes/services/getBusyTimes.test.ts @@ -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(); + }); +}); diff --git a/packages/features/busyTimes/services/getBusyTimes.ts b/packages/features/busyTimes/services/getBusyTimes.ts index 7609bf9834..d3d442261b 100644 --- a/packages/features/busyTimes/services/getBusyTimes.ts +++ b/packages/features/busyTimes/services/getBusyTimes.ts @@ -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; } }