import { Injectable, type OnModuleInit } from '@nestjs/common'; import { InjectMetric } from '@willsoto/nestjs-prometheus'; import { type Counter } from 'prom-client'; import { type LoggerService } from './logger.service'; import { type RedisService } from './redis.service'; export const CACHE_HIT_TOTAL = 'cache_hit_total'; export const CACHE_MISS_TOTAL = 'cache_miss_total'; export const CacheTTL = { /** Listing detail — moderate TTL, invalidated on mutation */ LISTING_DETAIL: 300, // 5 min /** Search results — short TTL, invalidated on listing mutations */ SEARCH_RESULTS: 120, // 2 min /** District stats — moderate TTL, invalidated on listing events */ DISTRICT_STATS: 300, // 5 min /** Market report — moderate TTL, invalidated on listing events */ MARKET_REPORT: 900, // 15 min /** Heatmap data — moderate TTL, invalidated on listing events */ HEATMAP: 300, // 5 min /** Price trend — long TTL, historical data changes infrequently */ MARKET_DATA: 1800, // 30 min /** User profile — moderate TTL, invalidated on mutation */ USER_PROFILE: 600, // 10 min /** User quota — very short TTL, invalidated on usage metering and plan changes */ USER_QUOTA: 60, // 1 min /** Subscription plan list — long TTL, rarely changes */ PLAN_LIST: 3600, // 1 hour /** Reference data (districts, wards) — very long TTL, static data */ REFERENCE_DATA: 86400, // 24 hours } as const; export enum CachePrefix { LISTING = 'cache:listing', SEARCH = 'cache:search', GEO_SEARCH = 'cache:geo_search', MARKET_REPORT = 'cache:market:report', MARKET_TREND = 'cache:market:trend', MARKET_HEATMAP = 'cache:market:heatmap', MARKET_DISTRICT = 'cache:market:district', USER_PROFILE = 'cache:user:profile', USER_QUOTA = 'cache:user:quota', VALUATION = 'cache:valuation', PLAN_LIST = 'cache:plan:list', REFERENCE = 'cache:reference', } @Injectable() export class CacheService implements OnModuleInit { constructor( private readonly redis: RedisService, private readonly logger: LoggerService, @InjectMetric(CACHE_HIT_TOTAL) private readonly cacheHitCounter: Counter, @InjectMetric(CACHE_MISS_TOTAL) private readonly cacheMissCounter: Counter, ) {} onModuleInit(): void { this.logger.log('CacheService initialized', 'CacheService'); } /** * Cache-aside: get from cache, or execute loader and store result. */ async getOrSet( key: string, loader: () => Promise, ttlSeconds: number, resource: string, ): Promise { try { const cached = await this.redis.get(key); if (cached !== null) { this.cacheHitCounter.inc({ resource }); return JSON.parse(cached) as T; } } catch (err) { this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService'); } this.cacheMissCounter.inc({ resource }); const result = await loader(); try { await this.redis.set(key, JSON.stringify(result), ttlSeconds); } catch (err) { this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService'); } return result; } /** Invalidate a single cache key. */ async invalidate(key: string): Promise { try { await this.redis.del(key); } catch (err) { this.logger.warn(`Cache invalidate error for ${key}: ${(err as Error).message}`, 'CacheService'); } } /** Invalidate all keys matching a prefix using SCAN (non-blocking). */ async invalidateByPrefix(prefix: string): Promise { try { const client = this.redis.getClient(); let cursor = '0'; do { const [nextCursor, keys] = await client.scan(cursor, 'MATCH', `${prefix}:*`, 'COUNT', 100); cursor = nextCursor; if (keys.length > 0) { await client.del(...keys); } } while (cursor !== '0'); } catch (err) { this.logger.warn(`Cache prefix invalidate error for ${prefix}: ${(err as Error).message}`, 'CacheService'); } } /** Build a deterministic cache key from prefix + parts. */ static buildKey(prefix: CachePrefix, ...parts: (string | number | undefined)[]): string { const sanitized = parts .filter((p) => p !== undefined) .map((p) => String(p).toLowerCase().replace(/\s+/g, '_')); return `${prefix}:${sanitized.join(':')}`; } }