comq 0.15.6 → 0.15.8

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 (51) hide show
  1. package/.releaserc +1 -8
  2. package/features/recovery.feature +30 -0
  3. package/features/silence.feature +46 -0
  4. package/features/steps/.types/context.d.ts +17 -0
  5. package/features/steps/backpressure.js +1 -1
  6. package/features/steps/broker.js +1 -1
  7. package/features/steps/brokers.js +1 -1
  8. package/features/steps/common.js +1 -1
  9. package/features/steps/connection.js +41 -1
  10. package/features/steps/context.js +12 -2
  11. package/features/steps/events.js +1 -1
  12. package/features/steps/hooks.js +1 -0
  13. package/features/steps/network.js +50 -0
  14. package/features/steps/networks.js +90 -0
  15. package/features/steps/parameters.js +6 -0
  16. package/features/steps/poison.js +1 -1
  17. package/features/steps/rpc.js +1 -1
  18. package/features/steps/tasks.js +1 -1
  19. package/package.json +3 -6
  20. package/readme.md +21 -6
  21. package/source/attributes/lazy.test.js +1 -1
  22. package/source/attributes/track.test.js +3 -3
  23. package/source/connection.js +137 -12
  24. package/source/emitter.js +23 -1
  25. package/source/encoders/index.js +0 -2
  26. package/source/events.js +2 -2
  27. package/source/io.js +6 -5
  28. package/source/shards/channel.js +34 -17
  29. package/source/shards/connection.js +0 -5
  30. package/test/amqplib.mock.js +16 -4
  31. package/test/channel.fixtures.js +1 -1
  32. package/test/channel.test.js +1 -1
  33. package/test/connection.test.js +176 -6
  34. package/test/decode.test.js +0 -13
  35. package/test/emitter.test.js +71 -0
  36. package/test/encode.test.js +0 -9
  37. package/test/encodings.js +1 -1
  38. package/test/helpers.js +77 -0
  39. package/test/io.close.test.js +4 -3
  40. package/test/io.diagnostics.test.js +2 -2
  41. package/test/io.reply.test.js +2 -2
  42. package/test/io.request.test.js +16 -2
  43. package/test/pipeline.test.js +1 -1
  44. package/test/shards/channel.test.js +97 -9
  45. package/test/shards/connection.test.js +4 -3
  46. package/types/diagnostic.d.ts +2 -2
  47. package/types/encoding.d.ts +1 -2
  48. package/types/io.d.ts +2 -0
  49. package/AGENTS.md +0 -35
  50. package/CHANGELOG.md +0 -76
  51. package/source/encoders/msgpack.js +0 -6
@@ -50,10 +50,7 @@ class Connection {
50
50
  * @param {string} url
51
51
  */
52
52
  constructor (url) {
53
- this.#url = url
54
-
55
- // EventEmitter throws on 'error' with no listeners
56
- this.#diagnostics.on('error', noop)
53
+ this.#url = heartbeaten(url)
57
54
  }
58
55
 
59
56
  get connected () {
@@ -73,6 +70,11 @@ class Connection {
73
70
 
74
71
  await this.#opening
75
72
 
73
+ if (this.#closed) return
74
+
75
+ // close may have landed after this attempt succeeded but before `#opening` was cleared
76
+ if (this.#connection === undefined) return this.open()
77
+
76
78
  this.#running = true
77
79
  }
78
80
 
@@ -109,16 +111,18 @@ class Connection {
109
111
  #open = async (retry) => {
110
112
  if (this.#closed) return
111
113
 
114
+ // the initial connect finishes before the caller can subscribe
115
+ if (this.#running) this.#diagnostics.emit('reconnect')
116
+
112
117
  /** @type {comq.amqp.Connection} */
113
118
  let connection
114
119
 
115
120
  try {
116
- connection = await amqp.connect(this.#url, { timeout: CONNECT_MS })
121
+ connection = await this.#connect()
117
122
  } catch (exception) {
118
123
  if (this.#closed) return
119
124
  if (!this.#transient(exception)) throw exception
120
125
 
121
- // attempts are made until one succeeds, so an outage is observable only here
122
126
  this.#diagnostics.emit('error', exception)
123
127
 
124
128
  return retry
@@ -193,30 +197,78 @@ class Connection {
193
197
  }
194
198
 
195
199
  connection.removeAllListeners()
196
- connection.connection?.stream?.destroy()
200
+
201
+ // amqplib keeps its heartbeater running on a connection it does not know is
202
+ // gone and emits 'error' on it, which throws once no listener is left
203
+ connection.on('error', noop)
204
+
205
+ // a socket destroyed without an error tells amqplib nothing, leaving its
206
+ // timers running and everything pending on it hanging forever
207
+ connection.connection?.stream?.destroy(silence())
197
208
  }
198
209
 
199
210
  #recover () {
200
211
  return this.#recovery
201
212
  }
202
213
 
214
+ /**
215
+ * amqplib's `timeout` is a socket idle timer and starts only after DNS.
216
+ * After a machine wakes, `getaddrinfo` itself can hang, so the attempt is
217
+ * also bounded here.
218
+ *
219
+ * @return {Promise<comq.amqp.Connection>}
220
+ */
221
+ async #connect () {
222
+ const connecting = amqp.connect(this.#url, SOCKET_OPTIONS)
223
+
224
+ let timer
225
+
226
+ const expired = new Promise((_resolve, reject) => {
227
+ timer = setTimeout(() => {
228
+ const exception = new Error('connect ETIMEDOUT')
229
+
230
+ exception.code = 'ETIMEDOUT'
231
+
232
+ reject(exception)
233
+ }, CONNECT_MS)
234
+
235
+ timer.unref()
236
+ })
237
+
238
+ expired.catch(noop)
239
+
240
+ try {
241
+ return await Promise.race([connecting, expired])
242
+ } catch (exception) {
243
+ connecting.then((connection) => this.#shutdown(connection), noop)
244
+
245
+ throw exception
246
+ } finally {
247
+ clearTimeout(timer)
248
+ }
249
+ }
250
+
203
251
  /**
204
252
  * @param {comq.amqp.Connection} connection
205
253
  * @param {number} [timeoutMs]
206
254
  */
207
- #armWatchdog (connection, timeoutMs = global.COMQ_TESTING_WATCHDOG_INTERVAL ?? WATCHDOG_MS) {
255
+ #armWatchdog (connection, timeoutMs = tolerance(connection)) {
256
+ this.#disarmWatchdog()
257
+
208
258
  const socket = connection.connection?.stream
209
259
 
210
- if (socket === undefined) return
260
+ if (socket === undefined || timeoutMs === undefined) return
211
261
 
212
262
  const reset = () => {
213
263
  clearTimeout(this.#heartbeatTimer)
214
- this.#heartbeatTimer = setTimeout(() => socket.destroy(), timeoutMs)
264
+ // destroying a socket without an error tells amqplib nothing: it only
265
+ // listens for 'error' and 'end', so a bare destroy() leaves the connection
266
+ // silently dead — no 'close' event, no recovery, every pending operation
267
+ // hanging forever
268
+ this.#heartbeatTimer = setTimeout(() => socket.destroy(silence()), timeoutMs)
215
269
  this.#heartbeatTimer.unref()
216
270
  }
217
271
 
218
- this.#disarmWatchdog()
219
-
220
272
  this.#heartbeatSocket = socket
221
273
  this.#heartbeatReset = reset
222
274
 
@@ -245,7 +297,25 @@ class Connection {
245
297
  }
246
298
  }
247
299
 
300
+ /**
301
+ * The heartbeat to ask for when the caller has not, in seconds. A broker is free
302
+ * to suggest one that leaves a connection lost for minutes before anything
303
+ * notices, and RabbitMQ suggests 60 by default.
304
+ *
305
+ * @type {number}
306
+ */
307
+ const HEARTBEAT_S = 15
308
+
309
+ /** @type {number} */
310
+ const KEEPALIVE_MS = 10_000
311
+
312
+ /** How many heartbeats a connection may miss before it is destroyed. */
313
+ const MISSED_HEARTBEATS = 3
314
+
248
315
  /** @type {number} */
316
+ const WATCHDOG_MIN_MS = 15_000
317
+
318
+ /** The watchdog interval used when the negotiated heartbeat is unknown. */
249
319
  const WATCHDOG_MS = 60_000
250
320
 
251
321
  /** @type {number} */
@@ -254,6 +324,15 @@ const CONNECT_MS = 30_000
254
324
  /** @type {number} */
255
325
  const SHUTDOWN_MS = 5_000
256
326
 
327
+ const SOCKET_OPTIONS = {
328
+ timeout: CONNECT_MS,
329
+ // a peer that went away without a word is noticed by the kernel as well
330
+ keepAlive: true,
331
+ keepAliveDelay: KEEPALIVE_MS
332
+ }
333
+
334
+ const HEARTBEAT_SET = /[?&]heartbeat=/
335
+
257
336
  const TRANSIENT_CODES = new Set([
258
337
  'ECONNREFUSED',
259
338
  'EAI_AGAIN',
@@ -279,6 +358,52 @@ function expiration () {
279
358
  return delay(timeoutMs, undefined, { ref: false })
280
359
  }
281
360
 
361
+ /**
362
+ * amqplib reads the heartbeat from the URL only, hence it cannot be passed along
363
+ * with the socket options.
364
+ *
365
+ * @param {string} url
366
+ * @return {string}
367
+ */
368
+ function heartbeaten (url) {
369
+ if (HEARTBEAT_SET.test(url)) return url
370
+
371
+ return url + (url.includes('?') ? '&' : '?') + 'heartbeat=' + HEARTBEAT_S
372
+ }
373
+
374
+ /**
375
+ * The watchdog measures silence, so it cannot be shorter than the interval at
376
+ * which a healthy broker is expected to say something, which is a heartbeat
377
+ * frame every half a heartbeat. A connection that has agreed to no heartbeats
378
+ * says nothing at all while it is idle, leaving nothing to measure.
379
+ *
380
+ * @param {comq.amqp.Connection} connection
381
+ * @return {number | undefined}
382
+ */
383
+ function tolerance (connection) {
384
+ const override = global.COMQ_TESTING_WATCHDOG_INTERVAL
385
+
386
+ if (override !== undefined) return override
387
+
388
+ const heartbeat = connection.connection?.heartbeat
389
+
390
+ if (heartbeat === undefined) return WATCHDOG_MS
391
+ if (heartbeat === 0) return undefined
392
+
393
+ return Math.max(heartbeat * MISSED_HEARTBEATS * 1000, WATCHDOG_MIN_MS)
394
+ }
395
+
396
+ /**
397
+ * @return {Error}
398
+ */
399
+ function silence () {
400
+ const exception = new Error('Connection is silent')
401
+
402
+ exception.code = 'ETIMEDOUT'
403
+
404
+ return exception
405
+ }
406
+
282
407
  function noop () {}
283
408
 
284
409
  exports.Connection = Connection
package/source/emitter.js CHANGED
@@ -2,8 +2,30 @@
2
2
 
3
3
  const { EventEmitter } = require('node:events')
4
4
 
5
+ /**
6
+ * A listener must not be able to break what it observes: an exception thrown by
7
+ * one used to leave the reconnection it was reporting on unfinished, or take the
8
+ * process down through the emitter amqplib was calling.
9
+ */
10
+ class Emitter extends EventEmitter {
11
+ emit (event, ...args) {
12
+ // raw listeners are used so that `once` still removes itself
13
+ const listeners = this.rawListeners(event)
14
+
15
+ for (const listener of listeners) {
16
+ try {
17
+ listener.apply(this, args)
18
+ } catch {
19
+ // a diagnostic listener has no one to report to
20
+ }
21
+ }
22
+
23
+ return listeners.length > 0
24
+ }
25
+ }
26
+
5
27
  function create () {
6
- const emitter = new EventEmitter()
28
+ const emitter = new Emitter()
7
29
 
8
30
  emitter.setMaxListeners(0)
9
31
 
@@ -1,9 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const json = require('./json')
4
- const msgpack = require('./msgpack')
5
4
  const plain = require('./plain')
6
5
 
7
6
  exports['application/json'] = json
8
- exports['application/msgpack'] = msgpack
9
7
  exports['text/plain'] = plain
package/source/events.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  /** @type {comq.diagnostics.Event[]} */
4
- exports.connection = ['open', 'close', 'error']
4
+ exports.connection = ['open', 'close', 'error', 'reconnect']
5
5
 
6
6
  /** @type {comq.diagnostics.Event[]} */
7
- exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost']
7
+ exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost', 'remove']
package/source/io.js CHANGED
@@ -1,7 +1,6 @@
1
1
  'use strict'
2
2
 
3
3
  const stream = require('node:stream')
4
- const { EventEmitter } = require('node:events')
5
4
  const { randomBytes } = require('node:crypto')
6
5
  const { setTimeout } = require('node:timers/promises')
7
6
  const { Promex } = require('promex')
@@ -11,6 +10,7 @@ const { decode } = require('./decode')
11
10
  const { encode } = require('./encode')
12
11
  const { pipeline, transform } = require('./pipeline')
13
12
  const events = require('./events')
13
+ const emitter = require('./emitter')
14
14
  const io = require('./.io')
15
15
 
16
16
  /**
@@ -44,7 +44,7 @@ class IO {
44
44
  /** @type {Set<comq.Destroyable>} */
45
45
  #replyPipes = new Set()
46
46
 
47
- #diagnostics = new EventEmitter()
47
+ #diagnostics = emitter.create()
48
48
 
49
49
  /**
50
50
  * @param {comq.Connection} connection
@@ -52,9 +52,6 @@ class IO {
52
52
  constructor (connection) {
53
53
  this.#connection = connection
54
54
 
55
- // EventEmitter throws on 'error' with no listeners
56
- this.#diagnostics.on('error', noop)
57
-
58
55
  for (const event of events.connection) {
59
56
  this.#connection.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args))
60
57
  }
@@ -220,6 +217,10 @@ class IO {
220
217
  } else {
221
218
  this.#requests.diagnose('recover', this.#retransmit)
222
219
  }
220
+
221
+ // a reply that could not be routed is dropped by the broker, and the queue
222
+ // it was addressed to only exists again once this channel has recovered
223
+ this.#replies.diagnose('recover', this.#retransmit)
223
224
  }
224
225
 
225
226
  /**
@@ -31,6 +31,9 @@ class Channel {
31
31
  /** @type {Map<comq.Channel, Promex>} */
32
32
  #bench = new Map()
33
33
 
34
+ /** @type {boolean} */
35
+ #paused = false
36
+
34
37
  /** @type {comq.topology.type} */
35
38
  #type
36
39
 
@@ -126,6 +129,9 @@ class Channel {
126
129
  this.#alive[index] = false
127
130
  this.#down[index].resolve()
128
131
 
132
+ // the channel stays in the pool, yet there is one less shard to publish to
133
+ this.#update()
134
+
129
135
  if (error !== undefined && connection.closed !== true) {
130
136
  this.#diagnostics.emit(LOST, index)
131
137
  }
@@ -134,6 +140,8 @@ class Channel {
134
140
  connection.diagnose('open', () => {
135
141
  this.#alive[index] = true
136
142
  this.#down[index] = new Promex()
143
+
144
+ this.#update()
137
145
  })
138
146
  }
139
147
 
@@ -159,6 +167,7 @@ class Channel {
159
167
  for (const event of events.channel) {
160
168
  if (event === RETURN) continue // returns are retried before being reported
161
169
  if (event === LOST) continue // a shard is lost with its connection, not its channel
170
+ if (event === REMOVE) continue // it is the pool that removes a channel, not the channel
162
171
 
163
172
  channel.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args, channel.index))
164
173
  }
@@ -214,20 +223,23 @@ class Channel {
214
223
  this.#bench.set(channel, new Promex())
215
224
  this.#channels.delete(channel)
216
225
  this.#update()
217
- this.#diagnostics.emit('remove', channel.index)
226
+ this.#diagnostics.emit(REMOVE, channel.index)
218
227
  }
219
228
 
229
+ /**
230
+ * A shard leaves the pool when it rejects a publish, and stops being reachable
231
+ * when its connection is lost, which the pool is not told about. Both leave
232
+ * `#one` waiting, so both are reported the same way.
233
+ */
220
234
  #update () {
221
- const from = this.#pool?.length
222
- const to = this.#channels.size
223
-
224
235
  this.#pool = Array.from(this.#channels)
225
236
 
226
- if (from === undefined) return
237
+ const paused = this.#reachable().length === 0
227
238
 
228
- if (from !== 0 && to === 0) { this.#diagnostics.emit('pause') }
239
+ if (paused === this.#paused) return
229
240
 
230
- if (from === 0 && to !== 0) { this.#diagnostics.emit('resume') }
241
+ this.#paused = paused
242
+ this.#diagnostics.emit(paused ? 'pause' : 'resume')
231
243
  }
232
244
 
233
245
  /**
@@ -318,26 +330,30 @@ class Channel {
318
330
  }
319
331
 
320
332
  /**
321
- * The channels of the shards that are known to be connected, falling back to the
322
- * whole pool while none is, so that a publish is attempted rather than dropped.
333
+ * The channels of the shards that are known to be connected.
334
+ * An empty result means every shard is down or benched — wait rather than
335
+ * publish: a destroyed socket accepts a write without complaining.
323
336
  *
324
337
  * @return {comq.Channel[]}
325
338
  */
326
339
  #reachable () {
327
- const reachable = this.#pool.filter((channel) => this.#alive[channel.index] !== false)
328
-
329
- return reachable.length === 0 ? this.#pool : reachable
340
+ return this.#pool.filter((channel) => this.#alive[channel.index] !== false)
330
341
  }
331
342
 
332
343
  /**
333
344
  * @param {(channel: comq.Channel) => void} fn
334
345
  */
335
346
  async #one (fn) {
336
- if (this.#pool.length === 0) await this.#recovery
337
-
338
- // publishing to a lost shard is silently accepted by the destroyed socket, so
339
- // it must be avoided rather than retried: nothing would report the loss twice
347
+ // capture before the check: recover may replace `#recovery` in between
348
+ const waiting = this.#recovery
340
349
  const pool = this.#reachable()
350
+
351
+ if (pool.length === 0) {
352
+ await waiting
353
+
354
+ return this.#one(fn)
355
+ }
356
+
341
357
  const channel = pool[Math.floor(Math.random() * pool.length)]
342
358
 
343
359
  try {
@@ -345,7 +361,7 @@ class Channel {
345
361
  } catch {
346
362
  this.#remove(channel)
347
363
 
348
- return await this.#one(fn)
364
+ return this.#one(fn)
349
365
  }
350
366
  }
351
367
  }
@@ -365,6 +381,7 @@ async function create (connections, type) {
365
381
 
366
382
  const DEFAULT = ''
367
383
  const RETURN = 'return'
384
+ const REMOVE = 'remove'
368
385
  const RETURN_HEADER = 'x-return'
369
386
  const LOST = 'lost'
370
387
 
@@ -19,9 +19,6 @@ class Connection {
19
19
  constructor (connections) {
20
20
  this.#connections = connections
21
21
 
22
- // EventEmitter throws on 'error' with no listeners
23
- this.#diagnostics.on('error', noop)
24
-
25
22
  connections.map(this.#pipe)
26
23
  }
27
24
 
@@ -75,6 +72,4 @@ class Connection {
75
72
  }
76
73
  }
77
74
 
78
- function noop () {}
79
-
80
75
  exports.Connection = Connection
@@ -18,14 +18,26 @@ class Channel extends EventEmitter {
18
18
  }
19
19
 
20
20
  class Connection extends EventEmitter {
21
- constructor () {
21
+ constructor (url = '') {
22
22
  super()
23
23
 
24
24
  const stream = new EventEmitter()
25
25
 
26
- stream.destroy = jest.fn()
26
+ // amqplib listens for 'error' and 'end' on the socket only, so destroying it
27
+ // without an error leaves the connection unaware and silent forever
28
+ stream.destroy = jest.fn((error) => {
29
+ if (error === undefined || stream.destroyed === true) return
27
30
 
28
- this.connection = { stream }
31
+ stream.destroyed = true
32
+
33
+ this.emit('error', error)
34
+ this.emit('close', error)
35
+ })
36
+
37
+ // a broker accepts the heartbeat the client asks for
38
+ const heartbeat = Number(/[?&]heartbeat=(\d+)/.exec(url)?.[1] ?? 60)
39
+
40
+ this.connection = { stream, heartbeat }
29
41
 
30
42
  // noinspection JSValidateTypes
31
43
  this.removeAllListeners = jest.spyOn(this, 'removeAllListeners')
@@ -39,7 +51,7 @@ class Connection extends EventEmitter {
39
51
  close = jest.fn(async () => undefined)
40
52
  }
41
53
 
42
- const connect = async () => new Connection()
54
+ const connect = async (url) => new Connection(url)
43
55
 
44
56
  /** @type {jest.MockedObject<import('amqplib')>} */
45
57
  const amqplib = {
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { random, flip } = require('@toa.io/generic')
3
+ const { random, flip } = require('./helpers')
4
4
 
5
5
  const preset = () => ({
6
6
  prefetch: random(10),
@@ -4,7 +4,7 @@
4
4
 
5
5
  const { randomBytes } = require('node:crypto')
6
6
  const { generate } = require('randomstring')
7
- const { flip, random, timeout } = require('@toa.io/generic')
7
+ const { flip, random, timeout } = require('./helpers')
8
8
 
9
9
  const backpressure = require('./backpressure')
10
10
  const { amqplib } = require('./amqplib.mock')