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
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { DataPipelineModule } from './data-pipeline/data-pipeline.module';
import { CanaryModule } from './canary/canary.module';
import { IncidentManagementModule } from './incident-management/incident-management.module';
import { MonitoringModule } from './monitoring/monitoring.module';
import { CachingModule } from './caching/caching.module';

const featureFlags = loadFeatureFlags();

Expand All @@ -37,6 +38,7 @@ const featureFlags = loadFeatureFlags();
CanaryModule,
IncidentManagementModule,
MonitoringModule,
...(featureFlags.ENABLE_CACHING ? [CachingModule] : []),
],
controllers: [AppController],
providers: featureFlags.ENABLE_RATE_LIMITING
Expand Down
49 changes: 49 additions & 0 deletions src/caching/cache-invalidation.listener.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { CacheInvalidationListener } from './cache-invalidation.listener';
import { CacheInvalidationService } from './cache-invalidation.service';
import { CACHE_EVENTS } from './caching.constants';

describe('CacheInvalidationListener', () => {
let listener: CacheInvalidationListener;
let invalidation: {
invalidateCourseCache: jest.Mock;
invalidateUserCache: jest.Mock;
invalidatePattern: jest.Mock;
};

beforeEach(() => {
invalidation = {
invalidateCourseCache: jest.fn().mockResolvedValue(undefined),
invalidateUserCache: jest.fn().mockResolvedValue(undefined),
invalidatePattern: jest.fn().mockResolvedValue(undefined),
};
listener = new CacheInvalidationListener(
invalidation as unknown as CacheInvalidationService,
);
});

it('invalidates course caches on course update events', async () => {
await listener.onCourseChange({ id: 'course-1' });
expect(invalidation.invalidateCourseCache).toHaveBeenCalledWith('course-1');
});

it('invalidates user profile caches on user update events', async () => {
await listener.onUserChange({ id: 'user-1' });
expect(invalidation.invalidateUserCache).toHaveBeenCalledWith('user-1');
});

it('invalidates list and popular caches on enrollment events', async () => {
await listener.onEnrollmentChange({ id: 'enrollment-1' });
expect(invalidation.invalidatePattern).toHaveBeenCalledWith('cache:courses:list:*');
expect(invalidation.invalidatePattern).toHaveBeenCalledWith('cache:popular:*');
});

it('invalidates search caches when search index updates', async () => {
await listener.onSearchIndexUpdated();
expect(invalidation.invalidatePattern).toHaveBeenCalledWith('cache:search:*');
});

it('responds to documented cache event constants', () => {
expect(CACHE_EVENTS.COURSE_UPDATED).toBe('cache.course.updated');
expect(CACHE_EVENTS.USER_UPDATED).toBe('cache.user.updated');
});
});
57 changes: 57 additions & 0 deletions src/caching/cache-invalidation.listener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { CACHE_EVENTS } from './caching.constants';
import { CacheInvalidationService } from './cache-invalidation.service';

interface EntityEventPayload {
id: string;
}

/**
* Listens for cache invalidation events and clears stale entries on write.
*/
@Injectable()
export class CacheInvalidationListener {
private readonly logger = new Logger(CacheInvalidationListener.name);

constructor(private readonly invalidation: CacheInvalidationService) {}

@OnEvent(CACHE_EVENTS.COURSE_CREATED)
@OnEvent(CACHE_EVENTS.COURSE_UPDATED)
@OnEvent(CACHE_EVENTS.COURSE_DELETED)
async onCourseChange(payload: EntityEventPayload): Promise<void> {
this.logger.debug(`Invalidating course cache for ${payload.id}`);
await this.invalidation.invalidateCourseCache(payload.id);
}

@OnEvent(CACHE_EVENTS.USER_UPDATED)
@OnEvent(CACHE_EVENTS.USER_DELETED)
async onUserChange(payload: EntityEventPayload): Promise<void> {
this.logger.debug(`Invalidating user cache for ${payload.id}`);
await this.invalidation.invalidateUserCache(payload.id);
}

@OnEvent(CACHE_EVENTS.ENROLLMENT_CREATED)
@OnEvent(CACHE_EVENTS.ENROLLMENT_UPDATED)
async onEnrollmentChange(payload: EntityEventPayload): Promise<void> {
this.logger.debug(`Invalidating course list caches after enrollment ${payload.id}`);
await this.invalidation.invalidatePattern('cache:courses:list:*');
await this.invalidation.invalidatePattern('cache:popular:*');
}

@OnEvent(CACHE_EVENTS.SEARCH_INDEX_UPDATED)
async onSearchIndexUpdated(): Promise<void> {
this.logger.debug('Invalidating search result caches');
await this.invalidation.invalidatePattern('cache:search:*');
}

@OnEvent(CACHE_EVENTS.CATEGORY_UPDATED)
@OnEvent(CACHE_EVENTS.TAG_UPDATED)
@OnEvent(CACHE_EVENTS.LESSON_UPDATED)
@OnEvent(CACHE_EVENTS.QUIZ_UPDATED)
async onRelatedContentUpdated(payload: EntityEventPayload): Promise<void> {
this.logger.debug(`Invalidating related caches for ${payload.id}`);
await this.invalidation.invalidatePattern('cache:search:*');
await this.invalidation.invalidatePattern('cache:courses:list:*');
}
}
58 changes: 58 additions & 0 deletions src/caching/cache-invalidation.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CacheInvalidationService } from './cache-invalidation.service';
import { APP_EVENTS } from '../common/constants/event.constants';
import { CACHE_PREFIXES } from './caching.constants';

jest.mock('../config/cache.config', () => ({
getSharedRedisClient: jest.fn(() => ({
scan: jest.fn().mockResolvedValue(['0', []]),
del: jest.fn(),
})),
}));

describe('CacheInvalidationService', () => {
let service: CacheInvalidationService;
let cacheManager: { del: jest.Mock; clear: jest.Mock };
let eventEmitter: { emit: jest.Mock };

beforeEach(() => {
cacheManager = {
del: jest.fn().mockResolvedValue(undefined),
clear: jest.fn().mockResolvedValue(undefined),
};
eventEmitter = { emit: jest.fn() };
service = new CacheInvalidationService(
cacheManager as never,
eventEmitter as unknown as EventEmitter2,
);
});

it('invalidates a single cache key and emits event', async () => {
await service.invalidateKey('cache:course:1');

expect(cacheManager.del).toHaveBeenCalledWith('cache:course:1');
expect(eventEmitter.emit).toHaveBeenCalledWith(APP_EVENTS.CACHE_INVALIDATED, {
key: 'cache:course:1',
type: 'single',
});
});

it('invalidates course-related keys on course cache invalidation', async () => {
await service.invalidateCourseCache('course-1');

expect(cacheManager.del).toHaveBeenCalledWith(`${CACHE_PREFIXES.COURSE}:course-1`);
});

it('invalidates user profile keys on user cache invalidation', async () => {
await service.invalidateUserCache('user-1');

expect(cacheManager.del).toHaveBeenCalledWith(`${CACHE_PREFIXES.USER}:user-1`);
expect(cacheManager.del).toHaveBeenCalledWith(`${CACHE_PREFIXES.USER_PROFILE}:user-1`);
});

it('routes entity changes to course invalidation', async () => {
await service.handleDataChange('course', 'course-99');
expect(cacheManager.del).toHaveBeenCalledWith(`${CACHE_PREFIXES.COURSE}:course-99`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { Injectable, Logger, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { APP_EVENTS } from '../../common/constants/event.constants';
import { APP_EVENTS } from '../common/constants/event.constants';
import { getSharedRedisClient } from '../config/cache.config';
import { CACHE_PREFIXES } from './caching.constants';

interface CacheStoreWithKeys {
keys?(pattern: string): Promise<string[]> | string[];
Expand All @@ -14,65 +16,90 @@ interface CacheManagerExtended extends Cache {
}

/**
* Provides cache Invalidation operations.
* Provides cache invalidation operations for the cache-aside layer.
*/
@Injectable()
export class CacheInvalidationService {
private readonly logger = new Logger(CacheInvalidationService.name);

constructor(
@Inject(CACHE_MANAGER)
private cacheManager: Cache,
private eventEmitter: EventEmitter2,
) {}
/**
* Invalidates a specific cache key.
*/

async invalidateKey(key: string): Promise<void> {
this.logger.log(`Invalidating cache key: ${key}`);
await this.cacheManager.del(key);
this.eventEmitter.emit(APP_EVENTS.CACHE_INVALIDATED, { key, type: 'single' });
}
/**
* Invalidates multiple cache keys based on a pattern.
* Note: Standard cache-manager doesn't support pattern deletion easily with all stores,
* so this is a simplified implementation.
*/

async invalidatePattern(pattern: string): Promise<void> {
this.logger.log(`Invalidating cache pattern: ${pattern}`);
// In a production environment with Redis, we'd use 'SCAN' and 'DEL'
// For now, we'll emit an event that other specialized listeners might handle
this.eventEmitter.emit(APP_EVENTS.CACHE_INVALIDATED, { pattern, type: 'pattern' });
// If the store supports a store-specific method, call it here.

const store = (this.cacheManager as CacheManagerExtended).store;
if (store?.keys) {
const keys = await store.keys(pattern);
const list = Array.isArray(keys) ? keys : [];
if (list.length > 0) {
await Promise.all(list.map((key: string) => this.cacheManager.del(key)));
}
return;
}

await this.invalidatePatternViaRedisScan(pattern);
}
/**
* Automatically invalidates cache based on data change events.
*/

async invalidateCourseCache(courseId: string): Promise<void> {
await this.invalidateKey(`${CACHE_PREFIXES.COURSE}:${courseId}`);
await this.invalidatePattern(`${CACHE_PREFIXES.COURSES_LIST}:*`);
await this.invalidatePattern(`${CACHE_PREFIXES.POPULAR}:*`);
await this.invalidatePattern(`${CACHE_PREFIXES.SEARCH}:*`);
}

async invalidateUserCache(userId: string): Promise<void> {
await this.invalidateKey(`${CACHE_PREFIXES.USER}:${userId}`);
await this.invalidateKey(`${CACHE_PREFIXES.USER_PROFILE}:${userId}`);
}

async handleDataChange(entity: string, id: string): Promise<void> {
this.logger.log(`Handling data change for ${entity}:${id}`);
const specificKey = `${entity}:${id}`;
const collectionKey = `${entity}:list:*`;
await this.invalidateKey(specificKey);
await this.invalidatePattern(collectionKey);

if (entity === 'course' || entity.startsWith('cache:course')) {
await this.invalidateCourseCache(id);
return;
}

if (entity === 'user' || entity.startsWith('cache:user')) {
await this.invalidateUserCache(id);
return;
}

await this.invalidateKey(`${entity}:${id}`);
await this.invalidatePattern(`${entity}:list:*`);
}
/**
* Purges the entire cache.
*/

async purgeAll(): Promise<void> {
this.logger.warn('Purging entire cache');
// cache-manager v5+ uses clear() instead of reset()
if (typeof this.cacheManager.clear === 'function') {
await this.cacheManager.clear();
} else if (typeof (this.cacheManager as CacheManagerExtended).reset === 'function') {
await (this.cacheManager as CacheManagerExtended).reset!();
}
this.eventEmitter.emit(APP_EVENTS.CACHE_PURGED, { timestamp: new Date() });
}

private async invalidatePatternViaRedisScan(pattern: string): Promise<void> {
const redis = getSharedRedisClient();
let cursor = '0';

do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== '0');
}
}
35 changes: 35 additions & 0 deletions src/caching/cache-key.builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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}`;
}

export function buildPopularCoursesKey(): string {
return `${CACHE_PREFIXES.POPULAR}:courses`;
}

export function buildCourseKey(courseId: string): string {
return `${CACHE_PREFIXES.COURSE}:${courseId}`;
}

export function buildUserProfileKey(userId: string): string {
return `${CACHE_PREFIXES.USER_PROFILE}:${userId}`;
}

export function buildSearchCacheKey(
query: string,
filters?: SearchFilters,
sort?: string,
page = 1,
limit: number = SEARCH_CONSTANTS.DEFAULT_PAGE_SIZE,
): string {
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()}`;
}
Loading