Skip to content
Merged
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
13 changes: 11 additions & 2 deletions src/caching/cache-warming.scheduler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,17 @@ describe('CacheWarmingScheduler', () => {
);
});

it('runs full warm-up on module init', async () => {
await scheduler.onModuleInit();
it('defers full warm-up to after bootstrap without blocking readiness', async () => {
scheduler.onApplicationBootstrap();

// Warming is dispatched via setImmediate, so it must not run synchronously
// during bootstrap.
expect(warming.warmAll).not.toHaveBeenCalled();

await new Promise((resolve) => setImmediate(resolve));
// Allow the swallowed promise chain inside runWarmUp to settle.
await Promise.resolve();

expect(warming.warmAll).toHaveBeenCalledTimes(1);
});

Expand Down
17 changes: 12 additions & 5 deletions src/caching/cache-warming.scheduler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { CacheWarmingService } from './cache-warming.service';
import { CachingService } from './caching.service';
Expand All @@ -7,17 +7,24 @@ import { CachingService } from './caching.service';
* Schedules background cache warming for high-traffic query patterns.
*/
@Injectable()
export class CacheWarmingScheduler implements OnModuleInit {
export class CacheWarmingScheduler implements OnApplicationBootstrap {
private readonly logger = new Logger(CacheWarmingScheduler.name);

constructor(
private readonly warming: CacheWarmingService,
private readonly caching: CachingService,
) {}

async onModuleInit(): Promise<void> {
this.logger.log('Running initial cache warm-up on startup');
await this.runWarmUp('startup');
/**
* Startup warming is deferred to `onApplicationBootstrap` and dispatched on the
* next tick without being awaited, so it runs once the app is up and accepting
* traffic rather than blocking bootstrap/readiness. Failures are swallowed by
* `runWarmUp`, so a cold cache never prevents the server from starting.
*/
onApplicationBootstrap(): void {
setImmediate(() => {
void this.runWarmUp('startup');
});
}

/** Search results — TTL 2 min */
Expand Down
84 changes: 78 additions & 6 deletions src/caching/cache-warming.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Course, CourseStatus } from '../courses/entities/course.entity';
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 { MetricsCollectionService } from '../monitoring/metrics/metrics-collection.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';
import { CACHE_TTL, CACHE_WARMING } from './caching.constants';
import {
buildCourseListKey,
buildPopularCoursesKey,
Expand All @@ -19,18 +21,34 @@ import {
describe('CacheWarmingService', () => {
let service: CacheWarmingService;
let caching: jest.Mocked<Pick<CachingService, 'set'>>;
let courseRepo: { find: jest.Mock };
let courseRepo: { find: jest.Mock; createQueryBuilder: jest.Mock };
let enrollmentRepo: { createQueryBuilder: jest.Mock };
let userRepo: { find: jest.Mock };
let searchService: { search: jest.Mock };
let profileCompleteness: { getScore: jest.Mock };
let metrics: { recordCacheWarming: jest.Mock };
let configStore: Record<string, unknown>;

const makeCourseQb = (courses: unknown[]) => {
const qb = {
leftJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(courses),
};
return qb;
};

beforeEach(async () => {
configStore = {};
caching = {
set: jest.fn().mockResolvedValue(undefined),
getCurrentTenantId: jest.fn().mockReturnValue('tenant-a'),
} as any;
courseRepo = { find: jest.fn() };
courseRepo = { find: jest.fn(), createQueryBuilder: jest.fn() };
enrollmentRepo = { createQueryBuilder: jest.fn() };
userRepo = { find: jest.fn() };
searchService = {
Expand All @@ -39,6 +57,7 @@ describe('CacheWarmingService', () => {
profileCompleteness = {
getScore: jest.fn().mockResolvedValue({ score: 80, percentage: 80 }),
};
metrics = { recordCacheWarming: jest.fn() };

const module: TestingModule = await Test.createTestingModule({
providers: [
Expand All @@ -53,6 +72,15 @@ describe('CacheWarmingService', () => {
provide: IsolationService,
useValue: { getTenantId: jest.fn().mockReturnValue('tenant-a') },
},
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string, fallback?: unknown) =>
key in configStore ? configStore[key] : fallback,
),
},
},
{ provide: MetricsCollectionService, useValue: metrics },
{ provide: getRepositoryToken(Course), useValue: courseRepo },
{ provide: getRepositoryToken(Enrollment), useValue: enrollmentRepo },
{ provide: getRepositoryToken(User), useValue: userRepo },
Expand All @@ -62,18 +90,33 @@ describe('CacheWarmingService', () => {
service = module.get(CacheWarmingService);
});

it('warms published course listings', async () => {
it('warms published course listings ranked by enrollment count', async () => {
const courses = [{ id: 'c1', status: CourseStatus.PUBLISHED }];
courseRepo.find.mockResolvedValue(courses);
const qb = makeCourseQb(courses);
courseRepo.createQueryBuilder.mockReturnValue(qb);

const result = await service.warmCoursesList();

expect(result.target).toBe('COURSES_LIST');
expect(qb.orderBy).toHaveBeenCalledWith('COUNT(enrollment.id)', 'DESC');
// Bounded by the default max-entries cap.
expect(qb.limit).toHaveBeenCalledWith(CACHE_WARMING.MAX_ENTRIES_DEFAULT);
expect(caching.set).toHaveBeenCalledWith(
buildCourseListKey('tenant-a', 'published'),
courses,
CACHE_TTL.COURSE_METADATA,
);
expect(metrics.recordCacheWarming).toHaveBeenCalledWith('COURSES_LIST', 1, expect.any(Number));
});

it('honours CACHE_WARM_MAX_ENTRIES when bounding the course listing read', async () => {
configStore.CACHE_WARM_MAX_ENTRIES = 5;
const qb = makeCourseQb([]);
courseRepo.createQueryBuilder.mockReturnValue(qb);

await service.warmCoursesList();

expect(qb.limit).toHaveBeenCalledWith(5);
});

it('warms popular courses using enrollment counts', async () => {
Expand All @@ -92,6 +135,11 @@ describe('CacheWarmingService', () => {
const result = await service.warmPopularCourses();

expect(result.target).toBe('POPULAR_COURSES');
// Popular list keeps its tighter product limit even under a larger global cap.
expect(qb.limit).toHaveBeenCalledWith(CACHE_WARMING.POPULAR_COURSES_LIMIT);
expect(courseRepo.find).toHaveBeenCalledWith(
expect.objectContaining({ take: CACHE_WARMING.POPULAR_COURSES_LIMIT }),
);
expect(caching.set).toHaveBeenCalledWith(
buildPopularCoursesKey('tenant-a'),
[{ id: 'c1' }],
Expand All @@ -112,17 +160,41 @@ describe('CacheWarmingService', () => {
);
});

it('warms user profile scores for recently active users', async () => {
it('warms user profile scores for recently active users, bounded and in batches', async () => {
userRepo.find.mockResolvedValue([{ id: 'u1' }]);

const result = await service.warmUserProfiles();

expect(result.target).toBe('USER_PROFILE');
expect(userRepo.find).toHaveBeenCalledWith(
expect.objectContaining({
order: { lastLoginAt: 'DESC' },
take: CACHE_WARMING.USER_PROFILE_WARM_LIMIT,
}),
);
expect(profileCompleteness.getScore).toHaveBeenCalledWith('u1');
expect(caching.set).toHaveBeenCalledWith(
buildUserProfileKey('tenant-a', 'u1'),
expect.objectContaining({ score: 80 }),
CACHE_TTL.USER_PROFILE,
);
expect(metrics.recordCacheWarming).toHaveBeenCalledWith('USER_PROFILE', 1, expect.any(Number));
});

it('processes user profiles in bounded batches with a delay between them', async () => {
configStore.CACHE_WARM_BATCH_SIZE = 2;
configStore.CACHE_WARM_BATCH_DELAY_MS = 5;
const users = Array.from({ length: 5 }, (_, i) => ({ id: `u${i}` }));
userRepo.find.mockResolvedValue(users);
const sleepSpy = jest
.spyOn(service as unknown as { sleep: (ms: number) => Promise<void> }, 'sleep')
.mockResolvedValue(undefined);

await service.warmUserProfiles();

expect(profileCompleteness.getScore).toHaveBeenCalledTimes(5);
// 5 items / batch size 2 => 3 batches => 2 inter-batch delays.
expect(sleepSpy).toHaveBeenCalledTimes(2);
expect(sleepSpy).toHaveBeenCalledWith(5);
});
});
Loading
Loading