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

it('runs full warm-up on module init', async () => {
await scheduler.onModuleInit();
// ── Deferred startup warming ────────────────────────────────────────────────

it('runs full warm-up after the startup delay via onApplicationBootstrap', async () => {
jest.useFakeTimers();
const bootstrapPromise = scheduler.onApplicationBootstrap();
// Advance past STARTUP_DELAY_MS
await jest.runAllTimersAsync();
await bootstrapPromise;
jest.useRealTimers();

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

it('does NOT call warmAll before the startup delay has elapsed', async () => {
jest.useFakeTimers();
// Start bootstrap but do not advance timers
void scheduler.onApplicationBootstrap();
expect(warming.warmAll).not.toHaveBeenCalled();
jest.useRealTimers();
});

// ── Scheduled methods ───────────────────────────────────────────────────────

it('delegates scheduled search warming to CacheWarmingService', async () => {
await scheduler.warmSearchResults();
expect(warming.warmSearchResults).toHaveBeenCalledTimes(1);
Expand All @@ -53,6 +71,16 @@ describe('CacheWarmingScheduler', () => {
expect(warming.warmUserProfiles).toHaveBeenCalledTimes(1);
});

it('delegates scheduled course list warming to CacheWarmingService', async () => {
await scheduler.warmCoursesList();
expect(warming.warmCoursesList).toHaveBeenCalledTimes(1);
});

it('delegates scheduled popular courses warming to CacheWarmingService', async () => {
await scheduler.warmPopularCourses();
expect(warming.warmPopularCourses).toHaveBeenCalledTimes(1);
});

it('publishes cache hit-rate metrics on schedule', () => {
scheduler.publishCacheMetrics();
expect(caching.publishHitRateMetrics).toHaveBeenCalledWith('application');
Expand Down
43 changes: 39 additions & 4 deletions src/caching/cache-warming.scheduler.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,51 @@
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';
import { CACHE_WARMING } from './caching.constants';

/**
* Schedules background cache warming for high-traffic query patterns.
*
* ### Startup behaviour
* Implements `OnApplicationBootstrap` instead of `OnModuleInit` so the
* initial warm-up only begins *after* the HTTP server is accepting traffic.
* An additional `CACHE_WARMING.STARTUP_DELAY_MS` pause (default 5 s, tunable
* via `CACHE_WARM_STARTUP_DELAY_MS`) further delays the first pass, giving
* the application time to finish any post-boot work before we fire expensive
* DB queries.
*
* ### Scheduled cadence (aligned with cache TTLs)
* | Target | Cron | TTL |
* |------------------|-----------------|---------|
* | Search results | every 2 min | 2 min |
* | User profiles | every 10 min | 10 min |
* | Course listings | every 15 min | 15 min |
* | Popular courses | every 30 min | 30 min |
*/
@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');
/**
* Deferred startup warm-up.
*
* Fires after the application is fully bootstrapped (HTTP server is live).
* Waits `CACHE_WARMING.STARTUP_DELAY_MS` milliseconds before running the
* full warm-up so that expensive DB queries do not compete with the last
* steps of the boot sequence.
*/
async onApplicationBootstrap(): Promise<void> {
this.logger.log(

Check failure on line 44 in src/caching/cache-warming.scheduler.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎······`Startup·cache·warm-up·deferred·by·${CACHE_WARMING.STARTUP_DELAY_MS}ms`,⏎····` with ``Startup·cache·warm-up·deferred·by·${CACHE_WARMING.STARTUP_DELAY_MS}ms``
`Startup cache warm-up deferred by ${CACHE_WARMING.STARTUP_DELAY_MS}ms`,
);
await this.delay(CACHE_WARMING.STARTUP_DELAY_MS);
this.logger.log('Running deferred startup cache warm-up');
await this.runWarmUp('startup');
}

Expand Down Expand Up @@ -50,6 +79,8 @@
this.caching.publishHitRateMetrics('application');
}

// ── Private helpers ─────────────────────────────────────────────────────────

private async runWarmUp(
label: string,
task?: () => Promise<{ target: string; keysWarmed: number; durationMs: number }>,
Expand All @@ -68,4 +99,8 @@
);
}
}

private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
216 changes: 165 additions & 51 deletions src/caching/cache-warming.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
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 { 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 @@ -23,6 +24,18 @@
let userRepo: { find: jest.Mock };
let searchService: { search: jest.Mock };
let profileCompleteness: { getScore: jest.Mock };
let metrics: { recordCacheWarming: jest.Mock };

/** Returns a fully-chained query-builder mock whose terminal call resolves to `rows`. */
const mockQb = (rows: unknown[]) => ({
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue(rows),
});

beforeEach(async () => {
caching = { set: jest.fn().mockResolvedValue(undefined) };
Expand All @@ -35,13 +48,15 @@
profileCompleteness = {
getScore: jest.fn().mockResolvedValue({ score: 80, percentage: 80 }),
};
metrics = { recordCacheWarming: jest.fn() };

const module: TestingModule = await Test.createTestingModule({
providers: [
CacheWarmingService,
{ provide: CachingService, useValue: caching },
{ provide: SearchService, useValue: searchService },
{ provide: ProfileCompletenessService, useValue: profileCompleteness },
{ provide: MetricsCollectionService, useValue: metrics },
{ provide: getRepositoryToken(Course), useValue: courseRepo },
{ provide: getRepositoryToken(Enrollment), useValue: enrollmentRepo },
{ provide: getRepositoryToken(User), useValue: userRepo },
Expand All @@ -51,67 +66,166 @@
service = module.get(CacheWarmingService);
});

it('warms published course listings', async () => {
const courses = [{ id: 'c1', status: CourseStatus.PUBLISHED }];
courseRepo.find.mockResolvedValue(courses);
// ── warmCoursesList ─────────────────────────────────────────────────────────

describe('warmCoursesList', () => {
it('prioritises courses by enrollment count when enrollment data exists', async () => {
enrollmentRepo.createQueryBuilder.mockReturnValue(
mockQb([{ courseId: 'c1', cnt: '10' }, { courseId: 'c2', cnt: '5' }]),

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

View workflow job for this annotation

GitHub Actions / validate

Replace `{·courseId:·'c1',·cnt:·'10'·},·{·courseId:·'c2',·cnt:·'5'·}` with `⏎··········{·courseId:·'c1',·cnt:·'10'·},⏎··········{·courseId:·'c2',·cnt:·'5'·},⏎········`
);
const courses = [
{ id: 'c1', status: CourseStatus.PUBLISHED },
{ id: 'c2', status: CourseStatus.PUBLISHED },
];
courseRepo.find.mockResolvedValue(courses);

const result = await service.warmCoursesList();

expect(result.target).toBe('COURSES_LIST');
expect(result.keysWarmed).toBe(1);
expect(caching.set).toHaveBeenCalledWith(
buildCourseListKey('published'),
expect.any(Array),
CACHE_TTL.COURSE_METADATA,
);
expect(metrics.recordCacheWarming).toHaveBeenCalledWith(
'COURSES_LIST',
1,
expect.any(Number),
);
});

const result = await service.warmCoursesList();
it('falls back to recency ordering when no enrollment data exists', async () => {
enrollmentRepo.createQueryBuilder.mockReturnValue(mockQb([]));
const courses = [{ id: 'c1', status: CourseStatus.PUBLISHED }];
courseRepo.find.mockResolvedValue(courses);

expect(result.target).toBe('COURSES_LIST');
expect(caching.set).toHaveBeenCalledWith(
buildCourseListKey('published'),
courses,
CACHE_TTL.COURSE_METADATA,
);
const result = await service.warmCoursesList();

expect(result.target).toBe('COURSES_LIST');
expect(courseRepo.find).toHaveBeenCalledWith(
expect.objectContaining({
order: { createdAt: 'DESC' },
take: CACHE_WARMING.MAX_ENTRIES,
}),
);
});
});

it('warms popular courses using enrollment counts', async () => {
const qb = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([{ courseId: 'c1', enrollmentCount: '10' }]),
};
enrollmentRepo.createQueryBuilder.mockReturnValue(qb);
courseRepo.find.mockResolvedValue([{ id: 'c1' }]);
// ── warmPopularCourses ──────────────────────────────────────────────────────

describe('warmPopularCourses', () => {
it('warms popular courses ranked by enrollment count', async () => {
enrollmentRepo.createQueryBuilder.mockReturnValue(
mockQb([{ courseId: 'c1', enrollmentCount: '10' }]),
);
courseRepo.find.mockResolvedValue([{ id: 'c1' }]);

const result = await service.warmPopularCourses();

expect(result.target).toBe('POPULAR_COURSES');
expect(caching.set).toHaveBeenCalledWith(
buildPopularCoursesKey(),
[{ id: 'c1' }],
CACHE_TTL.POPULAR_COURSES,
);
expect(metrics.recordCacheWarming).toHaveBeenCalledWith(
'POPULAR_COURSES',
1,
expect.any(Number),
);
});

const result = await service.warmPopularCourses();
it('falls back to recency when enrollment table is empty', async () => {
enrollmentRepo.createQueryBuilder.mockReturnValue(mockQb([]));
courseRepo.find.mockResolvedValue([{ id: 'c2' }]);

expect(result.target).toBe('POPULAR_COURSES');
expect(caching.set).toHaveBeenCalledWith(
buildPopularCoursesKey(),
[{ id: 'c1' }],
CACHE_TTL.POPULAR_COURSES,
);
const result = await service.warmPopularCourses();

expect(result.target).toBe('POPULAR_COURSES');
expect(courseRepo.find).toHaveBeenCalledWith(
expect.objectContaining({ where: { status: CourseStatus.PUBLISHED } }),
);
});
});

it('warms search result keys for configured queries', async () => {
const result = await service.warmSearchResults();

expect(result.target).toBe('SEARCH_RESULTS');
expect(result.keysWarmed).toBeGreaterThan(0);
expect(searchService.search).toHaveBeenCalled();
expect(caching.set).toHaveBeenCalledWith(
buildSearchCacheKey(''),
expect.any(Object),
CACHE_TTL.SEARCH_RESULTS,
);
// ── warmSearchResults ───────────────────────────────────────────────────────

describe('warmSearchResults', () => {
it('warms all configured search queries and emits metrics', async () => {
const result = await service.warmSearchResults();

expect(result.target).toBe('SEARCH_RESULTS');
expect(result.keysWarmed).toBeGreaterThan(0);
expect(searchService.search).toHaveBeenCalled();
expect(caching.set).toHaveBeenCalledWith(
buildSearchCacheKey(''),
expect.any(Object),
CACHE_TTL.SEARCH_RESULTS,
);
expect(metrics.recordCacheWarming).toHaveBeenCalledWith(
'SEARCH_RESULTS',
result.keysWarmed,
expect.any(Number),
);
});
});

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

describe('warmUserProfiles', () => {
it('warms recently active users and emits metrics', async () => {
userRepo.find.mockResolvedValue([{ id: 'u1' }]);

const result = await service.warmUserProfiles();

expect(result.target).toBe('USER_PROFILE');
expect(result.keysWarmed).toBe(1);
expect(profileCompleteness.getScore).toHaveBeenCalledWith('u1');
expect(caching.set).toHaveBeenCalledWith(
buildUserProfileKey('u1'),
expect.objectContaining({ score: 80 }),
CACHE_TTL.USER_PROFILE,
);
expect(metrics.recordCacheWarming).toHaveBeenCalledWith(
'USER_PROFILE',
1,
expect.any(Number),
);
});

it('falls back to updatedAt ordering when no user has a lastLoginAt', async () => {
// First call (lastLoginAt query) returns empty; second call returns user
userRepo.find

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

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎········.mockResolvedValueOnce([])⏎········` with `.mockResolvedValueOnce([])`
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'u2' }]);

const result = await service.warmUserProfiles();

expect(result.keysWarmed).toBe(1);
// Second find call should order by updatedAt
expect(userRepo.find).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ order: { updatedAt: 'DESC' } }),
);
});

it('processes users in batches respecting BATCH_SIZE', async () => {
// Produce exactly BATCH_SIZE + 1 users so we exercise the inter-batch delay path
const batchSize = CACHE_WARMING.BATCH_SIZE;
const users = Array.from({ length: batchSize + 1 }, (_, i) => ({ id: `u${i}` }));
userRepo.find.mockResolvedValue(users);

const result = await service.warmUserProfiles();
// Spy on the private delay via jest fake timers
jest.useFakeTimers();
const resultPromise = service.warmUserProfiles();
// Advance past inter-batch delay
await jest.runAllTimersAsync();
const result = await resultPromise;
jest.useRealTimers();

expect(result.target).toBe('USER_PROFILE');
expect(profileCompleteness.getScore).toHaveBeenCalledWith('u1');
expect(caching.set).toHaveBeenCalledWith(
buildUserProfileKey('u1'),
expect.objectContaining({ score: 80 }),
CACHE_TTL.USER_PROFILE,
);
expect(result.keysWarmed).toBe(users.length);
expect(profileCompleteness.getScore).toHaveBeenCalledTimes(users.length);
});
});
});
Loading
Loading