Description
The API has zero caching for any database queries. Hot paths like course listings, user progress, and reward history hit the database on every request. Under load, this creates significant latency and database connection pressure.
Problem Analysis
Hot paths with no caching
| Endpoint |
File:Line |
DB Queries |
Avg Latency |
GET /api/courses |
course.service.ts:12-81 |
3 sequential queries |
~15ms |
GET /api/courses/:id |
course.service.ts:84-119 |
2 sequential queries |
~10ms |
GET /api/users/me/progress |
user.service.ts:62-108 |
5 sequential queries |
~25ms |
GET /api/rewards/history |
reward.service.ts:105-143 |
2 JOIN queries |
~20ms |
GET /api/credentials |
credential.service.ts:101-125 |
2 JOIN queries |
~15ms |
Cache invalidation challenges
- Course data changes infrequently — cache for minutes
- User progress changes on every module completion — cache for seconds
- Reward claims change on every claim — cache must be invalidated immediately
- Enrollment counts change on every enrollment — stale counts are acceptable briefly
Required Implementation
A. Cache Abstraction Layer
// New file: src/cache/index.ts
import { redis } from "../config/redis.js";
import { logger } from "../utils/logger.js";
const DEFAULT_TTL = 60; // 1 minute
export interface CacheOptions {
ttl?: number; // Time to live in seconds
prefix?: string; // Key prefix for namespacing
staleWhileRevalidate?: number; // Serve stale while refreshing
}
export async function cacheGet<T>(key: string): Promise<T | null> {
try {
const raw = await redis.get(key);
if (!raw) return null;
return JSON.parse(raw) as T;
} catch (err) {
logger.warn({ err, key }, "Cache read failed");
return null; // Graceful degradation — cache miss is not an error
}
}
export async function cacheSet<T>(
key: string,
value: T,
ttl: number = DEFAULT_TTL
): Promise<void> {
try {
await redis.setex(key, ttl, JSON.stringify(value));
} catch (err) {
logger.warn({ err, key }, "Cache write failed");
}
}
export async function cacheDel(pattern: string): Promise<void> {
try {
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
}
} catch (err) {
logger.warn({ err, pattern }, "Cache delete failed");
}
}
export function cacheKey(namespace: string, ...parts: (string | number)[]): string {
return `chainlearn:${namespace}:${parts.join(":")}`;
}
B. Course Listing Cache
// Updated course.service.ts
import { cacheGet, cacheSet, cacheDel, cacheKey } from "../cache/index.js";
async listCourses(userId: string | null, query: ListCoursesQuery) {
// Cache key includes query params but NOT userId (public data)
const key = cacheKey("courses", "list", query.difficulty ?? "all", query.page, query.limit);
// Try cache first
const cached = await cacheGet<{ courses: CourseSummary[]; total: number }>(key);
if (cached) {
// If user is authenticated, we still need to check enrollment status
if (userId) {
const userEnrollments = await this.getUserEnrollmentIds(userId);
cached.courses = cached.courses.map(c => ({
...c,
isEnrolled: userEnrollments.includes(c.id),
}));
}
return cached;
}
// Cache miss — query DB
// ... existing query logic ...
// Store in cache (30 seconds — enrollment counts can be briefly stale)
await cacheSet(key, result, 30);
return result;
}
C. Course Detail Cache
async getCourseDetail(courseId: string, userId: string | null) {
const key = cacheKey("courses", "detail", courseId);
const cached = await cacheGet<CourseDetail>(key);
if (cached) {
// Always check fresh enrollment status for the current user
if (userId) {
cached.isEnrolled = await this.isUserEnrolled(userId, courseId);
}
return cached;
}
// ... existing query logic ...
await cacheSet(key, result, 120); // 2 minutes — course detail rarely changes
return result;
}
D. Progress Cache with Short TTL
// In user.service.ts
async getProgress(userId: string) {
const key = cacheKey("user", "progress", userId);
// Very short TTL — progress changes frequently
const cached = await cacheGet<UserProgress>(key);
if (cached) return cached;
// ... existing 5-query aggregate ...
await cacheSet(key, result, 10); // 10 seconds
return result;
}
E. Cache Invalidation on Mutations
// In course.service.ts — after enrollment
async enroll(userId: string, courseId: string) {
// ... existing enrollment logic ...
// Invalidate caches
await cacheDel(cacheKey("courses", "list", "*")); // All list variants
await cacheDel(cacheKey("courses", "detail", courseId));
await cacheDel(cacheKey("user", "progress", userId));
}
// In reward.service.ts — after claim
async claimReward(userId: string, submissionId: string) {
// ... existing claim logic ...
// Invalidate user progress and reward caches
await cacheDel(cacheKey("user", "progress", userId));
await cacheDel(cacheKey("rewards", "history", userId));
}
F. Cache Warming for Course Listings
// New file: src/cache/warmer.ts
// Pre-warm popular cache entries on startup and periodically
export async function warmCourseCache() {
const courses = await courseService.listCourses(null, { page: 1, limit: 20 });
const key = cacheKey("courses", "list", "all", 1, 20);
await cacheSet(key, courses, 60);
logger.info("Course listing cache warmed");
}
// Run on startup and every 5 minutes
setInterval(warmCourseCache, 5 * 60 * 1000);
G. Cache Metrics
// Add to src/metrics/index.ts
export const cacheHits = new Counter({
name: "cache_hits_total",
help: "Total cache hits",
labelNames: ["namespace"],
registers: [register],
});
export const cacheMisses = new Counter({
name: "cache_misses_total",
help: "Total cache misses",
labelNames: ["namespace"],
registers: [register],
});
export const cacheHitRate = new Gauge({
name: "cache_hit_rate",
help: "Cache hit rate (0-1)",
labelNames: ["namespace"],
registers: [register],
});
Cache TTL Strategy Summary
| Data Type |
TTL |
Rationale |
| Course listing |
30s |
Enrollment counts change infrequently |
| Course detail |
120s |
Course content rarely changes |
| User progress |
10s |
Changes on every module completion |
| Reward history |
30s |
Changes on every claim |
| Credential list |
60s |
Changes infrequently |
| Enrollment status |
0s (no cache) |
Must always be fresh for the current user |
Files to create/modify
- New:
src/cache/index.ts — cache abstraction
- New:
src/cache/warmer.ts — cache warming
- Modify:
src/modules/courses/course.service.ts — add caching
- Modify:
src/modules/users/user.service.ts — add caching
- Modify:
src/modules/rewards/reward.service.ts — add caching + invalidation
- Modify:
src/modules/credentials/credential.service.ts — add caching
- Modify:
src/server.ts — run cache warmer on startup
Testing Requirements
- First request hits DB (cache miss), second request returns from cache (cache hit)
- After mutation, cache is invalidated (next request hits DB)
- Cache failure does not crash the request (graceful degradation)
- Verify cache metrics are recorded
- Load test: 100 concurrent course list requests → verify only 1 DB query
- Test cache warming runs on startup
References
Description
The API has zero caching for any database queries. Hot paths like course listings, user progress, and reward history hit the database on every request. Under load, this creates significant latency and database connection pressure.
Problem Analysis
Hot paths with no caching
GET /api/coursescourse.service.ts:12-81GET /api/courses/:idcourse.service.ts:84-119GET /api/users/me/progressuser.service.ts:62-108GET /api/rewards/historyreward.service.ts:105-143GET /api/credentialscredential.service.ts:101-125Cache invalidation challenges
Required Implementation
A. Cache Abstraction Layer
B. Course Listing Cache
C. Course Detail Cache
D. Progress Cache with Short TTL
E. Cache Invalidation on Mutations
F. Cache Warming for Course Listings
G. Cache Metrics
Cache TTL Strategy Summary
Files to create/modify
src/cache/index.ts— cache abstractionsrc/cache/warmer.ts— cache warmingsrc/modules/courses/course.service.ts— add cachingsrc/modules/users/user.service.ts— add cachingsrc/modules/rewards/reward.service.ts— add caching + invalidationsrc/modules/credentials/credential.service.ts— add cachingsrc/server.ts— run cache warmer on startupTesting Requirements
References