Files
goodgo-platform/apps/api/src/modules/shared/infrastructure/cache.service.ts
Ho Ngoc Hai ecb217cf5e feat(analytics): add Redis 24h cache to neighborhood score endpoint (TEC-3072)
The GET /neighborhoods/:district/score handler was missing Redis caching.
Adds NEIGHBORHOOD_SCORE CachePrefix + CacheTTL (24h) and wires CacheService.getOrSet
into GetNeighborhoodScoreHandler. Updates handler tests to cover cache behavior.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 05:20:39 +07:00

207 lines
8.4 KiB
TypeScript

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<T>(
key: string,
loader: () => Promise<T>,
ttlSeconds: number,
resource: string,
): Promise<T> {
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<void> {
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<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.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(':')}`;
}
}