chore: Implement short-lived redis cache for slots (#22787)

* chore: Implement short-lived redis cache for slots

* chore: adapt apiv2 redis service to match with upstash redis

* chore: safer redis service and ms ttl

* fixup! chore: safer redis service and ms ttl

* Wrap with timeout, currently doesn't work yet

* Updated @upstash/redis for better signal support

* Fix type errors, remove ts value

* Inject NoopRedisService for NODE_ENV test

* chore: bump platform libs

* chore: bump platform libs

* Upstash Redis upgrade no longer resulted in expected hard crash on init, so updated factory and our Upstash Redis Adapter to mimick old behaviour

* Add SLOTS_CACHE_TTL variable for configurable ttl on slots cache

* Update parseInt to use right types

* chore: bump platform libs

* chore: bump platform libs

* chore: bump platform libs

* update e2e api v2 action

* set SLOTS_CACHE_TTL env var api v2 e2e

---------

Co-authored-by: cal.com <morgan@cal.com>
This commit is contained in:
Alex van Andel
2025-08-05 17:32:02 +00:00
committed by GitHub
co-authored by cal.com
parent a71d949a0c
commit b71d8baccc
19 changed files with 276 additions and 41 deletions
+1
View File
@@ -22,6 +22,7 @@ env:
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SLOTS_CACHE_TTL: ${{secret.CI_SLOTS_CACHE_TTL}}
jobs:
e2e:
timeout-minutes: 20
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.283",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.285",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -104,10 +104,11 @@ export class OutputBookingsService_2024_08_13 {
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -157,13 +158,12 @@ export class OutputBookingsService_2024_08_13 {
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy,
};
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
@@ -279,9 +279,11 @@ export class OutputBookingsService_2024_08_13 {
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -11,6 +11,7 @@ import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
import { Module } from "@nestjs/common";
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
@@ -25,6 +26,7 @@ import { UserAvailabilityService } from "@/lib/services/user-availability.servic
PrismaEventTypeRepository,
PrismaRoutingFormResponseRepository,
PrismaTeamRepository,
RedisService,
PrismaFeaturesRepository,
CheckBookingLimitsService,
CacheService,
@@ -9,6 +9,7 @@ import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
@@ -25,6 +26,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepository: PrismaSelectedSlotRepository,
eventTypeRepository: PrismaEventTypeRepository,
userRepository: PrismaUserRepository,
redisService: RedisService,
featuresRepository: PrismaFeaturesRepository
) {
super({
@@ -36,7 +38,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepo: selectedSlotRepository,
eventTypeRepo: eventTypeRepository,
userRepo: userRepository,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
redisClient: redisService,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
cacheService: new CacheService(featuresRepository),
userAvailabilityService: new UserAvailabilityService(oooRepoDependency, bookingRepository, eventTypeRepository)
});
@@ -7,12 +7,32 @@ import { Redis } from "ioredis";
export class RedisService implements OnModuleDestroy {
public redis: Redis;
private readonly logger = new Logger("RedisService");
private isReady = false; // Track connection status
constructor(readonly configService: ConfigService<AppConfig>) {
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
this.redis = new Redis(dbUrl);
this.redis.on("error", (err) => {
this.logger.error(`IoRedis connection error: ${err.message}`);
this.isReady = false;
});
this.redis.on("connect", () => {
this.logger.log("IoRedis connected!");
this.isReady = true;
});
this.redis.on("reconnecting", (delay: string) => {
this.logger.warn(`IoRedis reconnecting... next retry in ${delay}ms`);
});
this.redis.on("end", () => {
this.logger.warn("IoRedis connection ended.");
this.isReady = false;
});
}
async onModuleDestroy() {
@@ -22,4 +42,99 @@ export class RedisService implements OnModuleDestroy {
this.logger.error(err);
}
}
async get<TData>(key: string): Promise<TData | null> {
let data = null;
if (!this.isReady) {
return null;
}
try {
data = await this.redis.get(key);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis get failed: ${err.message}`);
}
if (data === null) {
return null;
}
try {
return JSON.parse(data) as TData;
} catch (e) {
return data as TData;
}
}
async del(key: string): Promise<number> {
if (!this.isReady) {
return 0;
}
try {
return this.redis.del(key);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis del failed: ${err.message}`);
return 0;
}
}
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
if (!this.isReady) {
return null;
}
try {
const stringifiedValue = typeof value === "object" ? JSON.stringify(value) : String(value);
if (opts?.ttl) {
await this.redis.set(key, stringifiedValue, "PX", opts.ttl);
} else {
await this.redis.set(key, stringifiedValue);
}
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis set failed: ${err.message}`);
return null;
}
return "OK";
}
async expire(key: string, seconds: number): Promise<0 | 1> {
if (!this.isReady) {
return 0;
}
try {
return this.redis.expire(key, seconds) as Promise<0 | 1>;
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis expire failed: ${err.message}`);
return 0;
}
}
async lrange<TResult = string>(key: string, start: number, end: number): Promise<TResult[]> {
if (!this.isReady) {
return [];
}
try {
const results = await this.redis.lrange(key, start, end);
return results.map((item) => JSON.parse(item) as TResult);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis lrange failed: ${err.message}`);
return [];
}
}
async lpush<TData>(key: string, ...elements: TData[]): Promise<number> {
if (!this.isReady) {
return 0;
}
try {
const stringifiedElements = elements.map((element) =>
typeof element === "object" ? JSON.stringify(element) : String(element)
);
return this.redis.lpush(key, ...stringifiedElements);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis lpush failed: ${err.message}`);
return 0;
}
}
}
+1
View File
@@ -36,4 +36,5 @@ process.env = {
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
INTEGRATION_TEST_MODE: "true",
e2e: "true",
SLOTS_CACHE_TTL: "1"
};
+1 -1
View File
@@ -78,7 +78,7 @@
"@team-plain/typescript-sdk": "^5.9.0",
"@types/turndown": "^5.0.1",
"@unkey/ratelimit": "^0.1.1",
"@upstash/redis": "^1.21.0",
"@upstash/redis": "^1.35.2",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.6.3",
+1 -1
View File
@@ -1,7 +1,7 @@
export interface IRedisService {
get: <TData>(key: string) => Promise<TData | null>;
set: <TData>(key: string, value: TData) => Promise<"OK" | TData | null>;
set: <TData>(key: string, value: TData, opts?: { ttl?: number }) => Promise<"OK" | TData | null>;
expire: (key: string, seconds: number) => Promise<0 | 1>;
@@ -0,0 +1,32 @@
import type { IRedisService } from "./IRedisService";
/**
* Noop implementation of IRedisService for testing or fallback scenarios.
*/
export class NoopRedisService implements IRedisService {
async get<TData>(_key: string): Promise<TData | null> {
return null;
}
async del(_key: string): Promise<number> {
return 0;
}
async set<TData>(_key: string, _value: TData, _opts?: { ttl?: number }): Promise<"OK" | TData | null> {
return "OK";
}
async expire(_key: string, _seconds: number): Promise<0 | 1> {
// Implementation for setting expiration time for key in Redis
return 0;
}
async lrange<TResult = string>(_key: string, _start: number, _end: number): Promise<TResult[]> {
return [];
}
async lpush<TData>(_key: string, ..._elements: TData[]): Promise<number> {
return 0;
}
}
+17 -4
View File
@@ -6,7 +6,13 @@ export class RedisService implements IRedisService {
private redis: Redis;
constructor() {
this.redis = Redis.fromEnv();
// Ensure we throw an Error to mimick old behavior
if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) {
throw new Error("Attempted to initialize Upstash Redis client without url or token.");
}
this.redis = Redis.fromEnv({
signal: () => AbortSignal.timeout(2000),
});
}
async get<TData>(key: string): Promise<TData | null> {
@@ -17,9 +23,16 @@ export class RedisService implements IRedisService {
return this.redis.del(key);
}
async set<TData>(key: string, value: TData): Promise<"OK" | TData | null> {
// Implementation for setting value in Redis
return this.redis.set(key, value);
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
return this.redis.set(
key,
value,
opts?.ttl
? {
px: opts.ttl,
}
: undefined
);
}
async expire(key: string, seconds: number): Promise<0 | 1> {
+17
View File
@@ -0,0 +1,17 @@
import { createModule } from "@evyweb/ioctopus";
import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { NoopRedisService } from "../NoopRedisService";
import { RedisService } from "../RedisService";
const redisModule = createModule();
redisModule.bind(DI_TOKENS.REDIS_CLIENT).toFactory(() => {
if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) {
return new RedisService();
}
return new NoopRedisService();
}, "singleton");
export { redisModule };
@@ -1,5 +1,6 @@
import { createContainer } from "@evyweb/ioctopus";
import { redisModule } from "@calcom/features/redis/di/redisModule";
import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { prismaModule } from "@calcom/prisma/prisma.module";
import type { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util";
@@ -19,6 +20,7 @@ import { userRepositoryModule } from "../modules/user";
import { getUserAvailabilityModule } from "../modules/get-user-availability";
const container = createContainer();
container.load(DI_TOKENS.REDIS_CLIENT, redisModule);
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule);
container.load(DI_TOKENS.SCHEDULE_REPOSITORY_MODULE, scheduleRepositoryModule);
@@ -15,6 +15,7 @@ availableSlotsModule.bind(DI_TOKENS.AVAILABLE_SLOTS_SERVICE).toClass(AvailableSl
bookingRepo: DI_TOKENS.BOOKING_REPOSITORY,
eventTypeRepo: DI_TOKENS.EVENT_TYPE_REPOSITORY,
routingFormResponseRepo: DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY,
redisClient: DI_TOKENS.REDIS_CLIENT,
cacheService: DI_TOKENS.CACHE_SERVICE,
checkBookingLimitsService: DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE,
userAvailabilityService: DI_TOKENS.GET_USER_AVAILABILITY_SERVICE
+1
View File
@@ -2,6 +2,7 @@ export const DI_TOKENS = {
PRISMA_CLIENT: Symbol("PrismaClient"),
READ_ONLY_PRISMA_CLIENT: Symbol("ReadOnlyPrismaClient"),
PRISMA_MODULE: Symbol("PrismaModule"),
REDIS_CLIENT: Symbol("RedisClient"),
OOO_REPOSITORY: Symbol("OOORepository"),
OOO_REPOSITORY_MODULE: Symbol("OOORepositoryModule"),
SCHEDULE_REPOSITORY: Symbol("ScheduleRepository"),
+2 -3
View File
@@ -154,10 +154,9 @@ type GetUsersAvailabilityProps = {
};
export interface IUserAvailabilityService {
eventTypeRepo: EventTypeRepository;
eventTypeRepo: EventTypeRepository;
oooRepo: PrismaOOORepository;
bookingRepo: BookingRepository;
}
export class UserAvailabilityService {
@@ -611,4 +610,4 @@ export class UserAvailabilityService {
}
getUsersAvailability = withReporting(this._getUsersAvailability.bind(this), "getUsersAvailability");
}
}
@@ -8,6 +8,7 @@ import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
import type { IRedisService } from "@calcom/features/redis/IRedisService";
import { findQualifiedHostsWithDelegationCredentials } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
@@ -64,6 +65,7 @@ import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots";
import type { GetScheduleOptions } from "./types";
const log = logger.getSubLogger({ prefix: ["[slots/util]"] });
const DEFAULT_SLOTS_CACHE_TTL = 2000;
type GetAvailabilityUserWithDelegationCredentials = Omit<GetAvailabilityUser, "credentials"> & {
credentials: CredentialForCalendarService[];
@@ -101,7 +103,47 @@ export interface IAvailableSlotsService {
routingFormResponseRepo: RoutingFormResponseRepository;
cacheService: CacheService;
checkBookingLimitsService: CheckBookingLimitsService;
userAvailabilityService: UserAvailabilityService
userAvailabilityService: UserAvailabilityService;
redisClient: IRedisService;
}
function withSlotsCache(
redisClient: IRedisService,
func: (args: GetScheduleOptions) => Promise<IGetAvailableSlots>
) {
return async (args: GetScheduleOptions): Promise<IGetAvailableSlots> => {
const cacheKey = `${JSON.stringify(args.input)}`;
let success = false;
let cachedResult: IGetAvailableSlots | null = null;
const startTime = process.hrtime();
try {
cachedResult = await redisClient.get(cacheKey);
success = true;
} catch (err: unknown) {
if (err instanceof Error && err.name === "TimeoutError") {
const endTime = process.hrtime(startTime);
log.error(`Redis request timed out after ${endTime[0]}${endTime[1] / 1e6}ms`);
} else {
throw err;
}
}
if (!success) {
// If the cache request fails, we proceed to call the function directly
return await func(args);
}
if (cachedResult) {
log.info("[CACHE HIT] Available slots", { cacheKey });
return cachedResult;
}
const result = await func(args);
const ttl = parseInt(process.env.SLOTS_CACHE_TTL ?? "", 10) || DEFAULT_SLOTS_CACHE_TTL;
// we do not wait for the cache to complete setting; we fire and forget, and hope it'll finish.
// this is to already start responding to the client.
redisClient.set(cacheKey, result, { ttl });
log.info("[CACHE MISS] Available slots", { cacheKey, ttl });
return result;
};
}
export class AvailableSlotsService {
@@ -884,7 +926,10 @@ export class AvailableSlotsService {
"getRegularOrDynamicEventType"
);
getAvailableSlots = withReporting(this._getAvailableSlots.bind(this), "getAvailableSlots");
getAvailableSlots = withReporting(
withSlotsCache(this.dependencies.redisClient, this._getAvailableSlots.bind(this)),
"getAvailableSlots"
);
async _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<IGetAvailableSlots> {
const {
+2 -1
View File
@@ -490,6 +490,7 @@
"NEXT_PUBLIC_SINGLE_ORG_MODE_ENABLED",
"CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY",
"CAL_VIDEO_RECORDING_TOKEN_SECRET",
"ORGANIZER_EMAIL_EXEMPT_DOMAINS"
"ORGANIZER_EMAIL_EXEMPT_DOMAINS",
"SLOTS_CACHE_TTL"
]
}
+18 -18
View File
@@ -2500,7 +2500,7 @@ __metadata:
"@axiomhq/winston": ^1.2.0
"@calcom/platform-constants": "*"
"@calcom/platform-enums": "*"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.283"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.285"
"@calcom/platform-types": "*"
"@calcom/platform-utils": "*"
"@calcom/prisma": "*"
@@ -3556,13 +3556,13 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.283":
version: 0.0.283
resolution: "@calcom/platform-libraries@npm:0.0.283"
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.285":
version: 0.0.285
resolution: "@calcom/platform-libraries@npm:0.0.285"
dependencies:
"@calcom/features": "*"
"@calcom/lib": "*"
checksum: 7d741bd13ac050f8552855658b2348cba0999529c12ca6741e5f802a7bc9ce4e29c9ac69a17306c42beb392f17acb7b5f8a0554b8d2ea9b5fc6d963950d0fca8
checksum: 2faebf08e2565a1eb75e70a1bf68af3a7593b8a2c35f4d05af97fdfba7fda6fba962984934a47c3a7fbf0a1a5b4f1ca4dd89029d710b6684b0f6fafa96526e11
languageName: node
linkType: hard
@@ -4076,7 +4076,7 @@ __metadata:
"@types/turndown": ^5.0.1
"@types/uuid": 8.3.1
"@unkey/ratelimit": ^0.1.1
"@upstash/redis": ^1.21.0
"@upstash/redis": ^1.35.2
"@vercel/edge-config": ^0.1.1
"@vercel/edge-functions-ui": ^0.2.1
"@vercel/og": ^0.6.3
@@ -18784,12 +18784,12 @@ __metadata:
languageName: node
linkType: hard
"@upstash/redis@npm:^1.21.0":
version: 1.30.1
resolution: "@upstash/redis@npm:1.30.1"
"@upstash/redis@npm:^1.35.2":
version: 1.35.2
resolution: "@upstash/redis@npm:1.35.2"
dependencies:
crypto-js: ^4.2.0
checksum: 1d8083650fbc1760fe4c58d263c62f9c658d08035480927316932df2757f4e987799f7ce752579e7dd871155e96996bb4caf8ad1034af3ab18965d7ca76421cf
uncrypto: ^0.1.3
checksum: 1d54217589418873702f08bdc27d9455ea44df0fce30ace612b5637a1c4c261b762a80fb85d7b3c72d3213e82b8938bb57c413ae44f9f1e713eeab3f31d5539a
languageName: node
linkType: hard
@@ -23319,13 +23319,6 @@ __metadata:
languageName: node
linkType: hard
"crypto-js@npm:^4.2.0":
version: 4.2.0
resolution: "crypto-js@npm:4.2.0"
checksum: f051666dbc077c8324777f44fbd3aaea2986f198fe85092535130d17026c7c2ccf2d23ee5b29b36f7a4a07312db2fae23c9094b644cc35f7858b1b4fcaf27774
languageName: node
linkType: hard
"crypto-random-string@npm:^2.0.0":
version: 2.0.0
resolution: "crypto-random-string@npm:2.0.0"
@@ -45439,6 +45432,13 @@ __metadata:
languageName: node
linkType: hard
"uncrypto@npm:^0.1.3":
version: 0.1.3
resolution: "uncrypto@npm:0.1.3"
checksum: 07160e08806dd6cea16bb96c3fd54cd70fc801e02fc3c6f86980144d15c9ebbd1c55587f7280a207b3af6cd34901c0d0b77ada5a02c2f7081a033a05acf409e2
languageName: node
linkType: hard
"undici-types@npm:~6.19.2":
version: 6.19.8
resolution: "undici-types@npm:6.19.8"