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:
Ho Ngoc Hai
2026-04-08 04:14:06 +07:00
parent 09034a5f9b
commit 2a392525a2
23 changed files with 472 additions and 60 deletions

View File

@@ -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 () => {

View File

@@ -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',
);
}
}