comq 0.19.0 → 0.20.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.
package/source/index.js CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  const { connect, assert } = require('./connect')
4
4
  const { Retry, Park } = require('./verdicts')
5
+ const { Unroutable } = require('./unroutable')
5
6
 
6
7
  exports.connect = connect
7
8
  exports.assert = assert
8
9
 
9
10
  exports.Retry = Retry
10
11
  exports.Park = Park
12
+ exports.Unroutable = Unroutable
package/source/io.js CHANGED
@@ -5,6 +5,7 @@ const { setTimeout } = require('node:timers/promises')
5
5
  const { Promex } = require('promex')
6
6
  const { memo, failsafe, lazy, track } = require('./attributes')
7
7
  const { verdictOf, PARK } = require('./verdicts')
8
+ const { Unroutable } = require('./unroutable')
8
9
 
9
10
  const { decode } = require('./decode')
10
11
  const { encode } = require('./encode')
@@ -75,31 +76,88 @@ class IO {
75
76
  await this.#requests.consume(queue, consumer)
76
77
  })
77
78
 
78
- // failsafe is aimed to retransmit unanswered messages
79
- request = lazy(this, [this.#createRequestReplyChannels, this.#consumeReplies],
79
+ /**
80
+ * @param {string} queue
81
+ * @param {any | Readable} payload
82
+ * @param {comq.Encoding | comq.RequestOptions} [options]
83
+ * @returns {Promise<any | Readable>}
84
+ */
85
+ request (queue, payload, options) {
86
+ const settled = terms(options)
87
+
88
+ // the channels and the queue a first Request declares are waited for within the terms too
89
+ return abortable(this.#request(queue, payload, settled), settled.signal)
90
+ }
91
+
92
+ /**
93
+ * A Request to whoever holds `key` on a routed `exchange`, through `back`. One nobody holds
94
+ * is returned by the broker and rejects with `Unroutable`.
95
+ *
96
+ * @param {string} exchange
97
+ * @param {string} key
98
+ * @param {any} payload
99
+ * @param {comq.Encoding | comq.RequestOptions} [options]
100
+ * @returns {Promise<any | Readable>}
101
+ */
102
+ call (exchange, key, payload, options) {
103
+ const settled = terms(options)
104
+
105
+ return abortable(this.#call(exchange, key, payload, settled), settled.signal)
106
+ }
107
+
108
+ /**
109
+ * Answers the Requests `call` makes under `key`, from a queue that belongs to this
110
+ * connection alone. Sealing withdraws the key before it stops consuming.
111
+ */
112
+ back = lazy(this, this.#createRequestReplyChannels,
113
+ /**
114
+ * @param {string} exchange
115
+ * @param {string} key
116
+ * @param {comq.Producer} callback
117
+ * @returns {Promise<void>}
118
+ */
119
+ async (exchange, key, callback) => {
120
+ const consumer = this.#getRequestConsumer(callback)
121
+
122
+ await this.#requests.held(exchange, exchange + '.' + key, key, consumer)
123
+ })
124
+
125
+ // failsafe is aimed to retransmit unanswered messages; the terms are taken once, so a
126
+ // re-sent Request keeps the deadline of the first
127
+ #request = lazy(this, [this.#createRequestReplyChannels, this.#consumeReplies],
80
128
  failsafe(this, this.#recover,
81
129
  /**
82
130
  * @param {string} queue
83
131
  * @param {any | Readable} payload
84
- * @param {comq.Encoding} [encoding]
132
+ * @param {comq.Terms} terms
85
133
  * @returns {Promise<any | Readable>}
86
134
  */
87
- async (queue, payload, encoding) => {
135
+ async (queue, payload, terms) => {
88
136
  if (payload instanceof stream.Readable) {
89
137
  return pipeline(
90
138
  payload,
91
- (payload) => this.request(queue, payload, encoding),
139
+ (payload) => this.#request(queue, payload, terms),
92
140
  this.#requests
93
141
  )
94
142
  }
95
143
 
96
- const [buffer, contentType] = this.#encode(payload, encoding)
97
- const request = this.#createRequest(queue, contentType)
98
- const reply = this.#createReply(request)
99
-
100
- await this.#requests.send(queue, buffer, request.properties)
144
+ return await this.#ask(queue, payload, terms,
145
+ (buffer, properties) => this.#requests.send(queue, buffer, properties))
146
+ }))
101
147
 
102
- return reply
148
+ #call = lazy(this, [this.#createRequestReplyChannels, this.#consumeReplies],
149
+ failsafe(this, this.#recover,
150
+ /**
151
+ * @param {string} exchange
152
+ * @param {string} key
153
+ * @param {any} payload
154
+ * @param {comq.Terms} terms
155
+ * @returns {Promise<any | Readable>}
156
+ */
157
+ async (exchange, key, payload, terms) => {
158
+ // mandatory: a Request nobody holds the key of is returned rather than dropped
159
+ return await this.#ask(exchange, payload, terms,
160
+ (buffer, properties) => this.#requests.route(exchange, key, buffer, { ...properties, mandatory: true }))
103
161
  }))
104
162
 
105
163
  consume = lazy(this, this.#createEventChannel,
@@ -221,6 +279,9 @@ class IO {
221
279
  this.#replies = await this.#createChannel('reply')
222
280
 
223
281
  this.#setupRetransmission()
282
+
283
+ // on a sharded connection, only once every shard has returned it
284
+ this.#requests.diagnose('return', this.#returned)
224
285
  }
225
286
 
226
287
  async #createEventChannel () {
@@ -350,22 +411,101 @@ class IO {
350
411
  await callback(payload, message.properties)
351
412
  })
352
413
 
414
+ /**
415
+ * Sends a Request and returns what settles with its Reply, within the terms.
416
+ *
417
+ * @param {string} target the queue or the exchange the Request is published to
418
+ * @param {any} payload
419
+ * @param {comq.Terms} terms
420
+ * @param {(buffer: Buffer, properties: comq.amqp.options.Publish) => Promise<void>} publish
421
+ * @returns {Promise<any | Readable>}
422
+ */
423
+ async #ask (target, payload, terms, publish) {
424
+ const { signal, expires } = terms
425
+
426
+ signal?.throwIfAborted()
427
+
428
+ const [buffer, contentType] = this.#encode(payload, terms.encoding)
429
+ const request = this.#createRequest(target, contentType, signal)
430
+ const properties = { ...request.properties }
431
+
432
+ if (expires !== undefined) {
433
+ const left = Math.ceil(expires - Date.now())
434
+
435
+ // a re-send past the deadline, ahead of the timer that is about to end it
436
+ if (left <= 0) throw new DOMException('The operation was aborted due to timeout', 'TimeoutError')
437
+
438
+ // what nobody has taken by the time its caller stops waiting, the broker drops
439
+ properties.expiration = String(left)
440
+ }
441
+
442
+ const reply = this.#createReply(request)
443
+
444
+ if (signal !== undefined) this.#abandon(request, reply, signal)
445
+
446
+ await abortable(publish(buffer, properties), signal)
447
+
448
+ return reply
449
+ }
450
+
451
+ /**
452
+ * Stops waiting for a Reply once the signal aborts. The Request is forgotten at once, so a
453
+ * retransmission leaves it be, and a Reply arriving later finds nobody waiting for it.
454
+ *
455
+ * @param {comq.Request} request
456
+ * @param {Promex} reply
457
+ * @param {AbortSignal} signal
458
+ */
459
+ #abandon (request, reply, signal) {
460
+ const abandon = () => {
461
+ request.emitter.off(request.properties.correlationId)
462
+ this.#pendingReplies.delete(reply)
463
+ reply.reject(signal.reason)
464
+ }
465
+
466
+ const settled = () => signal.removeEventListener('abort', abandon)
467
+
468
+ signal.addEventListener('abort', abandon, { once: true })
469
+ reply.then(settled, settled)
470
+ }
471
+
472
+ /**
473
+ * A Request the broker returned reached nobody, and nobody will answer it.
474
+ *
475
+ * @param {comq.amqp.Message} message
476
+ */
477
+ #returned = (message) => {
478
+ const { correlationId, replyTo } = message.properties
479
+
480
+ for (const [reply, request] of this.#pendingReplies) {
481
+ if (request.properties.correlationId !== correlationId) continue
482
+ if (request.emitter.queue !== replyTo) continue
483
+
484
+ request.emitter.off(correlationId)
485
+ this.#pendingReplies.delete(reply)
486
+ reply.reject(new Unroutable(message.fields.exchange, message.fields.routingKey))
487
+
488
+ return
489
+ }
490
+ }
491
+
353
492
  /**
354
493
  * The request holds no copy of what was sent: a retransmission encodes the
355
494
  * payload anew, and an unanswered request would otherwise keep two of it.
356
495
  *
357
496
  * @param {string} queue
358
497
  * @param {comq.Encoding} contentType
498
+ * @param {AbortSignal} [signal]
359
499
  * @return {comq.Request}
360
500
  */
361
- #createRequest (queue, contentType) {
501
+ #createRequest (queue, contentType, signal) {
362
502
  const emitter = this.#emitters.get(queue)
363
503
  const correlationId = emitter.next()
364
504
 
365
505
  /** @type {comq.amqp.Properties} */
366
506
  const properties = { contentType, correlationId, replyTo: emitter.queue }
367
507
 
368
- return { emitter, properties }
508
+ return { emitter, properties, signal }
369
509
  }
370
510
 
371
511
  /**
@@ -417,6 +557,9 @@ class IO {
417
557
  return reply.reject(RETRANSMISSION)
418
558
  }
419
559
 
560
+ // the caller stopped waiting while the stream was starting
561
+ if (request.signal?.aborted === true) return stream.destroy()
562
+
420
563
  reply.resolve(stream)
421
564
  } else {
422
565
  reply.resolve(payload)
@@ -550,6 +693,62 @@ const DEFAULT = 'application/json'
550
693
 
551
694
  const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
552
695
 
696
+ /**
697
+ * What a caller passed, settled once: a timeout becomes the moment the Request expires, so that
698
+ * every attempt of it shares one deadline.
699
+ *
700
+ * @param {comq.Encoding | comq.RequestOptions} [options]
701
+ * @returns {comq.Terms}
702
+ */
703
+ function terms (options) {
704
+ if (typeof options !== 'object' || options === null) return { encoding: options }
705
+
706
+ const { encoding, timeout, signal } = options
707
+ const signals = []
708
+
709
+ if (signal !== undefined) signals.push(signal)
710
+ if (timeout !== undefined) signals.push(AbortSignal.timeout(timeout))
711
+
712
+ return {
713
+ encoding,
714
+ expires: timeout === undefined ? undefined : Date.now() + timeout,
715
+ signal: signals.length === 0 ? undefined : AbortSignal.any(signals)
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Waits for a promise, or for the signal to abort, whichever comes first. Declaring a channel,
721
+ * a queue or publishing waits out back pressure and a lost connection, and a caller that has
722
+ * stopped waiting is released from all of it.
723
+ *
724
+ * @template T
725
+ * @param {Promise<T>} promise
726
+ * @param {AbortSignal} [signal]
727
+ * @returns {Promise<T>}
728
+ */
729
+ async function abortable (promise, signal) {
730
+ if (signal === undefined) return await promise
731
+
732
+ if (signal.aborted) {
733
+ // it settles later, with nobody waiting for it
734
+ promise.catch(() => undefined)
735
+
736
+ throw signal.reason
737
+ }
738
+
739
+ let abort
740
+
741
+ const aborted = new Promise((_resolve, reject) => { abort = () => reject(signal.reason) })
742
+
743
+ signal.addEventListener('abort', abort, { once: true })
744
+
745
+ try {
746
+ return await Promise.race([promise, aborted])
747
+ } finally {
748
+ signal.removeEventListener('abort', abort)
749
+ }
750
+ }
751
+
553
752
  /**
554
753
  * A verdict answers what should happen to a message now that it has failed and nobody
555
754
  * is waiting for it. A Producer has a caller waiting, and what it is owed is a reply,
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { Promex } = require('promex')
4
+ const { RETRY_PREFIX } = require('../channel')
4
5
  const events = require('../events')
5
6
  const emitter = require('../emitter')
6
7
 
@@ -68,6 +69,14 @@ class Channel {
68
69
  await this.#every((channel) => channel.bound(exchange, queue, key, consumer))
69
70
  }
70
71
 
72
+ /**
73
+ * A key is held once any shard holds it. A shard where another connection holds it goes on
74
+ * claiming it, and holds it as well once it is let go.
75
+ */
76
+ async held (exchange, queue, key, consumer) {
77
+ await Promise.any(this.#apply((channel) => channel.held(exchange, queue, key, consumer)))
78
+ }
79
+
71
80
  async send (queue, buffer, options) {
72
81
  await this.#one((channel) => channel.send(queue, buffer, options))
73
82
  }
@@ -203,9 +212,11 @@ class Channel {
203
212
  const report = () => this.#diagnostics.emit(RETURN, message, channel.index)
204
213
  const attempt = (message.properties.headers?.[RETURN_HEADER] ?? 0) + 1
205
214
  const rest = this.#pool.filter((one) => one !== channel)
215
+ const { exchange, routingKey } = message.fields
206
216
 
207
- // only replies are published to the default exchange, and only they are mandatory
208
- const exhausted = message.fields.exchange !== DEFAULT ||
217
+ // a failed message waits on an exchange of comq's own, a fanout that is declared as one
218
+ // where it is consumed, and declaring it on another shard as anything else is refused
219
+ const exhausted = exchange.startsWith(RETRY_PREFIX) ||
209
220
  attempt >= this.#connections.length ||
210
221
  rest.length === 0
211
222
 
@@ -219,7 +230,13 @@ class Channel {
219
230
 
220
231
  const next = rest[Math.floor(Math.random() * rest.length)]
221
232
 
222
- next.fire(message.fields.routingKey, message.content, properties).catch(report)
233
+ // a Request under a key goes through its routed exchange, which `route` declares on that
234
+ // shard before publishing: publishing to an exchange a broker lacks closes the connection
235
+ const retried = exchange === DEFAULT
236
+ ? next.fire(routingKey, message.content, properties)
237
+ : next.route(exchange, routingKey, message.content, properties)
238
+
239
+ retried.catch(report)
223
240
  }
224
241
 
225
242
  /**
@@ -0,0 +1,27 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * A Request the broker returned instead of routing: nothing was bound under its key, so it
5
+ * reached no one and did not run.
6
+ */
7
+ class Unroutable extends Error {
8
+ /** @type {string} */
9
+ exchange
10
+
11
+ /** @type {string} */
12
+ key
13
+
14
+ /**
15
+ * @param {string} exchange
16
+ * @param {string} key
17
+ */
18
+ constructor (exchange, key) {
19
+ super(`Nothing is bound to '${exchange}' under '${key}'`)
20
+
21
+ this.name = 'Unroutable'
22
+ this.exchange = exchange
23
+ this.key = key
24
+ }
25
+ }
26
+
27
+ exports.Unroutable = Unroutable
@@ -14,6 +14,7 @@ class Channel extends EventEmitter {
14
14
  assertQueue = jest.fn(async (name) => ({ queue: name ?? generate() }))
15
15
  assertExchange = jest.fn(async () => undefined)
16
16
  bindQueue = jest.fn(async () => undefined)
17
+ unbindQueue = jest.fn(async () => undefined)
17
18
  publish = jest.fn((_0, _1, _2, _3, resolve) => resolve?.(null))
18
19
  close = jest.fn(async () => undefined)
19
20
  }
@@ -0,0 +1,237 @@
1
+ 'use strict'
2
+
3
+ // region setup
4
+
5
+ const { generate } = require('randomstring')
6
+ const { timeout, until } = require('./helpers')
7
+
8
+ const { amqplib } = require('./amqplib.mock')
9
+ const fixtures = require('./channel.fixtures')
10
+ const { create } = require('../source/channel')
11
+
12
+ /** @type {jest.MockedObject<comq.amqp.Connection>} */
13
+ let connection
14
+
15
+ /** @type {comq.Topology} */
16
+ let topology
17
+
18
+ /** @type {comq.Channel} */
19
+ let channel
20
+
21
+ /** @type {jest.MockedObject<comq.amqp.Channel>} */
22
+ let chan
23
+
24
+ const exchange = generate()
25
+ const queue = generate()
26
+ const key = generate()
27
+ const consumer = jest.fn()
28
+
29
+ beforeEach(async () => {
30
+ jest.clearAllMocks()
31
+
32
+ global.COMQ_TESTING_CLAIM_BACKOFF = { base: 5, max: 5 }
33
+
34
+ connection = await amqplib.connect()
35
+ topology = fixtures.preset()
36
+ channel = await create(connection, topology)
37
+ chan = await getCreatedChannel(connection)
38
+ })
39
+
40
+ afterEach(() => {
41
+ delete global.COMQ_TESTING_CLAIM_BACKOFF
42
+ })
43
+
44
+ // endregion
45
+
46
+ describe('held', () => {
47
+ it('should assert a direct exchange', async () => {
48
+ await channel.held(exchange, queue, key, consumer)
49
+
50
+ expect(chan.assertExchange).toHaveBeenCalledWith(exchange, 'direct', expect.anything())
51
+ })
52
+
53
+ it('should declare the queue exclusive on a channel of its own', async () => {
54
+ await channel.held(exchange, queue, key, consumer)
55
+
56
+ const [probe] = await getProbes(connection)
57
+
58
+ expect(probe.assertQueue).toHaveBeenCalledWith(queue, { exclusive: true })
59
+ expect(probe.close).toHaveBeenCalled()
60
+ })
61
+
62
+ it('should bind the queue under the key', async () => {
63
+ await channel.held(exchange, queue, key, consumer)
64
+
65
+ expect(chan.bindQueue).toHaveBeenCalledWith(queue, exchange, key)
66
+ })
67
+
68
+ it('should consume the queue', async () => {
69
+ await channel.held(exchange, queue, key, consumer)
70
+
71
+ expect(chan.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.any(Object))
72
+ })
73
+
74
+ it('should claim the queue again while another connection holds it', async () => {
75
+ const taken = jest.fn()
76
+
77
+ channel.diagnose('taken', taken)
78
+ lock(connection, { times: 2 })
79
+
80
+ await channel.held(exchange, queue, key, consumer)
81
+
82
+ expect(taken).toHaveBeenCalledTimes(2)
83
+ expect(taken).toHaveBeenCalledWith(queue)
84
+ expect(chan.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.any(Object))
85
+ })
86
+
87
+ it('should stop claiming once sealed', async () => {
88
+ lock(connection)
89
+
90
+ const holding = channel.held(exchange, queue, key, consumer)
91
+
92
+ await timeout(20)
93
+ await channel.seal()
94
+ await holding
95
+
96
+ expect(chan.bindQueue).not.toHaveBeenCalled()
97
+ expect(chan.consume).not.toHaveBeenCalled()
98
+ })
99
+
100
+ it('should reject with any other refusal', async () => {
101
+ lock(connection, { code: 403 })
102
+
103
+ await expect(channel.held(exchange, queue, key, consumer)).rejects.toMatchObject({ code: 403 })
104
+ })
105
+ })
106
+
107
+ describe('seal', () => {
108
+ beforeEach(async () => {
109
+ await channel.held(exchange, queue, key, consumer)
110
+ })
111
+
112
+ it('should withdraw the key', async () => {
113
+ await channel.seal()
114
+
115
+ expect(chan.unbindQueue).toHaveBeenCalledWith(queue, exchange, key)
116
+ })
117
+
118
+ it('should withdraw the key before it stops consuming', async () => {
119
+ await channel.seal()
120
+
121
+ const [unbound] = chan.unbindQueue.mock.invocationCallOrder
122
+ const [cancelled] = chan.cancel.mock.invocationCallOrder
123
+
124
+ expect(unbound).toBeLessThan(cancelled)
125
+ })
126
+ })
127
+
128
+ describe('recovery', () => {
129
+ const other = generate()
130
+
131
+ beforeEach(async () => {
132
+ await channel.held(exchange, queue, key, consumer)
133
+ await channel.consume(other, consumer)
134
+ })
135
+
136
+ afterEach(async () => {
137
+ await channel.seal()
138
+ })
139
+
140
+ it('should hold the queue on the new connection', async () => {
141
+ const replacement = await amqplib.connect()
142
+
143
+ await channel.recover(replacement)
144
+
145
+ const repl = await getCreatedChannel(replacement)
146
+
147
+ expect(await until(() => repl.bindQueue.mock.calls.some(([bound]) => bound === queue), 1000)).toStrictEqual(true)
148
+ expect(repl.bindQueue).toHaveBeenCalledWith(queue, exchange, key)
149
+ })
150
+
151
+ it('should restore the rest while another connection holds the queue', async () => {
152
+ const replacement = await amqplib.connect()
153
+
154
+ lock(replacement, { main: true })
155
+
156
+ const recovered = channel.recover(replacement).then(() => true)
157
+ const hung = timeout(200).then(() => false)
158
+
159
+ await expect(Promise.race([recovered, hung])).resolves.toStrictEqual(true)
160
+
161
+ const repl = await getCreatedChannel(replacement)
162
+
163
+ expect(repl.consume).toHaveBeenCalledWith(other, expect.any(Function), expect.any(Object))
164
+ expect(repl.bindQueue).not.toHaveBeenCalledWith(queue, exchange, key)
165
+ })
166
+
167
+ it('should hold the queue once it is let go', async () => {
168
+ const replacement = await amqplib.connect()
169
+
170
+ lock(replacement, { main: true, times: 2 })
171
+
172
+ await channel.recover(replacement)
173
+
174
+ const repl = await getCreatedChannel(replacement)
175
+
176
+ expect(await until(() => repl.bindQueue.mock.calls.some(([bound]) => bound === queue), 1000)).toStrictEqual(true)
177
+ expect(repl.bindQueue).toHaveBeenCalledWith(queue, exchange, key)
178
+ })
179
+ })
180
+
181
+ /**
182
+ * Makes the channels a connection creates from now on refuse an exclusive queue, the way a
183
+ * broker does: by closing the channel, with the reason emitted as its error.
184
+ *
185
+ * @param {jest.MockedObject<comq.amqp.Connection>} conn
186
+ * @param {{ times?: number, code?: number, main?: boolean }} [options]
187
+ * `main` says the channel to be created first is the channel under test, which is left alone
188
+ */
189
+ function lock (conn, { times = Infinity, code = 405, main = false } = {}) {
190
+ const create = conn.createChannel.getMockImplementation()
191
+ let skip = main && !topology.confirms
192
+ let refused = 0
193
+
194
+ conn.createChannel.mockImplementation(async () => {
195
+ const created = await create()
196
+
197
+ if (skip) {
198
+ skip = false
199
+
200
+ return created
201
+ }
202
+
203
+ if (refused < times) {
204
+ refused++
205
+
206
+ created.assertQueue.mockImplementation(async () => {
207
+ created.emit('error', Object.assign(new Error('Refused'), { code }))
208
+
209
+ throw new Error('Channel closed')
210
+ })
211
+ }
212
+
213
+ return created
214
+ })
215
+ }
216
+
217
+ /**
218
+ * @param {jest.MockedObject<comq.amqp.Connection>} conn
219
+ * @returns {Promise<jest.MockedObject<comq.amqp.Channel>>}
220
+ */
221
+ function getCreatedChannel (conn) {
222
+ const method = `create${topology.confirms ? 'Confirm' : ''}Channel`
223
+
224
+ return conn[method].mock.results[0].value
225
+ }
226
+
227
+ /**
228
+ * The channels a connection created to declare an exclusive queue on.
229
+ *
230
+ * @param {jest.MockedObject<comq.amqp.Connection>} conn
231
+ * @returns {Promise<jest.MockedObject<comq.amqp.Channel>[]>}
232
+ */
233
+ async function getProbes (conn) {
234
+ const created = await Promise.all(conn.createChannel.mock.results.map((result) => result.value))
235
+
236
+ return topology.confirms ? created : created.slice(1)
237
+ }
@@ -10,6 +10,7 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
10
10
  fire: jest.fn(async () => undefined),
11
11
  subscribe: jest.fn(async () => undefined),
12
12
  bound: jest.fn(async () => undefined),
13
+ held: jest.fn(async () => undefined),
13
14
  publish: jest.fn(async () => undefined),
14
15
  route: jest.fn(async () => undefined),
15
16
  diagnose: jest.fn(async () => undefined),
@@ -684,6 +684,13 @@ describe('heartbeat', () => {
684
684
  expect(amqplib.connect)
685
685
  .toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ keepAlive: true }))
686
686
  })
687
+
688
+ it('should send without delay', async () => {
689
+ await connection.open()
690
+
691
+ expect(amqplib.connect)
692
+ .toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ noDelay: true }))
693
+ })
687
694
  })
688
695
 
689
696
  describe('close', () => {