Skip to content

Commit eccd6a2

Browse files
Merge pull request #1330 from Medo99-olo/task-done
feat: add prometheus metrics, improve cache & health
2 parents ae1a9d9 + c8760cd commit eccd6a2

9 files changed

Lines changed: 475 additions & 43 deletions

‎.env.example‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ PGBOUNCER_POOL_TIMEOUT=10000
5252
# Cache Warming
5353
# Set to 'false' to disable startup and periodic cache warming (default: enabled)
5454
CACHE_WARMING_ENABLED=true
55-
# Interval (ms) between periodic warming runs (default: 1800000 = 30 minutes)
55+
# Interval (ms) between periodic warming runs using setInterval (default: 1800000 = 30 minutes)
56+
# Note: This uses Node.js setInterval, not @nestjs/schedule. The timer is unref'd so it doesn't keep the process alive.
5657
CACHE_WARMING_INTERVAL=1800000
5758
# Google OAuth2
5859
GOOGLE_CLIENT_ID=your-google-client-id

‎src/cache/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Always build keys with `CACHE_KEYS.*` (e.g. `CACHE_KEYS.PROPERTY_BY_ID(id)` →
4848
| When | What |
4949
| ------------------------- | ----------------------------------------------------------- |
5050
| `onModuleInit` (startup) | Full `warmCache()`, **awaited**, so it delays app bootstrap |
51-
| `@Cron(EVERY_30_MINUTES)` | Full `warmCache()` |
51+
| Periodic interval | Full `warmCache()` via `setInterval` (configurable) |
5252

5353
`warmCache()` runs these in parallel with `Promise.allSettled`, so one failure doesn't stop the others:
5454

‎src/cache/cache-monitoring.service.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Injectable, Logger } from '@nestjs/common';
2+
import { cacheHitRatio, cacheHitsTotal, cacheMissesTotal } from '../metrics/metrics.controller';
23

34
/**
45
* Cache Monitoring Service
@@ -49,6 +50,8 @@ export class CacheMonitoringService {
4950
recordHit(): void {
5051
this.metrics.hits += 1;
5152
this.metrics.totalRequests += 1;
53+
cacheHitsTotal.inc();
54+
this.updateCacheHitRatio();
5255
}
5356

5457
/**
@@ -57,6 +60,8 @@ export class CacheMonitoringService {
5760
recordMiss(): void {
5861
this.metrics.misses += 1;
5962
this.metrics.totalRequests += 1;
63+
cacheMissesTotal.inc();
64+
this.updateCacheHitRatio();
6065
}
6166

6267
/**
@@ -67,6 +72,17 @@ export class CacheMonitoringService {
6772
this.metrics.totalRequests += 1;
6873
}
6974

75+
/**
76+
* Update the Prometheus cache hit ratio gauge based on current metrics.
77+
*/
78+
private updateCacheHitRatio(): void {
79+
const total = this.metrics.hits + this.metrics.misses;
80+
if (total > 0) {
81+
const ratio = this.metrics.hits / total;
82+
cacheHitRatio.set(ratio);
83+
}
84+
}
85+
7086
/**
7187
* Record response time.
7288
*/
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { CacheWarmingService, resolveWarmingIntervalMs, DEFAULT_WARMING_INTERVAL_MS } from './cache-warming.service';
3+
import { CacheService } from './cache.service';
4+
import { CacheMonitoringService } from './cache-monitoring.service';
5+
import { PrismaService } from '../database/prisma.service';
6+
7+
describe('CacheWarmingService', () => {
8+
let service: CacheWarmingService;
9+
let cacheService: jest.Mocked<CacheService>;
10+
let cacheMonitoringService: jest.Mocked<CacheMonitoringService>;
11+
let prisma: jest.Mocked<PrismaService>;
12+
13+
const originalEnv = process.env;
14+
15+
beforeEach(async () => {
16+
process.env = { ...originalEnv };
17+
delete process.env.CACHE_WARMING_ENABLED;
18+
19+
cacheService = {
20+
set: jest.fn().mockResolvedValue(undefined),
21+
get: jest.fn().mockResolvedValue(undefined),
22+
} as any;
23+
24+
cacheMonitoringService = {
25+
getMetrics: jest.fn().mockReturnValue({ hitRate: 85, totalRequests: 100 }),
26+
} as any;
27+
28+
prisma = {
29+
property: {
30+
findMany: jest.fn().mockResolvedValue([]),
31+
},
32+
user: {
33+
findMany: jest.fn().mockResolvedValue([]),
34+
},
35+
popularSearch: {
36+
findMany: jest.fn().mockResolvedValue([]),
37+
},
38+
} as any;
39+
40+
const module: TestingModule = await Test.createTestingModule({
41+
providers: [
42+
CacheWarmingService,
43+
{ provide: CacheService, useValue: cacheService },
44+
{ provide: CacheMonitoringService, useValue: cacheMonitoringService },
45+
{ provide: PrismaService, useValue: prisma },
46+
],
47+
}).compile();
48+
49+
service = module.get<CacheWarmingService>(CacheWarmingService);
50+
});
51+
52+
afterEach(() => {
53+
process.env = originalEnv;
54+
jest.clearAllMocks();
55+
jest.useRealTimers();
56+
});
57+
58+
describe('resolveWarmingIntervalMs', () => {
59+
it('should return default interval when env var is not set', () => {
60+
delete process.env.CACHE_WARMING_INTERVAL;
61+
expect(resolveWarmingIntervalMs()).toBe(DEFAULT_WARMING_INTERVAL_MS);
62+
});
63+
64+
it('should return default interval when env var is invalid', () => {
65+
process.env.CACHE_WARMING_INTERVAL = 'invalid';
66+
expect(resolveWarmingIntervalMs()).toBe(DEFAULT_WARMING_INTERVAL_MS);
67+
});
68+
69+
it('should return default interval when env var is zero', () => {
70+
process.env.CACHE_WARMING_INTERVAL = '0';
71+
expect(resolveWarmingIntervalMs()).toBe(DEFAULT_WARMING_INTERVAL_MS);
72+
});
73+
74+
it('should return default interval when env var is negative', () => {
75+
process.env.CACHE_WARMING_INTERVAL = '-1000';
76+
expect(resolveWarmingIntervalMs()).toBe(DEFAULT_WARMING_INTERVAL_MS);
77+
});
78+
79+
it('should return custom interval when env var is valid', () => {
80+
process.env.CACHE_WARMING_INTERVAL = '60000';
81+
expect(resolveWarmingIntervalMs()).toBe(60000);
82+
});
83+
84+
it('should parse string numbers correctly', () => {
85+
process.env.CACHE_WARMING_INTERVAL = '300000';
86+
expect(resolveWarmingIntervalMs()).toBe(300000);
87+
});
88+
});
89+
90+
describe('onModuleInit', () => {
91+
it('should perform initial warm when enabled', async () => {
92+
process.env.CACHE_WARMING_ENABLED = 'true';
93+
jest.useFakeTimers();
94+
95+
await service.onModuleInit();
96+
97+
expect(cacheService.set).toHaveBeenCalled();
98+
});
99+
100+
it('should skip initial warm when disabled', async () => {
101+
process.env.CACHE_WARMING_ENABLED = 'false';
102+
jest.useFakeTimers();
103+
104+
await service.onModuleInit();
105+
106+
expect(cacheService.set).not.toHaveBeenCalled();
107+
});
108+
109+
it('should schedule periodic warming when enabled', async () => {
110+
process.env.CACHE_WARMING_ENABLED = 'true';
111+
process.env.CACHE_WARMING_INTERVAL = '1000';
112+
jest.useFakeTimers();
113+
114+
await service.onModuleInit();
115+
116+
// Advance timer by interval
117+
jest.advanceTimersByTime(1000);
118+
await Promise.resolve(); // Allow pending promises to resolve
119+
120+
// Should have been called at least twice (initial + one periodic)
121+
expect(cacheService.set).toHaveBeenCalled();
122+
});
123+
124+
it('should not schedule periodic warming when disabled', async () => {
125+
process.env.CACHE_WARMING_ENABLED = 'false';
126+
jest.useFakeTimers();
127+
128+
await service.onModuleInit();
129+
130+
// Advance timer
131+
jest.advanceTimersByTime(10000);
132+
await Promise.resolve();
133+
134+
// Should not have been called
135+
expect(cacheService.set).not.toHaveBeenCalled();
136+
});
137+
});
138+
139+
describe('onModuleDestroy', () => {
140+
it('should clear interval on destroy', async () => {
141+
process.env.CACHE_WARMING_ENABLED = 'true';
142+
jest.useFakeTimers();
143+
144+
await service.onModuleInit();
145+
service.onModuleDestroy();
146+
147+
// Advance timer - should not trigger warming
148+
jest.advanceTimersByTime(10000);
149+
await Promise.resolve();
150+
151+
// Should only have initial warm, no periodic
152+
expect(cacheService.set).toHaveBeenCalled();
153+
});
154+
});
155+
156+
describe('warmCache', () => {
157+
it('should warm all cache keys in parallel', async () => {
158+
await service.warmCache();
159+
160+
expect(cacheService.set).toHaveBeenCalled();
161+
});
162+
163+
it('should continue warming if one warmer fails', async () => {
164+
cacheService.set.mockRejectedValueOnce(new Error('Redis error'));
165+
166+
await expect(service.warmCache()).resolves.not.toThrow();
167+
});
168+
169+
it('should log hit rate before and after warming', async () => {
170+
cacheMonitoringService.getMetrics
171+
.mockReturnValueOnce({ hitRate: 70, totalRequests: 100 })
172+
.mockReturnValueOnce({ hitRate: 85, totalRequests: 200 });
173+
174+
await service.warmCache();
175+
176+
expect(cacheMonitoringService.getMetrics).toHaveBeenCalled();
177+
});
178+
});
179+
180+
describe('handlePeriodicWarming', () => {
181+
it('should skip when disabled', async () => {
182+
process.env.CACHE_WARMING_ENABLED = 'false';
183+
184+
await service.handlePeriodicWarming();
185+
186+
expect(cacheService.set).not.toHaveBeenCalled();
187+
});
188+
189+
it('should execute warm when enabled', async () => {
190+
process.env.CACHE_WARMING_ENABLED = 'true';
191+
192+
await service.handlePeriodicWarming();
193+
194+
expect(cacheService.set).toHaveBeenCalled();
195+
});
196+
});
197+
198+
describe('individual warmers', () => {
199+
it('should warm featured properties', async () => {
200+
(prisma.property.findMany as jest.Mock).mockResolvedValue([
201+
{ id: '1', name: 'Property 1' },
202+
]);
203+
204+
await service['warmFeaturedProperties']();
205+
206+
expect(prisma.property.findMany).toHaveBeenCalled();
207+
expect(cacheService.set).toHaveBeenCalled();
208+
});
209+
210+
it('should handle featured properties warming failure gracefully', async () => {
211+
(prisma.property.findMany as jest.Mock).mockRejectedValue(new Error('DB error'));
212+
213+
await expect(service['warmFeaturedProperties']()).resolves.not.toThrow();
214+
});
215+
216+
it('should warm popular properties', async () => {
217+
(prisma.property.findMany as jest.Mock).mockResolvedValue([
218+
{ id: '2', name: 'Property 2' },
219+
]);
220+
221+
await service['warmPopularProperties']();
222+
223+
expect(prisma.property.findMany).toHaveBeenCalled();
224+
expect(cacheService.set).toHaveBeenCalled();
225+
});
226+
227+
it('should warm trust score leaderboard', async () => {
228+
(prisma.user.findMany as jest.Mock).mockResolvedValue([
229+
{ id: 'user1', email: 'user@example.com' },
230+
]);
231+
232+
await service['warmTrustScoreLeaderboard']();
233+
234+
expect(prisma.user.findMany).toHaveBeenCalled();
235+
expect(cacheService.set).toHaveBeenCalled();
236+
});
237+
238+
it('should warm search suggestions', async () => {
239+
(prisma.popularSearch.findMany as jest.Mock).mockResolvedValue([
240+
{ term: 'house', frequency: 100 },
241+
]);
242+
243+
await service['warmSearchSuggestions']();
244+
245+
expect(prisma.popularSearch.findMany).toHaveBeenCalled();
246+
expect(cacheService.set).toHaveBeenCalled();
247+
});
248+
});
249+
250+
describe('predictive warming', () => {
251+
it('should skip when no access pattern exists', async () => {
252+
cacheService.get.mockResolvedValue(undefined);
253+
254+
await service['warmPredictiveKeys']();
255+
256+
expect(cacheService.set).not.toHaveBeenCalled();
257+
});
258+
259+
it('should warm hot keys that are already cached', async () => {
260+
cacheService.get.mockResolvedValue({ data: 'cached' });
261+
262+
await service['warmPredictiveKeys']();
263+
264+
expect(cacheService.set).toHaveBeenCalled();
265+
});
266+
267+
it('should not warm keys that are not cached', async () => {
268+
cacheService.get.mockResolvedValue(undefined);
269+
270+
await service['warmPredictiveKeys']();
271+
272+
expect(cacheService.set).not.toHaveBeenCalled();
273+
});
274+
});
275+
276+
describe('public helpers', () => {
277+
it('should warm user cache', async () => {
278+
await service.warmUserCache('user123', { name: 'John' });
279+
280+
expect(cacheService.set).toHaveBeenCalled();
281+
});
282+
283+
it('should warm dashboard cache', async () => {
284+
await service.warmDashboardCache('user123', { stats: {} });
285+
286+
expect(cacheService.set).toHaveBeenCalled();
287+
});
288+
289+
it('should warm leaderboard cache', async () => {
290+
await service.warmLeaderboardCache([{ id: '1', score: 100 }]);
291+
292+
expect(cacheService.set).toHaveBeenCalled();
293+
});
294+
295+
it('should warm featured properties cache', async () => {
296+
await service.warmFeaturedPropertiesCache([{ id: '1', name: 'Property' }]);
297+
298+
expect(cacheService.set).toHaveBeenCalled();
299+
});
300+
301+
it('should return warming metrics', () => {
302+
cacheMonitoringService.getMetrics.mockReturnValue({
303+
hitRate: 90,
304+
totalRequests: 500,
305+
});
306+
307+
const metrics = service.getWarmingMetrics();
308+
309+
expect(metrics.lastWarmingHitRate).toBe(90);
310+
expect(metrics.totalCycles).toBe(500);
311+
});
312+
});
313+
});

0 commit comments

Comments
 (0)