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 { SearchPropertiesHandler } from '../queries/search-properties/search-properties.handler';
|
||||
import { SearchPropertiesQuery } from '../queries/search-properties/search-properties.query';
|
||||
import { type ISearchRepository, type SearchResult } from '../../domain/repositories/search.repository';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
|
||||
function createMockSearchResult(overrides?: Partial<SearchResult>): SearchResult {
|
||||
return {
|
||||
@@ -27,7 +28,8 @@ describe('SearchPropertiesHandler', () => {
|
||||
ensureCollection: vi.fn(),
|
||||
dropCollection: vi.fn(),
|
||||
};
|
||||
handler = new SearchPropertiesHandler(mockSearchRepo as any);
|
||||
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
|
||||
handler = new SearchPropertiesHandler(mockSearchRepo as any, mockCache);
|
||||
});
|
||||
|
||||
it('searches with basic query', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||
import { SearchPropertiesQuery } from './search-properties.query';
|
||||
import {
|
||||
SEARCH_REPOSITORY,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQuery> {
|
||||
constructor(
|
||||
@Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async execute(query: SearchPropertiesQuery): Promise<SearchResult> {
|
||||
@@ -46,12 +48,36 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
|
||||
filters.push(`city:=${query.city}`);
|
||||
}
|
||||
|
||||
return this.searchRepo.search({
|
||||
const searchParams = {
|
||||
query: query.query,
|
||||
filterBy: filters.join(' && '),
|
||||
sortBy: query.sortBy,
|
||||
page: query.page,
|
||||
perPage: query.perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const cacheKey = CacheService.buildKey(
|
||||
CachePrefix.SEARCH,
|
||||
query.query ?? '*',
|
||||
query.propertyType,
|
||||
query.transactionType,
|
||||
query.district,
|
||||
query.city,
|
||||
query.page,
|
||||
query.perPage,
|
||||
query.priceMin,
|
||||
query.priceMax,
|
||||
query.areaMin,
|
||||
query.areaMax,
|
||||
query.bedrooms,
|
||||
query.sortBy,
|
||||
);
|
||||
|
||||
return this.cache.getOrSet(
|
||||
cacheKey,
|
||||
() => this.searchRepo.search(searchParams),
|
||||
CacheTTL.SEARCH_RESULTS,
|
||||
'search',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user