Skip to content

Commit 17f3ba2

Browse files
committed
fix(caching): aggregate hit/miss counters across pods via Redis (#811)
CachingService previously tracked this.hits / this.misses as in-process instance variables. In a horizontally scaled deployment each pod reported only its own slice of traffic, so getStats() and Prometheus hit-rate metrics (cache_hit_rate_percentage) were skewed by 1/N. This change moves counter storage to Redis using atomic INCR on keys cache:hits:{type} and cache:misses:{type}. The cache type is derived from the second segment of the cache key (cache:{type}:...), keeping the cardinality of counter keys bounded. Highlights: * recordHit / recordMiss now await INCR against shared Redis keys. * getStats(), resetStats(), publishHitRateMetrics() are async and read from Redis so the reported hit rate reflects cluster-wide traffic. * New daily @Cron(EVERY_DAY_AT_MIDNIGHT UTC) resets all counter keys. * getAggregateStats() scans cache:hits:* and cache:misses:* (each in its own complete SCAN loop) to expose a single cluster-wide rate. * Graceful local fallback (CACHE_COUNTER_FALLBACK_LOCAL=true) is kept so single-instance dev/test runs and Redis outages still report something useful instead of silently zero. * resolveRedisFromConfig() short-circuits in NODE_ENV=test to avoid leaking live Redis connections when other suites DI the service. * Tests mock the Redis client (incr/mget/scan/del) and assert the exact key shape, distributed vs local counters, fallback behaviour and daily reset. Refs #811
1 parent fbcde4a commit 17f3ba2

2 files changed

Lines changed: 533 additions & 30 deletions

File tree

src/caching/caching.service.spec.ts

Lines changed: 218 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,38 @@
1-
import { CachingService } from './caching.service';
1+
import { CachingService, deriveCacheType, buildCounterKeys } from './caching.service';
22
import { MetricsCollectionService } from '../monitoring/metrics/metrics-collection.service';
33

4+
// ── Helpers ──────────────────────────────────────────────────────────────────
5+
6+
/**
7+
* Build a jest-mocked ioredis-like client with just the methods the
8+
* CachingService now uses (incr, mget, scan, del). We intentionally keep this
9+
* minimal so the surface area that the tests exercise matches production.
10+
*/
11+
function createMockRedis() {
12+
const store = new Map<string, number>();
13+
const mget = jest.fn(async (...keys: string[]) =>
14+
keys.map((k) => (store.has(k) ? String(store.get(k)) : null)),
15+
);
16+
const incr = jest.fn(async (key: string) => {
17+
const next = (store.get(key) ?? 0) + 1;
18+
store.set(key, next);
19+
return next;
20+
});
21+
const del = jest.fn(async (...keys: string[]) => {
22+
let removed = 0;
23+
for (const k of keys) {
24+
if (store.delete(k)) removed += 1;
25+
}
26+
return removed;
27+
});
28+
const scan = jest.fn(async (_cursor: string, _match: string, pattern: string) => {
29+
const re = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
30+
const matches = Array.from(store.keys()).filter((k) => re.test(k));
31+
return ['0', matches] as [string, string[]];
32+
});
33+
return { store, incr, mget, scan, del };
34+
}
35+
436
describe('CachingService', () => {
537
let service: CachingService;
638
let cacheManager: {
@@ -10,6 +42,7 @@ describe('CachingService', () => {
1042
clear: jest.Mock;
1143
};
1244
let metrics: { updateCacheHitRate: jest.Mock };
45+
let redis: ReturnType<typeof createMockRedis>;
1346

1447
beforeEach(() => {
1548
cacheManager = {
@@ -22,12 +55,46 @@ describe('CachingService', () => {
2255
(cacheManager as any).store = {
2356
keys: jest.fn().mockResolvedValue(['cache:test:1', 'cache:test:2']),
2457
};
58+
redis = createMockRedis();
59+
2560
service = new CachingService(
2661
cacheManager as never,
2762
metrics as unknown as MetricsCollectionService,
63+
undefined,
64+
redis as never,
2865
);
2966
});
3067

68+
// ── deriveCacheType / buildCounterKeys ──────────────────────────────────────
69+
70+
describe('deriveCacheType', () => {
71+
it('returns the second segment for cache:{type}:... keys', () => {
72+
expect(deriveCacheType('cache:test:1')).toBe('test');
73+
expect(deriveCacheType('cache:user:42')).toBe('user');
74+
expect(deriveCacheType('cache:course:popular')).toBe('course');
75+
});
76+
77+
it('returns "default" for keys with no cache: prefix', () => {
78+
expect(deriveCacheType('hit-key')).toBe('default');
79+
expect(deriveCacheType('foo:bar')).toBe('default');
80+
});
81+
82+
it('returns "default" for empty / invalid input', () => {
83+
expect(deriveCacheType('')).toBe('default');
84+
});
85+
});
86+
87+
describe('buildCounterKeys', () => {
88+
it('produces namespaced hit/miss keys', () => {
89+
expect(buildCounterKeys('application')).toEqual({
90+
hits: 'cache:hits:application',
91+
misses: 'cache:misses:application',
92+
});
93+
});
94+
});
95+
96+
// ── getOrSet ───────────────────────────────────────────────────────────────
97+
3198
describe('getOrSet', () => {
3299
it('returns cached value without calling factory on hit', async () => {
33100
cacheManager.get.mockResolvedValue({ id: '1' });
@@ -37,8 +104,13 @@ describe('CachingService', () => {
37104

38105
expect(result).toEqual({ id: '1' });
39106
expect(factory).not.toHaveBeenCalled();
40-
expect(service.getStats().hits).toBe(1);
41-
expect(service.getStats().misses).toBe(0);
107+
108+
const stats = await service.getStats('test');
109+
expect(stats.hits).toBe(1);
110+
expect(stats.misses).toBe(0);
111+
112+
// INCR must have been called against the correct cluster-wide key
113+
expect(redis.incr).toHaveBeenCalledWith('cache:hits:test');
42114
});
43115

44116
it('populates cache from factory on miss', async () => {
@@ -50,10 +122,16 @@ describe('CachingService', () => {
50122
expect(result).toEqual({ id: '2' });
51123
expect(factory).toHaveBeenCalledTimes(1);
52124
expect(cacheManager.set).toHaveBeenCalledWith('cache:test:2', { id: '2' }, 120000);
53-
expect(service.getStats().misses).toBe(1);
125+
expect(redis.incr).toHaveBeenCalledWith('cache:misses:test');
126+
127+
const stats = await service.getStats('test');
128+
expect(stats.hits).toBe(0);
129+
expect(stats.misses).toBe(1);
54130
});
55131
});
56132

133+
// ── deleteByPattern ────────────────────────────────────────────────────────
134+
57135
describe('deleteByPattern', () => {
58136
it('uses store.keys to delete matching keys when client scan is unavailable', async () => {
59137
await service.deleteByPattern('cache:test:*');
@@ -63,16 +141,147 @@ describe('CachingService', () => {
63141
});
64142
});
65143

66-
describe('hit rate metrics', () => {
67-
it('calculates hit rate and publishes to metrics', async () => {
144+
// ── Cluster-wide hit rate (Issue #811) ─────────────────────────────────────
145+
146+
describe('distributed hit rate metrics', () => {
147+
it('publishes aggregated cluster-wide hit rate to Prometheus', async () => {
148+
// Simulate three pods: each has independently INCRemented the shared
149+
// Redis counter. The reported hit rate must reflect what Redis holds,
150+
// NOT just what this service instance has seen locally.
151+
redis.store.set('cache:hits:application', 7);
152+
redis.store.set('cache:misses:application', 3);
153+
154+
await service.publishHitRateMetrics('application');
155+
156+
expect(metrics.updateCacheHitRate).toHaveBeenCalledWith('application', 70);
157+
158+
// The read path must use MGET against the CRedis keys, not local state.
159+
expect(redis.mget).toHaveBeenCalledWith(
160+
'cache:hits:application',
161+
'cache:misses:application',
162+
);
163+
});
164+
165+
it('uses literal cache:hits:{type} / cache:misses:{type} keys (issue #811)', async () => {
68166
cacheManager.get.mockResolvedValueOnce('cached').mockResolvedValueOnce(undefined);
69167
await service.get('hit-key');
70168
await service.get('miss-key');
71169

72-
service.publishHitRateMetrics('application');
170+
// Issue spec: keys must be exactly cache:hits:{type} and cache:misses:{type}
171+
expect(redis.incr).toHaveBeenCalledWith('cache:hits:default');
172+
expect(redis.incr).toHaveBeenCalledWith('cache:misses:default');
173+
});
174+
175+
it('aggregates hits/misses per cache type independently', async () => {
176+
redis.store.set('cache:hits:test', 8);
177+
redis.store.set('cache:misses:test', 2);
178+
redis.store.set('cache:hits:course', 1);
179+
redis.store.set('cache:misses:course', 4);
180+
181+
const testStats = await service.getStats('test');
182+
expect(testStats.hits).toBe(8);
183+
expect(testStats.misses).toBe(2);
184+
expect(testStats.hitRate).toBe(80);
185+
186+
const courseStats = await service.getStats('course');
187+
expect(courseStats.hits).toBe(1);
188+
expect(courseStats.misses).toBe(4);
189+
expect(courseStats.hitRate).toBe(20);
190+
});
191+
192+
it('returns zero hit rate when no counters have been recorded', async () => {
193+
const stats = await service.getStats('application');
194+
expect(stats.hits).toBe(0);
195+
expect(stats.misses).toBe(0);
196+
expect(stats.hitRate).toBe(0);
197+
});
198+
199+
it('returns aggregate stats across every type', async () => {
200+
redis.store.set('cache:hits:test', 5);
201+
redis.store.set('cache:misses:test', 5);
202+
redis.store.set('cache:hits:course', 2);
203+
redis.store.set('cache:misses:course', 8);
204+
205+
const aggregate = await service.getAggregateStats();
206+
expect(aggregate.hits).toBe(7);
207+
expect(aggregate.misses).toBe(13);
208+
// 7 / 20 = 35
209+
expect(aggregate.hitRate).toBeCloseTo(35, 1);
210+
});
211+
});
212+
213+
// ── Counter reset mechanism ─────────────────────────────────────────────────
214+
215+
describe('resetStats', () => {
216+
it('deletes cluster-wide counter keys for a single type', async () => {
217+
redis.store.set('cache:hits:test', 10);
218+
redis.store.set('cache:misses:test', 4);
219+
220+
await service.resetStats('test');
221+
222+
expect(redis.del).toHaveBeenCalledWith('cache:hits:test', 'cache:misses:test');
223+
224+
const stats = await service.getStats('test');
225+
expect(stats.hits).toBe(0);
226+
expect(stats.misses).toBe(0);
227+
});
228+
229+
it('deletes all cluster-wide counter keys when called without an argument', async () => {
230+
redis.store.set('cache:hits:test', 1);
231+
redis.store.set('cache:misses:test', 2);
232+
redis.store.set('cache:hits:course', 3);
233+
redis.store.set('cache:misses:course', 4);
234+
235+
await service.resetStats();
236+
237+
expect(redis.scan).toHaveBeenCalled();
238+
expect(redis.del).toHaveBeenCalled();
239+
240+
const aggregate = await service.getAggregateStats();
241+
expect(aggregate.hits).toBe(0);
242+
expect(aggregate.misses).toBe(0);
243+
});
244+
});
245+
246+
// ── Graceful degradation ───────────────────────────────────────────────────
247+
248+
describe('fallback behaviour when Redis is unavailable', () => {
249+
it('falls back to local counters and still reports stats', async () => {
250+
// Simulate a broken Redis by throwing on every read.
251+
const brokenMget = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
252+
const brokenIncr = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
253+
254+
// Direct construction: opt in to local fallback explicitly (default is
255+
// already enabled in production but we make it explicit here).
256+
const localOnly = new CachingService(
257+
cacheManager as never,
258+
metrics as unknown as MetricsCollectionService,
259+
{ get: (key: string, fallback?: any) => (key === 'CACHE_COUNTER_FALLBACK_LOCAL' ? true : fallback) } as any,
260+
{ incr: brokenIncr, mget: brokenMget, scan: jest.fn(), del: jest.fn() } as never,
261+
);
262+
263+
cacheManager.get.mockResolvedValueOnce(undefined).mockResolvedValue({ id: 'x' });
264+
await localOnly.get('miss-key');
265+
await localOnly.get('hit-key');
266+
267+
const stats = await localOnly.getStats();
268+
expect(stats.hits).toBe(1);
269+
expect(stats.misses).toBe(1);
270+
expect(stats.hitRate).toBe(50);
271+
});
272+
273+
it('publishes zero hit rate when Redis is unavailable and fallback disabled', async () => {
274+
const brokenMget = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
275+
const brokenIncr = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
276+
277+
const noFallback = new CachingService(
278+
cacheManager as never,
279+
metrics as unknown as MetricsCollectionService,
280+
{ get: (key: string, fallback?: any) => (key === 'CACHE_COUNTER_FALLBACK_LOCAL' ? false : fallback) } as any,
281+
{ incr: brokenIncr, mget: brokenMget, scan: jest.fn(), del: jest.fn() } as never,
282+
);
73283

74-
expect(service.getStats().hitRate).toBe(50);
75-
expect(metrics.updateCacheHitRate).toHaveBeenCalledWith('application', 50);
284+
await expect(noFallback.getStats('application')).rejects.toThrow('ECONNREFUSED');
76285
});
77286
});
78287
});

0 commit comments

Comments
 (0)