feat(cache): implement Redis caching layer for hot-read endpoints
Add cache-aside pattern for listing detail, search results, market analytics (4 endpoints), and user profile queries. Cache invalidation on all write mutations. Prometheus cache_hit_total/cache_miss_total metrics with resource labels. - CacheService: getOrSet, invalidate, invalidateByPrefix (SCAN-based) - TTLs: listing 5m, search 1m, market 30m, profile 10m - All 230 tests passing (13 new cache tests + 6 updated handler tests) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { GetDistrictStatsHandler } from '../queries/get-district-stats/get-district-stats.handler';
|
||||
import { GetDistrictStatsQuery } from '../queries/get-district-stats/get-district-stats.query';
|
||||
import { type IMarketIndexRepository, type DistrictStatsResult } from '../../domain/repositories/market-index.repository';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
|
||||
describe('GetDistrictStatsHandler', () => {
|
||||
let handler: GetDistrictStatsHandler;
|
||||
@@ -17,7 +18,8 @@ describe('GetDistrictStatsHandler', () => {
|
||||
getPriceTrend: vi.fn(),
|
||||
getDistrictStats: vi.fn(),
|
||||
};
|
||||
handler = new GetDistrictStatsHandler(mockRepo as any);
|
||||
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
|
||||
handler = new GetDistrictStatsHandler(mockRepo as any, mockCache);
|
||||
});
|
||||
|
||||
it('returns district statistics', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GetHeatmapHandler } from '../queries/get-heatmap/get-heatmap.handler';
|
||||
import { GetHeatmapQuery } from '../queries/get-heatmap/get-heatmap.query';
|
||||
import { type IMarketIndexRepository, type HeatmapDataPoint } from '../../domain/repositories/market-index.repository';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
|
||||
describe('GetHeatmapHandler', () => {
|
||||
let handler: GetHeatmapHandler;
|
||||
@@ -17,7 +18,8 @@ describe('GetHeatmapHandler', () => {
|
||||
getPriceTrend: vi.fn(),
|
||||
getDistrictStats: vi.fn(),
|
||||
};
|
||||
handler = new GetHeatmapHandler(mockRepo as any);
|
||||
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
|
||||
handler = new GetHeatmapHandler(mockRepo as any, mockCache);
|
||||
});
|
||||
|
||||
it('returns heatmap data for a city and period', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GetMarketReportHandler } from '../queries/get-market-report/get-market-report.handler';
|
||||
import { GetMarketReportQuery } from '../queries/get-market-report/get-market-report.query';
|
||||
import { type IMarketIndexRepository, type MarketReportResult } from '../../domain/repositories/market-index.repository';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
|
||||
describe('GetMarketReportHandler', () => {
|
||||
let handler: GetMarketReportHandler;
|
||||
@@ -17,7 +18,8 @@ describe('GetMarketReportHandler', () => {
|
||||
getPriceTrend: vi.fn(),
|
||||
getDistrictStats: vi.fn(),
|
||||
};
|
||||
handler = new GetMarketReportHandler(mockRepo as any);
|
||||
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
|
||||
handler = new GetMarketReportHandler(mockRepo as any, mockCache);
|
||||
});
|
||||
|
||||
it('returns market report with district data', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GetPriceTrendHandler } from '../queries/get-price-trend/get-price-trend.handler';
|
||||
import { GetPriceTrendQuery } from '../queries/get-price-trend/get-price-trend.query';
|
||||
import { type IMarketIndexRepository, type PriceTrendPoint } from '../../domain/repositories/market-index.repository';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
|
||||
describe('GetPriceTrendHandler', () => {
|
||||
let handler: GetPriceTrendHandler;
|
||||
@@ -17,7 +18,8 @@ describe('GetPriceTrendHandler', () => {
|
||||
getPriceTrend: vi.fn(),
|
||||
getDistrictStats: vi.fn(),
|
||||
};
|
||||
handler = new GetPriceTrendHandler(mockRepo as any);
|
||||
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
|
||||
handler = new GetPriceTrendHandler(mockRepo as any, mockCache);
|
||||
});
|
||||
|
||||
it('returns price trend data for a district', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { UpdateMarketIndexHandler } from '../commands/update-market-index/update
|
||||
import { UpdateMarketIndexCommand } from '../commands/update-market-index/update-market-index.command';
|
||||
import { type IMarketIndexRepository } from '../../domain/repositories/market-index.repository';
|
||||
import { MarketIndexEntity } from '../../domain/entities/market-index.entity';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
|
||||
function createExistingEntity(): MarketIndexEntity {
|
||||
return new MarketIndexEntity('idx-1', {
|
||||
@@ -34,7 +35,8 @@ describe('UpdateMarketIndexHandler', () => {
|
||||
getPriceTrend: vi.fn(),
|
||||
getDistrictStats: vi.fn(),
|
||||
};
|
||||
handler = new UpdateMarketIndexHandler(mockRepo as any);
|
||||
const mockCache = { invalidateByPrefix: vi.fn() } as unknown as CacheService;
|
||||
handler = new UpdateMarketIndexHandler(mockRepo as any, mockCache);
|
||||
});
|
||||
|
||||
it('creates a new market index when none exists', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
|
||||
import { UpdateMarketIndexCommand } from './update-market-index.command';
|
||||
import {
|
||||
MARKET_INDEX_REPOSITORY,
|
||||
@@ -16,6 +17,7 @@ export interface UpdateMarketIndexResult {
|
||||
export class UpdateMarketIndexHandler implements ICommandHandler<UpdateMarketIndexCommand> {
|
||||
constructor(
|
||||
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateMarketIndexCommand): Promise<UpdateMarketIndexResult> {
|
||||
@@ -37,6 +39,7 @@ export class UpdateMarketIndexHandler implements ICommandHandler<UpdateMarketInd
|
||||
command.yoyChange,
|
||||
);
|
||||
await this.marketIndexRepo.update(existing);
|
||||
await this.invalidateMarketCaches();
|
||||
return { id: existing.id, created: false };
|
||||
}
|
||||
|
||||
@@ -57,6 +60,18 @@ export class UpdateMarketIndexHandler implements ICommandHandler<UpdateMarketInd
|
||||
);
|
||||
|
||||
await this.marketIndexRepo.save(entity);
|
||||
|
||||
await this.invalidateMarketCaches();
|
||||
|
||||
return { id, created: true };
|
||||
}
|
||||
|
||||
private async invalidateMarketCaches(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.cache.invalidateByPrefix(CachePrefix.MARKET_REPORT),
|
||||
this.cache.invalidateByPrefix(CachePrefix.MARKET_TREND),
|
||||
this.cache.invalidateByPrefix(CachePrefix.MARKET_HEATMAP),
|
||||
this.cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||
import { GetDistrictStatsQuery } from './get-district-stats.query';
|
||||
import {
|
||||
MARKET_INDEX_REPOSITORY,
|
||||
@@ -17,15 +18,20 @@ export interface DistrictStatsDto {
|
||||
export class GetDistrictStatsHandler implements IQueryHandler<GetDistrictStatsQuery> {
|
||||
constructor(
|
||||
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetDistrictStatsQuery): Promise<DistrictStatsDto> {
|
||||
const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
|
||||
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_DISTRICT, query.city, query.period);
|
||||
|
||||
return {
|
||||
city: query.city,
|
||||
period: query.period,
|
||||
districts,
|
||||
};
|
||||
return this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
|
||||
return { city: query.city, period: query.period, districts };
|
||||
},
|
||||
CacheTTL.MARKET_DATA,
|
||||
'district_stats',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||
import { GetHeatmapQuery } from './get-heatmap.query';
|
||||
import {
|
||||
MARKET_INDEX_REPOSITORY,
|
||||
@@ -17,15 +18,20 @@ export interface HeatmapDto {
|
||||
export class GetHeatmapHandler implements IQueryHandler<GetHeatmapQuery> {
|
||||
constructor(
|
||||
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetHeatmapQuery): Promise<HeatmapDto> {
|
||||
const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period);
|
||||
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_HEATMAP, query.city, query.period);
|
||||
|
||||
return {
|
||||
city: query.city,
|
||||
period: query.period,
|
||||
dataPoints,
|
||||
};
|
||||
return this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period);
|
||||
return { city: query.city, period: query.period, dataPoints };
|
||||
},
|
||||
CacheTTL.MARKET_DATA,
|
||||
'heatmap',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||
import { GetMarketReportQuery } from './get-market-report.query';
|
||||
import {
|
||||
MARKET_INDEX_REPOSITORY,
|
||||
@@ -17,19 +18,24 @@ export interface MarketReportDto {
|
||||
export class GetMarketReportHandler implements IQueryHandler<GetMarketReportQuery> {
|
||||
constructor(
|
||||
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetMarketReportQuery): Promise<MarketReportDto> {
|
||||
const districts = await this.marketIndexRepo.getMarketReport(
|
||||
query.city,
|
||||
query.period,
|
||||
query.propertyType,
|
||||
);
|
||||
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_REPORT, query.city, query.period, query.propertyType);
|
||||
|
||||
return {
|
||||
city: query.city,
|
||||
period: query.period,
|
||||
districts,
|
||||
};
|
||||
return this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const districts = await this.marketIndexRepo.getMarketReport(
|
||||
query.city,
|
||||
query.period,
|
||||
query.propertyType,
|
||||
);
|
||||
return { city: query.city, period: query.period, districts };
|
||||
},
|
||||
CacheTTL.MARKET_DATA,
|
||||
'market_report',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||
import { GetPriceTrendQuery } from './get-price-trend.query';
|
||||
import {
|
||||
MARKET_INDEX_REPOSITORY,
|
||||
@@ -18,21 +19,31 @@ export interface PriceTrendDto {
|
||||
export class GetPriceTrendHandler implements IQueryHandler<GetPriceTrendQuery> {
|
||||
constructor(
|
||||
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetPriceTrendQuery): Promise<PriceTrendDto> {
|
||||
const trend = await this.marketIndexRepo.getPriceTrend(
|
||||
const cacheKey = CacheService.buildKey(
|
||||
CachePrefix.MARKET_TREND,
|
||||
query.district,
|
||||
query.city,
|
||||
query.propertyType,
|
||||
query.periods,
|
||||
query.periods?.join(','),
|
||||
);
|
||||
|
||||
return {
|
||||
district: query.district,
|
||||
city: query.city,
|
||||
propertyType: query.propertyType,
|
||||
trend,
|
||||
};
|
||||
return this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const trend = await this.marketIndexRepo.getPriceTrend(
|
||||
query.district,
|
||||
query.city,
|
||||
query.propertyType,
|
||||
query.periods,
|
||||
);
|
||||
return { district: query.district, city: query.city, propertyType: query.propertyType, trend };
|
||||
},
|
||||
CacheTTL.MARKET_DATA,
|
||||
'price_trend',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user