@adalo/metrics 0.1.123 → 0.1.124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/lib/health/healthCheckCache.d.ts +57 -0
  2. package/lib/health/healthCheckCache.d.ts.map +1 -0
  3. package/lib/health/healthCheckCache.js +200 -0
  4. package/lib/health/healthCheckCache.js.map +1 -0
  5. package/lib/{healthCheckClient.d.ts → health/healthCheckClient.d.ts} +53 -11
  6. package/lib/health/healthCheckClient.d.ts.map +1 -0
  7. package/lib/{healthCheckClient.js → health/healthCheckClient.js} +171 -25
  8. package/lib/health/healthCheckClient.js.map +1 -0
  9. package/lib/health/healthCheckUtils.d.ts +54 -0
  10. package/lib/health/healthCheckUtils.d.ts.map +1 -0
  11. package/lib/health/healthCheckUtils.js +142 -0
  12. package/lib/health/healthCheckUtils.js.map +1 -0
  13. package/lib/health/healthCheckWorker.d.ts +2 -0
  14. package/lib/health/healthCheckWorker.d.ts.map +1 -0
  15. package/lib/health/healthCheckWorker.js +64 -0
  16. package/lib/health/healthCheckWorker.js.map +1 -0
  17. package/lib/index.d.ts +8 -6
  18. package/lib/index.d.ts.map +1 -1
  19. package/lib/index.js +28 -6
  20. package/lib/index.js.map +1 -1
  21. package/lib/metrics/baseMetricsClient.d.ts.map +1 -0
  22. package/lib/metrics/baseMetricsClient.js.map +1 -0
  23. package/lib/metrics/metricsClient.d.ts.map +1 -0
  24. package/lib/metrics/metricsClient.js.map +1 -0
  25. package/lib/metrics/metricsDatabaseClient.d.ts.map +1 -0
  26. package/lib/metrics/metricsDatabaseClient.js.map +1 -0
  27. package/lib/metrics/metricsQueueRedisClient.d.ts.map +1 -0
  28. package/lib/{metricsQueueRedisClient.js → metrics/metricsQueueRedisClient.js} +2 -2
  29. package/lib/metrics/metricsQueueRedisClient.js.map +1 -0
  30. package/lib/metrics/metricsRedisClient.d.ts.map +1 -0
  31. package/lib/{metricsRedisClient.js → metrics/metricsRedisClient.js} +1 -1
  32. package/lib/metrics/metricsRedisClient.js.map +1 -0
  33. package/package.json +1 -1
  34. package/src/health/healthCheckCache.js +237 -0
  35. package/src/{healthCheckClient.js → health/healthCheckClient.js} +169 -29
  36. package/src/health/healthCheckUtils.js +143 -0
  37. package/src/health/healthCheckWorker.js +61 -0
  38. package/src/index.ts +8 -6
  39. package/src/{metricsQueueRedisClient.js → metrics/metricsQueueRedisClient.js} +2 -2
  40. package/src/{metricsRedisClient.js → metrics/metricsRedisClient.js} +1 -1
  41. package/lib/baseMetricsClient.d.ts.map +0 -1
  42. package/lib/baseMetricsClient.js.map +0 -1
  43. package/lib/healthCheckClient.d.ts.map +0 -1
  44. package/lib/healthCheckClient.js.map +0 -1
  45. package/lib/metricsClient.d.ts.map +0 -1
  46. package/lib/metricsClient.js.map +0 -1
  47. package/lib/metricsDatabaseClient.d.ts.map +0 -1
  48. package/lib/metricsDatabaseClient.js.map +0 -1
  49. package/lib/metricsQueueRedisClient.d.ts.map +0 -1
  50. package/lib/metricsQueueRedisClient.js.map +0 -1
  51. package/lib/metricsRedisClient.d.ts.map +0 -1
  52. package/lib/metricsRedisClient.js.map +0 -1
  53. /package/lib/{baseMetricsClient.d.ts → metrics/baseMetricsClient.d.ts} +0 -0
  54. /package/lib/{baseMetricsClient.js → metrics/baseMetricsClient.js} +0 -0
  55. /package/lib/{metricsClient.d.ts → metrics/metricsClient.d.ts} +0 -0
  56. /package/lib/{metricsClient.js → metrics/metricsClient.js} +0 -0
  57. /package/lib/{metricsDatabaseClient.d.ts → metrics/metricsDatabaseClient.d.ts} +0 -0
  58. /package/lib/{metricsDatabaseClient.js → metrics/metricsDatabaseClient.js} +0 -0
  59. /package/lib/{metricsQueueRedisClient.d.ts → metrics/metricsQueueRedisClient.d.ts} +0 -0
  60. /package/lib/{metricsRedisClient.d.ts → metrics/metricsRedisClient.d.ts} +0 -0
  61. /package/src/{baseMetricsClient.js → metrics/baseMetricsClient.js} +0 -0
  62. /package/src/{metricsClient.js → metrics/metricsClient.js} +0 -0
  63. /package/src/{metricsDatabaseClient.js → metrics/metricsDatabaseClient.js} +0 -0
@@ -0,0 +1,237 @@
1
+ const {
2
+ getRedisClientType,
3
+ REDIS_V4,
4
+ IOREDIS,
5
+ REDIS_V3,
6
+ } = require('../redisUtils')
7
+
8
+ /**
9
+ * HealthCheckCache provides a shared cache layer for health check results.
10
+ * It uses Redis if available for cross-process sharing, with graceful fallback
11
+ * to in-memory cache if Redis is not configured or unavailable.
12
+ */
13
+ class HealthCheckCache {
14
+ /**
15
+ * @param {Object} options
16
+ * @param {any} [options.redisClient] - Redis client instance (optional)
17
+ * @param {string} [options.appName] - Application name for cache key
18
+ * @param {number} [options.cacheTtlMs=60000] - Cache TTL in milliseconds
19
+ */
20
+ constructor(options = {}) {
21
+ this.redisClient = options.redisClient || null
22
+ this.appName = options.appName || process.env.BUILD_APP_NAME || 'unknown-app'
23
+ this.cacheTtlMs = options.cacheTtlMs ?? 60 * 1000
24
+ this.cacheKey = `healthcheck:${this.appName}`
25
+
26
+ /** In-memory fallback cache */
27
+ this._memoryCache = null
28
+ this._memoryCacheTimestamp = null
29
+
30
+ if (this.redisClient) {
31
+ this._redisClientType = getRedisClientType(this.redisClient)
32
+ this._redisAvailable = true
33
+ } else {
34
+ this._redisAvailable = false
35
+ console.warn(
36
+ `[HealthCheckCache] Redis not configured for ${this.appName}, using in-memory cache only (not shared across processes)`
37
+ )
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Checks if Redis is available and working.
43
+ * @returns {Promise<boolean>}
44
+ * @private
45
+ */
46
+ async _checkRedisAvailable() {
47
+ if (!this.redisClient || !this._redisAvailable) {
48
+ return false
49
+ }
50
+
51
+ try {
52
+ let pong
53
+ if (this._redisClientType === REDIS_V3) {
54
+ pong = await new Promise((resolve, reject) => {
55
+ this.redisClient.ping((err, result) => {
56
+ if (err) reject(err)
57
+ else resolve(result)
58
+ })
59
+ })
60
+ } else if (
61
+ this._redisClientType === REDIS_V4 ||
62
+ this._redisClientType === IOREDIS
63
+ ) {
64
+ pong = await this.redisClient.ping()
65
+ } else {
66
+ return false
67
+ }
68
+ return pong === 'PONG'
69
+ } catch (err) {
70
+ // Redis not available
71
+ if (this._redisAvailable) {
72
+ console.warn(
73
+ `[HealthCheckCache] Redis became unavailable: ${err.message}`
74
+ )
75
+ this._redisAvailable = false
76
+ }
77
+ return false
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Gets cached health check result from Redis (if available) or in-memory cache.
83
+ * Throws error if Redis is configured but read fails (so caller can return proper error format).
84
+ * @returns {Promise<Object | null>} Cached result or null
85
+ * @throws {Error} If Redis is configured but read fails
86
+ */
87
+ async get() {
88
+ // If Redis is configured, we MUST read from it (don't fall back to memory)
89
+ if (this.redisClient) {
90
+ try {
91
+ let cachedStr
92
+ if (this._redisClientType === REDIS_V3) {
93
+ cachedStr = await new Promise((resolve, reject) => {
94
+ this.redisClient.get(this.cacheKey, (err, result) => {
95
+ if (err) reject(err)
96
+ else resolve(result)
97
+ })
98
+ })
99
+ } else if (
100
+ this._redisClientType === REDIS_V4 ||
101
+ this._redisClientType === IOREDIS
102
+ ) {
103
+ cachedStr = await this.redisClient.get(this.cacheKey)
104
+ }
105
+
106
+ if (cachedStr) {
107
+ try {
108
+ const cached = JSON.parse(cachedStr)
109
+ if (cached.result && cached.timestamp) {
110
+ const age = Date.now() - cached.timestamp
111
+ if (age < this.cacheTtlMs) {
112
+ // Also update in-memory cache as backup
113
+ this._memoryCache = cached.result
114
+ this._memoryCacheTimestamp = cached.timestamp
115
+ return cached.result
116
+ }
117
+ }
118
+ } catch (parseErr) {
119
+ console.warn(
120
+ `[HealthCheckCache] Failed to parse Redis cache:`,
121
+ parseErr.message
122
+ )
123
+ }
124
+ }
125
+ // No cache in Redis - return null (worker may not have run yet)
126
+ return null
127
+ } catch (redisErr) {
128
+ // Redis read failed - throw error so caller can return proper error format
129
+ this._redisAvailable = false
130
+ throw new Error(`Redis cache read failed: ${redisErr.message}`)
131
+ }
132
+ }
133
+
134
+ // No Redis configured - fall back to in-memory cache
135
+ if (this._memoryCache && this._memoryCacheTimestamp) {
136
+ const age = Date.now() - this._memoryCacheTimestamp
137
+ if (age < this.cacheTtlMs) {
138
+ return this._memoryCache
139
+ }
140
+ }
141
+
142
+ return null
143
+ }
144
+
145
+ /**
146
+ * Sets cached health check result in Redis (if available) and in-memory.
147
+ * @param {Object} result - Health check result to cache
148
+ * @returns {Promise<void>}
149
+ */
150
+ async set(result) {
151
+ const cacheData = {
152
+ result,
153
+ timestamp: Date.now(),
154
+ }
155
+
156
+ // Update in-memory cache
157
+ this._memoryCache = result
158
+ this._memoryCacheTimestamp = cacheData.timestamp
159
+
160
+ // Try to update Redis if available
161
+ if (await this._checkRedisAvailable()) {
162
+ try {
163
+ const cacheStr = JSON.stringify(cacheData)
164
+ const ttlSeconds = Math.ceil(this.cacheTtlMs / 1000) + 10
165
+
166
+ if (this._redisClientType === REDIS_V3) {
167
+ await new Promise((resolve, reject) => {
168
+ this.redisClient.setex(
169
+ this.cacheKey,
170
+ ttlSeconds,
171
+ cacheStr,
172
+ (err) => {
173
+ if (err) reject(err)
174
+ else resolve()
175
+ }
176
+ )
177
+ })
178
+ } else if (
179
+ this._redisClientType === REDIS_V4 ||
180
+ this._redisClientType === IOREDIS
181
+ ) {
182
+ await this.redisClient.setex(this.cacheKey, ttlSeconds, cacheStr)
183
+ }
184
+ } catch (redisErr) {
185
+ // Redis write failed, but in-memory cache is updated
186
+ console.warn(
187
+ `[HealthCheckCache] Redis write failed (in-memory cache updated):`,
188
+ redisErr.message
189
+ )
190
+ this._redisAvailable = false
191
+ }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Clears the cache (both Redis and in-memory).
197
+ * @returns {Promise<void>}
198
+ */
199
+ async clear() {
200
+ this._memoryCache = null
201
+ this._memoryCacheTimestamp = null
202
+
203
+ if (await this._checkRedisAvailable()) {
204
+ try {
205
+ if (this._redisClientType === REDIS_V3) {
206
+ await new Promise((resolve, reject) => {
207
+ this.redisClient.del(this.cacheKey, (err) => {
208
+ if (err) reject(err)
209
+ else resolve()
210
+ })
211
+ })
212
+ } else if (
213
+ this._redisClientType === REDIS_V4 ||
214
+ this._redisClientType === IOREDIS
215
+ ) {
216
+ await this.redisClient.del(this.cacheKey)
217
+ }
218
+ } catch (redisErr) {
219
+ console.warn(
220
+ `[HealthCheckCache] Redis clear failed:`,
221
+ redisErr.message
222
+ )
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Checks if Redis is configured and available.
229
+ * @returns {boolean}
230
+ */
231
+ isRedisAvailable() {
232
+ return this._redisAvailable && this.redisClient !== null
233
+ }
234
+ }
235
+
236
+ module.exports = { HealthCheckCache }
237
+
@@ -4,7 +4,8 @@ const {
4
4
  REDIS_V4,
5
5
  IOREDIS,
6
6
  REDIS_V3,
7
- } = require('./redisUtils')
7
+ } = require('../redisUtils')
8
+ const { HealthCheckCache } = require('./healthCheckCache')
8
9
 
9
10
  const HEALTH_CHECK_CACHE_TTL_MS = 60 * 1000
10
11
 
@@ -68,7 +69,8 @@ function maskSensitiveData(text) {
68
69
  /**
69
70
  * @typedef {Object} ComponentHealth
70
71
  * @property {HealthStatus} status - Component health status
71
- * @property {string} [message] - Optional status message
72
+ * @property {string} [error] - Error message if status is unhealthy
73
+ * @property {string} [message] - Optional status message (deprecated, use error)
72
74
  * @property {number} [latencyMs] - Connection latency in milliseconds
73
75
  */
74
76
 
@@ -82,8 +84,8 @@ function maskSensitiveData(text) {
82
84
  * @typedef {Object} HealthCheckResult
83
85
  * @property {HealthStatus} status - Overall health status
84
86
  * @property {string} timestamp - ISO timestamp of the check
85
- * @property {boolean} cached - Whether this result is from cache
86
- * @property {Object<string, ComponentHealth | DatabaseClusterHealth>} components - Individual component health
87
+ * @property {Object<string, ComponentHealth | DatabaseClusterHealth>} checks - Individual service health checks
88
+ * @property {string[]} [errors] - Top-level error messages (not related to specific services)
87
89
  */
88
90
 
89
91
  /**
@@ -116,12 +118,14 @@ class HealthCheckClient {
116
118
  * @param {string} [options.databaseUrl] - Main PostgreSQL connection URL
117
119
  * @param {string} [options.databaseName='main'] - Name for the main database
118
120
  * @param {Object<string, string>} [options.additionalDatabaseUrls] - Additional DB clusters (name -> URL)
119
- * @param {any} [options.redisClient] - Redis client instance (ioredis or node-redis)
121
+ * @param {any} [options.redisClient] - Redis client instance (ioredis or node-redis) - used for cache
122
+ * @param {boolean} [options.includeRedisCheck=false] - Include Redis health check in results (for backend)
120
123
  * @param {number} [options.cacheTtlMs=60000] - Cache TTL in milliseconds (default: 60s)
121
124
  * @param {string} [options.appName] - Application name for logging
122
125
  */
123
126
  constructor(options = {}) {
124
127
  this.redisClient = options.redisClient || null
128
+ this.includeRedisCheck = options.includeRedisCheck || false
125
129
  this.cacheTtlMs = options.cacheTtlMs ?? HEALTH_CHECK_CACHE_TTL_MS
126
130
  this.appName =
127
131
  options.appName || process.env.BUILD_APP_NAME || 'unknown-app'
@@ -131,6 +135,9 @@ class HealthCheckClient {
131
135
  /** @type {CachedHealthResult | null} */
132
136
  this._cachedResult = null
133
137
 
138
+ /** @type {Promise<HealthCheckResult> | null} */
139
+ this._refreshPromise = null
140
+
134
141
  /** @type {Map<string, Pool>} */
135
142
  this._databasePools = new Map()
136
143
 
@@ -145,6 +152,12 @@ class HealthCheckClient {
145
152
  if (this.redisClient) {
146
153
  this._redisClientType = getRedisClientType(this.redisClient)
147
154
  }
155
+
156
+ this._cache = new HealthCheckCache({
157
+ redisClient: this.redisClient,
158
+ appName: this.appName,
159
+ cacheTtlMs: this.cacheTtlMs,
160
+ })
148
161
  }
149
162
 
150
163
  /**
@@ -218,7 +231,7 @@ class HealthCheckClient {
218
231
  } catch (err) {
219
232
  return {
220
233
  status: 'unhealthy',
221
- message: maskSensitiveData(err.message),
234
+ error: maskSensitiveData(err.message),
222
235
  latencyMs: Date.now() - start,
223
236
  }
224
237
  }
@@ -274,6 +287,8 @@ class HealthCheckClient {
274
287
  const result = { status: overallStatus }
275
288
  if (mainHealth) {
276
289
  result.latencyMs = mainHealth.latencyMs
290
+ if (mainHealth.error) result.error = mainHealth.error
291
+ // Keep message for backward compatibility
277
292
  if (mainHealth.message) result.message = mainHealth.message
278
293
  }
279
294
  if (clusters) {
@@ -338,24 +353,71 @@ class HealthCheckClient {
338
353
  /**
339
354
  * Performs a full health check on all configured components.
340
355
  * Results are cached for the configured TTL to prevent excessive load.
356
+ * Uses a mutex pattern to prevent concurrent health checks when cache expires.
357
+ * If cache is expired but a refresh is in progress, returns stale cache (if available).
341
358
  *
342
359
  * @returns {Promise<HealthCheckResult>}
343
360
  */
344
361
  async performHealthCheck() {
362
+ // If cache is valid, return immediately
345
363
  if (this._isCacheValid()) {
346
364
  return { ...this._cachedResult.result, cached: true }
347
365
  }
348
366
 
349
- const [dbHealth, redisHealth] = await Promise.all([
350
- this._checkAllDatabases(),
351
- this._checkRedis(),
352
- ])
367
+ // If a refresh is already in progress, return stale cache (if available) or wait for refresh
368
+ if (this._refreshPromise) {
369
+ // Return stale cache immediately if available, otherwise wait for refresh
370
+ if (this._cachedResult) {
371
+ return { ...this._cachedResult.result, cached: true }
372
+ }
373
+ // Wait for the in-progress refresh
374
+ return this._refreshPromise
375
+ }
376
+
377
+ // Start a new refresh
378
+ this._refreshPromise = this._performHealthCheckInternal()
379
+ .then(result => {
380
+ this._refreshPromise = null
381
+ return result
382
+ })
383
+ .catch(err => {
384
+ this._refreshPromise = null
385
+ throw err
386
+ })
387
+
388
+ // Return stale cache if available while refresh is happening, otherwise wait
389
+ if (this._cachedResult) {
390
+ // Return stale cache immediately, refresh will happen in background
391
+ return { ...this._cachedResult.result, cached: true }
392
+ }
353
393
 
354
- const components = {}
355
- if (dbHealth) components.database = dbHealth
356
- if (this.redisClient) components.redis = redisHealth
394
+ // No stale cache available, wait for refresh
395
+ return this._refreshPromise
396
+ }
357
397
 
358
- const statuses = Object.values(components).map(c => c.status)
398
+ /**
399
+ * Internal method that actually performs the health check.
400
+ * This is separated to allow the mutex pattern in performHealthCheck.
401
+ * Only checks database - Redis is used only for cache, not health check.
402
+ *
403
+ * @returns {Promise<HealthCheckResult>}
404
+ * @private
405
+ */
406
+ async _performHealthCheckInternal() {
407
+ // Check database
408
+ const dbHealth = await this._checkAllDatabases()
409
+
410
+ // Optionally check Redis (for backend)
411
+ let redisHealth = null
412
+ if (this.includeRedisCheck && this.redisClient) {
413
+ redisHealth = await this._checkRedis()
414
+ }
415
+
416
+ const checks = {}
417
+ if (dbHealth) checks.database = dbHealth
418
+ if (redisHealth) checks.redis = redisHealth
419
+
420
+ const statuses = Object.values(checks).map(c => c.status)
359
421
  let overallStatus = 'healthy'
360
422
 
361
423
  if (statuses.some(s => s === 'unhealthy')) {
@@ -368,8 +430,7 @@ class HealthCheckClient {
368
430
  const result = {
369
431
  status: overallStatus,
370
432
  timestamp: new Date().toISOString(),
371
- cached: false,
372
- components,
433
+ checks,
373
434
  }
374
435
 
375
436
  this._cachedResult = {
@@ -380,11 +441,59 @@ class HealthCheckClient {
380
441
  return result
381
442
  }
382
443
 
444
+ /**
445
+ * Gets cached result from shared cache (Redis if available, otherwise in-memory).
446
+ * Returns null if no cache is available. This is used by endpoints to read-only access.
447
+ * If Redis fails, returns error result with proper format.
448
+ *
449
+ * @returns {Promise<HealthCheckResult | null>} Cached result, error result if Redis fails, or null if not available
450
+ */
451
+ async getCachedResult() {
452
+ try {
453
+ const cached = await this._cache.get()
454
+ if (cached) {
455
+ return cached
456
+ }
457
+ // No cache available - worker may not have run yet
458
+ return null
459
+ } catch (err) {
460
+ // Redis read failed - return error result with proper format
461
+ console.error(`${this.prefixLogs} Failed to read from cache:`, err)
462
+ const errorMessage = maskSensitiveData(err.message || 'Cache read failed')
463
+ return {
464
+ status: 'unhealthy',
465
+ timestamp: new Date().toISOString(),
466
+ checks: {
467
+ redis: {
468
+ status: 'unhealthy',
469
+ error: errorMessage,
470
+ },
471
+ },
472
+ errors: [errorMessage],
473
+ }
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Forces a refresh of the health check cache.
479
+ * This is used by background workers to periodically update the cache.
480
+ * Updates shared cache (Redis if available, otherwise in-memory).
481
+ *
482
+ * @returns {Promise<HealthCheckResult>}
483
+ */
484
+ async refreshCache() {
485
+ const result = await this._performHealthCheckInternal()
486
+ await this._cache.set(result)
487
+
488
+ return result
489
+ }
490
+
383
491
  /**
384
492
  * Clears the cached health check result, forcing the next check to be fresh.
385
493
  */
386
494
  clearCache() {
387
495
  this._cachedResult = null
496
+ this._refreshPromise = null
388
497
  }
389
498
 
390
499
  /**
@@ -397,28 +506,29 @@ class HealthCheckClient {
397
506
  _getErrorMessages(result) {
398
507
  const errors = []
399
508
 
400
- if (result.components.database) {
401
- const db = result.components.database
509
+ if (result.checks.database) {
510
+ const db = result.checks.database
402
511
 
403
512
  // Check main database status
404
- if (db.status === 'unhealthy' && db.message) {
513
+ if (db.status === 'unhealthy' && (db.message || db.error)) {
405
514
  const dbName = this._mainDatabaseConfig?.name || 'main'
406
- errors.push(`DB ${dbName}: ${maskSensitiveData(db.message)}`)
515
+ const errorMsg = db.error || db.message
516
+ errors.push(`DB ${dbName}: ${maskSensitiveData(errorMsg)}`)
407
517
  }
408
518
 
409
519
  // Check clusters
410
520
  if (db.clusters) {
411
521
  for (const [name, health] of Object.entries(db.clusters)) {
412
522
  if (health.status === 'unhealthy') {
413
- const message = health.message || 'connection failed'
523
+ const message = health.error || health.message || 'connection failed'
414
524
  errors.push(`DB ${name}: ${maskSensitiveData(message)}`)
415
525
  }
416
526
  }
417
527
  }
418
528
  }
419
529
 
420
- if (result.components.redis && result.components.redis.status === 'unhealthy') {
421
- const message = result.components.redis.message || 'connection failed'
530
+ if (result.checks.redis && result.checks.redis.status === 'unhealthy') {
531
+ const message = result.checks.redis.error || result.checks.redis.message || 'connection failed'
422
532
  errors.push(`Redis: ${maskSensitiveData(message)}`)
423
533
  }
424
534
 
@@ -431,17 +541,41 @@ class HealthCheckClient {
431
541
  * Response includes errors array when status is not healthy.
432
542
  * All sensitive data (passwords, connection strings, etc.) is masked.
433
543
  *
544
+ * This handler only reads from cache and never triggers database queries.
545
+ * Use a background worker to periodically refresh the cache.
546
+ *
434
547
  * @returns {(req: any, res: any) => Promise<void>} Express request handler
435
548
  */
436
549
  healthHandler() {
437
550
  return async (req, res) => {
438
551
  try {
439
- const result = await this.performHealthCheck()
552
+ // Only read from cache, never trigger DB queries
553
+ const result = await this.getCachedResult()
554
+
555
+ if (!result) {
556
+ // No cache available - return unhealthy status with proper format
557
+ const errorMsg = 'Health check cache not available. Worker may not be running.'
558
+ res.status(503).json({
559
+ status: 'unhealthy',
560
+ timestamp: new Date().toISOString(),
561
+ checks: {
562
+ redis: {
563
+ status: 'unhealthy',
564
+ error: errorMsg,
565
+ },
566
+ },
567
+ errors: [errorMsg],
568
+ })
569
+ return
570
+ }
571
+
440
572
  const statusCode = result.status === 'unhealthy' ? 503 : 200
441
-
442
- // Build response with errors if not healthy
573
+
574
+ // Build response - errors are already in result if present
443
575
  const response = { ...result }
444
- if (result.status !== 'healthy') {
576
+
577
+ // Add top-level errors array if not healthy and errors not already present
578
+ if (result.status !== 'healthy' && !result.errors) {
445
579
  const errors = this._getErrorMessages(result)
446
580
  if (errors.length > 0) {
447
581
  response.errors = errors
@@ -451,11 +585,17 @@ class HealthCheckClient {
451
585
  res.status(statusCode).json(response)
452
586
  } catch (err) {
453
587
  console.error(`${this.prefixLogs} Health check failed:`, err)
588
+ const errorMsg = maskSensitiveData(err.message)
454
589
  res.status(503).json({
455
590
  status: 'unhealthy',
456
591
  timestamp: new Date().toISOString(),
457
- cached: false,
458
- errors: [maskSensitiveData(err.message)],
592
+ checks: {
593
+ redis: {
594
+ status: 'unhealthy',
595
+ error: errorMsg,
596
+ },
597
+ },
598
+ errors: [errorMsg],
459
599
  })
460
600
  }
461
601
  }