Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
89f083a
chore: Implement short-lived redis cache for slots
emrysal Jul 29, 2025
bb22720
chore: adapt apiv2 redis service to match with upstash redis
ThyMinimalDev Jul 29, 2025
02dda2c
chore: safer redis service and ms ttl
ThyMinimalDev Jul 29, 2025
b7d097c
fixup! chore: safer redis service and ms ttl
ThyMinimalDev Jul 29, 2025
e6af6a9
Wrap with timeout, currently doesn't work yet
emrysal Jul 29, 2025
bee11f0
Merge branch 'chore/shortlived-slots-cache' of https://github.com/cal…
emrysal Jul 29, 2025
eab438f
Merge branch 'main' into chore/shortlived-slots-cache
emrysal Jul 30, 2025
fc25614
Updated @upstash/redis for better signal support
emrysal Jul 30, 2025
3d070fc
Merge branch 'main' into chore/shortlived-slots-cache
emrysal Jul 31, 2025
f409cc5
Fix type errors, remove ts value
emrysal Jul 31, 2025
d9b8aac
Inject NoopRedisService for NODE_ENV test
emrysal Jul 31, 2025
fa149f1
chore: bump platform libs
ThyMinimalDev Jul 31, 2025
07e87b8
Merge branch 'main' into chore/shortlived-slots-cache
ThyMinimalDev Jul 31, 2025
0392c1a
chore: bump platform libs
ThyMinimalDev Jul 31, 2025
e9b28f9
Upstash Redis upgrade no longer resulted in expected hard crash on in…
emrysal Jul 31, 2025
58d4cba
Merge branch 'main' into chore/shortlived-slots-cache
emrysal Jul 31, 2025
0a01e29
Merge branch 'main' into chore/shortlived-slots-cache
emrysal Aug 1, 2025
6a2d5a9
Add SLOTS_CACHE_TTL variable for configurable ttl on slots cache
emrysal Aug 1, 2025
309ccee
Merge branch 'main' into chore/shortlived-slots-cache
emrysal Aug 1, 2025
eee2641
Update parseInt to use right types
emrysal Aug 1, 2025
a8ab694
Merge branch 'main' into chore/shortlived-slots-cache
emrysal Aug 1, 2025
11de996
Merge branch 'main' into chore/shortlived-slots-cache
emrysal Aug 1, 2025
d8726a6
chore: bump platform libs
ThyMinimalDev Aug 2, 2025
6dae3d6
Merge branch 'main' into chore/shortlived-slots-cache
emrysal Aug 2, 2025
2857d08
Merge branch 'main' into chore/shortlived-slots-cache
emrysal Aug 5, 2025
e49e555
chore: bump platform libs
ThyMinimalDev Aug 5, 2025
f465866
chore: bump platform libs
ThyMinimalDev Aug 5, 2025
59903d9
update e2e api v2 action
ThyMinimalDev Aug 5, 2025
d279e9a
set SLOTS_CACHE_TTL env var api v2 e2e
ThyMinimalDev Aug 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/e2e-api-v2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/api/v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {};

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/v2/src/lib/modules/available-slots.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -25,6 +26,7 @@ import { UserAvailabilityService } from "@/lib/services/user-availability.servic
PrismaEventTypeRepository,
PrismaRoutingFormResponseRepository,
PrismaTeamRepository,
RedisService,
PrismaFeaturesRepository,
CheckBookingLimitsService,
CacheService,
Expand Down
5 changes: 4 additions & 1 deletion apps/api/v2/src/lib/services/available-slots.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,6 +26,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepository: PrismaSelectedSlotRepository,
eventTypeRepository: PrismaEventTypeRepository,
userRepository: PrismaUserRepository,
redisService: RedisService,
featuresRepository: PrismaFeaturesRepository
) {
super({
Expand All @@ -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)
});
Expand Down
115 changes: 115 additions & 0 deletions apps/api/v2/src/modules/redis/redis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 change: 1 addition & 0 deletions apps/api/v2/test/setEnvVars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ process.env = {
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
INTEGRATION_TEST_MODE: "true",
e2e: "true",
SLOTS_CACHE_TTL: "1"
};
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/features/redis/IRedisService.d.ts
Original file line number Diff line number Diff line change
@@ -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>;

Expand Down
32 changes: 32 additions & 0 deletions packages/features/redis/NoopRedisService.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
21 changes: 17 additions & 4 deletions packages/features/redis/RedisService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand All @@ -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> {
Expand Down
17 changes: 17 additions & 0 deletions packages/features/redis/di/redisModule.ts
Original file line number Diff line number Diff line change
@@ -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 };
2 changes: 2 additions & 0 deletions packages/lib/di/containers/available-slots.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/lib/di/modules/available-slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/lib/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
5 changes: 2 additions & 3 deletions packages/lib/getUserAvailability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,9 @@ type GetUsersAvailabilityProps = {
};

export interface IUserAvailabilityService {
eventTypeRepo: EventTypeRepository;
eventTypeRepo: EventTypeRepository;
oooRepo: PrismaOOORepository;
bookingRepo: BookingRepository;

}

export class UserAvailabilityService {
Expand Down Expand Up @@ -611,4 +610,4 @@ export class UserAvailabilityService {
}

getUsersAvailability = withReporting(this._getUsersAvailability.bind(this), "getUsersAvailability");
}
}
Loading
Loading