feat(analytics): add cacheMeta to all /analytics/* and /avm/* responses (TEC-3056)

- Add CacheMetaStore (AsyncLocalStorage) in shared/infrastructure so
  cache metadata can propagate across async call stacks per-request
- Extend CacheService.getOrSet to store { __v, cachedAt, ttlSeconds }
  envelopes in Redis; reads back envelope to compute nextRefreshAt.
  Legacy plain-JSON entries are served transparently (cachedAt: null)
- Add CacheMetaInterceptor that wraps every analytics response as
  { data: T, cacheMeta: { cachedAt, nextRefreshAt, source } } using
  the per-request ALS store populated by CacheService
- Apply @UseInterceptors(CacheMetaInterceptor) on both
  AnalyticsController and AvmController (class-level)
- Update cache.service.spec.ts to expect envelope format on write
- Add cache-meta.interceptor.spec.ts with 6 tests covering market-report,
  price-trend, heatmap endpoints, cache-hit path, and ALS isolation
- Add analytics module README documenting the pattern for future devs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 02:18:28 +07:00
parent 641e91f4d4
commit a70db64da1
9 changed files with 359 additions and 6 deletions

View File

@@ -2,6 +2,7 @@ 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
@@ -34,6 +35,8 @@ export const CacheTTL = {
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
} as const;
export enum CachePrefix {
@@ -51,6 +54,7 @@ export enum CachePrefix {
REFERENCE = 'cache:reference',
AGENT_LISTINGS = 'cache:agent:listings',
MARKET_SNAPSHOT = 'cache:analytics:market_snapshot',
TRENDING_AREAS = 'cache:analytics:trending_areas',
}
@Injectable()
@@ -71,7 +75,12 @@ export class CacheService implements OnModuleInit {
* 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.
* 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,
@@ -79,10 +88,15 @@ export class CacheService implements OnModuleInit {
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();
}
@@ -90,7 +104,28 @@ export class CacheService implements OnModuleInit {
const cached = await this.redis.get(key);
if (cached !== null) {
this.cacheHitCounter.inc({ resource });
return JSON.parse(cached) as T;
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' });
@@ -100,8 +135,15 @@ export class CacheService implements OnModuleInit {
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 {
await this.redis.set(key, JSON.stringify(result), ttlSeconds);
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');