Skip to content

[Expert] Implement Redis caching layer with cache invalidation strategy for hot database paths #11

Description

@DeFiVC

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

  1. Course data changes infrequently — cache for minutes
  2. User progress changes on every module completion — cache for seconds
  3. Reward claims change on every claim — cache must be invalidated immediately
  4. 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

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignadvancedAdvanced difficultyenhancementNew feature or requesttypescriptTypeScript language

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions