import { MapperWrapper, Paginated, ResponseMapper, } from '@/common/response/response-mapper' import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common' import Redis from 'ioredis' interface PaginatedCache { items: T[] total: number page?: number perPage?: number } @Injectable() export class RedisService implements OnModuleDestroy { private static readonly scanCount = 100 private readonly logger = new Logger(RedisService.name) private readonly client: Redis constructor() { this.client = new Redis({ host: process.env.REDIS_HOST || 'redis', port: Number(process.env.REDIS_PORT || 6379), password: process.env.REDIS_PASSWORD || undefined, db: Number(process.env.REDIS_DB || 0), lazyConnect: true, maxRetriesPerRequest: 3, }) this.client.on('error', error => { this.logger.error(`Redis error: ${error.message}`) }) } async getClient(): Promise { if (this.client.status !== 'ready') { await this.client.connect() } return this.client } async get(key: string): Promise { const client = await this.getClient() return client.get(key) } async getJson(key: string): Promise { const value = await this.get(key) if (!value) { return null } try { return JSON.parse(value) as T } catch { this.logger.warn(`Invalid JSON for key "${key}"`) return null } } async set(key: string, value: string, ttlSeconds?: number): Promise<'OK' | null> { const client = await this.getClient() if (ttlSeconds && ttlSeconds > 0) { return client.set(key, value, 'EX', ttlSeconds) } return client.set(key, value) } async setJson(key: string, value: unknown, ttlSeconds?: number): Promise<'OK' | null> { return this.set(key, JSON.stringify(value), ttlSeconds) } async delete(key: string): Promise { const client = await this.getClient() return client.del(key) } async exists(key: string): Promise { const client = await this.getClient() const result = await client.exists(key) return result === 1 } async expire(key: string, ttlSeconds: number): Promise { const client = await this.getClient() const result = await client.expire(key, ttlSeconds) return result === 1 } async deleteByPattern(pattern: string): Promise { const client = await this.getClient() let deletedCount = 0 let cursor = '0' do { const [nextCursor, keys] = await client.scan( cursor, 'MATCH', pattern, 'COUNT', RedisService.scanCount, ) cursor = nextCursor if (keys.length > 0) { const results = await this.deleteKeys(client, keys) if (results) { deletedCount += results.reduce((sum, [, value]) => { return sum + (typeof value === 'number' ? value : 0) }, 0) } } } while (cursor !== '0') return deletedCount } async deleteByPatterns(patterns: string[]): Promise { const deletedCounts = await Promise.all( patterns.map(pattern => this.deleteByPattern(pattern)), ) return deletedCounts.reduce((sum, count) => sum + count, 0) } async getAndSet( key: string, type: 'single', callback: () => Promise, ttlSeconds?: number, ): Promise> async getAndSet( key: string, type: 'list', callback: () => Promise, ttlSeconds?: number, ): Promise> async getAndSet( key: string, type: 'paginate', callback: () => Promise>, ttlSeconds?: number, ): Promise> async getAndSet( key: string, type: 'single' | 'list' | 'paginate', callback: () => Promise>, ttlSeconds?: number, ): Promise | Paginated> { switch (type) { case 'single': return this.getAndSetSingle(key, callback as () => Promise, ttlSeconds) case 'list': return this.getAndSetList(key, callback as () => Promise, ttlSeconds) case 'paginate': return this.getAndSetPaginate( key, callback as () => Promise>, ttlSeconds, ) default: { const neverType: never = type throw new Error(`Unsupported cache type: ${neverType}`) } } } private async getAndSetSingle( key: string, callback: () => Promise, ttlSeconds?: number, ): Promise> { const cached = await this.tryReadJson(key) if (cached !== null) { return ResponseMapper.single(cached) } const data = await callback() await this.tryWriteJson(key, data, ttlSeconds) return ResponseMapper.single(data) } private async getAndSetList( key: string, callback: () => Promise, ttlSeconds?: number, ): Promise> { const cached = await this.tryReadJson(key) if (cached !== null) { return ResponseMapper.list(cached) } const data = await callback() await this.tryWriteJson(key, data, ttlSeconds) return ResponseMapper.list(data) } private async getAndSetPaginate( key: string, callback: () => Promise>, ttlSeconds?: number, ): Promise> { const cached = await this.tryReadJson>(key) if (cached !== null) { return ResponseMapper.paginate(cached.items, { total: cached.total, page: cached.page, perPage: cached.perPage, }) } const data = await callback() await this.tryWriteJson(key, data, ttlSeconds) return ResponseMapper.paginate(data.items, { total: data.total, page: data.page, perPage: data.perPage, }) } private async tryReadJson(key: string): Promise { try { return await this.getJson(key) } catch (error) { this.logger.warn(`Redis read failed for "${key}": ${(error as Error).message}`) return null } } private async tryWriteJson( key: string, value: unknown, ttlSeconds: number = 300, ): Promise { try { await this.setJson(key, value, ttlSeconds) } catch (error) { this.logger.warn(`Redis write failed for "${key}": ${(error as Error).message}`) } } private async deleteKeys( client: Redis, keys: string[], ): Promise> { const pipeline = client.pipeline() keys.forEach(key => pipeline.del(key)) const results = await pipeline.exec() return results ?? [] } async onModuleDestroy() { await this.client.quit() } }