comq 0.4.0-dev.6 → 0.4.0-dev.7
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/docs/notes.md +10 -0
- package/features/events.feature +7 -2
- package/features/steps/.test/events.test.js +33 -2
- package/features/steps/events.js +36 -11
- package/lerna.json +1 -1
- package/package.json +2 -2
- package/readme.md +7 -4
- package/source/channel.js +15 -5
- package/source/io.js +2 -1
- package/test/amqplib.mock.js +1 -1
- package/test/channel.test.js +29 -6
- package/test/io.consume.test.js +41 -19
- package/types/io.d.ts +2 -0
package/docs/notes.md
CHANGED
|
@@ -35,3 +35,13 @@ next Request is consumed from the Request queue*.
|
|
|
35
35
|
A lightweight Producer (one that produces responses quickly) can result in being "overloaded"
|
|
36
36
|
from the Consumer's perspective (that is, does not consume messages with an expected rate), even
|
|
37
37
|
though the Producer being idle while waiting for response publication confirmations.
|
|
38
|
+
|
|
39
|
+
## `replyTo` property prefix `direct:///`
|
|
40
|
+
|
|
41
|
+
`IO.request` accepts `replyTo` property modifier function `(replyTo): string` as fourth argument. It
|
|
42
|
+
is the temporary solution to provide an option for compatibility with
|
|
43
|
+
official [RabbitMQ client for .NET](https://github.com/rabbitmq/rabbitmq-dotnet-client/blob/feceedccd6b81d4b461795390432dc3330fdf596/projects/Unit/TestPublicationAddress.cs).
|
|
44
|
+
|
|
45
|
+
It is yet to be clarified and implemented correctly. [#6](https://github.com/toa-io/comq/issues/6)
|
|
46
|
+
|
|
47
|
+
See [Direct Reply-to](https://www.rabbitmq.com/direct-reply-to.html).
|
package/features/events.feature
CHANGED
|
@@ -5,12 +5,17 @@ Feature: Events (pub/sub)
|
|
|
5
5
|
|
|
6
6
|
Scenario: Sending and receiving an event
|
|
7
7
|
Given that `checker` is consuming events from the `numbers_added` exchange
|
|
8
|
-
When
|
|
8
|
+
When an event is emitted to the `numbers_added` exchange
|
|
9
9
|
Then `checker` receives the event
|
|
10
10
|
|
|
11
11
|
Scenario: Receiving an event by two consumer groups
|
|
12
12
|
Given that `first` is consuming events from the `numbers_added` exchange
|
|
13
13
|
And `second` is consuming events from the `numbers_added` exchange
|
|
14
|
-
When
|
|
14
|
+
When an event is emitted to the `numbers_added` exchange
|
|
15
15
|
Then `first` receives the event
|
|
16
16
|
And `second` receives the event
|
|
17
|
+
|
|
18
|
+
Scenario: Receiving an event exclusively
|
|
19
|
+
Given that events are exclusively consumed from the `numbers_added` exchange
|
|
20
|
+
When an event is emitted to the `numbers_added` exchange
|
|
21
|
+
Then the event is received
|
|
@@ -57,8 +57,14 @@ describe('Given {token} consuming events from the {token} exchange is expected',
|
|
|
57
57
|
it('should be', async () => undefined)
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
-
describe('
|
|
61
|
-
|
|
60
|
+
describe('Given that events are exclusively consumed from the {token} exchange', () => {
|
|
61
|
+
tomato.steps.Gi('that events are exclusively consumed from the {token} exchange')
|
|
62
|
+
|
|
63
|
+
it('should be', async () => undefined)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('When an event is emitted to the {token} exchange', () => {
|
|
67
|
+
const step = tomato.steps.Wh('an event is emitted to the {token} exchange')
|
|
62
68
|
|
|
63
69
|
it('should be', async () => undefined)
|
|
64
70
|
|
|
@@ -121,3 +127,28 @@ describe('Then {token} receives the event', () => {
|
|
|
121
127
|
await expect(step.call(context, group)).resolves.not.toThrow()
|
|
122
128
|
})
|
|
123
129
|
})
|
|
130
|
+
|
|
131
|
+
describe('Then the event is received', () => {
|
|
132
|
+
const step = tomato.steps.Th('the event is received')
|
|
133
|
+
|
|
134
|
+
const payload = generate()
|
|
135
|
+
const group = undefined
|
|
136
|
+
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
context.consumed = { [group]: payload }
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('should be', async () => undefined)
|
|
142
|
+
|
|
143
|
+
it('should throw if not consumed', async () => {
|
|
144
|
+
context.published = generate()
|
|
145
|
+
|
|
146
|
+
await expect(step.call(context, group)).rejects.toThrow(AssertionError)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should pass if consumed', async () => {
|
|
150
|
+
context.published = payload
|
|
151
|
+
|
|
152
|
+
await expect(step.call(context, group)).resolves.not.toThrow()
|
|
153
|
+
})
|
|
154
|
+
})
|
package/features/steps/events.js
CHANGED
|
@@ -12,7 +12,7 @@ Given('(that ){token} is consuming events from the {token} exchange',
|
|
|
12
12
|
* @this {comq.features.Context}
|
|
13
13
|
*/
|
|
14
14
|
async function (group, exchange) {
|
|
15
|
-
await consume(this, group, exchange)
|
|
15
|
+
await consume.call(this, group, exchange)
|
|
16
16
|
})
|
|
17
17
|
|
|
18
18
|
Given('{token} consuming events from the {token} exchange is expected',
|
|
@@ -24,10 +24,19 @@ Given('{token} consuming events from the {token} exchange is expected',
|
|
|
24
24
|
async function (group, exchange) {
|
|
25
25
|
await timeout(500) // let it crash
|
|
26
26
|
|
|
27
|
-
this.expected = consume(this, group, exchange)
|
|
27
|
+
this.expected = consume.call(this, group, exchange)
|
|
28
28
|
})
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
Given('that events are exclusively consumed from the {token} exchange',
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} exchange
|
|
33
|
+
* @this {comq.features.Context}
|
|
34
|
+
*/
|
|
35
|
+
async function (exchange) {
|
|
36
|
+
this.expected = consume.call(this, undefined, exchange)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
When('an event is emitted to the {token} exchange',
|
|
31
40
|
/**
|
|
32
41
|
* @param {string} exchange
|
|
33
42
|
* @this {comq.features.Context}
|
|
@@ -48,22 +57,38 @@ Then('{token} receives the event',
|
|
|
48
57
|
* @this {comq.features.Context}
|
|
49
58
|
*/
|
|
50
59
|
async function (group) {
|
|
51
|
-
await
|
|
60
|
+
await consumed.call(this, group)
|
|
61
|
+
})
|
|
52
62
|
|
|
53
|
-
|
|
54
|
-
|
|
63
|
+
Then('the event is received',
|
|
64
|
+
/**
|
|
65
|
+
* @this {comq.features.Context}
|
|
66
|
+
*/
|
|
67
|
+
async function () {
|
|
68
|
+
await consumed.call(this)
|
|
55
69
|
})
|
|
56
70
|
|
|
57
71
|
/**
|
|
58
|
-
* @
|
|
72
|
+
* @this {comq.features.Context} context
|
|
59
73
|
* @param {string} group
|
|
60
74
|
* @param {string} exchange
|
|
61
75
|
* @return {Promise<void>}
|
|
62
76
|
*/
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
async function consume (group, exchange) {
|
|
78
|
+
this.consumed ??= {}
|
|
65
79
|
|
|
66
|
-
return
|
|
67
|
-
|
|
80
|
+
return this.io.consume(exchange, group, async (payload) => {
|
|
81
|
+
this.consumed[group] = payload
|
|
68
82
|
})
|
|
69
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} group
|
|
87
|
+
* @return {Promise<void>}
|
|
88
|
+
*/
|
|
89
|
+
async function consumed (group) {
|
|
90
|
+
await timeout(100) // let it consume
|
|
91
|
+
|
|
92
|
+
assert.notEqual(this.published, undefined, 'No event has been published')
|
|
93
|
+
assert.equal(this.published, this.consumed[group], 'Event hasn\'t been exclusively consumed')
|
|
94
|
+
}
|
package/lerna.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comq",
|
|
3
|
-
"version": "0.4.0-dev.
|
|
3
|
+
"version": "0.4.0-dev.7",
|
|
4
4
|
"description": "ComQ",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Artem Gurtovoi",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"standard": "17.0.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@toa.io/generic": "0.
|
|
40
|
+
"@toa.io/generic": "0.4.0-dev.3",
|
|
41
41
|
"amqplib": "0.10.3",
|
|
42
42
|
"msgpackr": "1.8.5"
|
|
43
43
|
},
|
package/readme.md
CHANGED
|
@@ -109,19 +109,22 @@ const sum = await io.request('add_numbers', { a: 1, b: 2 })
|
|
|
109
109
|
|
|
110
110
|
## Consumption
|
|
111
111
|
|
|
112
|
-
`async IO.consume(exchange: string, group: string, consumer)`
|
|
112
|
+
`async IO.consume(exchange: string, [group: string], consumer)`
|
|
113
113
|
|
|
114
114
|
`consumer` function's signature is `async? (payload: any): void`
|
|
115
115
|
|
|
116
116
|
Start consuming decoded Events.
|
|
117
117
|
|
|
118
|
-
Asserts fanout `exchange` (once per unique `exchange`) and queue for the Consumer `group` (once
|
|
119
|
-
unique `exchange` and `group` pair), and then binds the queue to the exchange. That is, one
|
|
120
|
-
message is delivered to a single Consumer within *each group*.
|
|
118
|
+
Asserts fanout `exchange` (once per unique `exchange`) and the queue for the Consumer `group` (once
|
|
119
|
+
per unique `exchange` and `group` pair), and then binds the queue to the exchange. That is, one
|
|
120
|
+
Event message is delivered to a single Consumer within *each group*.
|
|
121
121
|
|
|
122
122
|
> Typically, the value of `group` refers to the name of a microservice running in multiple
|
|
123
123
|
> instances.
|
|
124
124
|
|
|
125
|
+
If the `group` is `undefined` or omitted, a unique value is generated and the queue is declared as
|
|
126
|
+
exclusive.
|
|
127
|
+
|
|
125
128
|
### Example
|
|
126
129
|
|
|
127
130
|
```javascript
|
package/source/channel.js
CHANGED
|
@@ -126,12 +126,15 @@ class Channel {
|
|
|
126
126
|
|
|
127
127
|
/**
|
|
128
128
|
* @param {string} name
|
|
129
|
-
* @returns {Promise<
|
|
129
|
+
* @returns {Promise<string[]>}
|
|
130
130
|
*/
|
|
131
131
|
async #assertQueue (name) {
|
|
132
|
-
const
|
|
132
|
+
const passed = arguments.length === 2 ? arguments[1] : undefined
|
|
133
|
+
const options = passed ?? (this.#topology.durable ? DURABLE : EXCLUSIVE)
|
|
134
|
+
|
|
135
|
+
const { queue } = await this.#channel.assertQueue(name, options)
|
|
133
136
|
|
|
134
|
-
|
|
137
|
+
return [queue]
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
/**
|
|
@@ -149,11 +152,18 @@ class Channel {
|
|
|
149
152
|
*
|
|
150
153
|
* @param {string} exchange
|
|
151
154
|
* @param {string} queue
|
|
152
|
-
* @returns {Promise<
|
|
155
|
+
* @returns {Promise<string[]>}
|
|
153
156
|
*/
|
|
154
157
|
async #assertBoundQueue (exchange, queue) {
|
|
155
|
-
|
|
158
|
+
/** @type {comq.amqp.options.Consume} */
|
|
159
|
+
let options
|
|
160
|
+
|
|
161
|
+
if (queue === undefined) options = { exclusive: true }
|
|
162
|
+
|
|
163
|
+
queue = (await this.#assertQueue(queue, options))[0]
|
|
156
164
|
await this.#channel.bindQueue(queue, exchange, '')
|
|
165
|
+
|
|
166
|
+
return [exchange, queue]
|
|
157
167
|
}
|
|
158
168
|
|
|
159
169
|
// endregion
|
package/source/io.js
CHANGED
|
@@ -81,7 +81,8 @@ class IO {
|
|
|
81
81
|
|
|
82
82
|
consume = lazy(this, this.#createEventChannel,
|
|
83
83
|
async (exchange, group, callback) => {
|
|
84
|
-
const
|
|
84
|
+
const exclusive = group === undefined || callback === undefined // 2 arguments passed
|
|
85
|
+
const queue = exclusive ? undefined : io.concat(exchange, group)
|
|
85
86
|
const consumer = this.#getEventConsumer(callback)
|
|
86
87
|
|
|
87
88
|
await this.#events.subscribe(exchange, queue, consumer)
|
package/test/amqplib.mock.js
CHANGED
|
@@ -11,7 +11,7 @@ class Channel extends EventEmitter {
|
|
|
11
11
|
cancel = jest.fn(async () => undefined)
|
|
12
12
|
ack = jest.fn(() => undefined)
|
|
13
13
|
nack = jest.fn(() => undefined)
|
|
14
|
-
assertQueue = jest.fn(async () =>
|
|
14
|
+
assertQueue = jest.fn(async (name) => ({ queue: name ?? generate() }))
|
|
15
15
|
assertExchange = jest.fn(async () => undefined)
|
|
16
16
|
bindQueue = jest.fn(async () => undefined)
|
|
17
17
|
publish = jest.fn((_0, _1, _2, _3, resolve) => resolve?.(null))
|
package/test/channel.test.js
CHANGED
|
@@ -293,9 +293,10 @@ describe('throw', () => {
|
|
|
293
293
|
})
|
|
294
294
|
})
|
|
295
295
|
|
|
296
|
-
describe('subscribe', () => {
|
|
296
|
+
describe.each(['group', 'exclusive'])('%s subscribe', (option) => {
|
|
297
|
+
let queue = option === 'group' ? generate() : undefined
|
|
298
|
+
|
|
297
299
|
const exchange = generate()
|
|
298
|
-
const queue = generate()
|
|
299
300
|
const consumer = /** @type {comq.channel.consumer} */ jest.fn(() => undefined)
|
|
300
301
|
|
|
301
302
|
beforeEach(async () => {
|
|
@@ -319,13 +320,33 @@ describe('subscribe', () => {
|
|
|
319
320
|
else expect(options).toMatchObject({ durable: false })
|
|
320
321
|
})
|
|
321
322
|
|
|
322
|
-
|
|
323
|
-
|
|
323
|
+
if (option === 'group') {
|
|
324
|
+
it('should assert queue', async () => {
|
|
325
|
+
const options = topology.durable ? { durable: true } : { exclusive: true }
|
|
324
326
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
+
expect(chan.assertQueue).toHaveBeenCalledWith(queue, expect.objectContaining(options))
|
|
328
|
+
})
|
|
329
|
+
} else {
|
|
330
|
+
it.each([true, false])('should assert exclusive queue (topology.durable: %s)', async (durable) => {
|
|
331
|
+
jest.clearAllMocks()
|
|
332
|
+
|
|
333
|
+
topology.durable = durable
|
|
334
|
+
|
|
335
|
+
channel = await create(connection, topology)
|
|
336
|
+
chan = await getCreatedChannel()
|
|
337
|
+
|
|
338
|
+
await channel.subscribe(exchange, undefined, consumer)
|
|
339
|
+
|
|
340
|
+
expect(chan.assertQueue).toHaveBeenCalledWith(
|
|
341
|
+
undefined,
|
|
342
|
+
expect.objectContaining({ exclusive: true })
|
|
343
|
+
)
|
|
344
|
+
})
|
|
345
|
+
}
|
|
327
346
|
|
|
328
347
|
it('should bind queue to exchange', async () => {
|
|
348
|
+
const { queue } = await chan.assertQueue.mock.results[0].value
|
|
349
|
+
|
|
329
350
|
expect(chan.bindQueue).toHaveBeenCalledTimes(1)
|
|
330
351
|
expect(chan.bindQueue).toHaveBeenCalledWith(queue, exchange, '')
|
|
331
352
|
})
|
|
@@ -345,6 +366,8 @@ describe('subscribe', () => {
|
|
|
345
366
|
|
|
346
367
|
const options = ack ? {} : { noAck: true }
|
|
347
368
|
|
|
369
|
+
queue = (await chan.assertQueue.mock.results[0].value).queue
|
|
370
|
+
|
|
348
371
|
expect(chan.consume).toHaveBeenCalledTimes(1)
|
|
349
372
|
expect(chan.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.objectContaining(options))
|
|
350
373
|
|
package/test/io.consume.test.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
// region setup
|
|
4
|
+
|
|
3
5
|
const { generate } = require('randomstring')
|
|
4
6
|
const { encode } = require('../source/encode')
|
|
5
7
|
|
|
@@ -21,6 +23,8 @@ beforeEach(async () => {
|
|
|
21
23
|
io = new IO(connection)
|
|
22
24
|
})
|
|
23
25
|
|
|
26
|
+
// endregion
|
|
27
|
+
|
|
24
28
|
it('should be', async () => {
|
|
25
29
|
expect(io.consume).toBeDefined()
|
|
26
30
|
})
|
|
@@ -32,36 +36,54 @@ const consumer = jest.fn(() => undefined)
|
|
|
32
36
|
/** @type {jest.MockedObject<comq.Channel>} */
|
|
33
37
|
let events
|
|
34
38
|
|
|
35
|
-
beforeEach(
|
|
39
|
+
beforeEach(() => {
|
|
36
40
|
jest.clearAllMocks()
|
|
41
|
+
})
|
|
37
42
|
|
|
38
|
-
|
|
43
|
+
describe('group consumption', () => {
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
await io.consume(exchange, group, consumer)
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
})
|
|
47
|
+
events = await findChannel('event')
|
|
48
|
+
})
|
|
42
49
|
|
|
43
|
-
it('should create events channel', async () => {
|
|
44
|
-
|
|
45
|
-
})
|
|
50
|
+
it('should create events channel', async () => {
|
|
51
|
+
expect(connection.createChannel).toHaveBeenCalledWith('event')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should subscribe', async () => {
|
|
55
|
+
expect(events).toBeDefined()
|
|
56
|
+
|
|
57
|
+
const queue = exchange + '..' + group
|
|
58
|
+
|
|
59
|
+
expect(events.subscribe).toHaveBeenCalledWith(exchange, queue, expect.any(Function))
|
|
60
|
+
})
|
|
46
61
|
|
|
47
|
-
it('should
|
|
48
|
-
|
|
62
|
+
it.each(encodings)('should pass decoded event (%s)', async (contentType) => {
|
|
63
|
+
const payload = generate()
|
|
64
|
+
const content = encode(payload, contentType)
|
|
65
|
+
const properties = { contentType }
|
|
66
|
+
const message = /** @type {import('amqplib').ConsumeMessage} */ { content, properties }
|
|
67
|
+
const callback = events.subscribe.mock.calls[0][2]
|
|
49
68
|
|
|
50
|
-
|
|
69
|
+
await callback(message)
|
|
51
70
|
|
|
52
|
-
|
|
71
|
+
expect(consumer).toHaveBeenCalledWith(payload)
|
|
72
|
+
})
|
|
53
73
|
})
|
|
54
74
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const message = /** @type {import('amqplib').ConsumeMessage} */ { content, properties }
|
|
60
|
-
const callback = events.subscribe.mock.calls[0][2]
|
|
75
|
+
describe.each(['omitted', 'undefined'])('exclusive consumption (group is %s)', (option) => {
|
|
76
|
+
beforeEach(async () => {
|
|
77
|
+
if (option === 'omitted') await io.consume(exchange, consumer)
|
|
78
|
+
else await io.consume(exchange, undefined, consumer)
|
|
61
79
|
|
|
62
|
-
|
|
80
|
+
events = await findChannel('event')
|
|
81
|
+
})
|
|
63
82
|
|
|
64
|
-
|
|
83
|
+
it('should subscribe', async () => {
|
|
84
|
+
expect(events).toBeDefined()
|
|
85
|
+
expect(events.subscribe).toHaveBeenCalledWith(exchange, undefined, expect.any(Function))
|
|
86
|
+
})
|
|
65
87
|
})
|
|
66
88
|
|
|
67
89
|
/**
|
package/types/io.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ declare namespace comq {
|
|
|
30
30
|
|
|
31
31
|
consume(exchange: string, group: string, consumer: consumer): Promise<void>
|
|
32
32
|
|
|
33
|
+
consume(exchange: string, consumer: consumer): Promise<void>
|
|
34
|
+
|
|
33
35
|
emit(exchange: string, payload: any, encoding?: _encoding.encoding): Promise<void>
|
|
34
36
|
|
|
35
37
|
seal(): Promise<void>
|