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
@@ -4,7 +4,8 @@
4
4
 
5
5
  const { generate } = require('randomstring')
6
6
 
7
- const { timeout, promex, random } = require('@toa.io/generic')
7
+ const { Promex } = require('promex')
8
+ const { timeout, random } = require('./helpers')
8
9
  const { amqplib, connect } = require('./amqplib.mock')
9
10
  const { channel: create } = require('./connection.mock')
10
11
  const mock = { amqplib, channel: { create } }
@@ -37,7 +38,8 @@ describe('initial connection', () => {
37
38
  it('should connect', async () => {
38
39
  await connection.open()
39
40
 
40
- expect(amqplib.connect).toHaveBeenCalledWith(url, { timeout: expect.any(Number) })
41
+ expect(amqplib.connect).toHaveBeenCalledWith(expect.stringContaining(url),
42
+ expect.objectContaining({ timeout: expect.any(Number) }))
41
43
  })
42
44
 
43
45
  it.each(/** @type {[string, Partial<Error>][]} */[
@@ -138,6 +140,41 @@ describe('reconnection', () => {
138
140
  expect(amqplib.connect.mock.calls.length).toBeGreaterThanOrEqual(3)
139
141
  }, 15000)
140
142
 
143
+ it('should keep an error sink on a dropped connection', async () => {
144
+ const channel = await connection.createChannel('request')
145
+
146
+ channel.recover
147
+ .mockRejectedValueOnce(new Error('Channel closed'))
148
+ .mockResolvedValue(undefined)
149
+
150
+ conn.emit('close', new Error('lost'))
151
+
152
+ await timeout(10)
153
+
154
+ const dropped = await amqplib.connect.mock.results[1].value
155
+
156
+ // amqplib goes on emitting on a connection it does not know is gone, and an
157
+ // 'error' with no listener left takes the process down
158
+ expect(() => dropped.emit('error', new Error('Heartbeat timeout'))).not.toThrow()
159
+
160
+ // let the attempt that follows the failed recovery settle
161
+ const start = Date.now()
162
+
163
+ while (channel.recover.mock.calls.length < 2 && Date.now() - start < 10000) await timeout(50)
164
+ }, 15000)
165
+
166
+ it('should reconnect despite a listener that throws', async () => {
167
+ // a diagnostic listener has no business breaking the reconnection it reports
168
+ connection.diagnose('reconnect', () => { throw new Error('listener') })
169
+ connection.diagnose('open', () => { throw new Error('listener') })
170
+
171
+ conn.emit('close', new Error('lost'))
172
+
173
+ await expect(connection.createChannel('event')).resolves.toBeDefined()
174
+
175
+ expect(amqplib.connect).toHaveBeenCalledTimes(2)
176
+ }, 10000)
177
+
141
178
  it('should emit error when reconnect open fails', async () => {
142
179
  const errors = []
143
180
  const unhandled = jest.fn()
@@ -211,6 +248,41 @@ describe('reconnection', () => {
211
248
  expect(errors).toContain(exception)
212
249
  }, 10000)
213
250
 
251
+ it('should emit reconnect when restoring a lost connection', async () => {
252
+ const reconnect = jest.fn()
253
+
254
+ connection.diagnose('reconnect', reconnect)
255
+ conn.emit('close', new Error('lost'))
256
+
257
+ await connection.createChannel('event')
258
+
259
+ expect(reconnect).toHaveBeenCalled()
260
+ }, 10000)
261
+
262
+ it('should bound a connect that never settles', async () => {
263
+ jest.useFakeTimers()
264
+
265
+ try {
266
+ const errors = []
267
+
268
+ connection.diagnose('error', (exception) => errors.push(exception))
269
+ amqplib.connect.mockImplementation(() => new Promise(() => undefined))
270
+
271
+ conn.emit('close', new Error('lost'))
272
+
273
+ await jest.advanceTimersByTimeAsync(0)
274
+ await jest.advanceTimersByTimeAsync(30_000)
275
+
276
+ expect(errors).toEqual([expect.objectContaining({ code: 'ETIMEDOUT' })])
277
+
278
+ await jest.advanceTimersByTimeAsync(2_000)
279
+
280
+ expect(amqplib.connect.mock.calls.length).toBeGreaterThan(2)
281
+ } finally {
282
+ jest.useRealTimers()
283
+ }
284
+ })
285
+
214
286
  it('should ignore close from a stale connection', async () => {
215
287
  const stale = conn
216
288
  const closeHandler = stale.on.mock.calls.find(([event]) => event === 'close')[1]
@@ -304,7 +376,7 @@ describe('create channel', () => {
304
376
 
305
377
  jest.clearAllMocks()
306
378
 
307
- const promise = promex()
379
+ const promise = new Promex()
308
380
 
309
381
  amqplib.connect.mockImplementationOnce(() => promise)
310
382
 
@@ -338,11 +410,23 @@ describe('watchdog', () => {
338
410
 
339
411
  const conn = await amqplib.connect.mock.results[0].value
340
412
 
341
- await jest.advanceTimersByTimeAsync(60_000)
413
+ await jest.advanceTimersByTimeAsync(46_000)
342
414
 
343
415
  expect(conn.connection.stream.destroy).toHaveBeenCalled()
344
416
  })
345
417
 
418
+ it('should reconnect after the watchdog destroys a silent connection', async () => {
419
+ jest.useFakeTimers()
420
+
421
+ await connection.open()
422
+
423
+ await jest.advanceTimersByTimeAsync(46_000)
424
+
425
+ // a socket destroyed without an error is a socket amqplib never reports,
426
+ // which leaves the connection silently dead instead of recovering
427
+ expect(amqplib.connect).toHaveBeenCalledTimes(2)
428
+ })
429
+
346
430
  it('should not let a replaced socket disarm the watchdog', async () => {
347
431
  jest.useFakeTimers()
348
432
 
@@ -372,11 +456,11 @@ describe('watchdog', () => {
372
456
 
373
457
  const conn = await amqplib.connect.mock.results[0].value
374
458
 
375
- await jest.advanceTimersByTimeAsync(50_000)
459
+ await jest.advanceTimersByTimeAsync(40_000)
376
460
 
377
461
  conn.connection.stream.emit('data', Buffer.alloc(0))
378
462
 
379
- await jest.advanceTimersByTimeAsync(50_000)
463
+ await jest.advanceTimersByTimeAsync(40_000)
380
464
 
381
465
  expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
382
466
 
@@ -384,6 +468,77 @@ describe('watchdog', () => {
384
468
 
385
469
  expect(conn.connection.stream.destroy).toHaveBeenCalled()
386
470
  })
471
+
472
+ it('should derive the watchdog from the negotiated heartbeat', async () => {
473
+ jest.useFakeTimers()
474
+
475
+ const connection = new Connection('amqp://localhost?heartbeat=30')
476
+
477
+ await connection.open()
478
+
479
+ const conn = await amqplib.connect.mock.results[0].value
480
+
481
+ // the broker is only expected to say something every 15 seconds
482
+ await jest.advanceTimersByTimeAsync(60_000)
483
+
484
+ expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
485
+
486
+ await jest.advanceTimersByTimeAsync(31_000)
487
+
488
+ expect(conn.connection.stream.destroy).toHaveBeenCalled()
489
+ })
490
+
491
+ it('should not arm the watchdog when heartbeats are disabled', async () => {
492
+ jest.useFakeTimers()
493
+
494
+ const connection = new Connection('amqp://localhost?heartbeat=0')
495
+
496
+ await connection.open()
497
+
498
+ const conn = await amqplib.connect.mock.results[0].value
499
+
500
+ // an idle connection that has agreed to no heartbeats is silent by design
501
+ await jest.advanceTimersByTimeAsync(600_000)
502
+
503
+ expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
504
+ })
505
+ })
506
+
507
+ describe('heartbeat', () => {
508
+ it('should ask for a heartbeat', async () => {
509
+ const connection = new Connection('amqp://developer@localhost')
510
+
511
+ await connection.open()
512
+
513
+ expect(amqplib.connect).toHaveBeenCalledWith('amqp://developer@localhost?heartbeat=15',
514
+ expect.anything())
515
+ })
516
+
517
+ it('should append the heartbeat to an existing query', async () => {
518
+ const connection = new Connection('amqp://localhost?frameMax=8192')
519
+
520
+ await connection.open()
521
+
522
+ expect(amqplib.connect).toHaveBeenCalledWith('amqp://localhost?frameMax=8192&heartbeat=15',
523
+ expect.anything())
524
+ })
525
+
526
+ it.each(['amqp://localhost?heartbeat=5', 'amqp://localhost?heartbeat=0'])(
527
+ 'should keep the heartbeat requested by the caller (%s)',
528
+ async (url) => {
529
+ const connection = new Connection(url)
530
+
531
+ await connection.open()
532
+
533
+ expect(amqplib.connect).toHaveBeenCalledWith(url, expect.anything())
534
+ })
535
+
536
+ it('should keep the socket alive', async () => {
537
+ await connection.open()
538
+
539
+ expect(amqplib.connect)
540
+ .toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ keepAlive: true }))
541
+ })
387
542
  })
388
543
 
389
544
  describe('close', () => {
@@ -476,6 +631,21 @@ describe('diagnostics', () => {
476
631
  expect(captured).toStrictEqual(true)
477
632
  })
478
633
 
634
+ it('should not emit reconnect on the initial open', async () => {
635
+ const reconnect = jest.fn()
636
+
637
+ connection.diagnose('reconnect', reconnect)
638
+
639
+ jest.clearAllMocks()
640
+
641
+ connection = new Connection(url)
642
+ connection.diagnose('reconnect', reconnect)
643
+
644
+ await connection.open()
645
+
646
+ expect(reconnect).not.toHaveBeenCalled()
647
+ })
648
+
479
649
  it('should re-emit `close` event', async () => {
480
650
  let captured
481
651
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  const { randomBytes } = require('node:crypto')
4
4
  const { generate } = require('randomstring')
5
- const { pack } = require('msgpackr')
6
5
 
7
6
  const { decode } = require('../source/decode')
8
7
 
@@ -23,18 +22,6 @@ it('should decode application/json', async () => {
23
22
  expect(decoded).toStrictEqual(object)
24
23
  })
25
24
 
26
- it('should decode application/msgpack', async () => {
27
- const object = { [generate()]: generate() }
28
- const content = pack(object)
29
- const contentType = 'application/msgpack'
30
- const properties = { contentType }
31
- const message = /** @type {comq.amqp.Message} */ { content, properties }
32
-
33
- const decoded = decode(message)
34
-
35
- expect(decoded).toStrictEqual(object)
36
- })
37
-
38
25
  it('should return buffer if content type is not defined', async () => {
39
26
  const object = { [generate()]: generate() }
40
27
  const json = JSON.stringify(object)
@@ -0,0 +1,71 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+ const emitter = require('../source/emitter')
5
+
6
+ /** @type {import('node:events').EventEmitter} */
7
+ let instance
8
+
9
+ beforeEach(() => {
10
+ instance = emitter.create()
11
+ })
12
+
13
+ it('should be', async () => {
14
+ expect(emitter.create).toBeInstanceOf(Function)
15
+ })
16
+
17
+ it('should emit', async () => {
18
+ const listener = jest.fn()
19
+ const event = generate()
20
+ const args = [generate(), generate()]
21
+
22
+ instance.on(event, listener)
23
+
24
+ expect(instance.emit(event, ...args)).toStrictEqual(true)
25
+ expect(listener).toHaveBeenCalledWith(...args)
26
+ })
27
+
28
+ it('should report an event no one listens to', async () => {
29
+ expect(instance.emit(generate())).toStrictEqual(false)
30
+ })
31
+
32
+ it('should not throw on error without listeners', async () => {
33
+ expect(() => instance.emit('error', new Error(generate()))).not.toThrow()
34
+ })
35
+
36
+ it('should not let a listener break the emission', async () => {
37
+ const event = generate()
38
+ const listener = jest.fn()
39
+
40
+ instance.on(event, () => { throw new Error(generate()) })
41
+ instance.on(event, listener)
42
+
43
+ expect(() => instance.emit(event)).not.toThrow()
44
+
45
+ // the listeners that follow a broken one are still called
46
+ expect(listener).toHaveBeenCalled()
47
+ })
48
+
49
+ it('should call a `once` listener once', async () => {
50
+ const event = generate()
51
+ const listener = jest.fn()
52
+
53
+ instance.once(event, listener)
54
+
55
+ instance.emit(event)
56
+ instance.emit(event)
57
+
58
+ expect(listener).toHaveBeenCalledTimes(1)
59
+ })
60
+
61
+ it('should not call a listener that has been removed', async () => {
62
+ const event = generate()
63
+ const listener = jest.fn()
64
+
65
+ instance.on(event, listener)
66
+ instance.off(event, listener)
67
+
68
+ instance.emit(event)
69
+
70
+ expect(listener).not.toHaveBeenCalled()
71
+ })
@@ -1,7 +1,6 @@
1
1
  'use strict'
2
2
 
3
3
  const { generate } = require('randomstring')
4
- const { pack } = require('msgpackr')
5
4
 
6
5
  const { encode } = require('../source/encode')
7
6
 
@@ -26,14 +25,6 @@ it('should encode as json', async () => {
26
25
  expect(buffer).toStrictEqual(expected)
27
26
  })
28
27
 
29
- it('should encode as msgpack', async () => {
30
- const value = { [generate()]: generate() }
31
- const packed = pack(value)
32
- const buffer = encode(value, 'application/msgpack')
33
-
34
- expect(buffer).toStrictEqual(packed)
35
- })
36
-
37
28
  /** @type {[comq.Encoding, string, Buffer][]} */
38
29
  const samples = [
39
30
  ['text/plain', 'some string', Buffer.from('some string')]
package/test/encodings.js CHANGED
@@ -1,4 +1,4 @@
1
1
  'use strict'
2
2
 
3
3
  /** @type {comq.Encoding[]} */
4
- exports.encodings = ['application/msgpack', 'application/json']
4
+ exports.encodings = ['application/json']
@@ -0,0 +1,77 @@
1
+ 'use strict'
2
+
3
+ const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
4
+
5
+ const immediate = () => new Promise((resolve) => setImmediate(resolve))
6
+
7
+ const random = (max = 100) => Math.floor(Math.random() * max)
8
+
9
+ const flip = () => Math.random() < 0.5
10
+
11
+ const sample = (array) => array[Math.floor(Math.random() * array.length)]
12
+
13
+ const quantity = (input) => {
14
+ const matched = String(input).match(/^(\d+(?:\.\d+)?)([^\d\W]*)$/)
15
+
16
+ if (matched === null) {
17
+ throw new Error(`'${input}' doesn't look like a quantity of something`)
18
+ }
19
+
20
+ const number = +matched[1]
21
+ const suffix = matched[2]
22
+
23
+ if (suffix.length === 0) {
24
+ return number
25
+ }
26
+
27
+ const pair = MULTIPLIERS.find(([unit]) => suffix.startsWith(unit))
28
+
29
+ if (pair === undefined) {
30
+ throw new Error(`'${suffix}' doesn't look like a quantity unit`)
31
+ }
32
+
33
+ return number * pair[1]
34
+ }
35
+
36
+ const match = (reference, candidate) => {
37
+ if (typeof candidate !== typeof reference) {
38
+ return false
39
+ }
40
+
41
+ if (Array.isArray(candidate)) {
42
+ return Array.isArray(reference) &&
43
+ candidate.every((value) => reference.some((item) => match(item, value)))
44
+ }
45
+
46
+ if (typeof candidate === 'object') {
47
+ if (candidate === null) {
48
+ return reference === null
49
+ }
50
+
51
+ if (reference === null) {
52
+ return false
53
+ }
54
+
55
+ return Object.entries(candidate).every(([key, value]) => match(reference[key], value))
56
+ }
57
+
58
+ return reference === candidate
59
+ }
60
+
61
+ const MULTIPLIERS = Object.entries({
62
+ k: 1000,
63
+ Ki: 1024,
64
+ K: 1024,
65
+ Mi: 1024 ** 2,
66
+ M: 1000 ** 2,
67
+ Gi: 1024 ** 3,
68
+ G: 1000 ** 3
69
+ })
70
+
71
+ exports.timeout = timeout
72
+ exports.immediate = immediate
73
+ exports.random = random
74
+ exports.flip = flip
75
+ exports.sample = sample
76
+ exports.quantity = quantity
77
+ exports.match = match
@@ -2,7 +2,8 @@
2
2
 
3
3
  const { randomBytes } = require('node:crypto')
4
4
  const { generate } = require('randomstring')
5
- const { promex, immediate } = require('@toa.io/generic')
5
+ const { Promex } = require('promex')
6
+ const { immediate } = require('./helpers')
6
7
 
7
8
  const mock = require('./connection.mock')
8
9
  const { IO } = require('../source/io')
@@ -93,7 +94,7 @@ describe('close', () => {
93
94
  })
94
95
 
95
96
  it('should wait for event processing completion', async () => {
96
- const promise = /** @type {Promise<void>} */ promex()
97
+ const promise = /** @type {Promise<void>} */ new Promex()
97
98
  const queue = generate()
98
99
  const group = generate()
99
100
  const consumer = jest.fn(async () => promise)
@@ -128,7 +129,7 @@ describe('close', () => {
128
129
  })
129
130
 
130
131
  it('should wait for request processing completion', async () => {
131
- const promise = /** @type {Promise<void>} */ promex()
132
+ const promise = /** @type {Promise<void>} */ new Promex()
132
133
  const producer = jest.fn(async () => promise)
133
134
 
134
135
  await reply(/** @type {Function} */ producer)
@@ -28,7 +28,7 @@ describe.each(['request', 'reply', 'event'])('%s channel events',
28
28
  * @param {comq.topology.type} type
29
29
  */
30
30
  (type) => {
31
- it.each(['flow', 'drain', 'recover', 'discard', 'pause', 'resume'])('should re-emit %s',
31
+ it.each(['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'lost', 'remove'])('should re-emit %s',
32
32
  /**
33
33
  * @param {comq.diagnostics.Event} event
34
34
  */
@@ -61,7 +61,7 @@ it('should not throw on error without listeners', async () => {
61
61
  expect(() => emit(new Error(generate()))).not.toThrow()
62
62
  })
63
63
 
64
- it.each(['open', 'close'])('should re-emit %s from connection',
64
+ it.each(['open', 'close', 'error', 'reconnect'])('should re-emit %s from connection',
65
65
  /**
66
66
  * @param {comq.diagnostics.Event} event
67
67
  */
@@ -194,12 +194,12 @@ describe('encoding', () => {
194
194
 
195
195
  it('should set octet-stream for Buffer reply', async () => {
196
196
  const request = { [generate()]: generate() }
197
- const content = encode(request, 'application/msgpack')
197
+ const content = encode(request, 'application/json')
198
198
 
199
199
  const properties = {
200
200
  replyTo: generate(),
201
201
  correlationId: generate(),
202
- contentType: 'application/msgpack'
202
+ contentType: 'application/json'
203
203
  }
204
204
 
205
205
  const message = /** @type {comq.amqp.Message} */ { content, properties }
@@ -5,7 +5,7 @@
5
5
  const stream = require('node:stream')
6
6
  const { randomBytes } = require('node:crypto')
7
7
  const { generate } = require('randomstring')
8
- const { immediate } = require('@toa.io/generic')
8
+ const { immediate } = require('./helpers')
9
9
  const { encode } = require('../source/encode')
10
10
 
11
11
  const mock = require('./connection.mock')
@@ -182,6 +182,20 @@ describe('send', () => {
182
182
  expect(requests.send).toHaveBeenCalledTimes(2)
183
183
  })
184
184
 
185
+ // a reply that could not be routed has been dropped by the broker
186
+ it('should resend unanswered Requests when the reply channel recovers', async () => {
187
+ expect(replies.diagnose).toHaveBeenCalledWith('recover', expect.any(Function))
188
+
189
+ const calls = replies.diagnose.mock.calls.filter((call) => call[0] === 'recover')
190
+ const listeners = calls.map((call) => call[1])
191
+
192
+ for (const listener of listeners) listener()
193
+
194
+ await immediate()
195
+
196
+ expect(requests.send).toHaveBeenCalledTimes(2)
197
+ })
198
+
185
199
  it('should resend unanswered Requests on sharded connection', async () => {
186
200
  jest.clearAllMocks()
187
201
 
@@ -338,7 +352,7 @@ describe('reply', () => {
338
352
  expect(output).toStrictEqual(content)
339
353
  })
340
354
 
341
- const encodings = ['application/msgpack', 'application/json']
355
+ const encodings = ['application/json']
342
356
 
343
357
  it.each(encodings)('should decode %s',
344
358
  /**
@@ -3,7 +3,7 @@
3
3
  const { EventEmitter } = require('node:events')
4
4
  const stream = require('node:stream')
5
5
  const { pipeline, transform } = require('../source/pipeline')
6
- const { timeout } = require('@toa.io/generic')
6
+ const { timeout } = require('./helpers')
7
7
 
8
8
  /** @type {Channel} */
9
9
  let channel