diff --git a/src/caching/cache-key.builder.ts b/src/caching/cache-key.builder.ts index 715a2f20..fa8919ed 100644 --- a/src/caching/cache-key.builder.ts +++ b/src/caching/cache-key.builder.ts @@ -2,46 +2,63 @@ import { CACHE_PREFIXES } from './caching.constants'; import { SearchFilters } from '../search/search.service'; import { SEARCH_CONSTANTS } from '../search/search.constants'; -export function buildCourseListKey(scope: 'published' | 'all' = 'published'): string { - return `${CACHE_PREFIXES.COURSES_LIST}:${scope}`; +function assertTenantId(tenantId: string | undefined, context: string): string { + if (typeof tenantId !== 'string' || tenantId.trim().length === 0) { + throw new Error(`Tenant identifier is required for ${context}`); + } + return tenantId; +} + +export function buildCourseListKey( + tenantId: string, + scope: 'published' | 'all' = 'published', +): string { + const resolvedTenantId = assertTenantId(tenantId, 'course list cache key'); + return `cache:${resolvedTenantId}:${CACHE_PREFIXES.COURSES_LIST.replace('cache:', '')}:${scope}`; } -export function buildPopularCoursesKey(): string { - return `${CACHE_PREFIXES.POPULAR}:courses`; +export function buildPopularCoursesKey(tenantId: string): string { + const resolvedTenantId = assertTenantId(tenantId, 'popular courses cache key'); + return `cache:${resolvedTenantId}:${CACHE_PREFIXES.POPULAR.replace('cache:', '')}:courses`; } -export function buildCourseKey(courseId: string): string { - return `${CACHE_PREFIXES.COURSE}:${courseId}`; +export function buildCourseKey(tenantId: string, courseId: string): string { + const resolvedTenantId = assertTenantId(tenantId, 'course cache key'); + return `cache:${resolvedTenantId}:${CACHE_PREFIXES.COURSE.replace('cache:', '')}:${courseId}`; } -export function buildUserProfileKey(userId: string): string { - return `${CACHE_PREFIXES.USER_PROFILE}:${userId}`; +export function buildUserProfileKey(tenantId: string, userId: string): string { + const resolvedTenantId = assertTenantId(tenantId, 'user profile cache key'); + return `cache:${resolvedTenantId}:${CACHE_PREFIXES.USER_PROFILE.replace('cache:', '')}:${userId}`; } export function buildSearchCacheKey( + tenantId: string, query: string, filters?: SearchFilters, sort?: string, page = 1, limit: number = SEARCH_CONSTANTS.DEFAULT_PAGE_SIZE, ): string { + const resolvedTenantId = assertTenantId(tenantId, 'search cache key'); const str = `${query}:${JSON.stringify(filters ?? {})}:${sort ?? 'default'}:${page}:${limit}`; let hash = 0; for (let i = 0; i < str.length; i++) { hash = (hash << 5) - hash + str.charCodeAt(i); hash = hash & hash; } - return `${CACHE_PREFIXES.SEARCH}:${hash.toString()}`; + return `cache:${resolvedTenantId}:${CACHE_PREFIXES.SEARCH.replace('cache:', '')}:${hash.toString()}`; } // --------------------------------------------------------------------------- // Computation cache key builder — Issue #603 // -// Format: cache:computation:: +// Format: cache:{tenant}:computation:{type}:{identifier} // Examples: -// cache:computation:leaderboard:top-players:10 -// cache:computation:leaderboard:user-rank:user-abc +// cache:tenant-a:computation:leaderboard:top-players:10 +// cache:tenant-a:computation:leaderboard:user-rank:user-abc // --------------------------------------------------------------------------- -export function buildComputationKey(type: string, identifier: string): string { - return `cache:computation:${type}:${identifier}`; +export function buildComputationKey(tenantId: string, type: string, identifier: string): string { + const resolvedTenantId = assertTenantId(tenantId, 'computation cache key'); + return `cache:${resolvedTenantId}:computation:${type}:${identifier}`; } diff --git a/src/caching/cache-warming.service.spec.ts b/src/caching/cache-warming.service.spec.ts index 9bff0793..b03a85e3 100644 --- a/src/caching/cache-warming.service.spec.ts +++ b/src/caching/cache-warming.service.spec.ts @@ -5,6 +5,7 @@ import { Enrollment } from '../courses/entities/enrollment.entity'; import { User } from '../users/entities/user.entity'; import { ProfileCompletenessService } from '../profile-completeness/profile-completeness.service'; import { SearchService } from '../search/search.service'; +import { IsolationService } from '../tenancy/isolation/isolation.service'; import { CachingService } from './caching.service'; import { CacheWarmingService } from './cache-warming.service'; import { CACHE_TTL } from './caching.constants'; @@ -25,7 +26,10 @@ describe('CacheWarmingService', () => { let profileCompleteness: { getScore: jest.Mock }; beforeEach(async () => { - caching = { set: jest.fn().mockResolvedValue(undefined) }; + caching = { + set: jest.fn().mockResolvedValue(undefined), + getCurrentTenantId: jest.fn().mockReturnValue('tenant-a'), + } as any; courseRepo = { find: jest.fn() }; enrollmentRepo = { createQueryBuilder: jest.fn() }; userRepo = { find: jest.fn() }; @@ -40,8 +44,12 @@ describe('CacheWarmingService', () => { providers: [ CacheWarmingService, { provide: CachingService, useValue: caching }, + { + provide: ProfileCompletenessService, + useValue: profileCompleteness, + }, { provide: SearchService, useValue: searchService }, - { provide: ProfileCompletenessService, useValue: profileCompleteness }, + { provide: IsolationService, useValue: { getTenantId: jest.fn().mockReturnValue('tenant-a') } }, { provide: getRepositoryToken(Course), useValue: courseRepo }, { provide: getRepositoryToken(Enrollment), useValue: enrollmentRepo }, { provide: getRepositoryToken(User), useValue: userRepo }, @@ -59,7 +67,7 @@ describe('CacheWarmingService', () => { expect(result.target).toBe('COURSES_LIST'); expect(caching.set).toHaveBeenCalledWith( - buildCourseListKey('published'), + buildCourseListKey('tenant-a', 'published'), courses, CACHE_TTL.COURSE_METADATA, ); @@ -82,7 +90,7 @@ describe('CacheWarmingService', () => { expect(result.target).toBe('POPULAR_COURSES'); expect(caching.set).toHaveBeenCalledWith( - buildPopularCoursesKey(), + buildPopularCoursesKey('tenant-a'), [{ id: 'c1' }], CACHE_TTL.POPULAR_COURSES, ); @@ -95,7 +103,7 @@ describe('CacheWarmingService', () => { expect(result.keysWarmed).toBeGreaterThan(0); expect(searchService.search).toHaveBeenCalled(); expect(caching.set).toHaveBeenCalledWith( - buildSearchCacheKey(''), + buildSearchCacheKey('tenant-a', ''), expect.any(Object), CACHE_TTL.SEARCH_RESULTS, ); @@ -109,7 +117,7 @@ describe('CacheWarmingService', () => { expect(result.target).toBe('USER_PROFILE'); expect(profileCompleteness.getScore).toHaveBeenCalledWith('u1'); expect(caching.set).toHaveBeenCalledWith( - buildUserProfileKey('u1'), + buildUserProfileKey('tenant-a', 'u1'), expect.objectContaining({ score: 80 }), CACHE_TTL.USER_PROFILE, ); diff --git a/src/caching/cache-warming.service.ts b/src/caching/cache-warming.service.ts index 8f8fcf04..4bf1d3cb 100644 --- a/src/caching/cache-warming.service.ts +++ b/src/caching/cache-warming.service.ts @@ -8,6 +8,7 @@ import { ProfileCompletenessService } from '../profile-completeness/profile-comp import { SearchService } from '../search/search.service'; import { CachingService } from './caching.service'; import { CACHE_TTL, CACHE_WARMING } from './caching.constants'; +import { IsolationService } from '../tenancy/isolation/isolation.service'; import { buildCourseListKey, buildPopularCoursesKey, @@ -30,6 +31,7 @@ export class CacheWarmingService { constructor( private readonly caching: CachingService, + private readonly isolationService: IsolationService, private readonly searchService: SearchService, private readonly profileCompleteness: ProfileCompletenessService, @InjectRepository(Course) @@ -51,7 +53,11 @@ export class CacheWarmingService { async warmCoursesList(): Promise { const started = Date.now(); - const key = buildCourseListKey('published'); + const tenantId = this.caching.getCurrentTenantId(); + if (!tenantId) { + throw new Error('Tenant context is required for tenant-scoped cache warmup'); + } + const key = buildCourseListKey(tenantId, 'published'); const courses = await this.courseRepo.find({ where: { status: CourseStatus.PUBLISHED }, order: { createdAt: 'DESC' }, @@ -67,7 +73,11 @@ export class CacheWarmingService { async warmPopularCourses(): Promise { const started = Date.now(); - const key = buildPopularCoursesKey(); + const tenantId = this.isolationService.getTenantId(); + if (!tenantId) { + throw new Error('Tenant context is required for tenant-scoped cache warmup'); + } + const key = buildPopularCoursesKey(tenantId); const popular = await this.enrollmentRepo .createQueryBuilder('enrollment') .select('enrollment.courseId', 'courseId') @@ -107,14 +117,19 @@ export class CacheWarmingService { const started = Date.now(); let keysWarmed = 0; + const tenantId = this.isolationService.getTenantId(); + if (!tenantId) { + throw new Error('Tenant context is required for tenant-scoped cache warmup'); + } + for (const query of CACHE_WARMING.SEARCH_WARM_QUERIES) { const result = await this.searchService.search(query); - const key = buildSearchCacheKey(query); + const key = buildSearchCacheKey(tenantId, query); await this.caching.set(key, result, CACHE_TTL.SEARCH_RESULTS); keysWarmed += 1; } - const filtersKey = buildSearchCacheKey('', { level: 'beginner' }); + const filtersKey = buildSearchCacheKey(tenantId, '', { level: 'beginner' }); const filteredResult = await this.searchService.search('', { level: 'beginner' }); await this.caching.set(filtersKey, filteredResult, CACHE_TTL.SEARCH_RESULTS); keysWarmed += 1; @@ -142,10 +157,15 @@ export class CacheWarmingService { }); } + const tenantId = this.isolationService.getTenantId(); + if (!tenantId) { + throw new Error('Tenant context is required for tenant-scoped cache warmup'); + } + await Promise.all( users.map(async (user) => { const profile = await this.profileCompleteness.getScore(user.id); - const key = buildUserProfileKey(user.id); + const key = buildUserProfileKey(tenantId, user.id); await this.caching.set(key, profile, CACHE_TTL.USER_PROFILE); }), ); diff --git a/src/caching/caching.module.ts b/src/caching/caching.module.ts index 76d798b6..6ea4d085 100644 --- a/src/caching/caching.module.ts +++ b/src/caching/caching.module.ts @@ -29,6 +29,7 @@ import { QueryCacheService } from './query-cache.service'; EventEmitterModule.forRoot(), MonitoringModule, SearchModule, + TenancyModule, TypeOrmModule.forFeature([Course, Enrollment, User]), CacheModule.registerAsync({ imports: [ConfigModule], diff --git a/src/caching/caching.service.spec.ts b/src/caching/caching.service.spec.ts index 74b74b75..91ac1c5e 100644 --- a/src/caching/caching.service.spec.ts +++ b/src/caching/caching.service.spec.ts @@ -72,10 +72,11 @@ describe('CachingService', () => { // ── deriveCacheType / buildCounterKeys ────────────────────────────────────── describe('deriveCacheType', () => { - it('returns the second segment for cache:{type}:... keys', () => { + it('returns the second segment for cache:{type}:... keys and the third for tenant-scoped keys', () => { expect(deriveCacheType('cache:test:1')).toBe('test'); expect(deriveCacheType('cache:user:42')).toBe('user'); expect(deriveCacheType('cache:course:popular')).toBe('course'); + expect(deriveCacheType('cache:tenant-a:course:popular')).toBe('course'); }); it('returns "default" for keys with no cache: prefix', () => { @@ -135,7 +136,7 @@ describe('CachingService', () => { expect(result).toEqual({ id: '2' }); expect(factory).toHaveBeenCalledTimes(1); - expect(cacheManager.set).toHaveBeenCalledWith('cache:test:2', { id: '2' }, 120000); + expect(cacheManager.set).toHaveBeenCalledWith('cache:tenant-a:test:2', { id: '2' }, 120000); expect(redis.incr).toHaveBeenCalledWith('cache:misses:test'); const stats = await service.getStats('test'); diff --git a/src/caching/caching.service.ts b/src/caching/caching.service.ts index e3e26f6d..a6b0c507 100644 --- a/src/caching/caching.service.ts +++ b/src/caching/caching.service.ts @@ -41,6 +41,9 @@ export const deriveCacheType = (key: string): string => { const parts = key.split(':'); if (parts.length >= 2 && parts[0] === 'cache') { + if (parts.length >= 4) { + return parts[2] || CACHE_TYPE_FALLBACK; + } return parts[1] || CACHE_TYPE_FALLBACK; } return CACHE_TYPE_FALLBACK; @@ -138,19 +141,52 @@ export class CachingService { } } - async get(key: string): Promise { - const value = await this.cacheManager.get(key); + getCurrentTenantId(): string | undefined { + return this.resolveTenantId(); + } + + private resolveTenantId(explicitTenantId?: string): string | undefined { + if (typeof explicitTenantId === 'string' && explicitTenantId.trim().length > 0) { + return explicitTenantId; + } + + const tenantIdFromIsolation = this.isolationService?.getTenantId(); + if (typeof tenantIdFromIsolation === 'string' && tenantIdFromIsolation.trim().length > 0) { + return tenantIdFromIsolation; + } + + return undefined; + } + + private buildTenantScopedKey(key: string, explicitTenantId?: string): string { + const tenantId = this.resolveTenantId(explicitTenantId); + if (!tenantId) { + throw new Error('Tenant context is required for tenant-scoped cache keys'); + } + + if (key.startsWith(`cache:${tenantId}:`)) { + return key; + } + + const normalizedKey = key.startsWith('cache:') ? key.slice('cache:'.length) : key; + return `cache:${tenantId}:${normalizedKey}`; + } + + async get(key: string, tenantId?: string): Promise { + const scopedKey = this.buildTenantScopedKey(key, tenantId); + const value = await this.cacheManager.get(scopedKey); if (value === undefined || value === null) { - await this.recordMiss(deriveCacheType(key), key); + await this.recordMiss(deriveCacheType(scopedKey), scopedKey); return undefined; } - await this.recordHit(deriveCacheType(key), key); + await this.recordHit(deriveCacheType(scopedKey), scopedKey); return value; } - async set(key: string, value: T, ttlSeconds?: number): Promise { + async set(key: string, value: T, ttlSeconds?: number, tenantId?: string): Promise { const ttlMs = ttlSeconds !== undefined ? ttlSeconds * 1000 : undefined; - await this.cacheManager.set(key, value, ttlMs); + const scopedKey = this.buildTenantScopedKey(key, tenantId); + await this.cacheManager.set(scopedKey, value, ttlMs); } /** @@ -214,16 +250,18 @@ export class CachingService { } const value = await factory(); - await this.set(key, value, ttlSeconds); + await this.set(scopedKey, value, ttlSeconds, tenantId); return value; } - async delete(key: string): Promise { - await this.cacheManager.del(key); + async delete(key: string, tenantId?: string): Promise { + const scopedKey = this.buildTenantScopedKey(key, tenantId); + await this.cacheManager.del(scopedKey); } - async deleteMany(keys: string[]): Promise { - await Promise.all(keys.map((key) => this.cacheManager.del(key))); + async deleteMany(keys: string[], tenantId?: string): Promise { + const scopedKeys = keys.map((key) => this.buildTenantScopedKey(key, tenantId)); + await Promise.all(scopedKeys.map((scopedKey) => this.cacheManager.del(scopedKey))); } async clear(): Promise { diff --git a/src/caching/computation-cache.service.spec.ts b/src/caching/computation-cache.service.spec.ts index 5c5df0bc..26934cb7 100644 --- a/src/caching/computation-cache.service.spec.ts +++ b/src/caching/computation-cache.service.spec.ts @@ -1,6 +1,7 @@ import { ComputationCacheService } from './computation-cache.service'; import { CachingService } from './caching.service'; import { MetricsCollectionService } from '../monitoring/metrics/metrics-collection.service'; +import { IsolationService } from '../tenancy/isolation/isolation.service'; import { COMPUTATION_TTL } from './caching.constants'; describe('ComputationCacheService', () => { @@ -17,10 +18,12 @@ describe('ComputationCacheService', () => { get: jest.fn(), set: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), + getCurrentTenantId: jest.fn().mockReturnValue('tenant-a'), }; metrics = { updateCacheHitRate: jest.fn() }; service = new ComputationCacheService( caching as unknown as CachingService, + { getTenantId: jest.fn().mockReturnValue('tenant-a') } as unknown as IsolationService, metrics as unknown as MetricsCollectionService, ); }); diff --git a/src/caching/computation-cache.service.ts b/src/caching/computation-cache.service.ts index 1c06c730..3f863b9b 100644 --- a/src/caching/computation-cache.service.ts +++ b/src/caching/computation-cache.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, Optional } from '@nestjs/common'; import { CachingService } from './caching.service'; import { MetricsCollectionService } from '../monitoring/metrics/metrics-collection.service'; import { buildComputationKey } from './cache-key.builder'; +import { IsolationService } from '../tenancy/isolation/isolation.service'; export interface ComputationCacheStats { hits: number; @@ -47,6 +48,7 @@ export class ComputationCacheService { constructor( private readonly caching: CachingService, + private readonly isolationService: IsolationService, @Optional() private readonly metrics?: MetricsCollectionService, ) {} @@ -65,7 +67,11 @@ export class ComputationCacheService { factory: () => Promise, ttlSeconds: number, ): Promise { - const key = buildComputationKey(type, identifier); + const tenantId = this.isolationService.getTenantId(); + if (!tenantId) { + throw new Error('Tenant context is required for tenant-scoped computation cache'); + } + const key = buildComputationKey(tenantId, type, identifier); // ── Cache hit ────────────────────────────────────────────────────────── const cached = await this.caching.get(key); @@ -103,7 +109,11 @@ export class ComputationCacheService { * Invalidates a specific computation cache entry. */ async invalidate(type: string, identifier: string): Promise { - const key = buildComputationKey(type, identifier); + const tenantId = this.isolationService.getTenantId(); + if (!tenantId) { + throw new Error('Tenant context is required for tenant-scoped computation cache'); + } + const key = buildComputationKey(tenantId, type, identifier); await this.caching.delete(key); this.logger.debug(`Invalidated computation cache [${type}] key: ${key}`); }