comq 0.18.0 → 0.20.0

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.
@@ -10,6 +10,7 @@ const backpressure = require('./backpressure')
10
10
  const { amqplib } = require('./amqplib.mock')
11
11
  const fixtures = require('./channel.fixtures')
12
12
  const { create } = require('../source/channel')
13
+ const { Retry, Park } = require('../source/verdicts')
13
14
 
14
15
  it('should be', async () => {
15
16
  expect(create).toBeDefined()
@@ -1584,6 +1585,109 @@ describe('failed messages', () => {
1584
1585
  })
1585
1586
  })
1586
1587
 
1588
+ describe('verdicts', () => {
1589
+ it('should park on the first delivery when the consumer says to', async () => {
1590
+ await channel.consume(queue, consumer)
1591
+
1592
+ consumer.mockImplementationOnce(async () => { throw new Park(generate()) })
1593
+
1594
+ await deliver(delivery({}))
1595
+
1596
+ const [exchange, key] = publications()[0]
1597
+
1598
+ expect(exchange).toStrictEqual('')
1599
+ expect(key).toStrictEqual('comq.parked.' + queue)
1600
+ expect(publications()).toHaveLength(1)
1601
+ })
1602
+
1603
+ it('should not retry a parked message even with the ladder untouched', async () => {
1604
+ await channel.consume(queue, consumer)
1605
+
1606
+ consumer.mockImplementationOnce(async () => { throw new Park(generate()) })
1607
+
1608
+ await deliver(delivery({}))
1609
+
1610
+ const [, , , options] = publications()[0]
1611
+
1612
+ // parked on the first delivery, so it never climbed a rung
1613
+ expect(options.headers['x-comq-attempt']).toBeUndefined()
1614
+ })
1615
+
1616
+ it('should treat Retry exactly as a bare rejection', async () => {
1617
+ await channel.consume(queue, consumer)
1618
+
1619
+ consumer.mockImplementationOnce(async () => { throw new Retry(generate()) })
1620
+
1621
+ await deliver(delivery({}))
1622
+
1623
+ const [exchange] = publications()[0]
1624
+
1625
+ expect(exchange).toStrictEqual('comq.retry.' + DELAY)
1626
+ })
1627
+
1628
+ it('should record the verdict message as the reason', async () => {
1629
+ await channel.consume(queue, consumer)
1630
+
1631
+ const reason = generate()
1632
+
1633
+ consumer.mockImplementationOnce(async () => { throw new Park(reason) })
1634
+
1635
+ await deliver(delivery({}))
1636
+
1637
+ const [, , , options] = publications()[0]
1638
+
1639
+ expect(options.headers['x-comq-reason']).toStrictEqual(reason)
1640
+ })
1641
+
1642
+ it('should record the cause alongside the reason', async () => {
1643
+ await channel.consume(queue, consumer)
1644
+
1645
+ const reason = generate()
1646
+ const cause = new Error(generate())
1647
+
1648
+ consumer.mockImplementationOnce(async () => { throw new Park(reason, { cause }) })
1649
+
1650
+ await deliver(delivery({}))
1651
+
1652
+ const [, , , options] = publications()[0]
1653
+
1654
+ expect(options.headers['x-comq-reason']).toStrictEqual(reason)
1655
+ expect(options.headers['x-comq-cause']).toStrictEqual(cause.message)
1656
+ })
1657
+
1658
+ it('should emit `discard` for a parked verdict', async () => {
1659
+ const listener = jest.fn()
1660
+
1661
+ channel.diagnose('discard', listener)
1662
+
1663
+ await channel.consume(queue, consumer)
1664
+
1665
+ const park = new Park(generate())
1666
+
1667
+ consumer.mockImplementationOnce(async () => { throw park })
1668
+
1669
+ const message = delivery({})
1670
+
1671
+ await deliver(message)
1672
+
1673
+ expect(listener).toHaveBeenCalledWith(message, park)
1674
+ })
1675
+
1676
+ it('should honour a Park thrown by another copy of comq', async () => {
1677
+ await channel.consume(queue, consumer)
1678
+
1679
+ const foreign = new Error(generate())
1680
+
1681
+ foreign[Symbol.for('comq.verdict')] = 'park'
1682
+
1683
+ consumer.mockImplementationOnce(async () => { throw foreign })
1684
+
1685
+ await deliver(delivery({}))
1686
+
1687
+ expect(publications()[0][1]).toStrictEqual('comq.parked.' + queue)
1688
+ })
1689
+ })
1690
+
1587
1691
  describe('when the message cannot be moved', () => {
1588
1692
  it('should give the delivery back', async () => {
1589
1693
  await channel.consume(queue, consumer)
@@ -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),
@@ -0,0 +1,161 @@
1
+ 'use strict'
2
+
3
+ // region setup
4
+
5
+ const { randomBytes } = require('node:crypto')
6
+ const { generate } = require('randomstring')
7
+ const { immediate } = require('./helpers')
8
+ const { encode } = require('../source/encode')
9
+ const { Unroutable } = require('../source/unroutable')
10
+
11
+ const mock = require('./connection.mock')
12
+ const { IO } = require('../source/io')
13
+
14
+ /** @type {comq.IO} */
15
+ let io
16
+
17
+ /** @type {jest.MockedObject<comq.Connection>} */
18
+ let connection
19
+
20
+ /** @type {jest.MockedObject<comq.Channel>} */
21
+ let requests
22
+
23
+ /** @type {jest.MockedObject<comq.Channel>} */
24
+ let replies
25
+
26
+ const exchange = generate()
27
+ const key = generate()
28
+ const payload = { [generate()]: generate() }
29
+
30
+ beforeEach(async () => {
31
+ jest.clearAllMocks()
32
+
33
+ connection = mock.connection()
34
+ io = new IO(connection)
35
+ })
36
+
37
+ // endregion
38
+
39
+ describe('call', () => {
40
+ /** @type {Promise<any>} */
41
+ let promise
42
+
43
+ beforeEach(async () => {
44
+ promise = io.call(exchange, key, payload)
45
+
46
+ // allows initializers to run
47
+ await immediate()
48
+
49
+ requests = await findChannel('request')
50
+ replies = await findChannel('reply')
51
+ })
52
+
53
+ it('should publish to the exchange under the key', async () => {
54
+ expect(requests.route).toHaveBeenCalledWith(exchange, key, expect.any(Buffer),
55
+ expect.objectContaining({ mandatory: true }))
56
+ })
57
+
58
+ it('should declare no queue to publish to', async () => {
59
+ expect(requests.send).not.toHaveBeenCalled()
60
+ })
61
+
62
+ it('should consume the queue it is answered to', async () => {
63
+ const properties = requests.route.mock.calls[0][3]
64
+
65
+ expect(replies.consume).toHaveBeenCalledWith(properties.replyTo, expect.any(Function))
66
+ })
67
+
68
+ it('should resolve with the reply', async () => {
69
+ const content = randomBytes(8)
70
+ const { correlationId } = requests.route.mock.calls[0][3]
71
+ const deliver = replies.consume.mock.calls[0][1]
72
+
73
+ await deliver({ content, properties: { correlationId } })
74
+
75
+ await expect(promise).resolves.toStrictEqual(content)
76
+ })
77
+
78
+ it('should reject with Unroutable once the broker returns it', async () => {
79
+ const properties = requests.route.mock.calls[0][3]
80
+
81
+ emit(requests, 'return', { content: randomBytes(8), fields: { exchange, routingKey: key }, properties })
82
+
83
+ await expect(promise).rejects.toBeInstanceOf(Unroutable)
84
+ await expect(promise).rejects.toMatchObject({ exchange, key })
85
+ })
86
+
87
+ it('should leave it waiting when another Request is returned', async () => {
88
+ const settled = jest.fn()
89
+ const properties = { ...requests.route.mock.calls[0][3], correlationId: generate() }
90
+
91
+ promise.then(settled, settled)
92
+
93
+ emit(requests, 'return', { content: randomBytes(8), fields: { exchange, routingKey: key }, properties })
94
+
95
+ await immediate()
96
+
97
+ expect(settled).not.toHaveBeenCalled()
98
+ })
99
+ })
100
+
101
+ describe('timeout', () => {
102
+ it('should stop waiting while the channels are being created', async () => {
103
+ connection.createChannel.mockImplementation(() => new Promise(() => undefined))
104
+
105
+ const promise = io.call(exchange, key, payload, { timeout: 20 })
106
+
107
+ await expect(promise).rejects.toMatchObject({ name: 'TimeoutError' })
108
+ })
109
+ })
110
+
111
+ describe('back', () => {
112
+ const producer = jest.fn(() => generate())
113
+
114
+ beforeEach(async () => {
115
+ await io.back(exchange, key, producer)
116
+
117
+ requests = await findChannel('request')
118
+ })
119
+
120
+ it('should hold a queue named after the exchange and the key', async () => {
121
+ expect(requests.held).toHaveBeenCalledWith(exchange, `${exchange}.${key}`, key, expect.any(Function))
122
+ })
123
+
124
+ it('should answer a Request', async () => {
125
+ const consumer = requests.held.mock.calls[0][3]
126
+ const contentType = 'application/json'
127
+ const properties = { contentType, correlationId: generate(), replyTo: generate() }
128
+
129
+ await consumer({ content: encode(payload, contentType), properties })
130
+
131
+ replies = await findChannel('reply')
132
+
133
+ expect(producer).toHaveBeenCalledWith(payload)
134
+
135
+ expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, expect.any(Buffer),
136
+ expect.objectContaining({ correlationId: properties.correlationId }))
137
+ })
138
+ })
139
+
140
+ /**
141
+ * @param {comq.topology.type} type
142
+ * @returns {jest.MockedObject<comq.Channel>}
143
+ */
144
+ const findChannel = (type) => {
145
+ const index = connection.createChannel.mock.calls.findIndex(([t]) => (t === type))
146
+
147
+ if (index === -1) throw new Error(`${type} channel hasn't been created`)
148
+
149
+ return connection.createChannel.mock.results[index].value
150
+ }
151
+
152
+ /**
153
+ * @param {jest.MockedObject<comq.Channel>} channel
154
+ * @param {string} event
155
+ * @param {...any} args
156
+ */
157
+ function emit (channel, event, ...args) {
158
+ for (const [name, listener] of channel.diagnose.mock.calls) {
159
+ if (name === event) listener(...args)
160
+ }
161
+ }
@@ -10,6 +10,7 @@ const mock = require('./connection.mock')
10
10
  const { encodings } = require('./encodings')
11
11
 
12
12
  const { IO } = require('../source/io')
13
+ const { Retry, Park } = require('../source/verdicts')
13
14
 
14
15
  /** @type {comq.IO} */
15
16
  let io
@@ -240,3 +241,68 @@ const findChannel = (type) => {
240
241
 
241
242
  return connection.createChannel.mock.results[index].value
242
243
  }
244
+
245
+ describe('verdicts', () => {
246
+ /** @type {jest.MockedObject<comq.Channel>} */
247
+ let requests
248
+
249
+ const message = () => /** @type {comq.amqp.Message} */ ({
250
+ content: randomBytes(10),
251
+ properties: { replyTo: generate(), contentType: 'text/plain' }
252
+ })
253
+
254
+ const findChannel = async (type) => {
255
+ const index = connection.createChannel.mock.calls.findIndex(([argument]) => argument === type)
256
+
257
+ return await connection.createChannel.mock.results[index].value
258
+ }
259
+
260
+ beforeEach(async () => {
261
+ await io.reply(queue, produce)
262
+
263
+ requests = await findChannel('request')
264
+ })
265
+
266
+ it('should refuse Park from a producer', async () => {
267
+ // a Request has a caller awaiting a reply, so a verdict is a category error there
268
+ const park = new Park(generate())
269
+
270
+ produce.mockImplementationOnce(async () => { throw park })
271
+
272
+ const consumer = requests.consume.mock.calls[0][1]
273
+
274
+ await expect(consumer(message())).rejects.toThrow(/not applicable to a reply producer/)
275
+ })
276
+
277
+ it('should keep the Park as the cause of what it throws instead', async () => {
278
+ const park = new Park(generate())
279
+
280
+ produce.mockImplementationOnce(async () => { throw park })
281
+
282
+ const consumer = requests.consume.mock.calls[0][1]
283
+ const exception = await consumer(message()).catch((error) => error)
284
+
285
+ // the explanation reaches the parked message as its reason, and this as its cause
286
+ expect(exception.cause).toStrictEqual(park)
287
+ })
288
+
289
+ it('should pass an ordinary rejection through untouched', async () => {
290
+ const failure = new Error(generate())
291
+
292
+ produce.mockImplementationOnce(async () => { throw failure })
293
+
294
+ const consumer = requests.consume.mock.calls[0][1]
295
+
296
+ await expect(consumer(message())).rejects.toStrictEqual(failure)
297
+ })
298
+
299
+ it('should pass Retry through untouched', async () => {
300
+ const retry = new Retry(generate())
301
+
302
+ produce.mockImplementationOnce(async () => { throw retry })
303
+
304
+ const consumer = requests.consume.mock.calls[0][1]
305
+
306
+ await expect(consumer(message())).rejects.toStrictEqual(retry)
307
+ })
308
+ })
@@ -0,0 +1,247 @@
1
+ 'use strict'
2
+
3
+ // region setup
4
+
5
+ const { randomBytes } = require('node:crypto')
6
+ const { generate } = require('randomstring')
7
+ const { immediate, timeout } = require('./helpers')
8
+
9
+ const mock = require('./connection.mock')
10
+ const { IO } = require('../source/io')
11
+
12
+ /** @type {comq.IO} */
13
+ let io
14
+
15
+ /** @type {jest.MockedObject<comq.Connection>} */
16
+ let connection
17
+
18
+ /** @type {jest.MockedObject<comq.Channel>} */
19
+ let requests
20
+
21
+ /** @type {jest.MockedObject<comq.Channel>} */
22
+ let replies
23
+
24
+ const queue = generate()
25
+ const payload = { [generate()]: generate() }
26
+
27
+ beforeEach(async () => {
28
+ jest.clearAllMocks()
29
+
30
+ connection = mock.connection()
31
+ io = new IO(connection)
32
+ })
33
+
34
+ // endregion
35
+
36
+ describe('timeout', () => {
37
+ it('should expire the Request when the caller stops waiting', async () => {
38
+ io.request(queue, payload, { timeout: 10000 }).catch(noop)
39
+
40
+ await initialized()
41
+
42
+ const expiration = Number(requests.send.mock.calls[0][2].expiration)
43
+
44
+ expect(expiration).toBeGreaterThan(9000)
45
+ expect(expiration).toBeLessThanOrEqual(10000)
46
+ })
47
+
48
+ it('should keep the encoding', async () => {
49
+ const encoding = 'application/octet-stream'
50
+
51
+ io.request(queue, randomBytes(8), { encoding, timeout: 10000 }).catch(noop)
52
+
53
+ await initialized()
54
+
55
+ expect(requests.send.mock.calls[0][2].contentType).toStrictEqual(encoding)
56
+ })
57
+
58
+ it('should stop waiting while the channels are being created', async () => {
59
+ connection.createChannel.mockImplementation(() => new Promise(noop))
60
+
61
+ const promise = io.request(queue, payload, { timeout: 20 })
62
+
63
+ await expect(promise).rejects.toMatchObject({ name: 'TimeoutError' })
64
+ })
65
+
66
+ it('should reject once it has passed', async () => {
67
+ const promise = io.request(queue, payload, { timeout: 20 })
68
+
69
+ await expect(promise).rejects.toMatchObject({ name: 'TimeoutError' })
70
+ })
71
+
72
+ it('should resolve with a reply that arrives in time', async () => {
73
+ const promise = io.request(queue, payload, { timeout: 10000 })
74
+ const content = randomBytes(8)
75
+
76
+ await initialized()
77
+ await answer(0, content)
78
+
79
+ await expect(promise).resolves.toStrictEqual(content)
80
+ })
81
+
82
+ it('should re-send with the time that is left', async () => {
83
+ io.request(queue, payload, { timeout: 10000 }).catch(noop)
84
+
85
+ await initialized()
86
+ await timeout(20)
87
+
88
+ emit(requests, 'recover')
89
+
90
+ await immediate()
91
+
92
+ expect(requests.send).toHaveBeenCalledTimes(2)
93
+
94
+ const first = Number(requests.send.mock.calls[0][2].expiration)
95
+ const second = Number(requests.send.mock.calls[1][2].expiration)
96
+
97
+ expect(second).toBeLessThan(first)
98
+ })
99
+ })
100
+
101
+ describe('signal', () => {
102
+ it('should reject with the reason it was aborted for', async () => {
103
+ const controller = new AbortController()
104
+ const reason = new Error(generate())
105
+ const promise = io.request(queue, payload, { signal: controller.signal })
106
+
107
+ await initialized()
108
+
109
+ controller.abort(reason)
110
+
111
+ await expect(promise).rejects.toBe(reason)
112
+ })
113
+
114
+ it('should leave the Request in its queue', async () => {
115
+ const controller = new AbortController()
116
+
117
+ io.request(queue, payload, { signal: controller.signal }).catch(noop)
118
+
119
+ await initialized()
120
+
121
+ expect(requests.send.mock.calls[0][2].expiration).toBeUndefined()
122
+ })
123
+
124
+ it('should reject at once when aborted before the channels exist', async () => {
125
+ connection.createChannel.mockImplementation(() => new Promise(noop))
126
+
127
+ const controller = new AbortController()
128
+ const reason = new Error(generate())
129
+
130
+ controller.abort(reason)
131
+
132
+ await expect(io.request(queue, payload, { signal: controller.signal })).rejects.toBe(reason)
133
+ })
134
+
135
+ it('should send nothing once aborted', async () => {
136
+ const controller = new AbortController()
137
+
138
+ // the channels exist before the aborted Request is made
139
+ io.request(queue, payload).catch(noop)
140
+
141
+ await initialized()
142
+
143
+ controller.abort()
144
+
145
+ await expect(io.request(queue, payload, { signal: controller.signal })).rejects.toBeDefined()
146
+
147
+ expect(requests.send).toHaveBeenCalledTimes(1)
148
+ })
149
+
150
+ it('should not re-send what it abandoned', async () => {
151
+ const controller = new AbortController()
152
+ const promise = io.request(queue, payload, { signal: controller.signal })
153
+
154
+ await initialized()
155
+
156
+ controller.abort()
157
+
158
+ await promise.catch(noop)
159
+
160
+ emit(requests, 'recover')
161
+
162
+ await immediate()
163
+
164
+ expect(requests.send).toHaveBeenCalledTimes(1)
165
+ })
166
+
167
+ it('should stop waiting for a publication', async () => {
168
+ io.request(queue, payload).catch(noop)
169
+
170
+ await initialized()
171
+
172
+ requests.send.mockImplementationOnce(() => new Promise(noop))
173
+
174
+ const controller = new AbortController()
175
+ const reason = new Error(generate())
176
+ const promise = io.request(queue, payload, { signal: controller.signal })
177
+
178
+ await immediate()
179
+
180
+ controller.abort(reason)
181
+
182
+ await expect(promise).rejects.toBe(reason)
183
+ })
184
+
185
+ it('should discard a reply that arrives afterwards', async () => {
186
+ const controller = new AbortController()
187
+ const promise = io.request(queue, payload, { signal: controller.signal })
188
+
189
+ await initialized()
190
+
191
+ controller.abort()
192
+
193
+ await promise.catch(noop)
194
+
195
+ await expect(answer(0)).resolves.toBeUndefined()
196
+ })
197
+
198
+ it('should end the wait within the timeout', async () => {
199
+ const controller = new AbortController()
200
+ const promise = io.request(queue, payload, { signal: controller.signal, timeout: 20 })
201
+
202
+ await expect(promise).rejects.toMatchObject({ name: 'TimeoutError' })
203
+ })
204
+ })
205
+
206
+ async function initialized () {
207
+ // allows initializers to run
208
+ await immediate()
209
+
210
+ requests = await findChannel('request')
211
+ replies = await findChannel('reply')
212
+ }
213
+
214
+ /**
215
+ * @param {number} index of the publication answered
216
+ * @param {Buffer} [content]
217
+ */
218
+ async function answer (index, content = randomBytes(8)) {
219
+ const { correlationId } = requests.send.mock.calls[index][2]
220
+ const deliver = replies.consume.mock.calls[0][1]
221
+
222
+ await deliver({ content, properties: { correlationId } })
223
+ }
224
+
225
+ /**
226
+ * @param {comq.topology.type} type
227
+ * @returns {jest.MockedObject<comq.Channel>}
228
+ */
229
+ const findChannel = (type) => {
230
+ const index = connection.createChannel.mock.calls.findIndex(([t]) => (t === type))
231
+
232
+ if (index === -1) throw new Error(`${type} channel hasn't been created`)
233
+
234
+ return connection.createChannel.mock.results[index].value
235
+ }
236
+
237
+ /**
238
+ * @param {jest.MockedObject<comq.Channel>} channel
239
+ * @param {string} event
240
+ */
241
+ function emit (channel, event) {
242
+ for (const [name, listener] of channel.diagnose.mock.calls) {
243
+ if (name === event) listener()
244
+ }
245
+ }
246
+
247
+ function noop () {}
@@ -569,6 +569,74 @@ describe('diagnose', () => {
569
569
  expect(listener).toHaveBeenCalledWith(message, index)
570
570
  })
571
571
 
572
+ it('should re-route a returned Request under a key on another shard', async () => {
573
+ const listener = /** @type {jest.MockedFunction} */ jest.fn()
574
+ const index = random(channels.length)
575
+ const chan = channels[index]
576
+ const message = returned()
577
+
578
+ message.fields.exchange = generate()
579
+ channel.diagnose('return', listener)
580
+
581
+ emitReturn(chan, message)
582
+
583
+ const rest = channels.filter((one) => one !== chan)
584
+
585
+ const properties = expect.objectContaining(
586
+ { mandatory: true, headers: { 'x-return': 1 } })
587
+
588
+ for (const one of rest) {
589
+ expect(one.route).toHaveBeenCalledWith(message.fields.exchange, message.fields.routingKey,
590
+ message.content, properties)
591
+ }
592
+
593
+ expect(listener).not.toHaveBeenCalled()
594
+ })
595
+
596
+ it('should report a returned retry at once', async () => {
597
+ const listener = /** @type {jest.MockedFunction} */ jest.fn()
598
+ const index = random(channels.length)
599
+ const chan = channels[index]
600
+ const message = returned()
601
+
602
+ message.fields.exchange = 'comq.retry.' + random(10000)
603
+ channel.diagnose('return', listener)
604
+
605
+ emitReturn(chan, message)
606
+
607
+ for (const one of channels) {
608
+ expect(one.fire).not.toHaveBeenCalled()
609
+ expect(one.route).not.toHaveBeenCalled()
610
+ }
611
+
612
+ expect(listener).toHaveBeenCalledWith(message, index)
613
+ })
614
+
615
+ it('should hold a queue on every shard', async () => {
616
+ const exchange = generate()
617
+ const queue = generate()
618
+ const key = generate()
619
+ const consumer = jest.fn()
620
+
621
+ await channel.held(exchange, queue, key, consumer)
622
+
623
+ for (const one of channels) expect(one.held).toHaveBeenCalledWith(exchange, queue, key, consumer)
624
+ })
625
+
626
+ it('should hold a queue once any shard holds it', async () => {
627
+ const [, ...rest] = channels
628
+
629
+ for (const one of rest) one.held.mockImplementationOnce(() => new Promise(() => undefined))
630
+
631
+ await expect(channel.held(generate(), generate(), generate(), jest.fn())).resolves.toBeUndefined()
632
+ })
633
+
634
+ it('should reject when every shard refuses to hold a queue', async () => {
635
+ for (const one of channels) one.held.mockImplementationOnce(async () => { throw new Error('refused') })
636
+
637
+ await expect(channel.held(generate(), generate(), generate(), jest.fn())).rejects.toBeInstanceOf(AggregateError)
638
+ })
639
+
572
640
  it('should emit `pause` and `unpause` events', async () => {
573
641
  const queue = generate()
574
642
  const buffer = randomBytes(8)