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:
@@ -42,12 +42,16 @@ describe('CacheService', () => {
|
||||
|
||||
describe('getOrSet', () => {
|
||||
it('should return cached value on cache hit', async () => {
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify({ id: '123', name: 'test' }));
|
||||
const data = { id: '123', name: 'test' };
|
||||
// Use the new envelope format (written by getOrSet since the cacheMeta change)
|
||||
mockRedis.get.mockResolvedValue(
|
||||
JSON.stringify({ __v: data, cachedAt: '2026-04-21T10:00:00.000Z', ttlSeconds: 300 }),
|
||||
);
|
||||
const loader = vi.fn();
|
||||
|
||||
const result = await cacheService.getOrSet('cache:listing:123', loader, 300, 'listing');
|
||||
|
||||
expect(result).toEqual({ id: '123', name: 'test' });
|
||||
expect(result).toEqual(data);
|
||||
expect(loader).not.toHaveBeenCalled();
|
||||
expect(mockHitCounter.inc).toHaveBeenCalledWith({ resource: 'listing' });
|
||||
expect(mockMissCounter.inc).not.toHaveBeenCalled();
|
||||
@@ -63,7 +67,12 @@ describe('CacheService', () => {
|
||||
expect(result).toEqual(data);
|
||||
expect(loader).toHaveBeenCalledOnce();
|
||||
expect(mockMissCounter.inc).toHaveBeenCalledWith({ resource: 'listing' });
|
||||
expect(mockRedis.set).toHaveBeenCalledWith('cache:listing:456', JSON.stringify(data), 300);
|
||||
// Envelope written: { __v: data, cachedAt: <iso>, ttlSeconds: 300 }
|
||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||
'cache:listing:456',
|
||||
expect.stringContaining('"__v"'),
|
||||
300,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call loader when cache read fails', async () => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
/**
|
||||
* Per-request cache metadata populated by CacheService.getOrSet.
|
||||
* Used by CacheMetaInterceptor to inject cacheMeta into analytics responses.
|
||||
*/
|
||||
export interface CacheMeta {
|
||||
/** ISO-8601 timestamp of when the cached value was stored. Null for pre-v1 cache entries. */
|
||||
cachedAt: string | null;
|
||||
/** ISO-8601 timestamp of when the cache entry will expire. Null for pre-v1 cache entries. */
|
||||
nextRefreshAt: string | null;
|
||||
/** Whether the data was served from cache or freshly fetched. */
|
||||
source: 'cache' | 'fresh';
|
||||
}
|
||||
|
||||
export interface CacheMetaStore {
|
||||
meta: CacheMeta | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AsyncLocalStorage context for per-request cache metadata propagation.
|
||||
* CacheService.getOrSet writes into this store; CacheMetaInterceptor reads from it.
|
||||
*/
|
||||
export const cacheMetaStorage = new AsyncLocalStorage<CacheMetaStore>();
|
||||
@@ -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');
|
||||
|
||||
@@ -40,3 +40,4 @@ export { EndpointRateLimitGuard } from './guards/endpoint-rate-limit.guard';
|
||||
export { FileValidationPipe } from './pipes/file-validation.pipe';
|
||||
export type { FileValidationOptions, UploadedFile } from './pipes/file-validation.pipe';
|
||||
export { validateEnv, validateJwtSecret } from './env-validation';
|
||||
export { cacheMetaStorage, type CacheMeta, type CacheMetaStore } from './cache-meta.store';
|
||||
|
||||
Reference in New Issue
Block a user