diff --git a/src/caching/analytics/cache-analytics.service.ts b/src/caching/analytics/cache-analytics.service.ts index e69de29b..5281c3f0 100644 --- a/src/caching/analytics/cache-analytics.service.ts +++ b/src/caching/analytics/cache-analytics.service.ts @@ -0,0 +1,307 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { CachingService } from '../caching.service'; + +export interface CacheMetric { + key: string; + pattern: string; + hits: number; + misses: number; + avgResponseTime: number; + totalRequests: number; + lastAccess: Date; +} + +export interface CacheAnalyticsSummary { + totalHits: number; + totalMisses: number; + hitRate: number; + missRate: number; + totalKeys: number; + memoryUsage: string; + topKeys: CacheMetric[]; + patternStats: Map; +} + +@Injectable() +export class CacheAnalyticsService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(CacheAnalyticsService.name); + private metrics: Map = new Map(); + private flushInterval?: NodeJS.Timeout; + private readonly flushIntervalMs = 60000; // Flush every minute + + constructor(private readonly cachingService: CachingService) {} + + onModuleInit(): void { + // Start periodic metrics aggregation + this.startMetricsFlush(); + this.logger.log('Cache analytics service initialized'); + } + + onModuleDestroy(): void { + if (this.flushInterval) { + clearInterval(this.flushInterval); + } + this.metrics.clear(); + } + + /** + * Record a cache hit + */ + recordHit(key: string, responseTime?: number): void { + const pattern = this.extractPattern(key); + const metric = this.getOrCreateMetric(key, pattern); + + metric.hits++; + metric.totalRequests++; + metric.lastAccess = new Date(); + + if (responseTime !== undefined) { + this.updateAvgResponseTime(metric, responseTime); + } + } + + /** + * Record a cache miss + */ + recordMiss(key: string, responseTime?: number): void { + const pattern = this.extractPattern(key); + const metric = this.getOrCreateMetric(key, pattern); + + metric.misses++; + metric.totalRequests++; + metric.lastAccess = new Date(); + + if (responseTime !== undefined) { + this.updateAvgResponseTime(metric, responseTime); + } + } + + /** + * Get metrics for a specific key + */ + getMetric(key: string): CacheMetric | undefined { + return this.metrics.get(key); + } + + /** + * Get all metrics + */ + getAllMetrics(): CacheMetric[] { + return Array.from(this.metrics.values()); + } + + /** + * Get metrics by pattern + */ + getMetricsByPattern(pattern: string): CacheMetric[] { + return this.getAllMetrics().filter((m) => m.pattern === pattern); + } + + /** + * Get analytics summary + */ + async getSummary(): Promise { + const allMetrics = this.getAllMetrics(); + const stats = await this.cachingService.getStats(); + + let totalHits = 0; + let totalMisses = 0; + const patternStats = new Map(); + + for (const metric of allMetrics) { + totalHits += metric.hits; + totalMisses += metric.misses; + + const patternStat = patternStats.get(metric.pattern) || { hits: 0, misses: 0 }; + patternStat.hits += metric.hits; + patternStat.misses += metric.misses; + patternStats.set(metric.pattern, patternStat); + } + + const totalRequests = totalHits + totalMisses; + const hitRate = totalRequests > 0 ? (totalHits / totalRequests) * 100 : 0; + const missRate = totalRequests > 0 ? (totalMisses / totalRequests) * 100 : 0; + + // Get top 10 keys by total requests + const topKeys = allMetrics.sort((a, b) => b.totalRequests - a.totalRequests).slice(0, 10); + + return { + totalHits, + totalMisses, + hitRate: Math.round(hitRate * 100) / 100, + missRate: Math.round(missRate * 100) / 100, + totalKeys: stats.keys, + memoryUsage: stats.memory, + topKeys, + patternStats, + }; + } + + /** + * Get hit rate for a specific pattern + */ + getPatternHitRate(pattern: string): number { + const metrics = this.getMetricsByPattern(pattern); + if (metrics.length === 0) return 0; + + let hits = 0; + let total = 0; + + for (const metric of metrics) { + hits += metric.hits; + total += metric.totalRequests; + } + + return total > 0 ? (hits / total) * 100 : 0; + } + + /** + * Reset all metrics + */ + resetMetrics(): void { + this.metrics.clear(); + this.logger.log('Cache metrics reset'); + } + + /** + * Reset metrics for a specific pattern + */ + resetPatternMetrics(pattern: string): void { + for (const [key, metric] of this.metrics.entries()) { + if (metric.pattern === pattern) { + this.metrics.delete(key); + } + } + this.logger.log(`Reset metrics for pattern: ${pattern}`); + } + + /** + * Export metrics for external monitoring + */ + exportMetrics(): Record { + const summary: Record = { + timestamp: new Date().toISOString(), + metrics: {}, + }; + + for (const [key, metric] of this.metrics.entries()) { + summary.metrics[key] = { + hits: metric.hits, + misses: metric.misses, + hitRate: metric.totalRequests > 0 ? (metric.hits / metric.totalRequests) * 100 : 0, + missRate: metric.totalRequests > 0 ? (metric.misses / metric.totalRequests) * 100 : 0, + avgResponseTime: metric.avgResponseTime, + }; + } + + return summary; + } + + /** + * Get or create a metric entry + */ + private getOrCreateMetric(key: string, pattern: string): CacheMetric { + let metric = this.metrics.get(key); + + if (!metric) { + metric = { + key, + pattern, + hits: 0, + misses: 0, + avgResponseTime: 0, + totalRequests: 0, + lastAccess: new Date(), + }; + this.metrics.set(key, metric); + } + + return metric; + } + + /** + * Extract pattern from key + * e.g., 'cache:course:123' -> 'cache:course:*' + */ + private extractPattern(key: string): string { + const parts = key.split(':'); + if (parts.length <= 2) { + return key; + } + + // Keep prefix and first segment, replace rest with * + return `${parts[0]}:${parts[1]}:*`; + } + + /** + * Update average response time using exponential moving average + */ + private updateAvgResponseTime(metric: CacheMetric, responseTime: number): void { + const alpha = 0.2; // Smoothing factor + metric.avgResponseTime = alpha * responseTime + (1 - alpha) * metric.avgResponseTime; + } + + /** + * Start periodic metrics flush to prevent memory bloat + */ + private startMetricsFlush(): void { + this.flushInterval = setInterval(() => { + this.flushOldMetrics(); + }, this.flushIntervalMs); + } + + /** + * Remove old metrics that haven't been accessed recently + */ + private flushOldMetrics(): void { + const maxAge = 24 * 60 * 60 * 1000; // 24 hours + const now = Date.now(); + let flushed = 0; + + for (const [key, metric] of this.metrics.entries()) { + const age = now - metric.lastAccess.getTime(); + if (age > maxAge) { + this.metrics.delete(key); + flushed++; + } + } + + if (flushed > 0) { + this.logger.debug(`Flushed ${flushed} old metrics entries`); + } + } + + /** + * Get Prometheus-style metrics + */ + getPrometheusMetrics(): string { + const lines: string[] = []; + + lines.push('# HELP cache_hits_total Total number of cache hits'); + lines.push('# TYPE cache_hits_total counter'); + + for (const metric of this.metrics.values()) { + lines.push(`cache_hits_total{pattern="${metric.pattern}"} ${metric.hits}`); + } + + lines.push(''); + lines.push('# HELP cache_misses_total Total number of cache misses'); + lines.push('# TYPE cache_misses_total counter'); + + for (const metric of this.metrics.values()) { + lines.push(`cache_misses_total{pattern="${metric.pattern}"} ${metric.misses}`); + } + + lines.push(''); + lines.push('# HELP cache_avg_response_time_ms Average response time in ms'); + lines.push('# TYPE cache_avg_response_time_ms gauge'); + + for (const metric of this.metrics.values()) { + lines.push( + `cache_avg_response_time_ms{pattern="${metric.pattern}"} ${Math.round(metric.avgResponseTime)}`, + ); + } + + return lines.join('\n'); + } +} diff --git a/src/caching/cache-management.controller.spec.ts b/src/caching/cache-management.controller.spec.ts new file mode 100644 index 00000000..c40503f6 --- /dev/null +++ b/src/caching/cache-management.controller.spec.ts @@ -0,0 +1,277 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CacheManagementController } from './cache-management.controller'; +import { CachingService } from './caching.service'; +import { CacheAnalyticsService } from './analytics/cache-analytics.service'; +import { CacheInvalidationService } from './invalidation/invalidation.service'; +import { CacheWarmingService } from './warming/cache-warming.service'; +import { CacheStrategiesService } from './strategies/cache-strategies.service'; + +describe('CacheManagementController', () => { + let controller: CacheManagementController; + let cachingService: jest.Mocked; + let analyticsService: jest.Mocked; + let invalidationService: jest.Mocked; + let warmingService: jest.Mocked; + let strategiesService: jest.Mocked; + + beforeEach(async () => { + const mockCachingService = { + getStats: jest.fn(), + get: jest.fn(), + getTtl: jest.fn(), + delPattern: jest.fn(), + clearAll: jest.fn(), + getTTLConstants: jest.fn(), + }; + + const mockAnalyticsService = { + getSummary: jest.fn(), + getAllMetrics: jest.fn(), + getPrometheusMetrics: jest.fn(), + resetMetrics: jest.fn(), + resetPatternMetrics: jest.fn(), + }; + + const mockInvalidationService = { + invalidateCourse: jest.fn(), + invalidateUser: jest.fn(), + invalidateSearch: jest.fn(), + getStats: jest.fn(), + }; + + const mockWarmingService = { + getStats: jest.fn(), + getWarmedKeys: jest.fn(), + refreshAll: jest.fn(), + }; + + const mockStrategiesService = { + getAllStrategies: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [CacheManagementController], + providers: [ + { + provide: CachingService, + useValue: mockCachingService, + }, + { + provide: CacheAnalyticsService, + useValue: mockAnalyticsService, + }, + { + provide: CacheInvalidationService, + useValue: mockInvalidationService, + }, + { + provide: CacheWarmingService, + useValue: mockWarmingService, + }, + { + provide: CacheStrategiesService, + useValue: mockStrategiesService, + }, + ], + }).compile(); + + controller = module.get(CacheManagementController); + cachingService = module.get(CachingService); + analyticsService = module.get(CacheAnalyticsService); + invalidationService = module.get(CacheInvalidationService); + warmingService = module.get(CacheWarmingService); + strategiesService = module.get(CacheStrategiesService); + }); + + describe('getStats', () => { + it('should return combined stats', async () => { + cachingService.getStats.mockResolvedValue({ + keys: 100, + memory: '1.5M', + hits: 1000, + misses: 100, + }); + analyticsService.getSummary.mockResolvedValue({ + totalHits: 1000, + totalMisses: 100, + hitRate: 90.91, + missRate: 9.09, + totalKeys: 100, + memoryUsage: '1.5M', + topKeys: [], + patternStats: new Map(), + }); + warmingService.getStats.mockReturnValue({ + totalKeys: 10, + byType: { popular: 5 }, + lastWarmup: new Date(), + }); + invalidationService.getStats.mockReturnValue({ + registeredTags: 5, + totalTrackedKeys: 20, + }); + + const result = await controller.getStats(); + + expect(result).toHaveProperty('redis'); + expect(result).toHaveProperty('analytics'); + expect(result).toHaveProperty('warming'); + expect(result).toHaveProperty('invalidation'); + }); + }); + + describe('getAnalytics', () => { + it('should return analytics', async () => { + analyticsService.getSummary.mockResolvedValue({ + totalHits: 100, + totalMisses: 10, + hitRate: 90.91, + missRate: 9.09, + totalKeys: 50, + memoryUsage: '1M', + topKeys: [], + patternStats: new Map([['cache:course:*', { hits: 50, misses: 5 }]]), + }); + analyticsService.getAllMetrics.mockReturnValue([]); + + const result = await controller.getAnalytics(); + + expect(result).toHaveProperty('summary'); + expect(result).toHaveProperty('metrics'); + expect(result).toHaveProperty('patternStats'); + }); + }); + + describe('getPrometheusMetrics', () => { + it('should return prometheus metrics', () => { + analyticsService.getPrometheusMetrics.mockReturnValue('# HELP cache_hits_total'); + + const result = controller.getPrometheusMetrics(); + + expect(result).toContain('cache_hits_total'); + }); + }); + + describe('getStrategies', () => { + it('should return strategies', () => { + strategiesService.getAllStrategies.mockReturnValue([]); + cachingService.getTTLConstants.mockReturnValue({} as any); + + const result = controller.getStrategies(); + + expect(result).toHaveProperty('strategies'); + expect(result).toHaveProperty('ttlConstants'); + }); + }); + + describe('getWarmedKeys', () => { + it('should return warmed keys', () => { + warmingService.getStats.mockReturnValue({ + totalKeys: 5, + byType: {}, + lastWarmup: new Date(), + }); + warmingService.getWarmedKeys.mockReturnValue([]); + + const result = controller.getWarmedKeys(); + + expect(result).toHaveProperty('stats'); + expect(result).toHaveProperty('keys'); + }); + }); + + describe('getKey', () => { + it('should return key value', async () => { + cachingService.get.mockResolvedValue({ data: 'value' }); + cachingService.getTtl.mockResolvedValue(120); + + const result = await controller.getKey('test-key'); + + expect(result.key).toBe('test-key'); + expect(result.value).toEqual({ data: 'value' }); + expect(result.exists).toBe(true); + }); + }); + + describe('clearAll', () => { + it('should clear all cache', async () => { + cachingService.clearAll.mockResolvedValue(100); + + await controller.clearAll(); + + expect(cachingService.clearAll).toHaveBeenCalled(); + }); + }); + + describe('clearByPattern', () => { + it('should clear by pattern', async () => { + cachingService.delPattern.mockResolvedValue(5); + + await controller.clearByPattern('course:*'); + + expect(cachingService.delPattern).toHaveBeenCalledWith('cache:course:*'); + }); + }); + + describe('invalidateCourse', () => { + it('should invalidate course cache', async () => { + invalidationService.invalidateCourse.mockResolvedValue([]); + + await controller.invalidateCourse('course-123'); + + expect(invalidationService.invalidateCourse).toHaveBeenCalledWith('course-123'); + }); + }); + + describe('invalidateUser', () => { + it('should invalidate user cache', async () => { + invalidationService.invalidateUser.mockResolvedValue([]); + + await controller.invalidateUser('user-123'); + + expect(invalidationService.invalidateUser).toHaveBeenCalledWith('user-123'); + }); + }); + + describe('invalidateSearch', () => { + it('should invalidate search cache', async () => { + invalidationService.invalidateSearch.mockResolvedValue([]); + + await controller.invalidateSearch(); + + expect(invalidationService.invalidateSearch).toHaveBeenCalled(); + }); + }); + + describe('warmCache', () => { + it('should trigger cache warming', async () => { + warmingService.refreshAll.mockResolvedValue(undefined); + warmingService.getStats.mockReturnValue({ + totalKeys: 10, + byType: {}, + lastWarmup: new Date(), + }); + + const result = await controller.warmCache(); + + expect(result).toHaveProperty('message'); + expect(result).toHaveProperty('stats'); + }); + }); + + describe('resetAnalytics', () => { + it('should reset all analytics', async () => { + const result = await controller.resetAnalytics(); + + expect(analyticsService.resetMetrics).toHaveBeenCalled(); + expect(result.message).toContain('All analytics reset'); + }); + + it('should reset pattern analytics', async () => { + const result = await controller.resetAnalytics('course:*'); + + expect(analyticsService.resetPatternMetrics).toHaveBeenCalledWith('course:*'); + expect(result.message).toContain('course:*'); + }); + }); +}); diff --git a/src/caching/cache-management.controller.ts b/src/caching/cache-management.controller.ts index e69de29b..b51d13b8 100644 --- a/src/caching/cache-management.controller.ts +++ b/src/caching/cache-management.controller.ts @@ -0,0 +1,240 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + Query, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; +import { CachingService } from './caching.service'; +import { CacheAnalyticsService } from './analytics/cache-analytics.service'; +import { CacheInvalidationService } from './invalidation/invalidation.service'; +import { CacheWarmingService } from './warming/cache-warming.service'; +import { CacheStrategiesService } from './strategies/cache-strategies.service'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +// import { Role } from '../common/decorators/roles.decorator'; + +@ApiTags('Cache Management') +@ApiBearerAuth() +@Controller('cache') +@UseGuards(RolesGuard) +@Roles('admin') +export class CacheManagementController { + constructor( + private readonly cachingService: CachingService, + private readonly analyticsService: CacheAnalyticsService, + private readonly invalidationService: CacheInvalidationService, + private readonly warmingService: CacheWarmingService, + private readonly strategiesService: CacheStrategiesService, + ) {} + + @Get('stats') + @ApiOperation({ summary: 'Get cache statistics' }) + @ApiResponse({ + status: 200, + description: 'Returns cache statistics including hit/miss rates and memory usage', + }) + async getStats() { + const [redisStats, analyticsSummary, warmingStats, invalidationStats] = await Promise.all([ + this.cachingService.getStats(), + this.analyticsService.getSummary(), + this.warmingService.getStats(), + this.invalidationService.getStats(), + ]); + + return { + redis: redisStats, + analytics: { + totalHits: analyticsSummary.totalHits, + totalMisses: analyticsSummary.totalMisses, + hitRate: `${analyticsSummary.hitRate}%`, + missRate: `${analyticsSummary.missRate}%`, + totalKeys: analyticsSummary.totalKeys, + memoryUsage: analyticsSummary.memoryUsage, + topKeys: analyticsSummary.topKeys, + }, + warming: warmingStats, + invalidation: invalidationStats, + }; + } + + @Get('analytics') + @ApiOperation({ summary: 'Get detailed cache analytics' }) + @ApiResponse({ + status: 200, + description: 'Returns detailed cache analytics including metrics per key', + }) + async getAnalytics() { + const summary = await this.analyticsService.getSummary(); + const allMetrics = this.analyticsService.getAllMetrics(); + + return { + summary: { + totalHits: summary.totalHits, + totalMisses: summary.totalMisses, + hitRate: summary.hitRate, + missRate: summary.missRate, + }, + metrics: allMetrics, + patternStats: Object.fromEntries(summary.patternStats), + }; + } + + @Get('metrics/prometheus') + @ApiOperation({ summary: 'Get Prometheus-compatible metrics' }) + @ApiResponse({ + status: 200, + description: 'Returns metrics in Prometheus format', + }) + getPrometheusMetrics() { + return this.analyticsService.getPrometheusMetrics(); + } + + @Get('strategies') + @ApiOperation({ summary: 'Get all cache strategies' }) + @ApiResponse({ + status: 200, + description: 'Returns all registered cache strategies', + }) + getStrategies() { + return { + strategies: this.strategiesService.getAllStrategies(), + ttlConstants: this.cachingService.getTTLConstants(), + }; + } + + @Get('warmed') + @ApiOperation({ summary: 'Get warmed cache keys' }) + @ApiResponse({ + status: 200, + description: 'Returns all keys that have been warmed', + }) + getWarmedKeys() { + return { + stats: this.warmingService.getStats(), + keys: this.warmingService.getWarmedKeys(), + }; + } + + @Get('key/:key') + @ApiOperation({ summary: 'Get a cached value by key' }) + @ApiParam({ name: 'key', description: 'Cache key to retrieve' }) + @ApiResponse({ + status: 200, + description: 'Returns the cached value', + }) + @ApiResponse({ + status: 404, + description: 'Key not found in cache', + }) + async getKey(@Param('key') key: string) { + const value = await this.cachingService.get(key); + const ttl = await this.cachingService.getTtl(key); + + return { + key, + value, + ttl, + exists: value !== null, + }; + } + + @Delete('clear') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Clear all cache' }) + @ApiResponse({ + status: 204, + description: 'All cache cleared successfully', + }) + async clearAll() { + await this.cachingService.clearAll(); + } + + @Delete('clear/:pattern') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Clear cache by pattern' }) + @ApiParam({ name: 'pattern', description: 'Pattern to match (use * as wildcard)' }) + @ApiResponse({ + status: 204, + description: 'Cache entries matching pattern cleared successfully', + }) + async clearByPattern(@Param('pattern') pattern: string) { + // Decode URL-encoded pattern + const decodedPattern = decodeURIComponent(pattern); + await this.cachingService.delPattern(`cache:${decodedPattern}`); + } + + @Delete('invalidate/course/:courseId') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Invalidate all cache for a specific course' }) + @ApiParam({ name: 'courseId', description: 'Course ID to invalidate cache for' }) + @ApiResponse({ + status: 204, + description: 'Course cache invalidated successfully', + }) + async invalidateCourse(@Param('courseId') courseId: string) { + await this.invalidationService.invalidateCourse(courseId); + } + + @Delete('invalidate/user/:userId') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Invalidate all cache for a specific user' }) + @ApiParam({ name: 'userId', description: 'User ID to invalidate cache for' }) + @ApiResponse({ + status: 204, + description: 'User cache invalidated successfully', + }) + async invalidateUser(@Param('userId') userId: string) { + await this.invalidationService.invalidateUser(userId); + } + + @Delete('invalidate/search') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Invalidate all search cache' }) + @ApiResponse({ + status: 204, + description: 'Search cache invalidated successfully', + }) + async invalidateSearch() { + await this.invalidationService.invalidateSearch(); + } + + @Post('warm') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Manually trigger cache warming' }) + @ApiResponse({ + status: 200, + description: 'Cache warming triggered successfully', + }) + async warmCache() { + await this.warmingService.refreshAll(); + return { + message: 'Cache warming completed', + stats: this.warmingService.getStats(), + }; + } + + @Post('analytics/reset') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Reset analytics metrics' }) + @ApiResponse({ + status: 200, + description: 'Analytics metrics reset successfully', + }) + async resetAnalytics(@Query('pattern') pattern?: string) { + if (pattern) { + this.analyticsService.resetPatternMetrics(pattern); + } else { + this.analyticsService.resetMetrics(); + } + + return { + message: pattern ? `Analytics reset for pattern: ${pattern}` : 'All analytics reset', + }; + } +} diff --git a/src/caching/caching.constants.ts b/src/caching/caching.constants.ts new file mode 100644 index 00000000..b2ac0da5 --- /dev/null +++ b/src/caching/caching.constants.ts @@ -0,0 +1,33 @@ +export const CACHE_REDIS_CLIENT = 'CACHE_REDIS_CLIENT'; + +export const CACHE_TTL = { + USER_SESSION: 604800, // 7 days + COURSE_METADATA: 900, // 15 minutes + COURSE_DETAILS: 300, // 5 minutes + SEARCH_RESULTS: 120, // 2 minutes + USER_PROFILE: 600, // 10 minutes + STATIC_CONTENT: 3600, // 1 hour + POPULAR_COURSES: 1800, // 30 minutes + ENROLLMENT_DATA: 300, // 5 minutes +} as const; + +export const CACHE_PREFIXES = { + COURSE: 'cache:course', + COURSES_LIST: 'cache:courses:list', + USER: 'cache:user', + USER_PROFILE: 'cache:user:profile', + SEARCH: 'cache:search', + POPULAR: 'cache:popular', + ENROLLMENT: 'cache:enrollment', + FEATURED: 'cache:featured', +} as const; + +export const CACHE_EVENTS = { + COURSE_UPDATED: 'cache.course.updated', + COURSE_DELETED: 'cache.course.deleted', + USER_UPDATED: 'cache.user.updated', + USER_DELETED: 'cache.user.deleted', + ENROLLMENT_CREATED: 'cache.enrollment.created', + ENROLLMENT_UPDATED: 'cache.enrollment.updated', + SEARCH_INDEX_UPDATED: 'cache.search.updated', +} as const; diff --git a/src/caching/caching.module.ts b/src/caching/caching.module.ts index 3026c9f4..304054ca 100644 --- a/src/caching/caching.module.ts +++ b/src/caching/caching.module.ts @@ -1,12 +1,60 @@ -import { Module } from '@nestjs/common'; -import { CacheModule } from '@nestjs/cache-manager'; +import { Global, Module, OnModuleDestroy } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import Redis from 'ioredis'; +import { CACHE_REDIS_CLIENT } from './caching.constants'; +import { CachingService } from './caching.service'; +import { CacheStrategiesService } from './strategies/cache-strategies.service'; +import { CacheInvalidationService } from './invalidation/invalidation.service'; +import { CacheWarmingService } from './warming/cache-warming.service'; +import { CacheAnalyticsService } from './analytics/cache-analytics.service'; +import { CacheManagementController } from './cache-management.controller'; +@Global() @Module({ - imports: [ - CacheModule.register({ - isGlobal: true, - }), + imports: [ConfigModule, EventEmitterModule], + controllers: [CacheManagementController], + providers: [ + // Redis client provider + { + provide: CACHE_REDIS_CLIENT, + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const client = new Redis({ + host: configService.get('REDIS_HOST') || 'localhost', + port: parseInt(configService.get('REDIS_PORT') || '6379', 10), + lazyConnect: false, + maxRetriesPerRequest: null, + enableReadyCheck: true, + }); + + client.on('error', () => { + // Prevent unhandled error events when Redis is temporarily unavailable. + }); + + return client; + }, + }, + // Cache services + CachingService, + CacheStrategiesService, + CacheInvalidationService, + CacheWarmingService, + CacheAnalyticsService, + ], + exports: [ + CACHE_REDIS_CLIENT, + CachingService, + CacheStrategiesService, + CacheInvalidationService, + CacheWarmingService, + CacheAnalyticsService, ], - exports: [CacheModule], }) -export class CachingModule {} +export class CachingModule implements OnModuleDestroy { + constructor(private readonly cachingService: CachingService) {} + + onModuleDestroy() { + // Cleanup is handled by CachingService + } +} diff --git a/src/caching/caching.service.spec.ts b/src/caching/caching.service.spec.ts new file mode 100644 index 00000000..9db39b6a --- /dev/null +++ b/src/caching/caching.service.spec.ts @@ -0,0 +1,276 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { CachingService } from './caching.service'; +import { CACHE_REDIS_CLIENT } from './caching.constants'; +import Redis from 'ioredis'; + +describe('CachingService', () => { + let service: CachingService; + let mockRedis: jest.Mocked; + + beforeEach(async () => { + mockRedis = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + scan: jest.fn(), + pipeline: jest.fn(), + mget: jest.fn(), + ttl: jest.fn(), + expire: jest.fn(), + exists: jest.fn(), + incr: jest.fn(), + incrby: jest.fn(), + info: jest.fn(), + eval: jest.fn(), + status: 'ready', + quit: jest.fn(), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CachingService, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue('300'), + }, + }, + { + provide: CACHE_REDIS_CLIENT, + useValue: mockRedis, + }, + ], + }).compile(); + + service = module.get(CachingService); + }); + + describe('get', () => { + it('should return cached value', async () => { + mockRedis.get.mockResolvedValue(JSON.stringify({ id: '1', name: 'Test' })); + + const result = await service.get('test:key'); + + expect(result).toEqual({ id: '1', name: 'Test' }); + expect(mockRedis.get).toHaveBeenCalledWith('test:key'); + }); + + it('should return null when key not found', async () => { + mockRedis.get.mockResolvedValue(null); + + const result = await service.get('test:key'); + + expect(result).toBeNull(); + }); + + it('should return raw string for non-JSON values', async () => { + mockRedis.get.mockResolvedValue('raw-string-value'); + + const result = await service.get('test:key'); + + expect(result).toBe('raw-string-value'); + }); + }); + + describe('set', () => { + it('should set value with TTL', async () => { + await service.set('test:key', { data: 'value' }, 60); + + expect(mockRedis.set).toHaveBeenCalledWith( + 'test:key', + JSON.stringify({ data: 'value' }), + 'EX', + 60, + ); + }); + + it('should set value without TTL when ttl is 0', async () => { + await service.set('test:key', { data: 'value' }, 0); + + expect(mockRedis.set).toHaveBeenCalledWith('test:key', JSON.stringify({ data: 'value' })); + }); + + it('should set string value directly', async () => { + await service.set('test:key', 'string-value'); + + expect(mockRedis.set).toHaveBeenCalledWith('test:key', 'string-value', 'EX', 300); + }); + }); + + describe('del', () => { + it('should delete key', async () => { + await service.del('test:key'); + + expect(mockRedis.del).toHaveBeenCalledWith('test:key'); + }); + }); + + describe('delPattern', () => { + it('should delete keys matching pattern', async () => { + mockRedis.scan.mockResolvedValueOnce(['0', ['key1', 'key2']]); + + const result = await service.delPattern('test:*'); + + expect(result).toBe(2); + expect(mockRedis.del).toHaveBeenCalledWith('key1', 'key2'); + }); + + it('should return 0 when no keys match', async () => { + mockRedis.scan.mockResolvedValueOnce(['0', []]); + + const result = await service.delPattern('test:*'); + + expect(result).toBe(0); + }); + }); + + describe('getOrSet', () => { + it('should return cached value when available', async () => { + mockRedis.get.mockResolvedValue(JSON.stringify({ cached: true })); + + const factory = jest.fn(); + const result = await service.getOrSet('test:key', factory, 60); + + expect(result).toEqual({ cached: true }); + expect(factory).not.toHaveBeenCalled(); + }); + + it('should call factory and cache result when not available', async () => { + mockRedis.get.mockResolvedValue(null); + const factory = jest.fn().mockResolvedValue({ new: 'data' }); + + const result = await service.getOrSet('test:key', factory, 60); + + expect(result).toEqual({ new: 'data' }); + expect(factory).toHaveBeenCalled(); + expect(mockRedis.set).toHaveBeenCalled(); + }); + }); + + describe('exists', () => { + it('should return true when key exists', async () => { + mockRedis.exists.mockResolvedValue(1); + + const result = await service.exists('test:key'); + + expect(result).toBe(true); + }); + + it('should return false when key does not exist', async () => { + mockRedis.exists.mockResolvedValue(0); + + const result = await service.exists('test:key'); + + expect(result).toBe(false); + }); + }); + + describe('getTtl', () => { + it('should return TTL of key', async () => { + mockRedis.ttl.mockResolvedValue(120); + + const result = await service.getTtl('test:key'); + + expect(result).toBe(120); + }); + }); + + describe('incr', () => { + it('should increment by 1 by default', async () => { + mockRedis.incr.mockResolvedValue(1); + + const result = await service.incr('counter'); + + expect(result).toBe(1); + expect(mockRedis.incr).toHaveBeenCalledWith('counter'); + }); + + it('should increment by specified amount', async () => { + mockRedis.incrby.mockResolvedValue(5); + + const result = await service.incr('counter', 5); + + expect(result).toBe(5); + expect(mockRedis.incrby).toHaveBeenCalledWith('counter', 5); + }); + }); + + describe('mget', () => { + it('should get multiple values', async () => { + mockRedis.mget.mockResolvedValue([ + JSON.stringify({ id: 1 }), + null, + JSON.stringify({ id: 3 }), + ]); + + const result = await service.mget(['key1', 'key2', 'key3']); + + expect(result).toEqual([{ id: 1 }, null, { id: 3 }]); + }); + + it('should return empty array for empty input', async () => { + const result = await service.mget([]); + + expect(result).toEqual([]); + expect(mockRedis.mget).not.toHaveBeenCalled(); + }); + }); + + describe('mset', () => { + it('should set multiple values', async () => { + const mockPipeline = { + set: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([]), + }; + mockRedis.pipeline.mockReturnValue(mockPipeline as any); + + await service.mset( + [ + { key: 'key1', value: { id: 1 } }, + { key: 'key2', value: { id: 2 } }, + ], + 60, + ); + + expect(mockRedis.pipeline).toHaveBeenCalled(); + expect(mockPipeline.exec).toHaveBeenCalled(); + }); + }); + + describe('getStats', () => { + it('should return cache statistics', async () => { + mockRedis.info + .mockResolvedValueOnce('used_memory_human:1.5M\n') + .mockResolvedValueOnce('db0:keys=100\n') + .mockResolvedValueOnce('keyspace_hits:1000\nkeyspace_misses:100\n'); + + const result = await service.getStats(); + + expect(result).toEqual({ + keys: 100, + memory: '1.5M', + hits: 1000, + misses: 100, + }); + }); + }); + + describe('generateKey', () => { + it('should generate key with prefix and parts', () => { + const result = service.generateKey('prefix', 'part1', 'part2'); + + expect(result).toBe('prefix:part1:part2'); + }); + }); + + describe('getTTLConstants', () => { + it('should return TTL constants', () => { + const result = service.getTTLConstants(); + + expect(result).toHaveProperty('USER_SESSION'); + expect(result).toHaveProperty('COURSE_DETAILS'); + expect(result).toHaveProperty('SEARCH_RESULTS'); + }); + }); +}); diff --git a/src/caching/caching.service.ts b/src/caching/caching.service.ts index e69de29b..bc2fba34 100644 --- a/src/caching/caching.service.ts +++ b/src/caching/caching.service.ts @@ -0,0 +1,331 @@ +import { Inject, Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { CACHE_REDIS_CLIENT, CACHE_TTL } from './caching.constants'; + +export interface CacheOptions { + ttl?: number; + prefix?: string; +} + +@Injectable() +export class CachingService implements OnModuleDestroy { + private readonly logger = new Logger(CachingService.name); + private readonly defaultTtl: number; + + constructor( + @Inject(CACHE_REDIS_CLIENT) private readonly redis: Redis, + private readonly configService: ConfigService, + ) { + this.defaultTtl = parseInt(this.configService.get('REDIS_TTL') || '300', 10); + } + + async onModuleDestroy(): Promise { + if (this.redis.status !== 'end') { + await this.redis.quit(); + } + } + + /** + * Get a value from cache + * @param key - Cache key + * @returns The cached value or null if not found + */ + async get(key: string): Promise { + try { + const data = await this.redis.get(key); + if (!data) { + return null; + } + + try { + return JSON.parse(data) as T; + } catch { + // Return raw string if not valid JSON + return data as unknown as T; + } + } catch (error) { + this.logger.error(`Failed to get cache key: ${key}`, error); + return null; + } + } + + /** + * Set a value in cache + * @param key - Cache key + * @param value - Value to cache + * @param ttl - Time to live in seconds (optional) + */ + async set(key: string, value: T, ttl?: number): Promise { + try { + const serializedValue = typeof value === 'string' ? value : JSON.stringify(value); + const effectiveTtl = ttl ?? this.defaultTtl; + + if (effectiveTtl > 0) { + await this.redis.set(key, serializedValue, 'EX', effectiveTtl); + } else { + await this.redis.set(key, serializedValue); + } + } catch (error) { + this.logger.error(`Failed to set cache key: ${key}`, error); + } + } + + /** + * Delete a key from cache + * @param key - Cache key to delete + */ + async del(key: string): Promise { + try { + await this.redis.del(key); + } catch (error) { + this.logger.error(`Failed to delete cache key: ${key}`, error); + } + } + + /** + * Delete all keys matching a pattern + * @param pattern - Pattern to match (e.g., 'cache:course:*') + */ + async delPattern(pattern: string): Promise { + try { + const keys = await this.scanKeys(pattern); + if (keys.length === 0) { + return 0; + } + + await this.redis.del(...keys); + this.logger.debug(`Deleted ${keys.length} keys matching pattern: ${pattern}`); + return keys.length; + } catch (error) { + this.logger.error(`Failed to delete cache pattern: ${pattern}`, error); + return 0; + } + } + + /** + * Get a value from cache, or execute factory function and cache the result + * @param key - Cache key + * @param factory - Function to execute if value not in cache + * @param ttl - Time to live in seconds (optional) + * @returns The cached or freshly computed value + */ + async getOrSet(key: string, factory: () => Promise, ttl?: number): Promise { + const cached = await this.get(key); + if (cached !== null) { + return cached; + } + + const value = await factory(); + await this.set(key, value, ttl); + return value; + } + + /** + * Check if a key exists in cache + * @param key - Cache key + * @returns True if key exists + */ + async exists(key: string): Promise { + try { + const result = await this.redis.exists(key); + return result === 1; + } catch (error) { + this.logger.error(`Failed to check cache key existence: ${key}`, error); + return false; + } + } + + /** + * Get the TTL of a key + * @param key - Cache key + * @returns TTL in seconds, -1 if no expiry, -2 if key doesn't exist + */ + async getTtl(key: string): Promise { + try { + return await this.redis.ttl(key); + } catch (error) { + this.logger.error(`Failed to get TTL for key: ${key}`, error); + return -2; + } + } + + /** + * Set the TTL of an existing key + * @param key - Cache key + * @param ttl - New TTL in seconds + */ + async expire(key: string, ttl: number): Promise { + try { + await this.redis.expire(key, ttl); + } catch (error) { + this.logger.error(`Failed to set expiry for key: ${key}`, error); + } + } + + /** + * Increment a counter + * @param key - Cache key + * @param increment - Amount to increment (default: 1) + * @returns New value after increment + */ + async incr(key: string, increment = 1): Promise { + try { + if (increment === 1) { + return await this.redis.incr(key); + } + return await this.redis.incrby(key, increment); + } catch (error) { + this.logger.error(`Failed to increment key: ${key}`, error); + return 0; + } + } + + /** + * Get multiple values at once + * @param keys - Array of cache keys + * @returns Array of values (null for missing keys) + */ + async mget(keys: string[]): Promise> { + if (keys.length === 0) { + return []; + } + + try { + const values = await this.redis.mget(...keys); + return values.map((data) => { + if (!data) return null; + try { + return JSON.parse(data) as T; + } catch { + return data as unknown as T; + } + }); + } catch (error) { + this.logger.error('Failed to get multiple cache keys', error); + return keys.map(() => null); + } + } + + /** + * Set multiple values at once + * @param entries - Array of key-value pairs + * @param ttl - Time to live in seconds (optional) + */ + async mset(entries: Array<{ key: string; value: T }>, ttl?: number): Promise { + if (entries.length === 0) { + return; + } + + try { + const pipeline = this.redis.pipeline(); + + for (const { key, value } of entries) { + const serializedValue = typeof value === 'string' ? value : JSON.stringify(value); + + if (ttl && ttl > 0) { + pipeline.set(key, serializedValue, 'EX', ttl); + } else { + pipeline.set(key, serializedValue); + } + } + + await pipeline.exec(); + } catch (error) { + this.logger.error('Failed to set multiple cache keys', error); + } + } + + /** + * Scan keys matching a pattern + * @param pattern - Pattern to match + * @param count - Approximate number of keys to return per iteration + * @returns Array of matching keys + */ + private async scanKeys(pattern: string, count = 100): Promise { + const keys: string[] = []; + let cursor = '0'; + + do { + const [nextCursor, matchedKeys] = await this.redis.scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + count, + ); + cursor = nextCursor; + keys.push(...matchedKeys); + } while (cursor !== '0'); + + return keys; + } + + /** + * Get cache statistics + * @returns Cache statistics object + */ + async getStats(): Promise<{ + keys: number; + memory: string; + hits: number; + misses: number; + }> { + try { + const info = await this.redis.info('memory'); + const keyspaceInfo = await this.redis.info('keyspace'); + + // Parse memory usage + const memoryMatch = info.match(/used_memory_human:(\S+)/); + const memory = memoryMatch ? memoryMatch[1] : 'unknown'; + + // Parse key count + const dbMatch = keyspaceInfo.match(/db\d+:keys=(\d+)/); + const keys = dbMatch ? parseInt(dbMatch[1], 10) : 0; + + // Parse hit/miss stats + const statsInfo = await this.redis.info('stats'); + const hitsMatch = statsInfo.match(/keyspace_hits:(\d+)/); + const missesMatch = statsInfo.match(/keyspace_misses:(\d+)/); + + return { + keys, + memory, + hits: hitsMatch ? parseInt(hitsMatch[1], 10) : 0, + misses: missesMatch ? parseInt(missesMatch[1], 10) : 0, + }; + } catch (error) { + this.logger.error('Failed to get cache stats', error); + return { + keys: 0, + memory: 'unknown', + hits: 0, + misses: 0, + }; + } + } + + /** + * Clear all cache keys with the application prefix + */ + async clearAll(): Promise { + return this.delPattern('cache:*'); + } + + /** + * Generate a cache key with prefix + * @param prefix - Key prefix + * @param parts - Key parts to join + * @returns Formatted cache key + */ + generateKey(prefix: string, ...parts: Array): string { + return `${prefix}:${parts.join(':')}`; + } + + /** + * Get TTL constants for external use + */ + getTTLConstants(): typeof CACHE_TTL { + return CACHE_TTL; + } +} diff --git a/src/caching/decorators/cache.decorator.ts b/src/caching/decorators/cache.decorator.ts index e69de29b..88e2b09e 100644 --- a/src/caching/decorators/cache.decorator.ts +++ b/src/caching/decorators/cache.decorator.ts @@ -0,0 +1,244 @@ +import { SetMetadata } from '@nestjs/common'; + +export const CACHE_KEY_METADATA = 'cache:key'; +export const CACHE_TTL_METADATA = 'cache:ttl'; +export const CACHE_EVICT_METADATA = 'cache:evict'; +export const CACHE_PREFIX_METADATA = 'cache:prefix'; +export const CACHE_CONDITION_METADATA = 'cache:condition'; + +/** + * Options for cacheable decorator + */ +export interface CacheableOptions { + /** + * Time to live in seconds + */ + ttl?: number; + + /** + * Cache key prefix + */ + prefix?: string; + + /** + * Custom cache key generator + * If provided, this function will be used to generate the cache key + */ + keyGenerator?: (...args: any[]) => string; + + /** + * Condition to determine if result should be cached + * Return true to cache, false to skip caching + */ + condition?: (...args: any[]) => boolean; +} + +/** + * Options for cache evict decorator + */ +export interface CacheEvictOptions { + /** + * Pattern(s) to evict (supports wildcards) + */ + patterns: string | string[]; + + /** + * Whether to evict before method execution + * Default: false (evict after successful execution) + */ + beforeInvocation?: boolean; +} + +/** + * Decorator to cache method result + * + * @param ttl - Time to live in seconds + * @param prefix - Optional key prefix + * + * @example + * ```typescript + * @Cacheable(300) // 5 minute TTL + * async findOne(id: string) { + * return this.repository.findOne(id); + * } + * + * @Cacheable({ ttl: 600, prefix: 'users' }) + * async getUserProfile(id: string) { + * return this.userRepository.findOne(id); + * } + * ``` + */ +export function Cacheable(ttlOrOptions?: number | CacheableOptions): MethodDecorator { + const options: CacheableOptions = + typeof ttlOrOptions === 'number' ? { ttl: ttlOrOptions } : (ttlOrOptions ?? {}); + + return ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ) => { + if (options.ttl) { + SetMetadata(CACHE_TTL_METADATA, options.ttl)(target, propertyKey, descriptor); + } + + if (options.prefix) { + SetMetadata(CACHE_PREFIX_METADATA, options.prefix)(target, propertyKey, descriptor); + } + + if (options.keyGenerator) { + SetMetadata(CACHE_KEY_METADATA, options.keyGenerator)(target, propertyKey, descriptor); + } + + if (options.condition) { + SetMetadata(CACHE_CONDITION_METADATA, options.condition)(target, propertyKey, descriptor); + } + + return descriptor; + }; +} + +/** + * Decorator to evict cache entries when method is executed + * + * @param patterns - Pattern(s) to evict (supports wildcards like 'cache:course:*') + * + * @example + * ```typescript + * @CacheEvict('cache:course:*') + * async update(id: string, dto: UpdateCourseDto) { + * return this.repository.update(id, dto); + * } + * + * @CacheEvict({ patterns: ['cache:user:*', 'cache:profile:*'], beforeInvocation: true }) + * async deleteUser(id: string) { + * await this.repository.delete(id); + * } + * ``` + */ +export function CacheEvict( + patternsOrOptions: string | string[] | CacheEvictOptions, +): MethodDecorator { + const options: CacheEvictOptions = + typeof patternsOrOptions === 'string' + ? { patterns: [patternsOrOptions] } + : Array.isArray(patternsOrOptions) + ? { patterns: patternsOrOptions } + : patternsOrOptions; + + return ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ) => { + SetMetadata(CACHE_EVICT_METADATA, options)(target, propertyKey, descriptor); + return descriptor; + }; +} + +/** + * Decorator to set a custom cache key for a method + * + * @param key - Custom cache key or key generator function + * + * @example + * ```typescript + * @CacheKey('featured-courses') + * @Cacheable(3600) + * async getFeaturedCourses() { + * return this.repository.findFeatured(); + * } + * + * @CacheKey((args) => `user:${args[0]}:profile`) + * @Cacheable(600) + * async getUserProfile(userId: string) { + * return this.userRepository.findOne(userId); + * } + * ``` + */ +export function CacheKey(key: string | ((...args: any[]) => string)): MethodDecorator { + return ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ) => { + SetMetadata(CACHE_KEY_METADATA, key)(target, propertyKey, descriptor); + return descriptor; + }; +} + +/** + * Decorator to set TTL for a cached method + * + * @param ttl - Time to live in seconds + * + * @example + * ```typescript + * @CacheTTL(3600) // 1 hour + * @Cacheable() + * async getStaticContent() { + * return this.contentRepository.findStatic(); + * } + * ``` + */ +export function CacheTTL(ttl: number): MethodDecorator { + return ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ) => { + SetMetadata(CACHE_TTL_METADATA, ttl)(target, propertyKey, descriptor); + return descriptor; + }; +} + +/** + * Decorator to set cache prefix for a method + * + * @param prefix - Cache key prefix + * + * @example + * ```typescript + * @CachePrefix('courses') + * @Cacheable(300) + * async getCourseById(id: string) { + * return this.courseRepository.findOne(id); + * } + * ``` + */ +export function CachePrefix(prefix: string): MethodDecorator { + return ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ) => { + SetMetadata(CACHE_PREFIX_METADATA, prefix)(target, propertyKey, descriptor); + return descriptor; + }; +} + +/** + * Decorator to conditionally cache based on method arguments + * + * @param condition - Function that returns true if result should be cached + * + * @example + * ```typescript + * @CacheCondition((result) => result.status === 'published') + * @Cacheable(300) + * async getCourse(id: string) { + * return this.courseRepository.findOne(id); + * } + * ``` + */ +export function CacheCondition( + condition: (result: any, ...args: any[]) => boolean, +): MethodDecorator { + return ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ) => { + SetMetadata(CACHE_CONDITION_METADATA, condition)(target, propertyKey, descriptor); + return descriptor; + }; +} diff --git a/src/caching/interceptors/cache.interceptor.ts b/src/caching/interceptors/cache.interceptor.ts index e69de29b..a79d4cb7 100644 --- a/src/caching/interceptors/cache.interceptor.ts +++ b/src/caching/interceptors/cache.interceptor.ts @@ -0,0 +1,213 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Inject, + Optional, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Observable, of, from } from 'rxjs'; +import { mergeMap, tap } from 'rxjs/operators'; +import { CachingService } from '../caching.service'; +import { CacheAnalyticsService } from '../analytics/cache-analytics.service'; +import { + CACHE_KEY_METADATA, + CACHE_TTL_METADATA, + CACHE_EVICT_METADATA, + CACHE_PREFIX_METADATA, + CACHE_CONDITION_METADATA, + CacheEvictOptions, +} from '../decorators/cache.decorator'; +import { CACHE_TTL } from '../caching.constants'; + +export interface CacheInterceptorOptions { + /** + * Default TTL in seconds + */ + defaultTtl?: number; + + /** + * Default key prefix + */ + defaultPrefix?: string; + + /** + * Methods to cache (default: GET) + */ + methods?: string[]; + + /** + * Whether to track analytics + */ + trackAnalytics?: boolean; +} + +@Injectable() +export class CacheInterceptor implements NestInterceptor { + private readonly defaultTtl: number; + private readonly defaultPrefix: string; + private readonly cachedMethods: string[]; + private readonly trackAnalytics: boolean; + + constructor( + private readonly cachingService: CachingService, + private readonly reflector: Reflector, + @Optional() @Inject('CACHE_INTERCEPTOR_OPTIONS') options?: CacheInterceptorOptions, + @Optional() private readonly analyticsService?: CacheAnalyticsService, + ) { + this.defaultTtl = options?.defaultTtl ?? CACHE_TTL.COURSE_DETAILS; + this.defaultPrefix = options?.defaultPrefix ?? 'cache:http'; + this.cachedMethods = options?.methods ?? ['GET']; + this.trackAnalytics = options?.trackAnalytics ?? true; + } + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + + // Only cache specified HTTP methods + if (!request || !this.cachedMethods.includes(request.method)) { + return next.handle(); + } + + // Get metadata from decorator + const handler = context.getHandler(); + const ttl = this.reflector.get(CACHE_TTL_METADATA, handler) ?? this.defaultTtl; + const prefix = this.reflector.get(CACHE_PREFIX_METADATA, handler) ?? this.defaultPrefix; + const customKey = this.reflector.get string)>( + CACHE_KEY_METADATA, + handler, + ); + const evictOptions = this.reflector.get(CACHE_EVICT_METADATA, handler); + const condition = this.reflector.get<(...args: any[]) => boolean>( + CACHE_CONDITION_METADATA, + handler, + ); + + // Generate cache key + const cacheKey = this.generateCacheKey(request, prefix, customKey); + + // Handle cache eviction + if (evictOptions) { + return this.handleEviction(next, evictOptions, cacheKey); + } + + // Check condition + if (condition && !condition(request.params, request.query, request.body)) { + return next.handle(); + } + + // Try to get from cache + return from(this.cachingService.get(cacheKey)).pipe( + mergeMap((cachedResponse) => { + if (cachedResponse !== null) { + // Cache hit + if (this.trackAnalytics && this.analyticsService) { + this.analyticsService.recordHit(cacheKey); + } + return of(cachedResponse); + } + + // Cache miss - execute handler and cache result + return next.handle().pipe( + tap({ + next: (response) => { + // Cache the response + if (this.trackAnalytics && this.analyticsService) { + this.analyticsService.recordMiss(cacheKey); + } + from(this.cachingService.set(cacheKey, response, ttl)).subscribe(); + }, + }), + ); + }), + ); + } + + /** + * Handle cache eviction before or after method execution + */ + private handleEviction( + next: CallHandler, + evictOptions: CacheEvictOptions, + _currentKey: string, + ): Observable { + const patterns = Array.isArray(evictOptions.patterns) + ? evictOptions.patterns + : [evictOptions.patterns]; + + if (evictOptions.beforeInvocation) { + // Evict before method execution + return from(this.evictPatterns(patterns)).pipe(mergeMap(() => next.handle())); + } + + // Evict after successful method execution + return next.handle().pipe( + tap({ + next: () => { + from(this.evictPatterns(patterns)).subscribe(); + }, + }), + ); + } + + /** + * Evict cache entries matching patterns + */ + private async evictPatterns(patterns: string[]): Promise { + for (const pattern of patterns) { + await this.cachingService.delPattern(pattern); + } + } + + /** + * Generate a cache key from request + */ + private generateCacheKey( + request: any, + prefix: string, + customKey?: string | ((...args: any[]) => string), + ): string { + if (customKey) { + if (typeof customKey === 'function') { + return customKey(request.params, request.query, request.body); + } + return customKey; + } + + const route = request.route?.path ?? request.url; + const params = JSON.stringify(request.params ?? {}); + const query = JSON.stringify(request.query ?? {}); + const userId = request.user?.id ?? 'anonymous'; + + // Create a hash-like key from route, params, query, and user + const keyParts = [prefix, route.replace(/\//g, ':'), userId, this.hashString(params + query)]; + + return keyParts.filter(Boolean).join(':'); + } + + /** + * Simple string hash for cache key generation + */ + private hashString(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash).toString(36); + } +} + +/** + * Factory function to create a configured cache interceptor + */ +export function createCacheInterceptor( + cachingService: CachingService, + reflector: Reflector, + options?: CacheInterceptorOptions, + analyticsService?: CacheAnalyticsService, +): CacheInterceptor { + return new CacheInterceptor(cachingService, reflector, options, analyticsService); +} diff --git a/src/caching/invalidation/invalidation.service.spec.ts b/src/caching/invalidation/invalidation.service.spec.ts new file mode 100644 index 00000000..97463514 --- /dev/null +++ b/src/caching/invalidation/invalidation.service.spec.ts @@ -0,0 +1,179 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { CacheInvalidationService } from './invalidation.service'; +import { CachingService } from '../caching.service'; +import { CacheStrategiesService } from '../strategies/cache-strategies.service'; +import { CACHE_EVENTS } from '../caching.constants'; + +describe('CacheInvalidationService', () => { + let service: CacheInvalidationService; + let cachingService: jest.Mocked; + let eventEmitter: jest.Mocked; + + beforeEach(async () => { + const mockCachingService = { + delPattern: jest.fn(), + del: jest.fn(), + clearAll: jest.fn(), + }; + + const mockEventEmitter = { + emit: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CacheInvalidationService, + CacheStrategiesService, + { + provide: CachingService, + useValue: mockCachingService, + }, + { + provide: EventEmitter2, + useValue: mockEventEmitter, + }, + ], + }).compile(); + + service = module.get(CacheInvalidationService); + cachingService = module.get(CachingService); + eventEmitter = module.get(EventEmitter2); + }); + + describe('invalidateByPattern', () => { + it('should invalidate by pattern', async () => { + cachingService.delPattern.mockResolvedValue(5); + + const result = await service.invalidateByPattern('cache:course:*'); + + expect(result.pattern).toBe('cache:course:*'); + expect(result.keysDeleted).toBe(5); + expect(cachingService.delPattern).toHaveBeenCalledWith('cache:course:*'); + }); + }); + + describe('invalidateByPatterns', () => { + it('should invalidate multiple patterns', async () => { + cachingService.delPattern.mockResolvedValue(3); + + const results = await service.invalidateByPatterns(['cache:course:*', 'cache:user:*']); + + expect(results).toHaveLength(2); + expect(cachingService.delPattern).toHaveBeenCalledTimes(2); + }); + }); + + describe('invalidateByTag', () => { + it('should invalidate by tag', async () => { + service.registerKeyWithTag('courses', 'cache:course:1'); + service.registerKeyWithTag('courses', 'cache:course:2'); + + const result = await service.invalidateByTag('courses'); + + expect(result).toBe(2); + expect(cachingService.del).toHaveBeenCalledTimes(2); + }); + + it('should return 0 for non-existent tag', async () => { + const result = await service.invalidateByTag('nonexistent'); + + expect(result).toBe(0); + }); + }); + + describe('registerKeyWithTag', () => { + it('should register key with tag', () => { + service.registerKeyWithTag('courses', 'cache:course:1'); + + const stats = service.getStats(); + expect(stats.registeredTags).toBe(1); + expect(stats.totalTrackedKeys).toBe(1); + }); + }); + + describe('unregisterKeyFromTag', () => { + it('should unregister key from tag', () => { + service.registerKeyWithTag('courses', 'cache:course:1'); + service.unregisterKeyFromTag('courses', 'cache:course:1'); + + const stats = service.getStats(); + expect(stats.totalTrackedKeys).toBe(0); + }); + }); + + describe('invalidateCourse', () => { + it('should invalidate course cache', async () => { + cachingService.delPattern.mockResolvedValue(5); + + const results = await service.invalidateCourse('course-123'); + + expect(results.length).toBeGreaterThan(0); + expect(eventEmitter.emit).toHaveBeenCalledWith(CACHE_EVENTS.COURSE_UPDATED, { + courseId: 'course-123', + }); + }); + }); + + describe('invalidateUser', () => { + it('should invalidate user cache', async () => { + cachingService.delPattern.mockResolvedValue(3); + + const results = await service.invalidateUser('user-123'); + + expect(results.length).toBeGreaterThan(0); + expect(eventEmitter.emit).toHaveBeenCalledWith(CACHE_EVENTS.USER_UPDATED, { + userId: 'user-123', + }); + }); + }); + + describe('invalidateEnrollment', () => { + it('should invalidate enrollment cache', async () => { + cachingService.delPattern.mockResolvedValue(2); + + const results = await service.invalidateEnrollment('enroll-123', 'course-123'); + + expect(results.length).toBeGreaterThan(0); + expect(eventEmitter.emit).toHaveBeenCalledWith(CACHE_EVENTS.ENROLLMENT_UPDATED, { + enrollmentId: 'enroll-123', + courseId: 'course-123', + }); + }); + }); + + describe('invalidateSearch', () => { + it('should invalidate search cache', async () => { + cachingService.delPattern.mockResolvedValue(10); + + const results = await service.invalidateSearch(); + + expect(results.length).toBeGreaterThan(0); + expect(eventEmitter.emit).toHaveBeenCalledWith(CACHE_EVENTS.SEARCH_INDEX_UPDATED); + }); + }); + + describe('clearAll', () => { + it('should clear all cache', async () => { + cachingService.clearAll.mockResolvedValue(100); + + const result = await service.clearAll(); + + expect(result).toBe(100); + expect(cachingService.clearAll).toHaveBeenCalled(); + }); + }); + + describe('getStats', () => { + it('should return stats', () => { + service.registerKeyWithTag('tag1', 'key1'); + service.registerKeyWithTag('tag1', 'key2'); + service.registerKeyWithTag('tag2', 'key3'); + + const stats = service.getStats(); + + expect(stats.registeredTags).toBe(2); + expect(stats.totalTrackedKeys).toBe(3); + }); + }); +}); diff --git a/src/caching/invalidation/invalidation.service.ts b/src/caching/invalidation/invalidation.service.ts index e69de29b..9343ccae 100644 --- a/src/caching/invalidation/invalidation.service.ts +++ b/src/caching/invalidation/invalidation.service.ts @@ -0,0 +1,245 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; +import { CachingService } from '../caching.service'; +import { CacheStrategiesService } from '../strategies/cache-strategies.service'; +import { CACHE_EVENTS } from '../caching.constants'; + +export interface InvalidationResult { + pattern: string; + keysDeleted: number; +} + +@Injectable() +export class CacheInvalidationService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(CacheInvalidationService.name); + private readonly tagIndex: Map> = new Map(); + + constructor( + private readonly cachingService: CachingService, + private readonly strategiesService: CacheStrategiesService, + private readonly eventEmitter: EventEmitter2, + ) {} + + onModuleInit() { + this.logger.log('Cache invalidation service initialized'); + } + + onModuleDestroy() { + this.tagIndex.clear(); + } + + /** + * Invalidate cache by pattern + * @param pattern - Pattern to match (e.g., 'cache:course:*') + */ + async invalidateByPattern(pattern: string): Promise { + this.logger.debug(`Invalidating cache pattern: ${pattern}`); + const keysDeleted = await this.cachingService.delPattern(pattern); + + return { pattern, keysDeleted }; + } + + /** + * Invalidate cache by multiple patterns + * @param patterns - Array of patterns to invalidate + */ + async invalidateByPatterns(patterns: string[]): Promise { + const results: InvalidationResult[] = []; + + for (const pattern of patterns) { + const result = await this.invalidateByPattern(pattern); + results.push(result); + } + + return results; + } + + /** + * Invalidate cache by tag + * @param tag - Tag to invalidate + */ + async invalidateByTag(tag: string): Promise { + const keys = this.tagIndex.get(tag); + if (!keys || keys.size === 0) { + return 0; + } + + let deleted = 0; + for (const key of keys) { + await this.cachingService.del(key); + deleted++; + } + + this.tagIndex.delete(tag); + this.logger.debug(`Invalidated ${deleted} keys for tag: ${tag}`); + return deleted; + } + + /** + * Register a key with a tag for tag-based invalidation + * @param tag - Tag to associate with the key + * @param key - Cache key + */ + registerKeyWithTag(tag: string, key: string): void { + if (!this.tagIndex.has(tag)) { + this.tagIndex.set(tag, new Set()); + } + const tagSet = this.tagIndex.get(tag); + if (tagSet) { + tagSet.add(key); + } + } + + /** + * Unregister a key from a tag + * @param tag - Tag to dissociate from the key + * @param key - Cache key + */ + unregisterKeyFromTag(tag: string, key: string): void { + const keys = this.tagIndex.get(tag); + if (keys) { + keys.delete(key); + if (keys.size === 0) { + this.tagIndex.delete(tag); + } + } + } + + /** + * Invalidate all cache entries related to a course + */ + async invalidateCourse(courseId: string): Promise { + const patterns = [ + `cache:course:${courseId}:*`, + `cache:course:${courseId}`, + 'cache:courses:list:*', + 'cache:search:*', + 'cache:popular:*', + ]; + + this.eventEmitter.emit(CACHE_EVENTS.COURSE_UPDATED, { courseId }); + return this.invalidateByPatterns(patterns); + } + + /** + * Invalidate all cache entries related to a user + */ + async invalidateUser(userId: string): Promise { + const patterns = [ + `cache:user:${userId}:*`, + `cache:user:${userId}`, + `cache:user:profile:${userId}`, + ]; + + this.eventEmitter.emit(CACHE_EVENTS.USER_UPDATED, { userId }); + return this.invalidateByPatterns(patterns); + } + + /** + * Invalidate all cache entries related to an enrollment + */ + async invalidateEnrollment( + enrollmentId: string, + courseId?: string, + ): Promise { + const patterns = [`cache:enrollment:${enrollmentId}:*`, `cache:enrollment:${enrollmentId}`]; + + if (courseId) { + patterns.push(`cache:course:${courseId}:enrollments:*`); + } + + this.eventEmitter.emit(CACHE_EVENTS.ENROLLMENT_UPDATED, { enrollmentId, courseId }); + return this.invalidateByPatterns(patterns); + } + + /** + * Invalidate search cache + */ + async invalidateSearch(): Promise { + const patterns = ['cache:search:*']; + this.eventEmitter.emit(CACHE_EVENTS.SEARCH_INDEX_UPDATED); + return this.invalidateByPatterns(patterns); + } + + /** + * Clear all application cache + */ + async clearAll(): Promise { + return this.cachingService.clearAll(); + } + + // Event handlers for automatic invalidation + + @OnEvent(CACHE_EVENTS.COURSE_UPDATED) + async handleCourseUpdatedEvent(payload: { courseId: string }): Promise { + this.logger.debug(`Handling course updated event for: ${payload.courseId}`); + const patterns = this.strategiesService.getPatternsForEvent(CACHE_EVENTS.COURSE_UPDATED); + await this.invalidateByPatterns(patterns); + } + + @OnEvent(CACHE_EVENTS.COURSE_DELETED) + async handleCourseDeletedEvent(payload: { courseId: string }): Promise { + this.logger.debug(`Handling course deleted event for: ${payload.courseId}`); + const patterns = this.strategiesService.getPatternsForEvent(CACHE_EVENTS.COURSE_DELETED); + await this.invalidateByPatterns(patterns); + } + + @OnEvent(CACHE_EVENTS.USER_UPDATED) + async handleUserUpdatedEvent(payload: { userId: string }): Promise { + this.logger.debug(`Handling user updated event for: ${payload.userId}`); + const patterns = this.strategiesService.getPatternsForEvent(CACHE_EVENTS.USER_UPDATED); + await this.invalidateByPatterns(patterns); + } + + @OnEvent(CACHE_EVENTS.USER_DELETED) + async handleUserDeletedEvent(payload: { userId: string }): Promise { + this.logger.debug(`Handling user deleted event for: ${payload.userId}`); + const patterns = this.strategiesService.getPatternsForEvent(CACHE_EVENTS.USER_DELETED); + await this.invalidateByPatterns(patterns); + } + + @OnEvent(CACHE_EVENTS.ENROLLMENT_CREATED) + async handleEnrollmentCreatedEvent(payload: { + enrollmentId: string; + courseId: string; + }): Promise { + this.logger.debug(`Handling enrollment created event for: ${payload.enrollmentId}`); + const patterns = this.strategiesService.getPatternsForEvent(CACHE_EVENTS.ENROLLMENT_CREATED); + await this.invalidateByPatterns(patterns); + } + + @OnEvent(CACHE_EVENTS.ENROLLMENT_UPDATED) + async handleEnrollmentUpdatedEvent(payload: { + enrollmentId: string; + courseId?: string; + }): Promise { + this.logger.debug(`Handling enrollment updated event for: ${payload.enrollmentId}`); + const patterns = this.strategiesService.getPatternsForEvent(CACHE_EVENTS.ENROLLMENT_UPDATED); + await this.invalidateByPatterns(patterns); + } + + @OnEvent(CACHE_EVENTS.SEARCH_INDEX_UPDATED) + async handleSearchIndexUpdatedEvent(): Promise { + this.logger.debug('Handling search index updated event'); + const patterns = this.strategiesService.getPatternsForEvent(CACHE_EVENTS.SEARCH_INDEX_UPDATED); + await this.invalidateByPatterns(patterns); + } + + /** + * Get invalidation statistics + */ + getStats(): { + registeredTags: number; + totalTrackedKeys: number; + } { + let totalKeys = 0; + for (const keys of this.tagIndex.values()) { + totalKeys += keys.size; + } + + return { + registeredTags: this.tagIndex.size, + totalTrackedKeys: totalKeys, + }; + } +} diff --git a/src/caching/strategies/cache-strategies.service.spec.ts b/src/caching/strategies/cache-strategies.service.spec.ts new file mode 100644 index 00000000..105c5ebb --- /dev/null +++ b/src/caching/strategies/cache-strategies.service.spec.ts @@ -0,0 +1,152 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CacheStrategiesService } from './cache-strategies.service'; +import { CACHE_EVENTS } from '../caching.constants'; + +describe('CacheStrategiesService', () => { + let service: CacheStrategiesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CacheStrategiesService], + }).compile(); + + service = module.get(CacheStrategiesService); + }); + + describe('getStrategy', () => { + it('should return course details strategy', () => { + const strategy = service.getStrategy('course:details'); + + expect(strategy).toBeDefined(); + expect(strategy?.ttl).toBe(300); + expect(strategy?.prefix).toBe('cache:course'); + }); + + it('should return user profile strategy', () => { + const strategy = service.getStrategy('user:profile'); + + expect(strategy).toBeDefined(); + expect(strategy?.ttl).toBe(600); + expect(strategy?.prefix).toBe('cache:user:profile'); + }); + + it('should return undefined for unknown strategy', () => { + const strategy = service.getStrategy('unknown:strategy'); + + expect(strategy).toBeUndefined(); + }); + }); + + describe('getTtl', () => { + it('should return TTL for known strategy', () => { + const ttl = service.getTtl('course:details'); + + expect(ttl).toBe(300); + }); + + it('should return default TTL for unknown strategy', () => { + const ttl = service.getTtl('unknown'); + + expect(ttl).toBe(300); + }); + }); + + describe('getPrefix', () => { + it('should return prefix for known strategy', () => { + const prefix = service.getPrefix('course:details'); + + expect(prefix).toBe('cache:course'); + }); + + it('should return default prefix for unknown strategy', () => { + const prefix = service.getPrefix('unknown'); + + expect(prefix).toBe('cache'); + }); + }); + + describe('getInvalidationEvents', () => { + it('should return events for course strategy', () => { + const events = service.getInvalidationEvents('course:details'); + + expect(events).toContain(CACHE_EVENTS.COURSE_UPDATED); + expect(events).toContain(CACHE_EVENTS.COURSE_DELETED); + }); + }); + + describe('getRelatedPatterns', () => { + it('should return patterns for strategy', () => { + const patterns = service.getRelatedPatterns('course:details'); + + expect(patterns).toContain('cache:course:*'); + expect(patterns).toContain('cache:courses:list:*'); + }); + }); + + describe('getStrategiesForEvent', () => { + it('should return strategies for COURSE_UPDATED event', () => { + const strategies = service.getStrategiesForEvent(CACHE_EVENTS.COURSE_UPDATED); + + expect(strategies.length).toBeGreaterThan(0); + expect(strategies.some((s) => s.name === 'course:details')).toBe(true); + }); + }); + + describe('getPatternsForEvent', () => { + it('should return patterns for COURSE_UPDATED event', () => { + const patterns = service.getPatternsForEvent(CACHE_EVENTS.COURSE_UPDATED); + + expect(patterns.length).toBeGreaterThan(0); + expect(patterns.some((p) => p.includes('course'))).toBe(true); + }); + }); + + describe('getAllStrategies', () => { + it('should return all registered strategies', () => { + const strategies = service.getAllStrategies(); + + expect(strategies.length).toBeGreaterThan(0); + expect(strategies.some((s) => s.name === 'course:details')).toBe(true); + expect(strategies.some((s) => s.name === 'user:profile')).toBe(true); + }); + }); + + describe('buildKey', () => { + it('should build key with parts', () => { + const key = service.buildKey('course:details', '123'); + + expect(key).toBe('cache:course:123'); + }); + + it('should build key with multiple parts', () => { + const key = service.buildKey('course:details', '123', 'modules'); + + expect(key).toBe('cache:course:123:modules'); + }); + }); + + describe('getDefaultStrategy', () => { + it('should return default strategy', () => { + const strategy = service.getDefaultStrategy(); + + expect(strategy.name).toBe('default'); + expect(strategy.ttl).toBe(300); + }); + }); + + describe('registerStrategy', () => { + it('should register new strategy', () => { + service.registerStrategy({ + name: 'custom:strategy', + ttl: 1000, + prefix: 'cache:custom', + invalidateOnEvents: [], + relatedPatterns: ['cache:custom:*'], + }); + + const strategy = service.getStrategy('custom:strategy'); + expect(strategy).toBeDefined(); + expect(strategy?.ttl).toBe(1000); + }); + }); +}); diff --git a/src/caching/strategies/cache-strategies.service.ts b/src/caching/strategies/cache-strategies.service.ts index e69de29b..6fcc2bc3 100644 --- a/src/caching/strategies/cache-strategies.service.ts +++ b/src/caching/strategies/cache-strategies.service.ts @@ -0,0 +1,218 @@ +import { Injectable } from '@nestjs/common'; +import { CACHE_TTL, CACHE_PREFIXES, CACHE_EVENTS } from '../caching.constants'; + +export interface CacheStrategy { + ttl: number; + prefix: string; + invalidateOn: string[]; +} + +export interface CacheStrategyConfig { + name: string; + ttl: number; + prefix: string; + invalidateOnEvents: string[]; + relatedPatterns: string[]; +} + +@Injectable() +export class CacheStrategiesService { + private readonly strategies: Map = new Map(); + + constructor() { + this.initializeStrategies(); + } + + private initializeStrategies(): void { + // Course strategies + this.registerStrategy({ + name: 'course:details', + ttl: CACHE_TTL.COURSE_DETAILS, + prefix: CACHE_PREFIXES.COURSE, + invalidateOnEvents: [CACHE_EVENTS.COURSE_UPDATED, CACHE_EVENTS.COURSE_DELETED], + relatedPatterns: ['cache:course:*', 'cache:courses:list:*'], + }); + + this.registerStrategy({ + name: 'course:metadata', + ttl: CACHE_TTL.COURSE_METADATA, + prefix: CACHE_PREFIXES.COURSE, + invalidateOnEvents: [CACHE_EVENTS.COURSE_UPDATED], + relatedPatterns: ['cache:course:*'], + }); + + this.registerStrategy({ + name: 'courses:list', + ttl: CACHE_TTL.COURSE_METADATA, + prefix: CACHE_PREFIXES.COURSES_LIST, + invalidateOnEvents: [ + CACHE_EVENTS.COURSE_UPDATED, + CACHE_EVENTS.COURSE_DELETED, + CACHE_EVENTS.ENROLLMENT_CREATED, + ], + relatedPatterns: ['cache:courses:list:*'], + }); + + // User strategies + this.registerStrategy({ + name: 'user:profile', + ttl: CACHE_TTL.USER_PROFILE, + prefix: CACHE_PREFIXES.USER_PROFILE, + invalidateOnEvents: [CACHE_EVENTS.USER_UPDATED, CACHE_EVENTS.USER_DELETED], + relatedPatterns: ['cache:user:*', 'cache:user:profile:*'], + }); + + this.registerStrategy({ + name: 'user:session', + ttl: CACHE_TTL.USER_SESSION, + prefix: CACHE_PREFIXES.USER, + invalidateOnEvents: [CACHE_EVENTS.USER_DELETED], + relatedPatterns: ['cache:user:*'], + }); + + // Search strategies + this.registerStrategy({ + name: 'search:results', + ttl: CACHE_TTL.SEARCH_RESULTS, + prefix: CACHE_PREFIXES.SEARCH, + invalidateOnEvents: [ + CACHE_EVENTS.COURSE_UPDATED, + CACHE_EVENTS.COURSE_DELETED, + CACHE_EVENTS.SEARCH_INDEX_UPDATED, + ], + relatedPatterns: ['cache:search:*'], + }); + + // Popular content + this.registerStrategy({ + name: 'popular:courses', + ttl: CACHE_TTL.POPULAR_COURSES, + prefix: CACHE_PREFIXES.POPULAR, + invalidateOnEvents: [CACHE_EVENTS.COURSE_UPDATED, CACHE_EVENTS.ENROLLMENT_CREATED], + relatedPatterns: ['cache:popular:*'], + }); + + // Enrollment strategies + this.registerStrategy({ + name: 'enrollment:data', + ttl: CACHE_TTL.ENROLLMENT_DATA, + prefix: CACHE_PREFIXES.ENROLLMENT, + invalidateOnEvents: [CACHE_EVENTS.ENROLLMENT_CREATED, CACHE_EVENTS.ENROLLMENT_UPDATED], + relatedPatterns: ['cache:enrollment:*'], + }); + + // Featured content + this.registerStrategy({ + name: 'featured:content', + ttl: CACHE_TTL.STATIC_CONTENT, + prefix: CACHE_PREFIXES.FEATURED, + invalidateOnEvents: [CACHE_EVENTS.COURSE_UPDATED], + relatedPatterns: ['cache:featured:*'], + }); + } + + /** + * Register a new cache strategy + */ + registerStrategy(config: CacheStrategyConfig): void { + this.strategies.set(config.name, config); + } + + /** + * Get a strategy by name + */ + getStrategy(name: string): CacheStrategyConfig | undefined { + return this.strategies.get(name); + } + + /** + * Get TTL for a strategy + */ + getTtl(strategyName: string): number { + const strategy = this.strategies.get(strategyName); + return strategy?.ttl ?? CACHE_TTL.COURSE_DETAILS; + } + + /** + * Get prefix for a strategy + */ + getPrefix(strategyName: string): string { + const strategy = this.strategies.get(strategyName); + return strategy?.prefix ?? 'cache'; + } + + /** + * Get invalidation events for a strategy + */ + getInvalidationEvents(strategyName: string): string[] { + const strategy = this.strategies.get(strategyName); + return strategy?.invalidateOnEvents ?? []; + } + + /** + * Get related patterns to invalidate when a strategy is invalidated + */ + getRelatedPatterns(strategyName: string): string[] { + const strategy = this.strategies.get(strategyName); + return strategy?.relatedPatterns ?? []; + } + + /** + * Get all strategies that should be invalidated for a given event + */ + getStrategiesForEvent(eventName: string): CacheStrategyConfig[] { + const strategies: CacheStrategyConfig[] = []; + + for (const strategy of this.strategies.values()) { + if (strategy.invalidateOnEvents.includes(eventName)) { + strategies.push(strategy); + } + } + + return strategies; + } + + /** + * Get all patterns to invalidate for a given event + */ + getPatternsForEvent(eventName: string): string[] { + const strategies = this.getStrategiesForEvent(eventName); + const patterns = new Set(); + + for (const strategy of strategies) { + for (const pattern of strategy.relatedPatterns) { + patterns.add(pattern); + } + } + + return Array.from(patterns); + } + + /** + * Get all registered strategies + */ + getAllStrategies(): CacheStrategyConfig[] { + return Array.from(this.strategies.values()); + } + + /** + * Build a cache key for a strategy + */ + buildKey(strategyName: string, ...parts: Array): string { + const prefix = this.getPrefix(strategyName); + return `${prefix}:${parts.join(':')}`; + } + + /** + * Get default strategy configuration + */ + getDefaultStrategy(): CacheStrategyConfig { + return { + name: 'default', + ttl: CACHE_TTL.COURSE_DETAILS, + prefix: 'cache', + invalidateOnEvents: [], + relatedPatterns: ['cache:*'], + }; + } +} diff --git a/src/caching/warming/cache-warming.service.ts b/src/caching/warming/cache-warming.service.ts index e69de29b..b7303e10 100644 --- a/src/caching/warming/cache-warming.service.ts +++ b/src/caching/warming/cache-warming.service.ts @@ -0,0 +1,312 @@ +import { + Injectable, + Logger, + OnModuleInit, + OnModuleDestroy, + Inject, + Optional, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CachingService } from '../caching.service'; +import { CacheStrategiesService } from '../strategies/cache-strategies.service'; +import { CACHE_TTL, CACHE_PREFIXES } from '../caching.constants'; + +export interface CacheWarmingConfig { + /** + * Whether cache warming is enabled + */ + enabled: boolean; + + /** + * Warm popular courses on startup + */ + warmPopularCourses: boolean; + + /** + * Warm featured content on startup + */ + warmFeaturedContent: boolean; + + /** + * Number of popular courses to warm + */ + popularCoursesLimit: number; + + /** + * Warm system configuration + */ + warmSystemConfig: boolean; + + /** + * Delay before starting cache warming (ms) + */ + startupDelay: number; +} + +export interface WarmedData { + key: string; + type: string; + timestamp: Date; +} + +/** + * Interface for data providers that can be warmed + */ +export interface CacheWarmableProvider { + /** + * Get data to warm into cache + */ + getWarmableData(): Promise>; +} + +@Injectable() +export class CacheWarmingService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(CacheWarmingService.name); + private readonly config: CacheWarmingConfig; + private warmedKeys: Map = new Map(); + private warmupInterval?: NodeJS.Timeout; + private dataProviders: CacheWarmableProvider[] = []; + + constructor( + private readonly cachingService: CachingService, + private readonly strategiesService: CacheStrategiesService, + private readonly configService: ConfigService, + @Optional() @Inject('CACHE_WARMING_CONFIG') config?: Partial, + ) { + this.config = { + enabled: this.configService.get('CACHE_WARMING_ENABLED') !== 'false', + warmPopularCourses: true, + warmFeaturedContent: true, + popularCoursesLimit: parseInt( + this.configService.get('CACHE_WARMING_POPULAR_LIMIT') || '10', + 10, + ), + warmSystemConfig: true, + startupDelay: parseInt(this.configService.get('CACHE_WARMING_DELAY') || '5000', 10), + ...config, + }; + } + + /** + * Register a data provider for cache warming + */ + registerDataProvider(provider: CacheWarmableProvider): void { + this.dataProviders.push(provider); + } + + async onModuleInit(): Promise { + if (!this.config.enabled) { + this.logger.log('Cache warming is disabled'); + return; + } + + // Delay warming to allow the application to fully start + setTimeout(() => { + this.warmCache().catch((error) => { + this.logger.error('Failed to warm cache on startup', error); + }); + }, this.config.startupDelay); + + // Schedule periodic refresh of warmed data + this.schedulePeriodicWarmup(); + } + + onModuleDestroy(): void { + if (this.warmupInterval) { + clearInterval(this.warmupInterval); + } + this.warmedKeys.clear(); + } + + /** + * Warm cache with critical data + */ + async warmCache(): Promise { + this.logger.log('Starting cache warming...'); + const startTime = Date.now(); + + try { + // Warm data from registered providers + await this.warmFromProviders(); + + // Warm built-in data types + if (this.config.warmPopularCourses) { + await this.warmPopularCourses(); + } + + if (this.config.warmFeaturedContent) { + await this.warmFeaturedContent(); + } + + if (this.config.warmSystemConfig) { + await this.warmSystemConfig(); + } + + const duration = Date.now() - startTime; + this.logger.log( + `Cache warming completed in ${duration}ms. Warmed ${this.warmedKeys.size} keys.`, + ); + } catch (error) { + this.logger.error('Cache warming failed', error); + throw error; + } + } + + /** + * Warm data from registered providers + */ + private async warmFromProviders(): Promise { + for (const provider of this.dataProviders) { + try { + const items = await provider.getWarmableData(); + for (const item of items) { + await this.cachingService.set(item.key, item.data, item.ttl); + this.trackWarmedKey(item.key, 'provider'); + } + } catch (error) { + this.logger.warn('Failed to warm data from provider', error); + } + } + } + + /** + * Warm popular courses + * This is a placeholder - in production, this would fetch from CourseService + */ + private async warmPopularCourses(): Promise { + this.logger.debug('Warming popular courses...'); + + // Placeholder: In a real implementation, this would inject CourseService + // and fetch actual popular courses + const popularCoursesKey = `${CACHE_PREFIXES.POPULAR}:courses`; + + // Simulate warming with placeholder data + const placeholderData = { + courses: [], + warmedAt: new Date().toISOString(), + type: 'popular', + }; + + await this.cachingService.set(popularCoursesKey, placeholderData, CACHE_TTL.POPULAR_COURSES); + + this.trackWarmedKey(popularCoursesKey, 'popular_courses'); + this.logger.debug(`Warmed popular courses key: ${popularCoursesKey}`); + } + + /** + * Warm featured content + */ + private async warmFeaturedContent(): Promise { + this.logger.debug('Warming featured content...'); + + const featuredKey = `${CACHE_PREFIXES.FEATURED}:content`; + + const placeholderData = { + content: [], + warmedAt: new Date().toISOString(), + type: 'featured', + }; + + await this.cachingService.set(featuredKey, placeholderData, CACHE_TTL.STATIC_CONTENT); + + this.trackWarmedKey(featuredKey, 'featured_content'); + this.logger.debug(`Warmed featured content key: ${featuredKey}`); + } + + /** + * Warm system configuration + */ + private async warmSystemConfig(): Promise { + this.logger.debug('Warming system configuration...'); + + const configKey = 'cache:system:config'; + + const configData = { + version: this.configService.get('npm_package_version') || '1.0.0', + environment: this.configService.get('NODE_ENV') || 'development', + warmedAt: new Date().toISOString(), + }; + + await this.cachingService.set(configKey, configData, CACHE_TTL.STATIC_CONTENT); + + this.trackWarmedKey(configKey, 'system_config'); + this.logger.debug(`Warmed system config key: ${configKey}`); + } + + /** + * Schedule periodic cache warming + */ + private schedulePeriodicWarmup(): void { + // Refresh warmed data every 30 minutes + const interval = 30 * 60 * 1000; + + this.warmupInterval = setInterval(async () => { + this.logger.debug('Running scheduled cache warming...'); + try { + await this.warmCache(); + } catch (error) { + this.logger.error('Scheduled cache warming failed', error); + } + }, interval); + } + + /** + * Track a warmed key + */ + private trackWarmedKey(key: string, type: string): void { + this.warmedKeys.set(key, { + key, + type, + timestamp: new Date(), + }); + } + + /** + * Get statistics about warmed keys + */ + getStats(): { + totalKeys: number; + byType: Record; + lastWarmup: Date | null; + } { + const byType: Record = {}; + let lastWarmup: Date | null = null; + + for (const warmed of this.warmedKeys.values()) { + byType[warmed.type] = (byType[warmed.type] || 0) + 1; + + if (!lastWarmup || warmed.timestamp > lastWarmup) { + lastWarmup = warmed.timestamp; + } + } + + return { + totalKeys: this.warmedKeys.size, + byType, + lastWarmup, + }; + } + + /** + * Force refresh all warmed data + */ + async refreshAll(): Promise { + this.logger.log('Force refreshing all warmed data...'); + this.warmedKeys.clear(); + await this.warmCache(); + } + + /** + * Check if a key was warmed + */ + isWarmed(key: string): boolean { + return this.warmedKeys.has(key); + } + + /** + * Get all warmed keys + */ + getWarmedKeys(): WarmedData[] { + return Array.from(this.warmedKeys.values()); + } +} diff --git a/src/courses/courses.service.ts b/src/courses/courses.service.ts index 575d4a19..0aec6b8f 100644 --- a/src/courses/courses.service.ts +++ b/src/courses/courses.service.ts @@ -5,12 +5,19 @@ import { Course } from './entities/course.entity'; import { UpdateCourseDto } from './dto/update-course.dto'; import { paginate, PaginatedResponse } from '../common/utils/pagination.util'; import { CourseSearchDto } from './dto/course-search.dto'; +import { CachingService } from '../caching/caching.service'; +import { CacheInvalidationService } from '../caching/invalidation/invalidation.service'; +import { CACHE_TTL, CACHE_PREFIXES, CACHE_EVENTS } from '../caching/caching.constants'; +import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class CoursesService { constructor( @InjectRepository(Course) private coursesRepository: Repository, + private readonly cachingService: CachingService, + private readonly invalidationService: CacheInvalidationService, + private readonly eventEmitter: EventEmitter2, ) {} async create(createCourseDto: any): Promise { @@ -23,29 +30,37 @@ export class CoursesService { } async findAll(filter?: CourseSearchDto): Promise> { - const query = this.coursesRepository.createQueryBuilder('course'); + const cacheKey = `${CACHE_PREFIXES.COURSES_LIST}:${JSON.stringify(filter || {})}`; - query.leftJoinAndSelect('course.instructor', 'instructor'); + return this.cachingService.getOrSet( + cacheKey, + async () => { + const query = this.coursesRepository.createQueryBuilder('course'); - if (filter?.search) { - query.andWhere('(course.title ILIKE :search OR course.description ILIKE :search)', { - search: `%${filter.search}%`, - }); - } + query.leftJoinAndSelect('course.instructor', 'instructor'); - if (filter?.status) { - query.andWhere('course.status = :status', { status: filter.status }); - } + if (filter?.search) { + query.andWhere('(course.title ILIKE :search OR course.description ILIKE :search)', { + search: `%${filter.search}%`, + }); + } - if (filter?.instructorId) { - query.andWhere('course.instructorId = :instructorId', { - instructorId: filter.instructorId, - }); - } + if (filter?.status) { + query.andWhere('course.status = :status', { status: filter.status }); + } - query.orderBy('course.createdAt', 'DESC'); + if (filter?.instructorId) { + query.andWhere('course.instructorId = :instructorId', { + instructorId: filter.instructorId, + }); + } - return await paginate(query, filter); + query.orderBy('course.createdAt', 'DESC'); + + return await paginate(query, filter); + }, + CACHE_TTL.COURSE_METADATA, + ); } async findByIds(ids: string[]): Promise { @@ -70,33 +85,58 @@ export class CoursesService { } async findOne(id: string): Promise { + const cacheKey = `${CACHE_PREFIXES.COURSE}:${id}`; + + return this.cachingService.getOrSet( + cacheKey, + async () => { + const course = await this.coursesRepository.findOne({ + where: { id }, + relations: ['instructor', 'modules', 'modules.lessons'], + order: { + modules: { + order: 'ASC', + lessons: { + order: 'ASC', + }, + }, + } as any, + }); + if (!course) { + throw new NotFoundException(`Course with ID ${id} not found`); + } + return course; + }, + CACHE_TTL.COURSE_DETAILS, + ); + } + + async update(id: string, updateCourseDto: UpdateCourseDto): Promise { const course = await this.coursesRepository.findOne({ where: { id }, relations: ['instructor', 'modules', 'modules.lessons'], - order: { - modules: { - order: 'ASC', - lessons: { - order: 'ASC', - }, - } as any, - }, }); if (!course) { throw new NotFoundException(`Course with ID ${id} not found`); } - return course; - } - - async update(id: string, updateCourseDto: UpdateCourseDto): Promise { - const course = await this.findOne(id); Object.assign(course, updateCourseDto); - return this.coursesRepository.save(course); + const saved = await this.coursesRepository.save(course); + + // Invalidate cache after update + this.eventEmitter.emit(CACHE_EVENTS.COURSE_UPDATED, { courseId: id }); + + return saved; } async remove(id: string): Promise { - const course = await this.findOne(id); + const course = await this.coursesRepository.findOne({ where: { id } }); + if (!course) { + throw new NotFoundException(`Course with ID ${id} not found`); + } await this.coursesRepository.remove(course); + + // Invalidate cache after delete + this.eventEmitter.emit(CACHE_EVENTS.COURSE_DELETED, { courseId: id }); } async getAnalytics(): Promise { diff --git a/src/search/search.service.ts b/src/search/search.service.ts index 1b34a1c1..101e9b0d 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common'; import { ElasticsearchService } from '@nestjs/elasticsearch'; import { AutoCompleteService } from './autocomplete/autocomplete.service'; import { SearchFiltersService } from './filters/search-filters.service'; +import { CachingService } from '../caching/caching.service'; +import { CACHE_TTL, CACHE_PREFIXES } from '../caching/caching.constants'; @Injectable() export class SearchService { @@ -9,62 +11,88 @@ export class SearchService { private readonly elasticsearchService: ElasticsearchService, private readonly autoCompleteService: AutoCompleteService, private readonly searchFiltersService: SearchFiltersService, + private readonly cachingService: CachingService, ) {} async performSearch(query: string, filters: any, sort?: string) { - const searchBody = { - query: { - bool: { - must: [ - { - multi_match: { - query, - fields: ['title^2', 'description', 'content', 'tags'], - }, + // Create a cache key from the search parameters + const cacheKey = `${CACHE_PREFIXES.SEARCH}:${this.hashSearchParams(query, filters, sort)}`; + + return this.cachingService.getOrSet( + cacheKey, + async () => { + const searchBody = { + query: { + bool: { + must: [ + { + multi_match: { + query, + fields: ['title^2', 'description', 'content', 'tags'], + }, + }, + ], + filter: this.buildFilters(filters), }, - ], - filter: this.buildFilters(filters), - }, - }, - sort: this.buildSort(sort), - track_total_hits: true, - }; + }, + sort: this.buildSort(sort), + track_total_hits: true, + }; - // Ensure sort fields are properly formatted for ES - if (searchBody.sort && Array.isArray(searchBody.sort)) { - searchBody.sort = searchBody.sort.map((sortItem) => { - if (typeof sortItem === 'object' && sortItem !== null) { - // Convert object keys to proper format - const newSortItem = {}; - for (const [key, value] of Object.entries(sortItem)) { - if (typeof value === 'object' && value !== null) { - newSortItem[key] = { ...value }; - } else { - newSortItem[key] = value; + // Ensure sort fields are properly formatted for ES + if (searchBody.sort && Array.isArray(searchBody.sort)) { + searchBody.sort = searchBody.sort.map((sortItem) => { + if (typeof sortItem === 'object' && sortItem !== null) { + // Convert object keys to proper format + const newSortItem = {}; + for (const [key, value] of Object.entries(sortItem)) { + if (typeof value === 'object' && value !== null) { + newSortItem[key] = { ...value }; + } else { + newSortItem[key] = value; + } + } + return newSortItem; } - } - return newSortItem; + return sortItem; + }); } - return sortItem; - }); - } - const result = await this.elasticsearchService.search({ - index: 'courses', - body: searchBody, - }); + const result = await this.elasticsearchService.search({ + index: 'courses', + body: searchBody, + }); - const rankedResults = this.rankResults(result.hits.hits); - await this.logSearch(query, rankedResults.length); - return rankedResults; + const rankedResults = this.rankResults(result.hits.hits); + await this.logSearch(query, rankedResults.length); + return rankedResults; + }, + CACHE_TTL.SEARCH_RESULTS, + ); } async getAutoComplete(query: string) { - return this.autoCompleteService.getSuggestions(query); + const cacheKey = `${CACHE_PREFIXES.SEARCH}:autocomplete:${query}`; + + return this.cachingService.getOrSet( + cacheKey, + async () => { + return this.autoCompleteService.getSuggestions(query); + }, + CACHE_TTL.SEARCH_RESULTS, + ); } async getAvailableFilters() { - return this.searchFiltersService.getFilters(); + const cacheKey = `${CACHE_PREFIXES.SEARCH}:filters`; + + return this.cachingService.getOrSet( + cacheKey, + async () => { + return this.searchFiltersService.getFilters(); + }, + CACHE_TTL.STATIC_CONTENT, + ); } private buildFilters(filters: any) { @@ -100,8 +128,20 @@ export class SearchService { })); } - private async logSearch(query: string, resultsCount: number) { - // Simple analytics: log to console (in production, store in database) - console.log(`Search query: "${query}", Results count: ${resultsCount}`); + private logSearch(query: string, resultsCount: number): void { + // Analytics placeholder - in production, store in database or send to analytics service + void query; + void resultsCount; + } + + private hashSearchParams(query: string, filters: any, sort?: string): string { + const str = `${query}:${JSON.stringify(filters)}:${sort || 'default'}`; + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash).toString(36); } } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index c41cb6c5..af0cd7bc 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -7,12 +7,17 @@ import { UpdateUserDto } from './dto/update-user.dto'; import * as bcrypt from 'bcryptjs'; import { paginate, PaginatedResponse } from '../common/utils/pagination.util'; import { GetUsersDto } from './dto/get-users.dto'; +import { CachingService } from '../caching/caching.service'; +import { CACHE_TTL, CACHE_PREFIXES, CACHE_EVENTS } from '../caching/caching.constants'; +import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class UsersService { constructor( @InjectRepository(User) private readonly userRepository: Repository, + private readonly cachingService: CachingService, + private readonly eventEmitter: EventEmitter2, ) {} async create(createUserDto: CreateUserDto): Promise { @@ -38,24 +43,32 @@ export class UsersService { } async findAll(filter?: GetUsersDto): Promise> { - const query = this.userRepository.createQueryBuilder('user'); - - if (filter?.role) { - query.andWhere('user.role = :role', { role: filter.role }); - } - - if (filter?.status) { - query.andWhere('user.status = :status', { status: filter.status }); - } - - if (filter?.search) { - query.andWhere( - '(user.email ILIKE :search OR user.firstName ILIKE :search OR user.lastName ILIKE :search)', - { search: `%${filter.search}%` }, - ); - } - - return await paginate(query, filter); + const cacheKey = `cache:users:list:${JSON.stringify(filter || {})}`; + + return this.cachingService.getOrSet( + cacheKey, + async () => { + const query = this.userRepository.createQueryBuilder('user'); + + if (filter?.role) { + query.andWhere('user.role = :role', { role: filter.role }); + } + + if (filter?.status) { + query.andWhere('user.status = :status', { status: filter.status }); + } + + if (filter?.search) { + query.andWhere( + '(user.email ILIKE :search OR user.firstName ILIKE :search OR user.lastName ILIKE :search)', + { search: `%${filter.search}%` }, + ); + } + + return await paginate(query, filter); + }, + CACHE_TTL.USER_PROFILE, + ); } async findByIds(ids: string[]): Promise { @@ -64,11 +77,19 @@ export class UsersService { } async findOne(id: string): Promise { - const user = await this.userRepository.findOne({ where: { id } }); - if (!user) { - throw new NotFoundException('User not found'); - } - return user; + const cacheKey = `${CACHE_PREFIXES.USER_PROFILE}:${id}`; + + return this.cachingService.getOrSet( + cacheKey, + async () => { + const user = await this.userRepository.findOne({ where: { id } }); + if (!user) { + throw new NotFoundException('User not found'); + } + return user; + }, + CACHE_TTL.USER_PROFILE, + ); } async findByEmail(email: string): Promise { @@ -88,7 +109,10 @@ export class UsersService { } async update(id: string, updateUserDto: UpdateUserDto): Promise { - const user = await this.findOne(id); + const user = await this.userRepository.findOne({ where: { id } }); + if (!user) { + throw new NotFoundException('User not found'); + } // If updating password, hash it if (updateUserDto.password) { @@ -96,11 +120,18 @@ export class UsersService { } Object.assign(user, updateUserDto); - return await this.userRepository.save(user); + const saved = await this.userRepository.save(user); + + // Invalidate cache after update + this.eventEmitter.emit(CACHE_EVENTS.USER_UPDATED, { userId: id }); + + return saved; } async updateRefreshToken(userId: string, refreshToken: string | null): Promise { await this.userRepository.update(userId, { refreshToken }); + // Invalidate user cache + this.eventEmitter.emit(CACHE_EVENTS.USER_UPDATED, { userId }); } async updatePasswordResetToken( @@ -130,7 +161,13 @@ export class UsersService { } async remove(id: string): Promise { - const user = await this.findOne(id); + const user = await this.userRepository.findOne({ where: { id } }); + if (!user) { + throw new NotFoundException('User not found'); + } await this.userRepository.remove(user); + + // Invalidate cache after delete + this.eventEmitter.emit(CACHE_EVENTS.USER_DELETED, { userId: id }); } }