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>
207 lines
8.4 KiB
TypeScript
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(':')}`;
|
|
}
|
|
}
|