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.
@@ -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
+ }
@@ -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)
@@ -22,6 +22,9 @@ declare namespace comq {
22
22
  /** consumes `queue`, bound to a routed `exchange` under `key` */
23
23
  bound (exchange: string, queue: string, key: string, consumer: channels.Consumer): Promise<void>
24
24
 
25
+ /** consumes `queue`, exclusive to this connection and bound to a routed `exchange` under `key` */
26
+ held (exchange: string, queue: string, key: string, consumer: channels.Consumer): Promise<void>
27
+
25
28
  send (queue: string, buffer: Buffer, options?: Options.Publish): Promise<void>
26
29
 
27
30
  publish (exchange: string, buffer: Buffer, options?: Options.Publish): Promise<void>
@@ -1,7 +1,7 @@
1
1
  declare namespace comq.diagnostics {
2
2
 
3
3
  type Event = 'open' | 'close' | 'error' | 'reconnect' | 'exhausted' | 'flow' | 'drain' |
4
- 'remove' | 'lost' | 'recover' | 'discard' | 'retry' | 'pause' | 'resume' | 'return'
4
+ 'remove' | 'lost' | 'recover' | 'discard' | 'retry' | 'pause' | 'resume' | 'return' | 'taken'
5
5
 
6
6
  interface Diagnosable {
7
7
  diagnose(event: Event, listener: Function): void
package/types/index.d.ts CHANGED
@@ -21,3 +21,12 @@ export class Retry extends Error {}
21
21
  * the count like any other failure.
22
22
  */
23
23
  export class Park extends Error {}
24
+
25
+ /**
26
+ * A Request `IO.call` sent under a key nobody holds. The broker returned it, so it reached no
27
+ * one and was not processed.
28
+ */
29
+ export class Unroutable extends Error {
30
+ readonly exchange: string
31
+ readonly key: string
32
+ }
package/types/io.d.ts CHANGED
@@ -34,14 +34,52 @@ declare namespace comq {
34
34
  interface Request {
35
35
  emitter: ReplyEmitter
36
36
  properties: _amqp.Properties
37
+ signal?: AbortSignal
38
+ }
39
+
40
+ /** How long a caller waits for a Reply. */
41
+ interface RequestOptions {
42
+ encoding?: _encoding.Encoding
43
+
44
+ /**
45
+ * Milliseconds to wait for the Reply. A Request nobody has taken by then is dropped by the
46
+ * broker; one already taken may still be processed, and its Reply is discarded.
47
+ */
48
+ timeout?: number
49
+
50
+ /**
51
+ * Ends the wait when aborted, within the timeout. The Request stays in its queue until the
52
+ * timeout passes, or, without one, until a Producer takes it.
53
+ */
54
+ signal?: AbortSignal
55
+ }
56
+
57
+ /** The options of a Request, settled once for all its attempts. */
58
+ interface Terms {
59
+ encoding?: _encoding.Encoding
60
+ expires?: number
61
+ signal?: AbortSignal
37
62
  }
38
63
 
39
64
  interface IO extends _diagnostics.Diagnosable {
40
65
  reply(queue: string, produce: Producer): Promise<void>
41
66
 
42
- request<Reply = any, Request = any>(queue: string, payload: Request, encoding?: _encoding.Encoding): Promise<Reply> | Promise<Readable>
67
+ request<Reply = any, Request = any>(queue: string, payload: Request, options?: _encoding.Encoding | RequestOptions): Promise<Reply> | Promise<Readable>
68
+
69
+ request(queue: string, stream: Readable, options?: _encoding.Encoding | RequestOptions): Promise<Readable>
70
+
71
+ /**
72
+ * Sends a Request to whoever holds `key` on the routed `exchange`, through `back`. One that
73
+ * nobody holds is refused with `Unroutable`.
74
+ */
75
+ call<Reply = any, Request = any>(exchange: string, key: string, payload: Request, options?: _encoding.Encoding | RequestOptions): Promise<Reply> | Promise<Readable>
43
76
 
44
- request(queue: string, stream: Readable, encoding?: _encoding.Encoding): Promise<Readable>
77
+ /**
78
+ * Answers the Requests `call` sends under `key`, from a queue this connection alone holds.
79
+ * While another connection holds it on a broker, claims it there again and again, emitting
80
+ * `taken`. Resolves once a broker holds it.
81
+ */
82
+ back(exchange: string, key: string, produce: Producer): Promise<void>
45
83
 
46
84
  consume<T = any>(exchange: string, group: string, consumer: Consumer<T>): Promise<void>
47
85
 
@@ -107,6 +145,8 @@ declare namespace comq {
107
145
 
108
146
  diagnose(event: 'retry', listener: (channel: _topology.type, message: any, error: Error, attempt: number, index?: number) => void): void
109
147
 
148
+ diagnose(event: 'taken', listener: (channel: _topology.type, queue: string, index?: number) => void): void
149
+
110
150
  diagnose(event: 'pause', listener: (channel: _topology.type) => void): void
111
151
 
112
152
  diagnose(event: 'resume', listener: (channel: _topology.type) => void): void