2025-08-22 17:02:49 +09:00
|
|
|
import { Inject, Injectable } from "@nestjs/common";
|
|
|
|
|
import Redis from "ioredis";
|
|
|
|
|
import { Logger } from "nestjs-pino";
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
@Injectable()
|
2025-08-21 15:24:40 +09:00
|
|
|
export class CacheService {
|
|
|
|
|
constructor(
|
2025-08-22 17:02:49 +09:00
|
|
|
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
|
|
|
|
@Inject(Logger) private readonly logger: Logger
|
2025-08-21 15:24:40 +09:00
|
|
|
) {}
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
async get<T>(key: string): Promise<T | null> {
|
|
|
|
|
const value = await this.redis.get(key);
|
2025-09-25 11:44:10 +09:00
|
|
|
if (!value) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(value) as T;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.warn({ key, error }, "Failed to parse cached value; evicting entry");
|
|
|
|
|
await this.redis.del(key);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
2025-08-21 15:24:40 +09:00
|
|
|
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
2025-08-20 18:02:50 +09:00
|
|
|
const serialized = JSON.stringify(value);
|
|
|
|
|
if (ttlSeconds) {
|
|
|
|
|
await this.redis.setex(key, ttlSeconds, serialized);
|
|
|
|
|
} else {
|
|
|
|
|
await this.redis.set(key, serialized);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async del(key: string): Promise<void> {
|
|
|
|
|
await this.redis.del(key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async delPattern(pattern: string): Promise<void> {
|
2025-09-06 10:01:44 +09:00
|
|
|
let cursor = "0";
|
|
|
|
|
const batch: string[] = [];
|
|
|
|
|
const flush = async () => {
|
|
|
|
|
if (batch.length > 0) {
|
|
|
|
|
const pipeline = this.redis.pipeline();
|
|
|
|
|
for (const k of batch.splice(0, batch.length)) pipeline.del(k);
|
|
|
|
|
await pipeline.exec();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
do {
|
|
|
|
|
const [next, keys] = (await this.redis.scan(
|
|
|
|
|
cursor,
|
|
|
|
|
"MATCH",
|
|
|
|
|
pattern,
|
|
|
|
|
"COUNT",
|
|
|
|
|
1000
|
|
|
|
|
)) as unknown as [string, string[]];
|
|
|
|
|
cursor = next;
|
|
|
|
|
if (keys && keys.length) {
|
|
|
|
|
batch.push(...keys);
|
|
|
|
|
if (batch.length >= 1000) {
|
|
|
|
|
await flush();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} while (cursor !== "0");
|
|
|
|
|
await flush();
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async exists(key: string): Promise<boolean> {
|
|
|
|
|
return (await this.redis.exists(key)) === 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
buildKey(prefix: string, userId: string, ...parts: string[]): string {
|
2025-08-22 17:02:49 +09:00
|
|
|
return [prefix, userId, ...parts].join(":");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
2025-08-22 17:02:49 +09:00
|
|
|
async getOrSet<T>(key: string, fetcher: () => Promise<T>, ttlSeconds: number = 300): Promise<T> {
|
2025-08-20 18:02:50 +09:00
|
|
|
const cached = await this.get<T>(key);
|
|
|
|
|
if (cached !== null) {
|
|
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fresh = await fetcher();
|
|
|
|
|
await this.set(key, fresh, ttlSeconds);
|
|
|
|
|
return fresh;
|
|
|
|
|
}
|
|
|
|
|
}
|