Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 31 additions & 14 deletions src/caching/cache-key.builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<type>:<identifier>
// 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}`;
}
20 changes: 14 additions & 6 deletions src/caching/cache-warming.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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';
Expand All @@ -25,7 +26,10 @@
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() };
Expand All @@ -40,8 +44,12 @@
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') } },

Check failure on line 52 in src/caching/cache-warming.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `·provide:·IsolationService,·useValue:·{·getTenantId:·jest.fn().mockReturnValue('tenant-a')·}` with `⏎··········provide:·IsolationService,⏎··········useValue:·{·getTenantId:·jest.fn().mockReturnValue('tenant-a')·},⏎·······`
{ provide: getRepositoryToken(Course), useValue: courseRepo },
{ provide: getRepositoryToken(Enrollment), useValue: enrollmentRepo },
{ provide: getRepositoryToken(User), useValue: userRepo },
Expand All @@ -59,7 +67,7 @@

expect(result.target).toBe('COURSES_LIST');
expect(caching.set).toHaveBeenCalledWith(
buildCourseListKey('published'),
buildCourseListKey('tenant-a', 'published'),
courses,
CACHE_TTL.COURSE_METADATA,
);
Expand All @@ -82,7 +90,7 @@

expect(result.target).toBe('POPULAR_COURSES');
expect(caching.set).toHaveBeenCalledWith(
buildPopularCoursesKey(),
buildPopularCoursesKey('tenant-a'),
[{ id: 'c1' }],
CACHE_TTL.POPULAR_COURSES,
);
Expand All @@ -95,7 +103,7 @@
expect(result.keysWarmed).toBeGreaterThan(0);
expect(searchService.search).toHaveBeenCalled();
expect(caching.set).toHaveBeenCalledWith(
buildSearchCacheKey(''),
buildSearchCacheKey('tenant-a', ''),
expect.any(Object),
CACHE_TTL.SEARCH_RESULTS,
);
Expand All @@ -109,7 +117,7 @@
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,
);
Expand Down
30 changes: 25 additions & 5 deletions src/caching/cache-warming.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -51,7 +53,11 @@ export class CacheWarmingService {

async warmCoursesList(): Promise<WarmResult> {
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' },
Expand All @@ -67,7 +73,11 @@ export class CacheWarmingService {

async warmPopularCourses(): Promise<WarmResult> {
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')
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}),
);
Expand Down
1 change: 1 addition & 0 deletions src/caching/caching.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { QueryCacheService } from './query-cache.service';
EventEmitterModule.forRoot(),
MonitoringModule,
SearchModule,
TenancyModule,
TypeOrmModule.forFeature([Course, Enrollment, User]),
CacheModule.registerAsync({
imports: [ConfigModule],
Expand Down
5 changes: 3 additions & 2 deletions src/caching/caching.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down
60 changes: 49 additions & 11 deletions src/caching/caching.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -138,19 +141,52 @@ export class CachingService {
}
}

async get<T>(key: string): Promise<T | undefined> {
const value = await this.cacheManager.get<T>(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<T>(key: string, tenantId?: string): Promise<T | undefined> {
const scopedKey = this.buildTenantScopedKey(key, tenantId);
const value = await this.cacheManager.get<T>(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<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
async set<T>(key: string, value: T, ttlSeconds?: number, tenantId?: string): Promise<void> {
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);
}

/**
Expand Down Expand Up @@ -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<void> {
await this.cacheManager.del(key);
async delete(key: string, tenantId?: string): Promise<void> {
const scopedKey = this.buildTenantScopedKey(key, tenantId);
await this.cacheManager.del(scopedKey);
}

async deleteMany(keys: string[]): Promise<void> {
await Promise.all(keys.map((key) => this.cacheManager.del(key)));
async deleteMany(keys: string[], tenantId?: string): Promise<void> {
const scopedKeys = keys.map((key) => this.buildTenantScopedKey(key, tenantId));
await Promise.all(scopedKeys.map((scopedKey) => this.cacheManager.del(scopedKey)));
}

async clear(): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions src/caching/computation-cache.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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,
);
});
Expand Down
Loading
Loading