import { Injectable, type OnModuleInit } from '@nestjs/common'; import { InjectMetric } from '@willsoto/nestjs-prometheus'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata import { Counter } from 'prom-client'; import { cacheMetaStorage } from './cache-meta.store'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata import { LoggerService } from './logger.service'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata import { RedisService } from './redis.service'; export const CACHE_HIT_TOTAL = 'cache_hit_total'; export const CACHE_MISS_TOTAL = 'cache_miss_total'; export const CACHE_DEGRADATION_TOTAL = 'cache_degradation_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 /** [TEC-3055] Ward-level heatmap / listing-volume drill-down — 30 min TTL */ HEATMAP_WARD: 1800, // 30 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 /** Market snapshot — 5 min TTL, dashboard tile data */ MARKET_SNAPSHOT: 300, // 5 min /** Trending areas — 30 min TTL, aggregation is expensive */ TRENDING_AREAS: 1800, // 30 min /** Price movers — 30 min TTL, aggregation over two time windows */ PRICE_MOVERS: 1800, // 30 min /** Market history — 6 hour TTL, time-series data recomputed nightly */ MARKET_HISTORY: 21600, // 6 hours /** AVM valuation estimate per listing — long TTL, model outputs are stable within a day */ VALUATION_LISTING: 86400, // 24 h /** [TEC-3072] Neighborhood score — 24h TTL, POI data changes infrequently */ NEIGHBORHOOD_SCORE: 86400, // 24 h } 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', /** [TEC-3055] Listing volume drill-down by ward */ LISTING_VOLUME_WARD = 'cache:market:listing_volume_ward', 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', AGENT_LISTINGS = 'cache:agent:listings', MARKET_SNAPSHOT = 'cache:analytics:market_snapshot', TRENDING_AREAS = 'cache:analytics:trending_areas', PRICE_MOVERS = 'cache:analytics:price_movers', MARKET_HISTORY = 'cache:analytics:market_history', /** [TEC-3072] Neighborhood score per district */ NEIGHBORHOOD_SCORE = 'cache:analytics:neighborhood_score', } @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, @InjectMetric(CACHE_DEGRADATION_TOTAL) private readonly cacheDegradationCounter: Counter, ) {} onModuleInit(): void { this.logger.log('CacheService initialized', 'CacheService'); } /** * Cache-aside: get from cache, or execute loader and store result. * * When Redis is down the loader is called directly (graceful degradation). * Degradation events are counted via cache_degradation_total for alerting. * * Cache entries are stored as { __v, cachedAt, ttlSeconds } envelopes so * that CacheMetaInterceptor can surface freshness metadata to the frontend. * Legacy plain-JSON entries (written before this version) are served * transparently; they receive cacheMeta: { cachedAt: null, ... }. */ async getOrSet( key: string, loader: () => Promise, ttlSeconds: number, resource: string, ): Promise { const store = cacheMetaStorage.getStore(); // Fast-path: skip Redis entirely when it is known to be disconnected. if (!this.redis.isAvailable()) { this.cacheDegradationCounter.inc({ resource, operation: 'skip_unavailable' }); this.cacheMissCounter.inc({ resource }); if (store) { store.meta = { cachedAt: null, nextRefreshAt: null, source: 'fresh' }; } return loader(); } try { const cached = await this.redis.get(key); if (cached !== null) { this.cacheHitCounter.inc({ resource }); const parsed = JSON.parse(cached) as unknown; // Detect enveloped entries written by this method. if ( parsed !== null && typeof parsed === 'object' && '__v' in (parsed as object) && 'cachedAt' in (parsed as object) ) { const envelope = parsed as { __v: T; cachedAt: string; ttlSeconds: number }; if (store) { const nextRefreshAt = new Date( new Date(envelope.cachedAt).getTime() + envelope.ttlSeconds * 1000, ).toISOString(); store.meta = { cachedAt: envelope.cachedAt, nextRefreshAt, source: 'cache' }; } return envelope.__v; } // Legacy plain value — serve without timestamp meta. if (store) { store.meta = { cachedAt: null, nextRefreshAt: null, source: 'cache' }; } return parsed as T; } } catch (err) { this.cacheDegradationCounter.inc({ resource, operation: 'read_error' }); this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService'); } this.cacheMissCounter.inc({ resource }); const result = await loader(); const cachedAt = new Date().toISOString(); if (store) { const nextRefreshAt = new Date(new Date(cachedAt).getTime() + ttlSeconds * 1000).toISOString(); store.meta = { cachedAt, nextRefreshAt, source: 'fresh' }; } try { const envelope = { __v: result, cachedAt, ttlSeconds }; await this.redis.set(key, JSON.stringify(envelope), ttlSeconds); } catch (err) { this.cacheDegradationCounter.inc({ resource, operation: 'write_error' }); 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.cacheDegradationCounter.inc({ resource: 'invalidation', operation: 'invalidate_error' }); 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.cacheDegradationCounter.inc({ resource: 'invalidation', operation: 'prefix_invalidate_error' }); 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(':')}`; } }