Skip to content

Commit a06b200

Browse files
perf: parallelize getBusyTimes calls to improve performance (calcom#21372)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
1 parent 2a786b4 commit a06b200

3 files changed

Lines changed: 151 additions & 89 deletions

File tree

packages/lib/getBusyTimes.ts

Lines changed: 100 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,72 @@ import type { CredentialForCalendarService } from "@calcom/types/Credential";
2020
import { getDefinedBufferTimes } from "../features/eventtypes/lib/getDefinedBufferTimes";
2121
import { BookingRepository as BookingRepo } from "./server/repository/booking";
2222

23+
const processBookingsToBusyTimes = (
24+
bookings: (Pick<Booking, "id" | "uid" | "userId" | "startTime" | "endTime" | "title"> & {
25+
eventType: Pick<EventType, "id" | "beforeEventBuffer" | "afterEventBuffer" | "seatsPerTimeSlot"> | null;
26+
_count?: {
27+
seatsReferences: number;
28+
};
29+
})[],
30+
rescheduleUid: string | null | undefined,
31+
eventTypeId: number | undefined,
32+
beforeEventBuffer: number | undefined,
33+
afterEventBuffer: number | undefined
34+
) => {
35+
const bookingSeatCountMap: { [x: string]: number } = {};
36+
return {
37+
busyTimes: bookings.reduce((aggregate: EventBusyDetails[], booking) => {
38+
const { id, startTime, endTime, eventType, title, ...rest } = booking;
39+
40+
const minutesToBlockBeforeEvent = (eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0);
41+
const minutesToBlockAfterEvent = (eventType?.afterEventBuffer || 0) + (beforeEventBuffer || 0);
42+
43+
if (rest._count?.seatsReferences) {
44+
const bookedAt = `${dayjs(startTime).utc().format()}<>${dayjs(endTime).utc().format()}`;
45+
bookingSeatCountMap[bookedAt] = bookingSeatCountMap[bookedAt] || 0;
46+
bookingSeatCountMap[bookedAt]++;
47+
// Seat references on the current event are non-blocking until the event is fully booked.
48+
if (
49+
// there are still seats available.
50+
bookingSeatCountMap[bookedAt] < (eventType?.seatsPerTimeSlot || 1) &&
51+
// and this is the seated event, other event types should be blocked.
52+
eventTypeId === eventType?.id
53+
) {
54+
// then we ONLY add the before/after buffer times as busy times.
55+
if (minutesToBlockBeforeEvent) {
56+
aggregate.push({
57+
start: dayjs(startTime).subtract(minutesToBlockBeforeEvent, "minute").toDate(),
58+
end: dayjs(startTime).toDate(), // The event starts after the buffer
59+
});
60+
}
61+
if (minutesToBlockAfterEvent) {
62+
aggregate.push({
63+
start: dayjs(endTime).toDate(), // The event ends before the buffer
64+
end: dayjs(endTime).add(minutesToBlockAfterEvent, "minute").toDate(),
65+
});
66+
}
67+
return aggregate;
68+
}
69+
// if it does get blocked at this point; we remove the bookingSeatCountMap entry
70+
// doing this allows using the map later to remove the ranges from calendar busy times.
71+
delete bookingSeatCountMap[bookedAt];
72+
}
73+
// rescheduling the same booking to the same time should be possible. Why?
74+
if (rest.uid === rescheduleUid) {
75+
return aggregate;
76+
}
77+
aggregate.push({
78+
start: dayjs(startTime).subtract(minutesToBlockBeforeEvent, "minute").toDate(),
79+
end: dayjs(endTime).add(minutesToBlockAfterEvent, "minute").toDate(),
80+
title,
81+
source: `eventType-${eventType?.id}-booking-${id}`,
82+
});
83+
return aggregate;
84+
}, []),
85+
bookingSeatCountMap,
86+
};
87+
};
88+
2389
const _getBusyTimes = async (params: {
2490
credentials: CredentialForCalendarService[];
2591
userId: number;
@@ -107,65 +173,45 @@ const _getBusyTimes = async (params: {
107173
// to avoid potential side effects.
108174
let bookings = params.currentBookings;
109175

176+
const promises = [];
177+
let bookingsPromise;
178+
110179
if (!bookings) {
111-
bookings = await BookingRepo.findAllExistingBookingsForEventTypeBetween({
180+
bookingsPromise = BookingRepo.findAllExistingBookingsForEventTypeBetween({
112181
userIdAndEmailMap: new Map([[userId, userEmail]]),
113182
eventTypeId,
114183
startDate: startTimeAdjustedWithMaxBuffer,
115184
endDate: endTimeAdjustedWithMaxBuffer,
116185
seatedEvent,
117186
});
187+
promises.push(bookingsPromise);
118188
}
119189

120-
const bookingSeatCountMap: { [x: string]: number } = {};
121-
const busyTimes = bookings.reduce((aggregate: EventBusyDetails[], booking) => {
122-
const { id, startTime, endTime, eventType, title, ...rest } = booking;
123-
124-
const minutesToBlockBeforeEvent = (eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0);
125-
const minutesToBlockAfterEvent = (eventType?.afterEventBuffer || 0) + (beforeEventBuffer || 0);
126-
127-
if (rest._count?.seatsReferences) {
128-
const bookedAt = `${dayjs(startTime).utc().format()}<>${dayjs(endTime).utc().format()}`;
129-
bookingSeatCountMap[bookedAt] = bookingSeatCountMap[bookedAt] || 0;
130-
bookingSeatCountMap[bookedAt]++;
131-
// Seat references on the current event are non-blocking until the event is fully booked.
132-
if (
133-
// there are still seats available.
134-
bookingSeatCountMap[bookedAt] < (eventType?.seatsPerTimeSlot || 1) &&
135-
// and this is the seated event, other event types should be blocked.
136-
eventTypeId === eventType?.id
137-
) {
138-
// then we ONLY add the before/after buffer times as busy times.
139-
if (minutesToBlockBeforeEvent) {
140-
aggregate.push({
141-
start: dayjs(startTime).subtract(minutesToBlockBeforeEvent, "minute").toDate(),
142-
end: dayjs(startTime).toDate(), // The event starts after the buffer
143-
});
144-
}
145-
if (minutesToBlockAfterEvent) {
146-
aggregate.push({
147-
start: dayjs(endTime).toDate(), // The event ends before the buffer
148-
end: dayjs(endTime).add(minutesToBlockAfterEvent, "minute").toDate(),
149-
});
150-
}
151-
return aggregate;
152-
}
153-
// if it does get blocked at this point; we remove the bookingSeatCountMap entry
154-
// doing this allows using the map later to remove the ranges from calendar busy times.
155-
delete bookingSeatCountMap[bookedAt];
156-
}
157-
// rescheduling the same booking to the same time should be possible. Why?
158-
if (rest.uid === rescheduleUid) {
159-
return aggregate;
160-
}
161-
aggregate.push({
162-
start: dayjs(startTime).subtract(minutesToBlockBeforeEvent, "minute").toDate(),
163-
end: dayjs(endTime).add(minutesToBlockAfterEvent, "minute").toDate(),
164-
title,
165-
source: `eventType-${eventType?.id}-booking-${id}`,
166-
});
167-
return aggregate;
168-
}, []);
190+
let calendarBusyTimesPromise;
191+
if (credentials?.length > 0 && !bypassBusyCalendarTimes) {
192+
calendarBusyTimesPromise = getBusyCalendarTimes(
193+
credentials,
194+
startTime,
195+
endTime,
196+
selectedCalendars,
197+
shouldServeCache
198+
);
199+
promises.push(calendarBusyTimesPromise);
200+
}
201+
202+
await Promise.all(promises);
203+
204+
if (bookingsPromise) {
205+
bookings = await bookingsPromise;
206+
}
207+
208+
const { busyTimes, bookingSeatCountMap } = processBookingsToBusyTimes(
209+
bookings || [],
210+
rescheduleUid,
211+
eventTypeId,
212+
beforeEventBuffer,
213+
afterEventBuffer
214+
);
169215

170216
logger.debug(
171217
`Busy Time from Cal Bookings ${JSON.stringify({
@@ -176,15 +222,10 @@ const _getBusyTimes = async (params: {
176222
);
177223
performance.mark("prismaBookingGetEnd");
178224
performance.measure(`prisma booking get took $1'`, "prismaBookingGetStart", "prismaBookingGetEnd");
179-
if (credentials?.length > 0 && !bypassBusyCalendarTimes) {
225+
226+
if (credentials?.length > 0 && !bypassBusyCalendarTimes && calendarBusyTimesPromise) {
180227
const startConnectedCalendarsGet = performance.now();
181-
const calendarBusyTimes = await getBusyCalendarTimes(
182-
credentials,
183-
startTime,
184-
endTime,
185-
selectedCalendars,
186-
shouldServeCache
187-
);
228+
const calendarBusyTimes = await calendarBusyTimesPromise;
188229
const endConnectedCalendarsGet = performance.now();
189230
logger.debug(
190231
`Connected Calendars get took ${
@@ -204,7 +245,7 @@ const _getBusyTimes = async (params: {
204245
});
205246

206247
if (rescheduleUid) {
207-
const originalRescheduleBooking = bookings.find((booking) => booking.uid === rescheduleUid);
248+
const originalRescheduleBooking = bookings?.find((booking) => booking.uid === rescheduleUid);
208249
// calendar busy time from original rescheduled booking should not be blocked
209250
if (originalRescheduleBooking) {
210251
openSeatsDateRanges.push({

packages/lib/getUserAvailability.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -349,12 +349,15 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
349349
const durationLimits = parseDurationLimit(eventType?.durationLimits);
350350

351351
let busyTimesFromLimits: EventBusyDetails[] = [];
352+
let busyTimesFromTeamLimits: EventBusyDetails[] = [];
353+
354+
const busyTimesPromises = [];
352355

353356
if (initialData?.busyTimesFromLimits && initialData?.eventTypeForLimits) {
354357
busyTimesFromLimits = initialData.busyTimesFromLimits.get(user.id) || [];
355358
} else if (eventType && (bookingLimits || durationLimits)) {
356359
// Fall back to individual query if not available in initialData
357-
busyTimesFromLimits = await getBusyTimesFromLimits(
360+
const busyTimesFromLimitsPromise = getBusyTimesFromLimits(
358361
bookingLimits,
359362
durationLimits,
360363
dateFrom.tz(timeZone),
@@ -364,7 +367,10 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
364367
initialData?.busyTimesFromLimitsBookings ?? [],
365368
timeZone,
366369
initialData?.rescheduleUid ?? undefined
367-
);
370+
).then((result) => {
371+
busyTimesFromLimits = result;
372+
});
373+
busyTimesPromises.push(busyTimesFromLimitsPromise);
368374
}
369375

370376
const teamForBookingLimits =
@@ -374,13 +380,11 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
374380

375381
const teamBookingLimits = parseBookingLimit(teamForBookingLimits?.bookingLimits);
376382

377-
let busyTimesFromTeamLimits: EventBusyDetails[] = [];
378-
379383
if (initialData?.teamBookingLimits && teamForBookingLimits) {
380384
busyTimesFromTeamLimits = initialData.teamBookingLimits.get(user.id) || [];
381385
} else if (teamForBookingLimits && teamBookingLimits) {
382386
// Fall back to individual query if not available in initialData
383-
busyTimesFromTeamLimits = await getBusyTimesFromTeamLimits(
387+
const busyTimesFromTeamLimitsPromise = getBusyTimesFromTeamLimits(
384388
user,
385389
teamBookingLimits,
386390
dateFrom.tz(timeZone),
@@ -389,7 +393,14 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
389393
teamForBookingLimits.includeManagedEventsInLimits,
390394
timeZone,
391395
initialData?.rescheduleUid ?? undefined
392-
);
396+
).then((result) => {
397+
busyTimesFromTeamLimits = result;
398+
});
399+
busyTimesPromises.push(busyTimesFromTeamLimitsPromise);
400+
}
401+
402+
if (busyTimesPromises.length > 0) {
403+
await Promise.all(busyTimesPromises);
393404
}
394405

395406
// TODO: only query what we need after applying limits (shrink date range)

packages/lib/intervalLimits/server/getBusyTimesFromLimits.ts

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,41 +31,51 @@ const _getBusyTimesFromLimits = async (
3131
// shared amongst limiters to prevent processing known busy periods
3232
const limitManager = new LimitManager();
3333

34+
const limitChecks = [];
35+
3436
// run this first, as counting bookings should always run faster..
3537
if (bookingLimits) {
3638
performance.mark("bookingLimitsStart");
37-
await getBusyTimesFromBookingLimits({
38-
bookings,
39-
bookingLimits,
40-
dateFrom,
41-
dateTo,
42-
eventTypeId: eventType.id,
43-
limitManager,
44-
rescheduleUid,
45-
timeZone,
46-
});
47-
performance.mark("bookingLimitsEnd");
48-
performance.measure(`checking booking limits took $1'`, "bookingLimitsStart", "bookingLimitsEnd");
39+
limitChecks.push(
40+
getBusyTimesFromBookingLimits({
41+
bookings,
42+
bookingLimits,
43+
dateFrom,
44+
dateTo,
45+
eventTypeId: eventType.id,
46+
limitManager,
47+
rescheduleUid,
48+
timeZone,
49+
}).then(() => {
50+
performance.mark("bookingLimitsEnd");
51+
performance.measure(`checking booking limits took $1'`, "bookingLimitsStart", "bookingLimitsEnd");
52+
})
53+
);
4954
}
5055

5156
// ..than adding up durations (especially for the whole year)
5257
if (durationLimits) {
5358
performance.mark("durationLimitsStart");
54-
await getBusyTimesFromDurationLimits(
55-
bookings,
56-
durationLimits,
57-
dateFrom,
58-
dateTo,
59-
duration,
60-
eventType,
61-
limitManager,
62-
timeZone,
63-
rescheduleUid
59+
limitChecks.push(
60+
getBusyTimesFromDurationLimits(
61+
bookings,
62+
durationLimits,
63+
dateFrom,
64+
dateTo,
65+
duration,
66+
eventType,
67+
limitManager,
68+
timeZone,
69+
rescheduleUid
70+
).then(() => {
71+
performance.mark("durationLimitsEnd");
72+
performance.measure(`checking duration limits took $1'`, "durationLimitsStart", "durationLimitsEnd");
73+
})
6474
);
65-
performance.mark("durationLimitsEnd");
66-
performance.measure(`checking duration limits took $1'`, "durationLimitsStart", "durationLimitsEnd");
6775
}
6876

77+
await Promise.all(limitChecks);
78+
6979
performance.mark("limitsEnd");
7080
performance.measure(`checking all limits took $1'`, "limitsStart", "limitsEnd");
7181

0 commit comments

Comments
 (0)