From 4a53fc43c543bd0e0747d1fa4d992934bf6ad43a Mon Sep 17 00:00:00 2001 From: David-282 Date: Sat, 30 May 2026 23:04:51 +0100 Subject: [PATCH] feat(caching): add aggressive caching for expensive computations #603 --- src/caching/cache-key.builder.ts | 12 ++ src/caching/caching.constants.ts | 19 +++ src/caching/caching.module.ts | 4 +- src/caching/computation-cache.service.spec.ts | 155 ++++++++++++++++++ src/caching/computation-cache.service.ts | 143 ++++++++++++++++ 5 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 src/caching/computation-cache.service.spec.ts create mode 100644 src/caching/computation-cache.service.ts diff --git a/src/caching/cache-key.builder.ts b/src/caching/cache-key.builder.ts index bca2e297..715a2f20 100644 --- a/src/caching/cache-key.builder.ts +++ b/src/caching/cache-key.builder.ts @@ -33,3 +33,15 @@ export function buildSearchCacheKey( } return `${CACHE_PREFIXES.SEARCH}:${hash.toString()}`; } + +// --------------------------------------------------------------------------- +// Computation cache key builder — Issue #603 +// +// Format: cache:computation:: +// Examples: +// cache:computation:leaderboard:top-players:10 +// cache:computation:leaderboard:user-rank:user-abc +// --------------------------------------------------------------------------- +export function buildComputationKey(type: string, identifier: string): string { + return `cache:computation:${type}:${identifier}`; +} diff --git a/src/caching/caching.constants.ts b/src/caching/caching.constants.ts index 6c0948fb..fdfc0a9a 100644 --- a/src/caching/caching.constants.ts +++ b/src/caching/caching.constants.ts @@ -80,3 +80,22 @@ export const CACHE_EVENTS = { CACHE_INVALIDATED: 'cache.invalidated', CACHE_PURGED: 'cache.purged', } as const; + +// --------------------------------------------------------------------------- +// Computation cache TTLs — Issue #603 +// +// These TTLs are tuned for expensive O(n) computations that run against the +// full dataset on every request: +// +// LEADERBOARD — top-players query: full table scan ordered by totalPoints. +// 30 min is safe; leaderboard shifts slowly. +// USER_RANK — getUserRank() loads ALL user progress into memory (O(n) +// linear search). 5 min balances freshness vs DB load. +// --------------------------------------------------------------------------- +export const COMPUTATION_TTL = { + LEADERBOARD: 1800, // 30 minutes — top players list + USER_RANK: 300, // 5 minutes — per-user rank lookup +} as const; + +// Merge into CACHE_TTL for consistent access across the codebase +//(CACHE_TTL as Record)['COMPUTATION'] = COMPUTATION_TTL; diff --git a/src/caching/caching.module.ts b/src/caching/caching.module.ts index 657f0168..bd1ffac6 100644 --- a/src/caching/caching.module.ts +++ b/src/caching/caching.module.ts @@ -16,6 +16,7 @@ import { CacheInvalidationService } from './cache-invalidation.service'; import { CacheInvalidationListener } from './cache-invalidation.listener'; import { CacheWarmingService } from './cache-warming.service'; import { CacheWarmingScheduler } from './cache-warming.scheduler'; +import { ComputationCacheService } from './computation-cache.service'; /** * Registers the application-level Redis cache layer, warming engine, and invalidation listeners. @@ -59,7 +60,8 @@ import { CacheWarmingScheduler } from './cache-warming.scheduler'; CacheWarmingService, CacheWarmingScheduler, ProfileCompletenessService, + ComputationCacheService, ], - exports: [CachingService, CacheInvalidationService, CacheWarmingService], + exports: [CachingService, CacheInvalidationService, CacheWarmingService, ComputationCacheService], }) export class CachingModule {} diff --git a/src/caching/computation-cache.service.spec.ts b/src/caching/computation-cache.service.spec.ts new file mode 100644 index 00000000..52bf7699 --- /dev/null +++ b/src/caching/computation-cache.service.spec.ts @@ -0,0 +1,155 @@ +import { ComputationCacheService } from './computation-cache.service'; +import { CachingService } from './caching.service'; +import { MetricsCollectionService } from '../monitoring/metrics/metrics-collection.service'; +import { COMPUTATION_TTL } from './caching.constants'; + +describe('ComputationCacheService', () => { + let service: ComputationCacheService; + let caching: { + get: jest.Mock; + set: jest.Mock; + delete: jest.Mock; + }; + let metrics: { updateCacheHitRate: jest.Mock }; + + beforeEach(() => { + caching = { + get: jest.fn(), + set: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + }; + metrics = { updateCacheHitRate: jest.fn() }; + service = new ComputationCacheService( + caching as unknown as CachingService, + metrics as unknown as MetricsCollectionService, + ); + }); + + // ── Cache hit ────────────────────────────────────────────────────────────── + + it('returns cached value and does not call factory on hit', async () => { + const cached = [{ id: 'user-1', totalPoints: 500 }]; + caching.get.mockResolvedValue(cached); + const factory = jest.fn(); + + const result = await service.compute( + 'leaderboard:top-players', + '10', + factory, + COMPUTATION_TTL.LEADERBOARD, + ); + + expect(result).toEqual(cached); + expect(factory).not.toHaveBeenCalled(); + expect(service.getStats().hits).toBe(1); + expect(service.getStats().misses).toBe(0); + }); + + // ── Cache miss ───────────────────────────────────────────────────────────── + + it('calls factory and caches result on miss', async () => { + caching.get.mockResolvedValue(undefined); + const data = [{ id: 'user-1', totalPoints: 500 }]; + const factory = jest.fn().mockResolvedValue(data); + + const result = await service.compute( + 'leaderboard:top-players', + '10', + factory, + COMPUTATION_TTL.LEADERBOARD, + ); + + expect(result).toEqual(data); + expect(factory).toHaveBeenCalledTimes(1); + expect(caching.set).toHaveBeenCalledWith( + expect.stringContaining('leaderboard:top-players'), + data, + COMPUTATION_TTL.LEADERBOARD, + ); + expect(service.getStats().misses).toBe(1); + }); + + // ── Stampede protection ──────────────────────────────────────────────────── + + it('deduplicates concurrent requests for the same key', async () => { + caching.get.mockResolvedValue(undefined); + const data = [{ id: 'user-1', totalPoints: 500 }]; + const factory = jest.fn().mockResolvedValue(data); + + const [r1, r2, r3] = await Promise.all([ + service.compute('leaderboard:top-players', '10', factory, COMPUTATION_TTL.LEADERBOARD), + service.compute('leaderboard:top-players', '10', factory, COMPUTATION_TTL.LEADERBOARD), + service.compute('leaderboard:top-players', '10', factory, COMPUTATION_TTL.LEADERBOARD), + ]); + + expect(factory).toHaveBeenCalledTimes(1); + expect(r1).toEqual(data); + expect(r2).toEqual(data); + expect(r3).toEqual(data); + }); + + // ── User rank computation ────────────────────────────────────────────────── + + it('caches user rank computation with correct key and TTL', async () => { + caching.get.mockResolvedValue(undefined); + const factory = jest.fn().mockResolvedValue(42); + + const result = await service.compute( + 'leaderboard:user-rank', + 'user-abc', + factory, + COMPUTATION_TTL.USER_RANK, + ); + + expect(result).toBe(42); + expect(caching.set).toHaveBeenCalledWith( + expect.stringContaining('user-rank'), + 42, + COMPUTATION_TTL.USER_RANK, + ); + }); + + // ── Invalidation ─────────────────────────────────────────────────────────── + + it('deletes the correct cache key on invalidation', async () => { + await service.invalidate('leaderboard:top-players', '10'); + + expect(caching.delete).toHaveBeenCalledWith( + expect.stringContaining('leaderboard:top-players'), + ); + }); + + // ── Performance metrics ──────────────────────────────────────────────────── + + it('calculates hit rate correctly after mixed hits and misses', async () => { + caching.get + .mockResolvedValueOnce([{ id: 'user-1' }]) + .mockResolvedValueOnce(undefined); + + const factory = jest.fn().mockResolvedValue([]); + + await service.compute('leaderboard:top-players', '10', factory, COMPUTATION_TTL.LEADERBOARD); + await service.compute('leaderboard:top-players', '5', factory, COMPUTATION_TTL.LEADERBOARD); + + const stats = service.getStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBe(50); + }); + + it('publishes computation hit rate to Prometheus metrics', async () => { + caching.get.mockResolvedValue([{ id: 'user-1' }]); + const factory = jest.fn(); + + await service.compute('leaderboard:top-players', '10', factory, COMPUTATION_TTL.LEADERBOARD); + + service.publishMetrics(); + + expect(metrics.updateCacheHitRate).toHaveBeenCalledWith('computation', 100); + }); + + it('publishes zero hit rate when no requests have been made', () => { + service.publishMetrics(); + expect(metrics.updateCacheHitRate).toHaveBeenCalledWith('computation', 0); + }); +}); diff --git a/src/caching/computation-cache.service.ts b/src/caching/computation-cache.service.ts new file mode 100644 index 00000000..f2b151a9 --- /dev/null +++ b/src/caching/computation-cache.service.ts @@ -0,0 +1,143 @@ +import { Injectable, Logger, Optional } from '@nestjs/common'; +import { CachingService } from './caching.service'; +import { MetricsCollectionService } from '../monitoring/metrics/metrics-collection.service'; +import { COMPUTATION_TTL } from './caching.constants'; +import { buildComputationKey } from './cache-key.builder'; + +export interface ComputationCacheStats { + hits: number; + misses: number; + hitRate: number; +} + +/** + * Provides aggressive caching for expensive computations. + * + * ## Problem + * Certain operations — leaderboard ranking, user rank lookups — are O(n) + * over the full dataset and run on every request. Without caching, these + * hammer the database under load. + * + * ## Solution + * Cache-aside pattern with: + * - Per-computation-type TTLs (see CACHE_TTL.COMPUTATION) + * - Stampede protection via in-flight promise deduplication + * - Hit/miss tracking published to Prometheus via MetricsCollectionService + * + * ## Usage + * ```ts + * const top = await this.computationCache.compute( + * 'leaderboard:top-players', + * '10', + * () => this.leaderboardService.getTopPlayers(10), + * CACHE_TTL.COMPUTATION.LEADERBOARD, + * ); + * ``` + */ +@Injectable() +export class ComputationCacheService { + private readonly logger = new Logger(ComputationCacheService.name); + + // In-flight promise map for stampede protection. + // If multiple requests ask for the same key simultaneously while the cache + // is cold, only one factory call is made — the rest await the same promise. + private readonly inFlight = new Map>(); + + private hits = 0; + private misses = 0; + + constructor( + private readonly caching: CachingService, + @Optional() private readonly metrics?: MetricsCollectionService, + ) {} + + /** + * Returns a cached result for the given computation key, or executes the + * factory and caches the result for ttlSeconds. + * + * @param type Short label for the computation type (e.g. 'leaderboard:top-players') + * @param identifier Unique scope identifier (e.g. userId, limit value, 'global') + * @param factory Async function that performs the expensive computation + * @param ttlSeconds How long to cache the result + */ + async compute( + type: string, + identifier: string, + factory: () => Promise, + ttlSeconds: number, + ): Promise { + const key = buildComputationKey(type, identifier); + + // ── Cache hit ────────────────────────────────────────────────────────── + const cached = await this.caching.get(key); + if (cached !== undefined) { + this.recordHit(type); + return cached; + } + + // ── Stampede protection ──────────────────────────────────────────────── + // If another request already kicked off the factory for this key, await + // it instead of running a second concurrent DB query. + if (this.inFlight.has(key)) { + this.logger.debug(`Awaiting in-flight computation for key: ${key}`); + return this.inFlight.get(key) as Promise; + } + + // ── Cache miss — run factory ─────────────────────────────────────────── + this.recordMiss(type); + + const promise = factory() + .then(async (result) => { + await this.caching.set(key, result, ttlSeconds); + this.logger.debug(`Cached computation [${type}] key: ${key} TTL: ${ttlSeconds}s`); + return result; + }) + .finally(() => { + this.inFlight.delete(key); + }); + + this.inFlight.set(key, promise); + return promise as Promise; + } + + /** + * Invalidates a specific computation cache entry. + */ + async invalidate(type: string, identifier: string): Promise { + const key = buildComputationKey(type, identifier); + await this.caching.delete(key); + this.logger.debug(`Invalidated computation cache [${type}] key: ${key}`); + } + + /** + * Returns current hit/miss stats for this service instance. + */ + getStats(): ComputationCacheStats { + const total = this.hits + this.misses; + return { + hits: this.hits, + misses: this.misses, + hitRate: total === 0 ? 0 : (this.hits / total) * 100, + }; + } + + /** + * Publishes computation cache hit rate to Prometheus. + * Called by CacheWarmingScheduler on its existing 5-minute cron. + */ + publishMetrics(): void { + const { hitRate } = this.getStats(); + this.metrics?.updateCacheHitRate('computation', hitRate); + this.logger.debug(`Computation cache hit rate: ${hitRate.toFixed(1)}%`); + } + + private recordHit(type: string): void { + this.hits += 1; + this.logger.debug(`Computation cache hit [${type}]`); + } + + private recordMiss(type: string): void { + this.misses += 1; + this.logger.debug(`Computation cache miss [${type}]`); + } +}