Skip to content

Commit 5b1eb48

Browse files
zomarshbjORbj
andauthored
perf: optimize google's getAvailability with caching (calcom#22191)
* refactor: optimize google's getAvailability with caching Refactored the getAvailability method to improve readability and efficiency by extracting logic into helper methods for cache checking, calendar ID retrieval, and chunked API fetching. Added early cache lookup for selected calendar IDs, improved cache hit logging, and modularized the code for easier maintenance and future enhancements. * Update CalendarService.test.ts * Update CalendarService.ts * Update CalendarService.test.ts * Update CalendarService.ts * Update CalendarService.test.ts * Update CalendarService.ts * Apply suggestion from @hbjORbj Co-authored-by: Benny Joo <sldisek783@gmail.com> --------- Co-authored-by: Benny Joo <sldisek783@gmail.com>
1 parent da6fc9f commit 5b1eb48

2 files changed

Lines changed: 1020 additions & 67 deletions

File tree

packages/app-store/googlecalendar/lib/CalendarService.ts

Lines changed: 165 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import type { calendar_v3 } from "@googleapis/calendar";
3-
import type { Prisma } from "@prisma/client";
43
import type { GaxiosResponse } from "googleapis-common";
54
import { RRule } from "rrule";
65
import { v4 as uuid } from "uuid";
76

87
import { MeetLocationType } from "@calcom/app-store/locations";
9-
import dayjs from "@calcom/dayjs";
108
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
119
import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface";
1210
import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache";
@@ -16,6 +14,7 @@ import logger from "@calcom/lib/logger";
1614
import { safeStringify } from "@calcom/lib/safeStringify";
1715
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
1816
import prisma from "@calcom/prisma";
17+
import type { Prisma } from "@calcom/prisma/client";
1918
import type {
2019
Calendar,
2120
CalendarServiceEvent,
@@ -232,14 +231,13 @@ export default class GoogleCalendarService implements Calendar {
232231
eventId: calEvent.existingRecurringEvent.recurringEventId,
233232
});
234233
if (recurringEventInstances.data.items) {
235-
const calComEventStartTime = dayjs(calEvent.startTime).tz(calEvent.organizer.timeZone).format();
234+
// Compare timestamps directly for more reliable and faster matching
235+
const calComEventStartTimeMs = new Date(calEvent.startTime).getTime();
236236
for (let i = 0; i < recurringEventInstances.data.items.length; i++) {
237237
const instance = recurringEventInstances.data.items[i];
238-
const instanceStartTime = dayjs(instance.start?.dateTime)
239-
.tz(instance.start?.timeZone == null ? undefined : instance.start?.timeZone)
240-
.format();
238+
const instanceStartTimeMs = new Date(instance.start?.dateTime || "").getTime();
241239

242-
if (instanceStartTime === calComEventStartTime) {
240+
if (instanceStartTimeMs === calComEventStartTimeMs) {
243241
event = instance;
244242
break;
245243
}
@@ -562,10 +560,6 @@ export default class GoogleCalendarService implements Calendar {
562560
try {
563561
const calIdsWithTimeZone = await getCalIdsWithTimeZone();
564562
const calIds = calIdsWithTimeZone.map((calIdWithTimeZone) => ({ id: calIdWithTimeZone.id }));
565-
566-
const originalStartDate = dayjs(dateFrom);
567-
const originalEndDate = dayjs(dateTo);
568-
const diff = originalEndDate.diff(originalStartDate, "days");
569563
const freeBusyData = await this.getCacheOrFetchAvailability({
570564
timeMin: dateFrom,
571565
timeMax: dateTo,
@@ -593,6 +587,148 @@ export default class GoogleCalendarService implements Calendar {
593587
}
594588
}
595589

590+
/**
591+
* Converts FreeBusy response data to EventBusyDate array
592+
*/
593+
private convertFreeBusyToEventBusyDates(
594+
freeBusyResult: calendar_v3.Schema$FreeBusyResponse
595+
): EventBusyDate[] {
596+
if (!freeBusyResult.calendars) return [];
597+
598+
return Object.values(freeBusyResult.calendars).flatMap(
599+
(calendar) =>
600+
calendar.busy?.map((busyTime) => ({
601+
start: busyTime.start || "",
602+
end: busyTime.end || "",
603+
})) || []
604+
);
605+
}
606+
607+
/**
608+
* Attempts to get availability from cache
609+
*/
610+
private async tryGetAvailabilityFromCache(
611+
timeMin: string,
612+
timeMax: string,
613+
calendarIds: string[]
614+
): Promise<EventBusyDate[] | null> {
615+
try {
616+
const calendarCache = await CalendarCache.init(null);
617+
const cached = await calendarCache.getCachedAvailability({
618+
credentialId: this.credential.id,
619+
userId: this.credential.userId,
620+
args: {
621+
// Expand the start date to the start of the month to increase cache hits
622+
timeMin: getTimeMin(timeMin),
623+
// Expand the end date to the end of the month to increase cache hits
624+
timeMax: getTimeMax(timeMax),
625+
items: calendarIds.map((id) => ({ id })),
626+
},
627+
});
628+
629+
if (cached) {
630+
this.log.debug(
631+
"[Cache Hit] Returning cached availability result",
632+
safeStringify({ timeMin, timeMax, calendarIds })
633+
);
634+
const freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
635+
return this.convertFreeBusyToEventBusyDates(freeBusyResult);
636+
}
637+
638+
return null;
639+
} catch (error) {
640+
this.log.debug("Cache check failed, proceeding with API call", safeStringify(error));
641+
return null;
642+
}
643+
}
644+
645+
/**
646+
* Gets calendar IDs for the request, either from selected calendars or fallback logic
647+
*/
648+
private async getCalendarIds(
649+
selectedCalendarIds: string[],
650+
fallbackToPrimary?: boolean
651+
): Promise<string[]> {
652+
if (selectedCalendarIds.length !== 0) return selectedCalendarIds;
653+
654+
const calendar = await this.authedCalendar();
655+
const cals = await this.getAllCalendars(calendar, ["id", "primary"]);
656+
if (!cals.length) return [];
657+
658+
if (!fallbackToPrimary) {
659+
return this.getValidCalendars(cals).map((cal) => cal.id);
660+
}
661+
662+
const primaryCalendar = this.filterPrimaryCalendar(cals);
663+
return primaryCalendar ? [primaryCalendar.id] : [];
664+
}
665+
666+
/**
667+
* Fetches availability data using the cache-or-fetch pattern
668+
*/
669+
private async fetchAvailabilityData(
670+
calendarIds: string[],
671+
dateFrom: string,
672+
dateTo: string,
673+
shouldServeCache?: boolean
674+
): Promise<EventBusyDate[]> {
675+
// More efficient date difference calculation using native Date objects
676+
// Use Math.floor to match dayjs diff behavior (truncates, doesn't round up)
677+
const fromDate = new Date(dateFrom);
678+
const toDate = new Date(dateTo);
679+
const oneDayMs = 1000 * 60 * 60 * 24;
680+
const diff = Math.floor((toDate.getTime() - fromDate.getTime()) / (oneDayMs));
681+
682+
// Google API only allows a date range of 90 days for /freebusy
683+
if (diff <= 90) {
684+
const freeBusyData = await this.getCacheOrFetchAvailability(
685+
{
686+
timeMin: dateFrom,
687+
timeMax: dateTo,
688+
items: calendarIds.map((id) => ({ id })),
689+
},
690+
shouldServeCache
691+
);
692+
693+
if (!freeBusyData) throw new Error("No response from google calendar");
694+
return freeBusyData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end }));
695+
}
696+
697+
// Handle longer periods by chunking into 90-day periods
698+
const busyData: EventBusyDate[] = [];
699+
const loopsNumber = Math.ceil(diff / 90);
700+
let currentStartTime = fromDate.getTime();
701+
const originalEndTime = toDate.getTime();
702+
const ninetyDaysMs = 90 * 24 * 60 * 60 * 1000;
703+
const oneMinuteMs = 60 * 1000;
704+
705+
for (let i = 0; i < loopsNumber; i++) {
706+
let currentEndTime = currentStartTime + ninetyDaysMs;
707+
708+
// Don't go beyond the original end date
709+
if (currentEndTime > originalEndTime) {
710+
currentEndTime = originalEndTime;
711+
}
712+
713+
const chunkData = await this.getCacheOrFetchAvailability(
714+
{
715+
timeMin: new Date(currentStartTime).toISOString(),
716+
timeMax: new Date(currentEndTime).toISOString(),
717+
items: calendarIds.map((id) => ({ id })),
718+
},
719+
shouldServeCache
720+
);
721+
722+
if (chunkData) {
723+
busyData.push(...chunkData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end })));
724+
}
725+
726+
currentStartTime = currentEndTime + oneMinuteMs;
727+
}
728+
729+
return busyData;
730+
}
731+
596732
async getAvailability(
597733
dateFrom: string,
598734
dateTo: string,
@@ -604,71 +740,33 @@ export default class GoogleCalendarService implements Calendar {
604740
fallbackToPrimary?: boolean
605741
): Promise<EventBusyDate[]> {
606742
this.log.debug("Getting availability", safeStringify({ dateFrom, dateTo, selectedCalendars }));
607-
const calendar = await this.authedCalendar();
743+
608744
const selectedCalendarIds = selectedCalendars
609745
.filter((e) => e.integration === this.integrationName)
610746
.map((e) => e.externalId);
747+
748+
// Early return if only other integrations are selected
611749
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
612-
// Only calendars of other integrations selected
613750
return [];
614751
}
615-
const getCalIds = async () => {
616-
if (selectedCalendarIds.length !== 0) return selectedCalendarIds;
617-
const cals = await this.getAllCalendars(calendar, ["id", "primary"]);
618-
if (!cals.length) return [];
619-
if (!fallbackToPrimary) return this.getValidCalendars(cals).map((cal) => cal.id);
620-
621-
const primaryCalendar = this.filterPrimaryCalendar(cals);
622-
if (!primaryCalendar) return [];
623-
return [primaryCalendar.id];
624-
};
625752

626-
try {
627-
const calsIds = await getCalIds();
628-
const originalStartDate = dayjs(dateFrom);
629-
const originalEndDate = dayjs(dateTo);
630-
const diff = originalEndDate.diff(originalStartDate, "days");
631-
632-
// /freebusy from google api only allows a date range of 90 days
633-
if (diff <= 90) {
634-
const freeBusyData = await this.getCacheOrFetchAvailability(
635-
{
636-
timeMin: dateFrom,
637-
timeMax: dateTo,
638-
items: calsIds.map((id) => ({ id })),
639-
},
640-
shouldServeCache
641-
);
642-
if (!freeBusyData) throw new Error("No response from google calendar");
643-
644-
return freeBusyData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end }));
645-
} else {
646-
const busyData = [];
647-
648-
const loopsNumber = Math.ceil(diff / 90);
649-
650-
let startDate = originalStartDate;
651-
let endDate = originalStartDate.add(90, "days");
652-
653-
for (let i = 0; i < loopsNumber; i++) {
654-
if (endDate.isAfter(originalEndDate)) endDate = originalEndDate;
753+
// Try cache first when we have selected calendar IDs
754+
if (selectedCalendarIds.length > 0 && shouldServeCache !== false) {
755+
const cachedResult = await this.tryGetAvailabilityFromCache(dateFrom, dateTo, selectedCalendarIds);
756+
if (cachedResult) {
757+
return cachedResult;
758+
}
759+
}
655760

656-
busyData.push(
657-
...((await this.getCacheOrFetchAvailability(
658-
{
659-
timeMin: startDate.format(),
660-
timeMax: endDate.format(),
661-
items: calsIds.map((id) => ({ id })),
662-
},
663-
shouldServeCache
664-
)) || [])
665-
);
761+
// Cache miss - proceed with API calls
762+
this.log.debug(
763+
"[Cache Miss] Proceeding with Google API calls",
764+
safeStringify({ selectedCalendarIds, fallbackToPrimary })
765+
);
666766

667-
startDate = endDate.add(1, "minutes");
668-
endDate = startDate.add(90, "days");
669-
}
670-
return busyData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end }));
671-
}
767+
try {
768+
const calendarIds = await this.getCalendarIds(selectedCalendarIds, fallbackToPrimary);
769+
return await this.fetchAvailabilityData(calendarIds, dateFrom, dateTo, shouldServeCache);
672770
} catch (error) {
673771
this.log.error(
674772
"There was an error getting availability from google calendar: ",

0 commit comments

Comments
 (0)