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:
Ho Ngoc Hai
2026-04-08 04:14:06 +07:00
parent 09034a5f9b
commit 2a392525a2
23 changed files with 472 additions and 60 deletions

View 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(':')}`;
}
}