comq 0.23.0 → 0.24.1

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 (41) hide show
  1. package/discussions/amqp-1.0.md +127 -0
  2. package/features/heartbeat.feature +16 -0
  3. package/features/rpc.call.feature +23 -1
  4. package/features/rpc.call.recovery.feature +2 -23
  5. package/features/rpc.parked.feature +6 -6
  6. package/features/rpc.shards.retransmission.feature +46 -0
  7. package/features/shards.feature +1 -1
  8. package/features/silence.feature +3 -2
  9. package/features/steps/.types/context.d.ts +6 -1
  10. package/features/steps/brokers.js +1 -1
  11. package/features/steps/call.js +90 -30
  12. package/features/steps/connection.js +23 -0
  13. package/features/steps/context.js +1 -2
  14. package/features/steps/events.js +1 -1
  15. package/features/steps/hooks.js +0 -1
  16. package/features/steps/network.js +11 -2
  17. package/features/steps/networks.js +31 -2
  18. package/features/steps/parking.js +5 -5
  19. package/features/steps/retransmission.js +165 -0
  20. package/features/steps/rpc.js +0 -6
  21. package/package.json +20 -4
  22. package/readme.md +46 -46
  23. package/source/channel.js +3 -1
  24. package/source/connection.js +10 -90
  25. package/source/io.js +106 -25
  26. package/source/shards/channel.js +73 -12
  27. package/test/amqplib.mock.js +1 -1
  28. package/test/connection.mock.js +3 -2
  29. package/test/connection.test.js +16 -78
  30. package/test/{io.request.terms.test.js → io.call.terms.test.js} +31 -30
  31. package/test/io.call.test.js +5 -13
  32. package/test/io.reply.test.js +5 -3
  33. package/test/io.request.test.js +277 -2
  34. package/test/memory.test.js +1 -3
  35. package/test/shards/channel.test.js +168 -2
  36. package/types/amqp.d.ts +1 -1
  37. package/types/channel.d.ts +14 -5
  38. package/types/io.d.ts +18 -8
  39. package/features/rpc.timeout.feature +0 -19
  40. package/source/batch.js +0 -62
  41. package/test/batch.test.js +0 -94
package/source/io.js CHANGED
@@ -84,16 +84,21 @@ class IO {
84
84
  })
85
85
 
86
86
  /**
87
+ * Waits for the Reply for as long as it takes: a Request is answered eventually, and one no
88
+ * Producer can answer is parked rather than abandoned.
89
+ *
87
90
  * @param {string} queue
88
91
  * @param {any | Readable} payload
89
- * @param {comq.Encoding | comq.RequestOptions} [options]
92
+ * @param {comq.Encoding} [encoding]
90
93
  * @returns {Promise<any | Readable>}
91
94
  */
92
- request (queue, payload, options) {
93
- const settled = terms(options)
95
+ async request (queue, payload, encoding) {
96
+ // a caller of the version that took a timeout here would otherwise wait on in silence
97
+ if (typeof encoding === 'object' && encoding !== null) {
98
+ throw new TypeError('A Request takes an encoding and waits for its Reply')
99
+ }
94
100
 
95
- // the channels and the queue a first Request declares are waited for within the terms too
96
- return abortable(this.#request(queue, payload, settled), settled.signal)
101
+ return await this.#request(queue, payload, { encoding })
97
102
  }
98
103
 
99
104
  /**
@@ -103,12 +108,13 @@ class IO {
103
108
  * @param {string} exchange
104
109
  * @param {string} key
105
110
  * @param {any} payload
106
- * @param {comq.Encoding | comq.RequestOptions} [options]
111
+ * @param {comq.Encoding | comq.CallOptions} [options]
107
112
  * @returns {Promise<any | Readable>}
108
113
  */
109
114
  call (exchange, key, payload, options) {
110
115
  const settled = terms(options)
111
116
 
117
+ // the channels a first call declares are waited for within the terms too
112
118
  return abortable(this.#call(exchange, key, payload, settled), settled.signal)
113
119
  }
114
120
 
@@ -129,8 +135,7 @@ class IO {
129
135
  await this.#requests.held(exchange, exchange + '.' + key, key, consumer)
130
136
  })
131
137
 
132
- // failsafe is aimed to retransmit unanswered messages; the terms are taken once, so a
133
- // re-sent Request keeps the deadline of the first
138
+ // failsafe is aimed to retransmit unanswered messages
134
139
  #request = lazy(this, [this.#createRequestReplyChannels, this.#consumeReplies],
135
140
  failsafe(this, this.#recover,
136
141
  /**
@@ -149,9 +154,10 @@ class IO {
149
154
  }
150
155
 
151
156
  return await this.#ask(queue, payload, terms,
152
- (buffer, properties) => this.#requests.send(queue, buffer, properties))
157
+ (buffer, properties, via) => this.#requests.send(queue, buffer, properties, via))
153
158
  }))
154
159
 
160
+ // the terms are taken once, so a re-sent call keeps the deadline of the first
155
161
  #call = lazy(this, [this.#createRequestReplyChannels, this.#consumeReplies],
156
162
  failsafe(this, this.#recover,
157
163
  /**
@@ -164,7 +170,8 @@ class IO {
164
170
  async (exchange, key, payload, terms) => {
165
171
  // mandatory: a Request nobody holds the key of is returned rather than dropped
166
172
  return await this.#ask(exchange, payload, terms,
167
- (buffer, properties) => this.#requests.route(exchange, key, buffer, { ...properties, mandatory: true }))
173
+ (buffer, properties, via) =>
174
+ this.#requests.route(exchange, key, buffer, { ...properties, mandatory: true }, via))
168
175
  }))
169
176
 
170
177
  consume = lazy(this, this.#createEventChannel,
@@ -351,12 +358,20 @@ class IO {
351
358
  return channel
352
359
  }
353
360
 
361
+ /**
362
+ * On a sharded connection, what is re-sent is what the shard in question holds: the index of
363
+ * the shard comes with each of the events, and a Request that went through another shard is
364
+ * answered through that one.
365
+ */
354
366
  #setupRetransmission () {
355
367
  if (this.#requests.sharded === true) {
356
368
  // a shard leaves the pool when it rejects a publish, and is lost when its
357
369
  // connection drops, which leaves an already sent request unanswered
358
370
  this.#requests.diagnose('remove', this.#retransmit)
359
371
  this.#requests.diagnose('lost', this.#retransmit)
372
+
373
+ // a returned call is published on another shard, and is held by that one from then on
374
+ this.#requests.diagnose('reroute', this.#rerouted)
360
375
  } else {
361
376
  this.#requests.diagnose('recover', this.#retransmit)
362
377
  }
@@ -456,11 +471,33 @@ class IO {
456
471
 
457
472
  if (signal !== undefined) this.#abandon(request, reply, signal)
458
473
 
459
- await abortable(publish(buffer, properties), signal)
474
+ request.publishing = true
475
+
476
+ try {
477
+ await abortable(publish(buffer, properties, (index) => this.#board(request, index)), signal)
478
+ } finally {
479
+ request.publishing = false
480
+ }
481
+
482
+ // the shard it went through was lost while it was being published, and it has not failed
483
+ // over to another since
484
+ if (request.lost === true) this.#resend(request, reply)
460
485
 
461
486
  return reply
462
487
  }
463
488
 
489
+ /**
490
+ * Records the shard a Request is published through. It is recorded as the shard is chosen,
491
+ * rather than once the publish is done, so that a shard lost in between is known to hold it.
492
+ *
493
+ * @param {comq.Request} request
494
+ * @param {number} index
495
+ */
496
+ #board (request, index) {
497
+ request.shard = index
498
+ request.lost = false
499
+ }
500
+
464
501
  /**
465
502
  * Stops waiting for a Reply once the signal aborts. The Request is forgotten at once, so a
466
503
  * retransmission leaves it be, and a Reply arriving later finds nobody waiting for it.
@@ -488,17 +525,41 @@ class IO {
488
525
  * @param {comq.amqp.Message} message
489
526
  */
490
527
  #returned = (message) => {
528
+ const pending = this.#pendingOf(message)
529
+
530
+ if (pending === undefined) return
531
+
532
+ const [reply, request] = pending
533
+
534
+ request.emitter.off(request.properties.correlationId)
535
+ this.#pendingReplies.delete(reply)
536
+ reply.reject(new Unroutable(message.fields.exchange, message.fields.routingKey))
537
+ }
538
+
539
+ /**
540
+ * A Request one shard returned is published on another.
541
+ *
542
+ * @param {comq.amqp.Message} message
543
+ * @param {number} index
544
+ */
545
+ #rerouted = (message, index) => {
546
+ const pending = this.#pendingOf(message)
547
+
548
+ if (pending !== undefined) this.#board(pending[1], index)
549
+ }
550
+
551
+ /**
552
+ * @param {comq.amqp.Message} message
553
+ * @return {[Promex, comq.Request] | undefined}
554
+ */
555
+ #pendingOf (message) {
491
556
  const { correlationId, replyTo } = message.properties
492
557
 
493
558
  for (const [reply, request] of this.#pendingReplies) {
494
559
  if (request.properties.correlationId !== correlationId) continue
495
560
  if (request.emitter.queue !== replyTo) continue
496
561
 
497
- request.emitter.off(correlationId)
498
- this.#pendingReplies.delete(reply)
499
- reply.reject(new Unroutable(message.fields.exchange, message.fields.routingKey))
500
-
501
- return
562
+ return [reply, request]
502
563
  }
503
564
  }
504
565
 
@@ -659,24 +720,44 @@ class IO {
659
720
  // an unroutable reply must be returned by the broker rather than dropped
660
721
  properties.mandatory = true
661
722
 
662
- return await this.#replies.fire(replyTo, buffer, properties)
723
+ // back the way the request came: see `fire` of a sharded channel
724
+ return await this.#replies.fire(replyTo, buffer, properties, request)
663
725
  }
664
726
 
665
727
  #recover (exception) {
666
728
  if (exception !== RETRANSMISSION) return false
667
729
  }
668
730
 
669
- #retransmit = () => {
731
+ /**
732
+ * Re-sends the Requests awaiting their replies on a shard, or on the connection when no shard
733
+ * is given. A Request that went through another shard is not re-sent: it is answered through
734
+ * that shard, and it is not for comq to assume that it is idempotent.
735
+ *
736
+ * @param {number} [index]
737
+ */
738
+ #retransmit = (index) => {
670
739
  for (const [reply, request] of this.#pendingReplies) {
671
- // detaching this attempt alone leaves the listeners of the reply streams
672
- // that are still flowing over the other shards in place
673
- request.emitter.off(request.properties.correlationId)
740
+ if (index !== undefined && request.shard !== index) continue
674
741
 
675
- // trigger failsafe attribute
676
- reply.reject(RETRANSMISSION)
742
+ // one still being published is re-sent once it is, unless it fails over to another shard
743
+ if (request.publishing === true) request.lost = true
744
+ else this.#resend(request, reply)
677
745
  }
678
746
  }
679
747
 
748
+ /**
749
+ * @param {comq.Request} request
750
+ * @param {Promex} reply
751
+ */
752
+ #resend (request, reply) {
753
+ // detaching this attempt alone leaves the listeners of the reply streams
754
+ // that are still flowing over the other shards in place
755
+ request.emitter.off(request.properties.correlationId)
756
+
757
+ // trigger failsafe attribute
758
+ reply.reject(RETRANSMISSION)
759
+ }
760
+
680
761
  /**
681
762
  * @param {any} payload
682
763
  * @param {comq.Encoding} [contentType]
@@ -708,10 +789,10 @@ const REPLY = 'comq.reply'
708
789
  const CONTROL = 'control'
709
790
 
710
791
  /**
711
- * What a caller passed, settled once: a timeout becomes the moment the Request expires, so that
792
+ * What a caller passed, settled once: a timeout becomes the moment the call expires, so that
712
793
  * every attempt of it shares one deadline.
713
794
  *
714
- * @param {comq.Encoding | comq.RequestOptions} [options]
795
+ * @param {comq.Encoding | comq.CallOptions} [options]
715
796
  * @returns {comq.Terms}
716
797
  */
717
798
  function terms (options) {
@@ -57,8 +57,12 @@ class Channel {
57
57
  await Promise.any(promises)
58
58
  }
59
59
 
60
+ /**
61
+ * A message is told which shard it arrived on, so that whatever answers it can go back the same
62
+ * way. See `fire`.
63
+ */
60
64
  async consume (queue, consumer) {
61
- return await this.#every((channel) => channel.consume(queue, consumer))
65
+ return await this.#every((channel) => channel.consume(queue, arrival(consumer, channel)))
62
66
  }
63
67
 
64
68
  async subscribe (queue, group, consumer) {
@@ -74,24 +78,51 @@ class Channel {
74
78
  * claiming it, and holds it as well once it is let go.
75
79
  */
76
80
  async held (exchange, queue, key, consumer) {
77
- await Promise.any(this.#apply((channel) => channel.held(exchange, queue, key, consumer)))
81
+ await Promise.any(this.#apply((channel) => channel.held(exchange, queue, key, arrival(consumer, channel))))
78
82
  }
79
83
 
80
- async send (queue, buffer, options) {
81
- await this.#one((channel) => channel.send(queue, buffer, options))
84
+ /**
85
+ * @param {string} queue
86
+ * @param {Buffer} buffer
87
+ * @param {comq.amqp.options.Publish} [options]
88
+ * @param {(index: number) => void} [via] told which shard the message is published through,
89
+ * as soon as one is chosen and again whenever the publish fails over to another
90
+ */
91
+ async send (queue, buffer, options, via) {
92
+ await this.#one((channel) => channel.send(queue, buffer, options), { via })
82
93
  }
83
94
 
84
95
  async publish (exchange, buffer, options) {
85
96
  await this.#one((channel) => channel.publish(exchange, buffer, options))
86
97
  }
87
98
 
88
- async route (exchange, key, buffer, options) {
89
- await this.#one((channel) => channel.route(exchange, key, buffer, options))
99
+ /**
100
+ * @param {string} exchange
101
+ * @param {string} key
102
+ * @param {Buffer} buffer
103
+ * @param {comq.amqp.options.Publish} [options]
104
+ * @param {(index: number) => void} [via] as `send` has it
105
+ */
106
+ async route (exchange, key, buffer, options, via) {
107
+ await this.#one((channel) => channel.route(exchange, key, buffer, options), { via })
90
108
  }
91
109
 
92
- async fire (queue, buffer, options) {
110
+ /**
111
+ * A reply goes back through the shard the message it answers arrived on. Whoever sent that
112
+ * message re-sends what it sent through a shard once the shard is lost, and nothing else, so a
113
+ * reply taking another way could be lost with a shard that nothing is re-sent for. It takes
114
+ * another way when that shard is not reachable, which leaves its message unacknowledged there,
115
+ * to be delivered and answered again.
116
+ *
117
+ * @param {string} queue
118
+ * @param {Buffer} buffer
119
+ * @param {comq.amqp.options.Publish} [options]
120
+ * @param {comq.amqp.Message} [origin] the message this one answers
121
+ * @return {Promise<boolean>}
122
+ */
123
+ async fire (queue, buffer, options, origin) {
93
124
  // noinspection JSValidateTypes
94
- return await this.#one((channel) => channel.fire(queue, buffer, options))
125
+ return await this.#one((channel) => channel.fire(queue, buffer, options), { prefer: origin?.[ARRIVAL] })
95
126
  }
96
127
 
97
128
  async close () {
@@ -230,6 +261,9 @@ class Channel {
230
261
 
231
262
  const next = rest[Math.floor(Math.random() * rest.length)]
232
263
 
264
+ // the message is on that shard now, and is lost with it rather than with this one
265
+ this.#diagnostics.emit(REROUTE, message, next.index)
266
+
233
267
  // a Request under a key goes through its routed exchange, which `route` declares on that
234
268
  // shard before publishing: publishing to an exchange a broker lacks closes the connection
235
269
  const retried = exchange === DEFAULT
@@ -374,9 +408,13 @@ class Channel {
374
408
  }
375
409
 
376
410
  /**
411
+ * Publishes through a shard chosen at random, or through the one preferred while it is
412
+ * reachable, and fails over to another when that one fails.
413
+ *
377
414
  * @param {(channel: comq.Channel) => void} fn
415
+ * @param {{ via?: (index: number) => void, prefer?: number }} [route]
378
416
  */
379
- async #one (fn) {
417
+ async #one (fn, route = {}) {
380
418
  // capture before the check: recover may replace `#recovery` in between
381
419
  const waiting = this.#recovery
382
420
  const pool = this.#reachable()
@@ -384,21 +422,38 @@ class Channel {
384
422
  if (pool.length === 0) {
385
423
  await waiting
386
424
 
387
- return this.#one(fn)
425
+ return this.#one(fn, route)
388
426
  }
389
427
 
390
- const channel = pool[Math.floor(Math.random() * pool.length)]
428
+ const channel = pool.find((channel) => channel.index === route.prefer) ??
429
+ pool[Math.floor(Math.random() * pool.length)]
430
+
431
+ // told before the publish is awaited: a shard lost while it is under way has it on board
432
+ route.via?.(channel.index)
391
433
 
392
434
  try {
393
435
  return await fn(channel)
394
436
  } catch {
395
437
  this.#remove(channel)
396
438
 
397
- return this.#one(fn)
439
+ return this.#one(fn, route)
398
440
  }
399
441
  }
400
442
  }
401
443
 
444
+ /**
445
+ * @param {comq.channels.Consumer} consumer
446
+ * @param {comq.Channel} channel
447
+ * @return {comq.channels.Consumer}
448
+ */
449
+ function arrival (consumer, channel) {
450
+ return (message) => {
451
+ message[ARRIVAL] = channel.index
452
+
453
+ return consumer(message)
454
+ }
455
+ }
456
+
402
457
  /**
403
458
  * @param {comq.Connection[]} connections
404
459
  * @param {comq.topology.type} type
@@ -418,6 +473,12 @@ const REMOVE = 'remove'
418
473
  const RETURN_HEADER = 'x-return'
419
474
  const LOST = 'lost'
420
475
 
476
+ /** A returned message published on another shard, which is where it can be lost from now on. */
477
+ const REROUTE = 'reroute'
478
+
479
+ /** Which shard a consumed message arrived on. */
480
+ const ARRIVAL = Symbol('arrival')
481
+
421
482
  function noop () {}
422
483
 
423
484
  exports.create = create
@@ -58,7 +58,7 @@ class Connection extends EventEmitter {
58
58
 
59
59
  const connect = async (url) => new Connection(url)
60
60
 
61
- /** @type {jest.MockedObject<import('amqplib')>} */
61
+ /** @type {jest.MockedObject<import('@toa.io/amqplib')>} */
62
62
  const amqplib = {
63
63
  connect: jest.fn(connect)
64
64
  }
@@ -6,13 +6,14 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
6
6
  sharded,
7
7
  consume: jest.fn(async () => undefined),
8
8
  deliver: jest.fn(async () => undefined),
9
- send: jest.fn(async () => undefined),
9
+ // a sharded channel tells which shard a message goes through, the first one unless told otherwise
10
+ send: jest.fn(async (_queue, _buffer, _options, via) => { if (sharded) via?.(0) }),
10
11
  fire: jest.fn(async () => undefined),
11
12
  subscribe: jest.fn(async () => undefined),
12
13
  bound: jest.fn(async () => undefined),
13
14
  held: jest.fn(async () => undefined),
14
15
  publish: jest.fn(async () => undefined),
15
- route: jest.fn(async () => undefined),
16
+ route: jest.fn(async (_exchange, _key, _buffer, _options, via) => { if (sharded) via?.(0) }),
16
17
  diagnose: jest.fn(async () => undefined),
17
18
  forget: jest.fn(() => undefined),
18
19
  seal: jest.fn(async () => undefined),
@@ -10,7 +10,7 @@ const { amqplib, connect } = require('./amqplib.mock')
10
10
  const { channel: create } = require('./connection.mock')
11
11
  const mock = { amqplib, channel: { create } }
12
12
 
13
- jest.mock('amqplib', () => mock.amqplib)
13
+ jest.mock('@toa.io/amqplib', () => mock.amqplib)
14
14
  jest.mock('../source/channel', () => mock.channel)
15
15
 
16
16
  const presets = require('../source/topology')
@@ -543,109 +543,47 @@ describe('channel exhaustion', () => {
543
543
  })
544
544
  })
545
545
 
546
- describe('watchdog', () => {
546
+ describe('silence', () => {
547
547
  afterEach(() => {
548
548
  jest.useRealTimers()
549
549
  })
550
550
 
551
- it('should destroy silent connection when watchdog expires', async () => {
551
+ // amqplib reads the socket through a callback rather than 'data' events, and tells a
552
+ // connection gone silent by its heartbeat
553
+ it('should keep an idle connection', async () => {
552
554
  jest.useFakeTimers()
553
555
 
554
556
  await connection.open()
555
557
 
556
558
  const conn = await amqplib.connect.mock.results[0].value
557
559
 
558
- await jest.advanceTimersByTimeAsync(46_000)
559
-
560
- expect(conn.connection.stream.destroy).toHaveBeenCalled()
561
- })
562
-
563
- it('should reconnect after the watchdog destroys a silent connection', async () => {
564
- jest.useFakeTimers()
565
-
566
- await connection.open()
567
-
568
- await jest.advanceTimersByTimeAsync(46_000)
569
-
570
- // a socket destroyed without an error is a socket amqplib never reports,
571
- // which leaves the connection silently dead instead of recovering
572
- expect(amqplib.connect).toHaveBeenCalledTimes(2)
573
- })
574
-
575
- it('should not let a replaced socket disarm the watchdog', async () => {
576
- jest.useFakeTimers()
577
-
578
- await connection.open()
579
-
580
- const stale = await amqplib.connect.mock.results[0].value
581
-
582
- stale.emit('close', new Error('lost'))
583
-
584
- await jest.advanceTimersByTimeAsync(1)
585
-
586
- const live = await amqplib.connect.mock.results[1].value
587
-
588
- await jest.advanceTimersByTimeAsync(30_000)
589
-
590
- stale.connection.stream.emit('data', Buffer.alloc(0))
591
-
592
- await jest.advanceTimersByTimeAsync(30_000)
593
-
594
- expect(live.connection.stream.destroy).toHaveBeenCalled()
595
- })
596
-
597
- it('should reset watchdog on socket data', async () => {
598
- jest.useFakeTimers()
599
-
600
- await connection.open()
601
-
602
- const conn = await amqplib.connect.mock.results[0].value
603
-
604
- await jest.advanceTimersByTimeAsync(40_000)
605
-
606
- conn.connection.stream.emit('data', Buffer.alloc(0))
607
-
608
- await jest.advanceTimersByTimeAsync(40_000)
560
+ await jest.advanceTimersByTimeAsync(600_000)
609
561
 
610
562
  expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
611
-
612
- await jest.advanceTimersByTimeAsync(10_000)
613
-
614
- expect(conn.connection.stream.destroy).toHaveBeenCalled()
563
+ expect(amqplib.connect).toHaveBeenCalledTimes(1)
615
564
  })
616
565
 
617
- it('should derive the watchdog from the negotiated heartbeat', async () => {
618
- jest.useFakeTimers()
619
-
620
- const connection = new Connection('amqp://localhost?heartbeat=30')
621
-
566
+ it('should destroy the socket of a connection lost with an error', async () => {
622
567
  await connection.open()
623
568
 
624
569
  const conn = await amqplib.connect.mock.results[0].value
570
+ const stream = conn.connection.stream
625
571
 
626
- // the broker is only expected to say something every 15 seconds
627
- await jest.advanceTimersByTimeAsync(60_000)
628
-
629
- expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
630
-
631
- await jest.advanceTimersByTimeAsync(31_000)
572
+ conn.emit('close', new Error('Heartbeat timeout'))
632
573
 
633
- expect(conn.connection.stream.destroy).toHaveBeenCalled()
574
+ expect(stream.destroy).toHaveBeenCalled()
634
575
  })
635
576
 
636
- it('should not arm the watchdog when heartbeats are disabled', async () => {
637
- jest.useFakeTimers()
638
-
639
- const connection = new Connection('amqp://localhost?heartbeat=0')
640
-
577
+ it('should reconnect once a connection is lost with an error', async () => {
641
578
  await connection.open()
642
579
 
643
580
  const conn = await amqplib.connect.mock.results[0].value
644
581
 
645
- // an idle connection that has agreed to no heartbeats is silent by design
646
- await jest.advanceTimersByTimeAsync(600_000)
582
+ conn.emit('close', new Error('Heartbeat timeout'))
647
583
 
648
- expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
584
+ await timeout(0)
585
+
586
+ expect(amqplib.connect).toHaveBeenCalledTimes(2)
649
587
  })
650
588
  })
651
589