feat(cache): implement Redis caching layer for hot-read endpoints
Add cache-aside pattern for listing detail, search results, market analytics (4 endpoints), and user profile queries. Cache invalidation on all write mutations. Prometheus cache_hit_total/cache_miss_total metrics with resource labels. - CacheService: getOrSet, invalidate, invalidateByPrefix (SCAN-based) - TTLs: listing 5m, search 1m, market 30m, profile 10m - All 230 tests passing (13 new cache tests + 6 updated handler tests) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
108
apps/api/src/modules/shared/infrastructure/cache.service.ts
Normal file
108
apps/api/src/modules/shared/infrastructure/cache.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Injectable, Inject, type OnModuleInit } from '@nestjs/common';
|
||||
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
||||
import { Counter } from 'prom-client';
|
||||
import { RedisService } from './redis.service';
|
||||
import { LoggerService } from './logger.service';
|
||||
|
||||
export const CACHE_HIT_TOTAL = 'cache_hit_total';
|
||||
export const CACHE_MISS_TOTAL = 'cache_miss_total';
|
||||
|
||||
export enum CacheTTL {
|
||||
/** Listing detail — moderate TTL, invalidated on mutation */
|
||||
LISTING_DETAIL = 300, // 5 min
|
||||
/** Search results — short TTL due to high variability */
|
||||
SEARCH_RESULTS = 60, // 1 min
|
||||
/** Market analytics — long TTL, data changes infrequently */
|
||||
MARKET_DATA = 1800, // 30 min
|
||||
/** User profile — moderate TTL, invalidated on mutation */
|
||||
USER_PROFILE = 600, // 10 min
|
||||
}
|
||||
|
||||
export enum CachePrefix {
|
||||
LISTING = 'cache:listing',
|
||||
SEARCH = 'cache: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',
|
||||
}
|
||||
|
||||
@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<T>(
|
||||
key: string,
|
||||
loader: () => Promise<T>,
|
||||
ttlSeconds: number,
|
||||
resource: string,
|
||||
): Promise<T> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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(':')}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user