- Create @Cacheable method decorator for declarative cache-aside pattern with configurable prefix, TTL, resource label, and key extraction - Add PLAN_LIST (1h TTL) and REFERENCE_DATA (24h TTL) cache constants - Add CachePrefix.PLAN_LIST and CachePrefix.REFERENCE entries - Cache subscription plan queries in GetPlanHandler (single + list) - Export Cacheable decorator from shared module barrel - Add comprehensive tests for decorator and handler caching The caching infrastructure (CacheService, Redis, Prometheus metrics, event-driven invalidation) was already production-ready with 10+ hot paths cached. This commit adds the missing declarative decorator and plan list caching. Resolves: TEC-1567 Co-Authored-By: Paperclip <noreply@paperclip.ing>
126 lines
4.3 KiB
TypeScript
126 lines
4.3 KiB
TypeScript
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<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(':')}`;
|
|
}
|
|
}
|