@adalo/metrics 0.1.162 → 0.1.164

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.
@@ -9,6 +9,9 @@ const {
9
9
  const redisConnectionStableFields = ['name', 'flags', 'cmd']
10
10
  const redisConnectionFields = ['name', 'flags', 'tot-mem', 'cmd']
11
11
 
12
+ /** Stream entries older than this (ms) are trimmed so messages are not kept in Redis. Fixed 60s. */
13
+ const GRACEFUL_SHUTDOWN_STREAM_MAXAGE_MS = 60000
14
+
12
15
  /**
13
16
  * RedisMetricsClient extends BaseMetricsClient to collect
14
17
  * Redis metrics periodically and push them to Prometheus Pushgateway.
@@ -32,7 +35,11 @@ class RedisMetricsClient extends BaseMetricsClient {
32
35
  * @param {function} [options.startupValidation] - Function to validate startup (from BaseMetricsClient)
33
36
  * @param {boolean} [options.disablePushgateway] - Disable pushing to Pushgateway (use HTTP scraping instead)
34
37
  */
35
- constructor({ redisClient, gracefulShutdownRedis, ...metricsConfig } = {}) {
38
+ constructor({ redisClient, ...metricsConfig } = {}) {
39
+ if (redisClient == null) {
40
+ throw new Error('RedisMetricsClient requires redisClient')
41
+ }
42
+
36
43
  const intervalSec =
37
44
  metricsConfig.intervalSec ||
38
45
  parseInt(process.env.METRICS_QUEUE_INTERVAL_SEC || '', 10) ||
@@ -42,36 +49,16 @@ class RedisMetricsClient extends BaseMetricsClient {
42
49
  ...metricsConfig,
43
50
  processType: metricsConfig.processType || 'queue-metrics',
44
51
  intervalSec,
45
- skipFirstPush: metricsConfig.skipFirstPush !== false,
46
52
  })
47
53
 
48
54
  /** Redis client used for metrics */
49
55
  this.redisClient = redisClient
50
56
  this.redisClientType = getRedisClientType(redisClient)
51
57
 
52
- /** When true, new instance publishes once to Redis on start; old instances subscribed to same channel exit immediately. Channel = metrics:graceful-shutdown:{app}:{process_type}. Default true; set METRICS_GRACEFUL_SHUTDOWN_REDIS=false or gracefulShutdownRedis: false to disable. */
53
- const disabledByParam = gracefulShutdownRedis === false
54
- const disabledByEnv =
55
- process.env.METRICS_GRACEFUL_SHUTDOWN_REDIS === 'false'
56
- this._gracefulShutdownRedis = !disabledByParam && !disabledByEnv
57
- this._gracefulShutdownChannel = this._gracefulShutdownRedis
58
- ? `metrics:graceful-shutdown:${this.appName}:${this.processType}`
59
- : null
60
58
  /** Dedicated Redis connection for subscribe (subscriber mode); closed in cleanup. */
61
59
  this._subClient = null
62
60
 
63
- /** Log prefix for graceful-shutdown messages (always logged, not behind METRICS_LOG_VALUES). */
64
- this._gracefulShutdownLogPrefix = `[graceful-shutdown] [${this.processType}] [${this.appName}] [${this.dynoId}]`
65
-
66
- console.info(
67
- `${this._gracefulShutdownLogPrefix} startup: graceful_shutdown_redis=${
68
- this._gracefulShutdownRedis
69
- } channel=${this._gracefulShutdownChannel || 'none'}`
70
- )
71
-
72
- if (this._gracefulShutdownRedis && this._gracefulShutdownChannel) {
73
- this._setupGracefulShutdownSubscribe()
74
- }
61
+ this._initGracefulShutdown(metricsConfig.gracefulShutdownRedis)
75
62
 
76
63
  /** Counter for Redis client connections */
77
64
  this.redisConnectionsGauge = this.createGauge({
@@ -113,139 +100,207 @@ class RedisMetricsClient extends BaseMetricsClient {
113
100
  }
114
101
 
115
102
  /**
116
- * Set up Redis subscribe for graceful shutdown. Uses one connection: either redisClient.duplicate() (ioredis) or a client created from REDIS_URL (node-redis). Caller passes only redisClient.
103
+ * Initialize graceful-shutdown state and subscribe when enabled. Called from constructor.
104
+ * @param {boolean} [gracefulShutdownRedis] - Explicit false to disable; otherwise enabled unless METRICS_GRACEFUL_SHUTDOWN_REDIS=false.
117
105
  */
118
- _setupGracefulShutdownSubscribe() {
119
- const channel = this._gracefulShutdownChannel
120
- if (!channel) return
121
-
122
- let subClient = null
123
- if (this.redisClient && typeof this.redisClient.duplicate === 'function') {
124
- subClient = this.redisClient.duplicate()
125
- console.info(
126
- `${this._gracefulShutdownLogPrefix} setup_subscribe: using redisClient.duplicate() for channel=${channel}`
127
- )
128
- } else if (process.env.REDIS_URL) {
106
+ _initGracefulShutdown(gracefulShutdownRedis) {
107
+ const disabledByParam = gracefulShutdownRedis === false
108
+ const disabledByEnv =
109
+ process.env.METRICS_GRACEFUL_SHUTDOWN_REDIS === 'false'
110
+ this._gracefulShutdownRedis = !disabledByParam && !disabledByEnv
111
+ this._gracefulShutdownStream = this._gracefulShutdownRedis
112
+ ? `metrics:graceful-shutdown:${this.appName}:${this.processType}`
113
+ : null
114
+ this._gracefulShutdownAckChannel = this._gracefulShutdownRedis
115
+ ? `metrics:graceful-shutdown-ack:${this.appName}:${this.processType}`
116
+ : null
117
+ this._gracefulShutdownLogPrefix = `[graceful-shutdown] [${this.processType}] [${this.appName}] [${this.dynoId}]`
118
+ if (this._gracefulShutdownRedis && this._gracefulShutdownStream) {
119
+ this._setupGracefulShutdownSubscribe()
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Create a dedicated Redis client for subscribe (pub/sub). Uses duplicate() when available, else createClient from REDIS_URL. Branches by redisClientType (IOREDIS, REDIS_V3, REDIS_V4).
125
+ * @returns {any|null} Subscriber client or null if not possible.
126
+ */
127
+ _createSubscriberClient() {
128
+ if (this.redisClientType === IOREDIS && this.redisClient && typeof this.redisClient.duplicate === 'function') {
129
+ return this.redisClient.duplicate()
130
+ }
131
+ if ((this.redisClientType === REDIS_V3 || this.redisClientType === REDIS_V4) && this.redisClient && typeof this.redisClient.duplicate === 'function') {
132
+ return this.redisClient.duplicate()
133
+ }
134
+ if ((this.redisClientType === REDIS_V3 || this.redisClientType === REDIS_V4) && process.env.REDIS_URL) {
129
135
  try {
130
136
  const redis = require('redis')
131
- subClient = redis.createClient({ url: process.env.REDIS_URL })
132
- if (subClient && typeof subClient.on === 'function') {
133
- subClient.on('error', err => {
134
- console.error(
135
- `${this._gracefulShutdownLogPrefix} subscriber client error:`,
136
- err.message
137
- )
138
- })
137
+ if (typeof redis.createClient !== 'function') return null
138
+ try {
139
+ return redis.createClient({ url: process.env.REDIS_URL })
140
+ } catch {
141
+ return redis.createClient(process.env.REDIS_URL)
139
142
  }
140
- console.info(
141
- `${this._gracefulShutdownLogPrefix} setup_subscribe: created subscriber from REDIS_URL for channel=${channel}`
142
- )
143
- } catch (e) {
144
- console.info(
145
- `${this._gracefulShutdownLogPrefix} setup_subscribe: could not create subscriber from REDIS_URL:`,
146
- e.message
147
- )
143
+ } catch {
144
+ return null
148
145
  }
149
146
  }
147
+ return null
148
+ }
150
149
 
151
- if (!subClient) {
152
- console.info(
153
- `${this._gracefulShutdownLogPrefix} No subscriber (need ioredis or REDIS_URL). Graceful shutdown via Redis disabled.`
154
- )
155
- return
150
+ /**
151
+ * Set up Redis subscribe for graceful shutdown. Uses _createSubscriberClient() so subscriber matches client type (ioredis vs node-redis v3/v4).
152
+ */
153
+ /**
154
+ * Set up Redis stream read for graceful shutdown. Uses _createSubscriberClient() and XREAD BLOCK; stream is trimmed (MAXLEN and MINID) so messages are not kept in Redis forever.
155
+ */
156
+ _setupGracefulShutdownSubscribe() {
157
+ const streamKey = this._gracefulShutdownStream
158
+ if (!streamKey) return
159
+
160
+ const subClient = this._createSubscriberClient()
161
+ if (subClient && typeof subClient.on === 'function') {
162
+ subClient.on('error', () => {})
156
163
  }
157
164
 
158
- console.info(
159
- `${this._gracefulShutdownLogPrefix} setup_subscribe: channel=${channel} hasSubClient=true`
160
- )
165
+ if (!subClient) return
161
166
 
162
167
  this._subClient = subClient
168
+ this._streamReadLoop(streamKey)
169
+ }
163
170
 
164
- const onMessage = () => {
165
- console.info(
166
- `${this._gracefulShutdownLogPrefix} OLD: received new-instance message on channel ${channel}; exiting.`
167
- )
168
- this.cleanup()
171
+ _streamReadLoop(streamKey) {
172
+ const self = this
173
+ const blockMs = 5000
174
+
175
+ const runRead = () => {
176
+ if (!self._subClient) return
177
+ self._xreadBlock(streamKey, blockMs)
178
+ .then((entries) => {
179
+ if (!entries || entries.length === 0) {
180
+ setImmediate(runRead)
181
+ return
182
+ }
183
+ self._cleanupAndPublishAck()
184
+ })
185
+ .catch(() => {
186
+ setImmediate(runRead)
187
+ })
169
188
  }
189
+ runRead()
190
+ }
191
+
192
+ _xreadBlock(streamKey, blockMs) {
193
+ const client = this._subClient || this.redisClient
194
+ if (!client) return Promise.resolve([])
170
195
 
171
196
  if (this.redisClientType === REDIS_V3) {
172
- subClient.on('subscribe', () => {
173
- console.info(
174
- `${this._gracefulShutdownLogPrefix} Subscribed to channel=${channel} (waiting for new-instance message).`
175
- )
176
- })
177
- subClient.on('message', (ch, _msg) => {
178
- if (ch === channel) onMessage()
179
- })
180
- subClient.subscribe(channel)
181
- } else if (this.redisClientType === REDIS_V4) {
182
- subClient.on('subscribe', () => {
183
- console.info(
184
- `${this._gracefulShutdownLogPrefix} Subscribed to channel=${channel} (waiting for new-instance message).`
185
- )
186
- })
187
- subClient.on('message', (ch, _msg) => {
188
- if (ch === channel) onMessage()
189
- })
190
- subClient.subscribe(channel)
191
- } else if (this.redisClientType === IOREDIS) {
192
- subClient.subscribe(channel, (err, count) => {
193
- if (err) {
194
- console.error(
195
- `${this._gracefulShutdownLogPrefix} Subscribe error:`,
196
- err
197
- )
198
- return
199
- }
200
- console.info(
201
- `${this._gracefulShutdownLogPrefix} Subscribed to channel=${channel} (waiting for new-instance message).`
202
- )
203
- })
204
- subClient.on('message', (ch, _msg) => {
205
- if (ch === channel) onMessage()
197
+ return new Promise((resolve, reject) => {
198
+ client.send_command('XREAD', ['BLOCK', String(blockMs), 'STREAMS', streamKey, '$'], (err, result) => {
199
+ if (err) return reject(err)
200
+ resolve(this._parseXreadReply(result, streamKey))
201
+ })
206
202
  })
207
203
  }
204
+ if (this.redisClientType === REDIS_V4) {
205
+ const p = client.sendCommand(['XREAD', 'BLOCK', String(blockMs), 'STREAMS', streamKey, '$'])
206
+ return Promise.resolve(p).then(result => this._parseXreadReply(result, streamKey)).catch(() => [])
207
+ }
208
+ if (this.redisClientType === IOREDIS) {
209
+ const p = client.call('XREAD', 'BLOCK', blockMs, 'STREAMS', streamKey, '$')
210
+ return Promise.resolve(p).then(result => this._parseXreadReply(result, streamKey)).catch(() => [])
211
+ }
212
+ return Promise.resolve([])
213
+ }
214
+
215
+ _parseXreadReply(reply, streamKey) {
216
+ if (!reply || !Array.isArray(reply)) return []
217
+ const streamReply = reply.find(r => r && r[0] === streamKey)
218
+ if (!streamReply || !Array.isArray(streamReply[1])) return []
219
+ return streamReply[1].map(entry => (Array.isArray(entry) ? [entry[0], entry[1] || []] : [entry, []]))
208
220
  }
209
221
 
210
222
  /**
211
- * Publish one-time "new instance started" so old instances (subscribed to the same channel) exit. Call from new instance after startPush.
223
+ * Old instance: stop push, clear metrics from VM, then publish ack so new can start; then exit.
212
224
  */
213
- _publishNewInstanceStarted() {
214
- if (!this._gracefulShutdownChannel || !this.redisClient) return
215
- const channel = this._gracefulShutdownChannel
216
- const message = this.dynoId || '1'
217
-
218
- const done = err => {
219
- if (err) {
220
- console.info(
221
- `${this._gracefulShutdownLogPrefix} NEW: publish failed:`,
222
- err.message
223
- )
224
- } else {
225
- console.info(
226
- `${this._gracefulShutdownLogPrefix} NEW: published to channel=${channel} (new instance started; old instances will exit).`
227
- )
225
+ async _cleanupAndPublishAck() {
226
+ this.stopPush()
227
+ if (this.enabled) {
228
+ await this.gatewayDelete()
229
+ }
230
+ await this._publishAck()
231
+ if (this._subClient) {
232
+ try {
233
+ if (this.redisClientType === REDIS_V3) {
234
+ await new Promise((resolve, reject) => {
235
+ if (typeof this._subClient.quit === 'function') {
236
+ this._subClient.quit(err => (err ? reject(err) : resolve()))
237
+ } else resolve()
238
+ })
239
+ } else if (this.redisClientType === REDIS_V4) {
240
+ if (this._subClient.quit) await this._subClient.quit()
241
+ } else if (this.redisClientType === IOREDIS) {
242
+ if (this._subClient.disconnect) await this._subClient.disconnect()
243
+ }
244
+ } catch {
245
+ // ignore
228
246
  }
247
+ this._subClient = null
229
248
  }
249
+ try {
250
+ if (this.redisClient) {
251
+ if (this.redisClientType === REDIS_V3 || this.redisClientType === REDIS_V4) {
252
+ await this.redisClient.quit()
253
+ } else if (this.redisClientType === IOREDIS) {
254
+ await this.redisClient.disconnect()
255
+ }
256
+ }
257
+ } catch {
258
+ // ignore
259
+ }
260
+ process.exit(0)
261
+ }
230
262
 
231
- console.info(
232
- `${
233
- this._gracefulShutdownLogPrefix
234
- } NEW: publishing to channel=${channel} dynoId=${this.dynoId || '1'}`
263
+ _publishAck() {
264
+ const ackChannel = this._gracefulShutdownAckChannel
265
+ if (!ackChannel || !this.redisClient) return Promise.resolve()
266
+ console.warn(
267
+ `${this._gracefulShutdownLogPrefix} OLD: clearing metrics and exiting.`
235
268
  )
269
+ const msg = this.dynoId || 'stopped'
270
+ if (this.redisClientType === REDIS_V3) {
271
+ return new Promise((resolve) => {
272
+ this.redisClient.send_command('PUBLISH', [ackChannel, msg], () => resolve())
273
+ })
274
+ }
275
+ if (this.redisClientType === REDIS_V4) {
276
+ return this.redisClient.sendCommand(['PUBLISH', ackChannel, msg]).catch(() => {})
277
+ }
278
+ if (this.redisClientType === IOREDIS) {
279
+ return this.redisClient.publish(ackChannel, msg).catch(() => {})
280
+ }
281
+ return Promise.resolve()
282
+ }
283
+
284
+ /**
285
+ * Publish "new instance started" to stream so old instances exit. Stream is trimmed (MAXLEN ~ 10 and MINID older than 1 min) so messages are not kept in Redis forever.
286
+ */
287
+ _publishNewInstanceStarted() {
288
+ const streamKey = this._gracefulShutdownStream
289
+ if (!streamKey || !this.redisClient) return
290
+ const value = this.dynoId || '1'
291
+ const noop = () => {}
292
+ const maxAgeMs = GRACEFUL_SHUTDOWN_STREAM_MAXAGE_MS
293
+ const minId = `${Date.now() - maxAgeMs}-0`
236
294
 
237
295
  if (this.redisClientType === REDIS_V3) {
238
- this.redisClient.send_command('PUBLISH', [channel, message], done)
296
+ this.redisClient.send_command('XADD', [streamKey, 'MAXLEN', '~', '10', '*', 'dyno_id', value], noop)
297
+ this.redisClient.send_command('XTRIM', [streamKey, 'MINID', minId], noop)
239
298
  } else if (this.redisClientType === REDIS_V4) {
240
- this.redisClient
241
- .sendCommand(['PUBLISH', channel, message])
242
- .then(() => done())
243
- .catch(done)
299
+ this.redisClient.sendCommand(['XADD', streamKey, 'MAXLEN', '~', '10', '*', 'dyno_id', value]).catch(noop)
300
+ this.redisClient.sendCommand(['XTRIM', streamKey, 'MINID', minId]).catch(noop)
244
301
  } else if (this.redisClientType === IOREDIS) {
245
- this.redisClient
246
- .publish(channel, message)
247
- .then(() => done())
248
- .catch(done)
302
+ this.redisClient.call('XADD', streamKey, 'MAXLEN', '~', 10, '*', 'dyno_id', value).catch(noop)
303
+ this.redisClient.call('XTRIM', streamKey, 'MINID', minId).catch(noop)
249
304
  }
250
305
  }
251
306
 
@@ -498,7 +553,7 @@ class RedisMetricsClient extends BaseMetricsClient {
498
553
  )
499
554
  }
500
555
  } catch (error) {
501
- console.info(
556
+ console.error(
502
557
  `[queue-metrics] Failed to collect Redis metrics:`,
503
558
  error.message
504
559
  )
@@ -514,14 +569,6 @@ class RedisMetricsClient extends BaseMetricsClient {
514
569
  await this.collectRedisMetrics()
515
570
  await this.gatewayPush()
516
571
  this.clearAllCounters()
517
-
518
- if (this.metricsLogValues) {
519
- const metricObjects = await this.registry.getMetricsAsJSON()
520
- console.info(
521
- `[queue-metrics] Collected metrics for Redis`,
522
- JSON.stringify(metricObjects, null, 2)
523
- )
524
- }
525
572
  } catch (error) {
526
573
  console.error(
527
574
  `[queue-metrics] Failed to collect Redis metrics: ${error.message}`
@@ -531,8 +578,7 @@ class RedisMetricsClient extends BaseMetricsClient {
531
578
  }
532
579
 
533
580
  /**
534
- * Start periodic collection.
535
- * @param {number} [intervalSec=this.intervalSec] - Interval in seconds
581
+ * Start periodic collection. When graceful shutdown is on: new publishes "new started" so old exits and clears; new does not push until after one interval (skipFirstPush), giving old time to stop.
536
582
  */
537
583
  startPush = (intervalSec = this.intervalSec) => {
538
584
  this._startPush(intervalSec, () => {
@@ -587,7 +633,7 @@ class RedisMetricsClient extends BaseMetricsClient {
587
633
  } catch (err) {
588
634
  console.error('[queue-metrics] Error closing Redis client:', err)
589
635
  }
590
- process.exit(0)
636
+ await super.cleanup()
591
637
  }
592
638
 
593
639
  _setCleanupHandlers = () => {