Skip to content

Commit b71d8ba

Browse files
chore: Implement short-lived redis cache for slots (calcom#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>
1 parent a71d949 commit b71d8ba

19 files changed

Lines changed: 276 additions & 41 deletions

File tree

.github/workflows/e2e-api-v2.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ env:
2222
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
2323
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
2424
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
25+
SLOTS_CACHE_TTL: ${{secret.CI_SLOTS_CACHE_TTL}}
2526
jobs:
2627
e2e:
2728
timeout-minutes: 20

apps/api/v2/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@axiomhq/winston": "^1.2.0",
3939
"@calcom/platform-constants": "*",
4040
"@calcom/platform-enums": "*",
41-
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.283",
41+
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.285",
4242
"@calcom/platform-types": "*",
4343
"@calcom/platform-utils": "*",
4444
"@calcom/prisma": "*",

apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,11 @@ export class OutputBookingsService_2024_08_13 {
104104
const rescheduledToInfo = databaseBooking.rescheduled
105105
? await this.getRescheduledToInfo(databaseBooking.uid)
106106
: undefined;
107-
108-
const rescheduledToUid = rescheduledToInfo?.uid;
109-
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
110107

108+
const rescheduledToUid = rescheduledToInfo?.uid;
109+
const rescheduledByEmail = databaseBooking.rescheduled
110+
? rescheduledToInfo?.rescheduledBy
111+
: databaseBooking.rescheduledBy;
111112

112113
const booking = {
113114
id: databaseBooking.id,
@@ -157,13 +158,12 @@ export class OutputBookingsService_2024_08_13 {
157158

158159
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
159160
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
160-
return {
161-
uid: rescheduledTo?.uid,
162-
rescheduledBy: rescheduledTo?.rescheduledBy
161+
return {
162+
uid: rescheduledTo?.uid,
163+
rescheduledBy: rescheduledTo?.rescheduledBy,
163164
};
164165
}
165166

166-
167167
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
168168
if (databaseMetadata === null) return {};
169169

@@ -279,9 +279,11 @@ export class OutputBookingsService_2024_08_13 {
279279
const rescheduledToInfo = databaseBooking.rescheduled
280280
? await this.getRescheduledToInfo(databaseBooking.uid)
281281
: undefined;
282-
282+
283283
const rescheduledToUid = rescheduledToInfo?.uid;
284-
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
284+
const rescheduledByEmail = databaseBooking.rescheduled
285+
? rescheduledToInfo?.rescheduledBy
286+
: databaseBooking.rescheduledBy;
285287

286288
const booking = {
287289
id: databaseBooking.id,

apps/api/v2/src/lib/modules/available-slots.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { AvailableSlotsService } from "@/lib/services/available-slots.service";
1111
import { CacheService } from "@/lib/services/cache.service";
1212
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
1313
import { PrismaModule } from "@/modules/prisma/prisma.module";
14+
import { RedisService } from "@/modules/redis/redis.service";
1415
import { Module } from "@nestjs/common";
1516
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
1617

@@ -25,6 +26,7 @@ import { UserAvailabilityService } from "@/lib/services/user-availability.servic
2526
PrismaEventTypeRepository,
2627
PrismaRoutingFormResponseRepository,
2728
PrismaTeamRepository,
29+
RedisService,
2830
PrismaFeaturesRepository,
2931
CheckBookingLimitsService,
3032
CacheService,

apps/api/v2/src/lib/services/available-slots.service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"
99
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
1010
import { CacheService } from "@/lib/services/cache.service";
1111
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
12+
import { RedisService } from "@/modules/redis/redis.service";
1213
import { Injectable } from "@nestjs/common";
1314

1415
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
@@ -25,6 +26,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
2526
selectedSlotRepository: PrismaSelectedSlotRepository,
2627
eventTypeRepository: PrismaEventTypeRepository,
2728
userRepository: PrismaUserRepository,
29+
redisService: RedisService,
2830
featuresRepository: PrismaFeaturesRepository
2931
) {
3032
super({
@@ -36,7 +38,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
3638
selectedSlotRepo: selectedSlotRepository,
3739
eventTypeRepo: eventTypeRepository,
3840
userRepo: userRepository,
39-
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
41+
redisClient: redisService,
42+
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
4043
cacheService: new CacheService(featuresRepository),
4144
userAvailabilityService: new UserAvailabilityService(oooRepoDependency, bookingRepository, eventTypeRepository)
4245
});

apps/api/v2/src/modules/redis/redis.service.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,32 @@ import { Redis } from "ioredis";
77
export class RedisService implements OnModuleDestroy {
88
public redis: Redis;
99
private readonly logger = new Logger("RedisService");
10+
private isReady = false; // Track connection status
1011

1112
constructor(readonly configService: ConfigService<AppConfig>) {
1213
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
1314
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
1415

1516
this.redis = new Redis(dbUrl);
17+
18+
this.redis.on("error", (err) => {
19+
this.logger.error(`IoRedis connection error: ${err.message}`);
20+
this.isReady = false;
21+
});
22+
23+
this.redis.on("connect", () => {
24+
this.logger.log("IoRedis connected!");
25+
this.isReady = true;
26+
});
27+
28+
this.redis.on("reconnecting", (delay: string) => {
29+
this.logger.warn(`IoRedis reconnecting... next retry in ${delay}ms`);
30+
});
31+
32+
this.redis.on("end", () => {
33+
this.logger.warn("IoRedis connection ended.");
34+
this.isReady = false;
35+
});
1636
}
1737

1838
async onModuleDestroy() {
@@ -22,4 +42,99 @@ export class RedisService implements OnModuleDestroy {
2242
this.logger.error(err);
2343
}
2444
}
45+
46+
async get<TData>(key: string): Promise<TData | null> {
47+
let data = null;
48+
if (!this.isReady) {
49+
return null;
50+
}
51+
52+
try {
53+
data = await this.redis.get(key);
54+
} catch (err) {
55+
if (err instanceof Error) this.logger.error(`IoRedis get failed: ${err.message}`);
56+
}
57+
58+
if (data === null) {
59+
return null;
60+
}
61+
62+
try {
63+
return JSON.parse(data) as TData;
64+
} catch (e) {
65+
return data as TData;
66+
}
67+
}
68+
69+
async del(key: string): Promise<number> {
70+
if (!this.isReady) {
71+
return 0;
72+
}
73+
try {
74+
return this.redis.del(key);
75+
} catch (err) {
76+
if (err instanceof Error) this.logger.error(`IoRedis del failed: ${err.message}`);
77+
return 0;
78+
}
79+
}
80+
81+
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
82+
if (!this.isReady) {
83+
return null;
84+
}
85+
86+
try {
87+
const stringifiedValue = typeof value === "object" ? JSON.stringify(value) : String(value);
88+
if (opts?.ttl) {
89+
await this.redis.set(key, stringifiedValue, "PX", opts.ttl);
90+
} else {
91+
await this.redis.set(key, stringifiedValue);
92+
}
93+
} catch (err) {
94+
if (err instanceof Error) this.logger.error(`IoRedis set failed: ${err.message}`);
95+
return null;
96+
}
97+
98+
return "OK";
99+
}
100+
101+
async expire(key: string, seconds: number): Promise<0 | 1> {
102+
if (!this.isReady) {
103+
return 0;
104+
}
105+
try {
106+
return this.redis.expire(key, seconds) as Promise<0 | 1>;
107+
} catch (err) {
108+
if (err instanceof Error) this.logger.error(`IoRedis expire failed: ${err.message}`);
109+
return 0;
110+
}
111+
}
112+
113+
async lrange<TResult = string>(key: string, start: number, end: number): Promise<TResult[]> {
114+
if (!this.isReady) {
115+
return [];
116+
}
117+
try {
118+
const results = await this.redis.lrange(key, start, end);
119+
return results.map((item) => JSON.parse(item) as TResult);
120+
} catch (err) {
121+
if (err instanceof Error) this.logger.error(`IoRedis lrange failed: ${err.message}`);
122+
return [];
123+
}
124+
}
125+
126+
async lpush<TData>(key: string, ...elements: TData[]): Promise<number> {
127+
if (!this.isReady) {
128+
return 0;
129+
}
130+
try {
131+
const stringifiedElements = elements.map((element) =>
132+
typeof element === "object" ? JSON.stringify(element) : String(element)
133+
);
134+
return this.redis.lpush(key, ...stringifiedElements);
135+
} catch (err) {
136+
if (err instanceof Error) this.logger.error(`IoRedis lpush failed: ${err.message}`);
137+
return 0;
138+
}
139+
}
25140
}

apps/api/v2/test/setEnvVars.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ process.env = {
3636
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
3737
INTEGRATION_TEST_MODE: "true",
3838
e2e: "true",
39+
SLOTS_CACHE_TTL: "1"
3940
};

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
"@team-plain/typescript-sdk": "^5.9.0",
7979
"@types/turndown": "^5.0.1",
8080
"@unkey/ratelimit": "^0.1.1",
81-
"@upstash/redis": "^1.21.0",
81+
"@upstash/redis": "^1.35.2",
8282
"@vercel/edge-config": "^0.1.1",
8383
"@vercel/edge-functions-ui": "^0.2.1",
8484
"@vercel/og": "^0.6.3",

packages/features/redis/IRedisService.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export interface IRedisService {
22
get: <TData>(key: string) => Promise<TData | null>;
33

4-
set: <TData>(key: string, value: TData) => Promise<"OK" | TData | null>;
4+
set: <TData>(key: string, value: TData, opts?: { ttl?: number }) => Promise<"OK" | TData | null>;
55

66
expire: (key: string, seconds: number) => Promise<0 | 1>;
77

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { IRedisService } from "./IRedisService";
2+
3+
/**
4+
* Noop implementation of IRedisService for testing or fallback scenarios.
5+
*/
6+
7+
export class NoopRedisService implements IRedisService {
8+
async get<TData>(_key: string): Promise<TData | null> {
9+
return null;
10+
}
11+
12+
async del(_key: string): Promise<number> {
13+
return 0;
14+
}
15+
16+
async set<TData>(_key: string, _value: TData, _opts?: { ttl?: number }): Promise<"OK" | TData | null> {
17+
return "OK";
18+
}
19+
20+
async expire(_key: string, _seconds: number): Promise<0 | 1> {
21+
// Implementation for setting expiration time for key in Redis
22+
return 0;
23+
}
24+
25+
async lrange<TResult = string>(_key: string, _start: number, _end: number): Promise<TResult[]> {
26+
return [];
27+
}
28+
29+
async lpush<TData>(_key: string, ..._elements: TData[]): Promise<number> {
30+
return 0;
31+
}
32+
}

0 commit comments

Comments
 (0)