comq 0.24.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.
@@ -35,18 +35,9 @@ class Connection {
35
35
  /** @type {boolean} */
36
36
  #closed = false
37
37
 
38
- /** @type {NodeJS.Timeout | null} */
39
- #heartbeatTimer = null
40
-
41
38
  /** @type {comq.topology.Overrides} */
42
39
  #overrides
43
40
 
44
- /** @type {import('node:net').Socket | null} */
45
- #heartbeatSocket = null
46
-
47
- /** @type {(() => void) | null} */
48
- #heartbeatReset = null
49
-
50
41
  #diagnostics = emitter.create()
51
42
 
52
43
  /**
@@ -147,7 +138,6 @@ class Connection {
147
138
  connection.on('close', (error) => this.#close(connection, error))
148
139
  this.#connection = connection
149
140
 
150
- this.#armWatchdog(connection)
151
141
  this.#diagnostics.emit('open')
152
142
 
153
143
  try {
@@ -177,11 +167,14 @@ class Connection {
177
167
  #close = (connection, error) => {
178
168
  if (this.#connection !== connection) return
179
169
 
180
- this.#disarmWatchdog()
181
170
  this.#diagnostics.emit('close', error)
182
171
  connection.removeAllListeners()
183
172
  this.#connection = undefined
184
173
 
174
+ // amqplib only ends the socket of a connection it has given up on, and a peer that has gone
175
+ // silent never ends its side, which would leave the socket to the kernel's keepalive
176
+ if (error !== undefined) connection.connection?.stream?.destroy()
177
+
185
178
  if (error !== undefined && !this.#closed) {
186
179
  this.#reopen().catch((exception) => this.#diagnostics.emit('error', exception))
187
180
  }
@@ -224,10 +217,7 @@ class Connection {
224
217
  * @param {comq.amqp.Connection} connection
225
218
  */
226
219
  #drop (connection) {
227
- if (this.#connection === connection) {
228
- this.#disarmWatchdog()
229
- this.#connection = undefined
230
- }
220
+ if (this.#connection === connection) this.#connection = undefined
231
221
 
232
222
  connection.removeAllListeners()
233
223
 
@@ -294,46 +284,6 @@ class Connection {
294
284
  }
295
285
  }
296
286
 
297
- /**
298
- * @param {comq.amqp.Connection} connection
299
- * @param {number} [timeoutMs]
300
- */
301
- #armWatchdog (connection, timeoutMs = tolerance(connection)) {
302
- this.#disarmWatchdog()
303
-
304
- const socket = connection.connection?.stream
305
-
306
- if (socket === undefined || timeoutMs === undefined) return
307
-
308
- const reset = () => {
309
- clearTimeout(this.#heartbeatTimer)
310
- // destroying a socket without an error tells amqplib nothing: it only
311
- // listens for 'error' and 'end', so a bare destroy() leaves the connection
312
- // silently dead — no 'close' event, no recovery, every pending operation
313
- // hanging forever
314
- this.#heartbeatTimer = setTimeout(() => socket.destroy(silence()), timeoutMs)
315
- this.#heartbeatTimer.unref()
316
- }
317
-
318
- this.#heartbeatSocket = socket
319
- this.#heartbeatReset = reset
320
-
321
- reset()
322
- socket.on('data', reset)
323
- }
324
-
325
- #disarmWatchdog () {
326
- clearTimeout(this.#heartbeatTimer)
327
- this.#heartbeatTimer = null
328
-
329
- // otherwise a byte arriving on a replaced socket rearms the watchdog of a
330
- // connection that is already gone, leaving the current one unguarded
331
- this.#heartbeatSocket?.off('data', this.#heartbeatReset)
332
-
333
- this.#heartbeatSocket = null
334
- this.#heartbeatReset = null
335
- }
336
-
337
287
  #transient (exception) {
338
288
  if (this.#running) return true
339
289
  if (TRANSIENT_CODES.has(exception.code)) return true
@@ -348,6 +298,10 @@ class Connection {
348
298
  * to suggest one that leaves a connection lost for minutes before anything
349
299
  * notices, and RabbitMQ suggests 60 by default.
350
300
  *
301
+ * It is amqplib that tells a connection gone silent: one that has received nothing
302
+ * for two heartbeats is closed with an error, whatever the broker and the operating
303
+ * system have reported, which is what restores it.
304
+ *
351
305
  * @type {number}
352
306
  */
353
307
  const HEARTBEAT_S = 15
@@ -355,15 +309,6 @@ const HEARTBEAT_S = 15
355
309
  /** @type {number} */
356
310
  const KEEPALIVE_MS = 10_000
357
311
 
358
- /** How many heartbeats a connection may miss before it is destroyed. */
359
- const MISSED_HEARTBEATS = 3
360
-
361
- /** @type {number} */
362
- const WATCHDOG_MIN_MS = 15_000
363
-
364
- /** The watchdog interval used when the negotiated heartbeat is unknown. */
365
- const WATCHDOG_MS = 60_000
366
-
367
312
  /** @type {number} */
368
313
  const CONNECT_MS = 30_000
369
314
 
@@ -422,28 +367,6 @@ function heartbeaten (url) {
422
367
  return url + (url.includes('?') ? '&' : '?') + 'heartbeat=' + HEARTBEAT_S
423
368
  }
424
369
 
425
- /**
426
- * The watchdog measures silence, so it cannot be shorter than the interval at
427
- * which a healthy broker is expected to say something, which is a heartbeat
428
- * frame every half a heartbeat. A connection that has agreed to no heartbeats
429
- * says nothing at all while it is idle, leaving nothing to measure.
430
- *
431
- * @param {comq.amqp.Connection} connection
432
- * @return {number | undefined}
433
- */
434
- function tolerance (connection) {
435
- const override = global.COMQ_TESTING_WATCHDOG_INTERVAL
436
-
437
- if (override !== undefined) return override
438
-
439
- const heartbeat = connection.connection?.heartbeat
440
-
441
- if (heartbeat === undefined) return WATCHDOG_MS
442
- if (heartbeat === 0) return undefined
443
-
444
- return Math.max(heartbeat * MISSED_HEARTBEATS * 1000, WATCHDOG_MIN_MS)
445
- }
446
-
447
370
  /**
448
371
  * @return {Error}
449
372
  */
package/source/io.js CHANGED
@@ -154,7 +154,7 @@ class IO {
154
154
  }
155
155
 
156
156
  return await this.#ask(queue, payload, terms,
157
- (buffer, properties) => this.#requests.send(queue, buffer, properties))
157
+ (buffer, properties, via) => this.#requests.send(queue, buffer, properties, via))
158
158
  }))
159
159
 
160
160
  // the terms are taken once, so a re-sent call keeps the deadline of the first
@@ -170,7 +170,8 @@ class IO {
170
170
  async (exchange, key, payload, terms) => {
171
171
  // mandatory: a Request nobody holds the key of is returned rather than dropped
172
172
  return await this.#ask(exchange, payload, terms,
173
- (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))
174
175
  }))
175
176
 
176
177
  consume = lazy(this, this.#createEventChannel,
@@ -357,12 +358,20 @@ class IO {
357
358
  return channel
358
359
  }
359
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
+ */
360
366
  #setupRetransmission () {
361
367
  if (this.#requests.sharded === true) {
362
368
  // a shard leaves the pool when it rejects a publish, and is lost when its
363
369
  // connection drops, which leaves an already sent request unanswered
364
370
  this.#requests.diagnose('remove', this.#retransmit)
365
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)
366
375
  } else {
367
376
  this.#requests.diagnose('recover', this.#retransmit)
368
377
  }
@@ -462,11 +471,33 @@ class IO {
462
471
 
463
472
  if (signal !== undefined) this.#abandon(request, reply, signal)
464
473
 
465
- 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)
466
485
 
467
486
  return reply
468
487
  }
469
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
+
470
501
  /**
471
502
  * Stops waiting for a Reply once the signal aborts. The Request is forgotten at once, so a
472
503
  * retransmission leaves it be, and a Reply arriving later finds nobody waiting for it.
@@ -494,17 +525,41 @@ class IO {
494
525
  * @param {comq.amqp.Message} message
495
526
  */
496
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) {
497
556
  const { correlationId, replyTo } = message.properties
498
557
 
499
558
  for (const [reply, request] of this.#pendingReplies) {
500
559
  if (request.properties.correlationId !== correlationId) continue
501
560
  if (request.emitter.queue !== replyTo) continue
502
561
 
503
- request.emitter.off(correlationId)
504
- this.#pendingReplies.delete(reply)
505
- reply.reject(new Unroutable(message.fields.exchange, message.fields.routingKey))
506
-
507
- return
562
+ return [reply, request]
508
563
  }
509
564
  }
510
565
 
@@ -665,24 +720,44 @@ class IO {
665
720
  // an unroutable reply must be returned by the broker rather than dropped
666
721
  properties.mandatory = true
667
722
 
668
- 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)
669
725
  }
670
726
 
671
727
  #recover (exception) {
672
728
  if (exception !== RETRANSMISSION) return false
673
729
  }
674
730
 
675
- #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) => {
676
739
  for (const [reply, request] of this.#pendingReplies) {
677
- // detaching this attempt alone leaves the listeners of the reply streams
678
- // that are still flowing over the other shards in place
679
- request.emitter.off(request.properties.correlationId)
740
+ if (index !== undefined && request.shard !== index) continue
680
741
 
681
- // trigger failsafe attribute
682
- 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)
683
745
  }
684
746
  }
685
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
+
686
761
  /**
687
762
  * @param {any} payload
688
763
  * @param {comq.Encoding} [contentType]
@@ -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
@@ -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),
@@ -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
 
@@ -52,7 +52,7 @@ describe('call', () => {
52
52
 
53
53
  it('should publish to the exchange under the key', async () => {
54
54
  expect(requests.route).toHaveBeenCalledWith(exchange, key, expect.any(Buffer),
55
- expect.objectContaining({ mandatory: true }))
55
+ expect.objectContaining({ mandatory: true }), expect.any(Function))
56
56
  })
57
57
 
58
58
  it('should declare no queue to publish to', async () => {
@@ -116,14 +116,16 @@ describe('back', () => {
116
116
  const contentType = 'application/json'
117
117
  const properties = { contentType, correlationId: generate(), replyTo: generate() }
118
118
 
119
- await consumer({ content: encode(payload, contentType), properties })
119
+ const request = { content: encode(payload, contentType), properties }
120
+
121
+ await consumer(request)
120
122
 
121
123
  replies = await findChannel('reply')
122
124
 
123
125
  expect(producer).toHaveBeenCalledWith(payload)
124
126
 
125
127
  expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, expect.any(Buffer),
126
- expect.objectContaining({ correlationId: properties.correlationId }))
128
+ expect.objectContaining({ correlationId: properties.correlationId }), request)
127
129
  })
128
130
  })
129
131
 
@@ -98,7 +98,8 @@ describe('reply', () => {
98
98
  const reply = await produce.mock.results[0].value
99
99
  const buffer = encode(reply, properties.contentType)
100
100
 
101
- expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, buffer, expect.anything())
101
+ // the request goes along, so that the reply goes back the way it came
102
+ expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, buffer, expect.anything(), message)
102
103
  })
103
104
 
104
105
  it('should throw if producer returned undefined', async () => {
@@ -190,7 +191,7 @@ describe('encoding', () => {
190
191
  mandatory: true
191
192
  }
192
193
 
193
- expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, reply, props)
194
+ expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, reply, props, message)
194
195
  })
195
196
 
196
197
  it('should set octet-stream for Buffer reply', async () => {
@@ -215,7 +216,8 @@ describe('encoding', () => {
215
216
  .toHaveBeenCalledWith(
216
217
  expect.any(String),
217
218
  buffer,
218
- expect.objectContaining({ contentType: 'application/octet-stream' })
219
+ expect.objectContaining({ contentType: 'application/octet-stream' }),
220
+ message
219
221
  )
220
222
  })
221
223