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
12 changes: 12 additions & 0 deletions src/caching/cache-key.builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,15 @@ export function buildSearchCacheKey(
}
return `${CACHE_PREFIXES.SEARCH}:${hash.toString()}`;
}

// ---------------------------------------------------------------------------
// Computation cache key builder — Issue #603
//
// Format: cache:computation:<type>:<identifier>
// 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}`;
}
19 changes: 19 additions & 0 deletions src/caching/caching.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)['COMPUTATION'] = COMPUTATION_TTL;
4 changes: 3 additions & 1 deletion src/caching/caching.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {}
155 changes: 155 additions & 0 deletions src/caching/computation-cache.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
143 changes: 143 additions & 0 deletions src/caching/computation-cache.service.ts
Original file line number Diff line number Diff line change
@@ -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';

Check warning on line 4 in src/caching/computation-cache.service.ts

View workflow job for this annotation

GitHub Actions / ESLint

'COMPUTATION_TTL' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 4 in src/caching/computation-cache.service.ts

View workflow job for this annotation

GitHub Actions / Quality Gates (lint)

'COMPUTATION_TTL' is defined but never used. Allowed unused vars must match /^_/u
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<string, Promise<unknown>>();

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<T>(
type: string,
identifier: string,
factory: () => Promise<T>,
ttlSeconds: number,
): Promise<T> {
const key = buildComputationKey(type, identifier);

// ── Cache hit ──────────────────────────────────────────────────────────
const cached = await this.caching.get<T>(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<T>;
}

// ── 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<T>;
}

/**
* Invalidates a specific computation cache entry.
*/
async invalidate(type: string, identifier: string): Promise<void> {
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}]`);
}
}
Loading