comq 0.4.0-dev.6 → 0.4.1-dev.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.
- package/docs/notes.md +10 -0
- package/features/events.feature +7 -2
- package/features/recovery.feature +3 -3
- package/features/steps/.test/connection.test.js +12 -11
- package/features/steps/.test/context.mock.js +8 -1
- package/features/steps/.test/context.test.js +1 -10
- package/features/steps/.test/events.test.js +33 -2
- package/features/steps/.types/context.d.ts +3 -2
- package/features/steps/connection.js +3 -3
- package/features/steps/context.js +20 -16
- package/features/steps/events.js +36 -11
- package/features/steps/hooks.js +3 -4
- package/lerna.json +1 -1
- package/package.json +2 -2
- package/readme.md +12 -9
- package/source/channel.js +18 -6
- package/source/connection.js +5 -1
- package/source/io.js +2 -1
- package/source/presets/event.json +1 -1
- package/source/presets/reply.json +1 -1
- package/source/presets/request.json +1 -1
- package/test/amqplib.mock.js +1 -1
- package/test/channel.fixtures.js +1 -1
- package/test/channel.test.js +60 -15
- package/test/connection.test.js +18 -5
- package/test/io.consume.test.js +41 -19
- package/test/presets.test.js +3 -3
- package/types/io.d.ts +2 -0
- package/types/topology.d.ts +1 -1
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
|
|
@@ -87,14 +87,14 @@ Feature: Request-Reply Topology Recovery
|
|
|
87
87
|
Given the broker is down
|
|
88
88
|
And `logger` consuming events from the `numbers_added` exchange is expected
|
|
89
89
|
When the broker is up
|
|
90
|
-
And
|
|
90
|
+
And an event is emitted to the `numbers_added` exchange
|
|
91
91
|
Then `logger` receives the event
|
|
92
92
|
|
|
93
93
|
Scenario: Defining emitter while broker crashed
|
|
94
94
|
Given `someone` consuming events from the `numbers_added` exchange is expected
|
|
95
95
|
And the broker has crashed
|
|
96
96
|
When the broker is up
|
|
97
|
-
Then
|
|
97
|
+
Then an event is emitted to the `numbers_added` exchange
|
|
98
98
|
And `someone` receives the event
|
|
99
99
|
|
|
100
100
|
Scenario: Start consuming events while broker is down
|
|
@@ -105,5 +105,5 @@ Feature: Request-Reply Topology Recovery
|
|
|
105
105
|
And `second` consuming events from the `numbers_added` exchange is expected
|
|
106
106
|
When the broker has crashed
|
|
107
107
|
And the broker is up
|
|
108
|
-
Then
|
|
108
|
+
Then an event is emitted to the `numbers_added` exchange
|
|
109
109
|
And `second` receives the event
|
|
@@ -125,23 +125,24 @@ describe('When I attempt to connect to the broker as {string} with password {str
|
|
|
125
125
|
})
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
-
describe.each([['', true], [' not', false]])('Then the connection is%s established',
|
|
129
|
-
|
|
128
|
+
describe.each([['', true], [' not', false]])('Then the connection is%s established',
|
|
129
|
+
(not, connected) => {
|
|
130
|
+
const step = tomato.steps.Th(`the connection is${not} established`)
|
|
130
131
|
|
|
131
|
-
|
|
132
|
+
it('should be', async () => undefined)
|
|
132
133
|
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
it(`should fail if is${connected ? 'n\'t' : ''} connected`, async () => {
|
|
135
|
+
context.connected = !connected
|
|
135
136
|
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
await expect(step.call(context)).rejects.toThrow(AssertionError)
|
|
138
|
+
})
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
it(`should pass if is${connected ? '' : 'n\'t'} connected`, async () => {
|
|
141
|
+
context.connected = connected
|
|
141
142
|
|
|
142
|
-
|
|
143
|
+
await expect(step.call(context)).resolves.not.toThrow()
|
|
144
|
+
})
|
|
143
145
|
})
|
|
144
|
-
})
|
|
145
146
|
|
|
146
147
|
describe('Then no exceptions are thrown', () => {
|
|
147
148
|
const step = tomato.steps.Th('no exceptions are thrown')
|
|
@@ -3,5 +3,12 @@
|
|
|
3
3
|
const { generate } = require('randomstring')
|
|
4
4
|
|
|
5
5
|
const connect = jest.fn(async () => generate())
|
|
6
|
+
const disconnect = jest.fn(async () => undefined)
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
/**
|
|
9
|
+
* @return {jest.MockedObject<comq.features.Context>}
|
|
10
|
+
*/
|
|
11
|
+
exports.context = () => (/** @type {jest.MockedObject<comq.features.Context>} */{
|
|
12
|
+
connect,
|
|
13
|
+
disconnect
|
|
14
|
+
})
|
|
@@ -40,21 +40,12 @@ describe('connect', () => {
|
|
|
40
40
|
expect(comq.connect).toHaveBeenCalledWith('amqp://developer:secret@localhost:5673')
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
-
it('should
|
|
44
|
-
await context.connect()
|
|
45
|
-
|
|
46
|
-
expect(comq.connect).toHaveBeenCalledTimes(1)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('should reconnect with credentials', async () => {
|
|
43
|
+
it('should connect with credentials', async () => {
|
|
50
44
|
const user = generate()
|
|
51
45
|
const password = generate()
|
|
52
46
|
|
|
53
47
|
await context.connect(user, password)
|
|
54
48
|
|
|
55
|
-
expect(io.close).toHaveBeenCalled()
|
|
56
|
-
|
|
57
|
-
expect(comq.connect).toHaveBeenCalledTimes(2)
|
|
58
49
|
expect(comq.connect).toHaveBeenCalledWith(`amqp://${user}:${password}@localhost:5673`)
|
|
59
50
|
})
|
|
60
51
|
|
|
@@ -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
|
+
})
|
|
@@ -4,16 +4,17 @@ import * as _io from '../../../types/io'
|
|
|
4
4
|
declare namespace comq.features {
|
|
5
5
|
|
|
6
6
|
interface Context {
|
|
7
|
-
url?: string
|
|
8
7
|
io?: _io.IO
|
|
8
|
+
connected?: boolean
|
|
9
9
|
reply?: Promise<any>
|
|
10
10
|
consumed?: Record<string, any>
|
|
11
11
|
published?: any
|
|
12
12
|
events?: { [K in _diagnostics.event]?: boolean }
|
|
13
13
|
exception?: Error
|
|
14
|
-
expected
|
|
14
|
+
expected?: Promise<any>
|
|
15
15
|
|
|
16
16
|
connect(user?: string, password?: string): Promise<void>
|
|
17
|
+
disconnect(): Promise<void>
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
}
|
|
@@ -51,7 +51,7 @@ Then('the connection is not established',
|
|
|
51
51
|
* @this {comq.features.Context}
|
|
52
52
|
*/
|
|
53
53
|
async function () {
|
|
54
|
-
assert.equal(this.
|
|
54
|
+
assert.equal(this.connected, false, 'connection is established contrary to expectations')
|
|
55
55
|
})
|
|
56
56
|
|
|
57
57
|
Then('the connection is established',
|
|
@@ -59,9 +59,9 @@ Then('the connection is established',
|
|
|
59
59
|
* @this {comq.features.Context}
|
|
60
60
|
*/
|
|
61
61
|
async function () {
|
|
62
|
-
if (this.connecting) await this.connecting
|
|
62
|
+
if (this.connecting !== undefined) await this.connecting
|
|
63
63
|
|
|
64
|
-
assert.
|
|
64
|
+
assert.equal(this.connected, true, 'connection is not established')
|
|
65
65
|
})
|
|
66
66
|
|
|
67
67
|
Then('no exceptions are thrown',
|
|
@@ -7,11 +7,8 @@ const { connect } = require('../../')
|
|
|
7
7
|
* @implements {comq.features.Context}
|
|
8
8
|
*/
|
|
9
9
|
class Context extends World {
|
|
10
|
-
/** @type {comq.IO} */
|
|
11
|
-
static io
|
|
12
|
-
static url
|
|
13
|
-
|
|
14
10
|
io
|
|
11
|
+
connected = false
|
|
15
12
|
connecting
|
|
16
13
|
reply
|
|
17
14
|
consumed
|
|
@@ -23,26 +20,33 @@ class Context extends World {
|
|
|
23
20
|
async connect (user, password) {
|
|
24
21
|
const url = locator(user, password)
|
|
25
22
|
|
|
26
|
-
|
|
23
|
+
await this.#connect(url)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async disconnect () {
|
|
27
|
+
if (this.io === undefined) return
|
|
27
28
|
|
|
28
|
-
this.io
|
|
29
|
+
await this.io.close()
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
this.io = undefined
|
|
32
|
+
this.connected = false
|
|
33
|
+
this.events = {}
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} url
|
|
38
|
+
* @return {Promise<void>}
|
|
39
|
+
*/
|
|
33
40
|
async #connect (url) {
|
|
34
|
-
if (
|
|
41
|
+
if (this.io !== undefined) await this.disconnect()
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
static async disconnect () {
|
|
41
|
-
if (Context.io === undefined) return
|
|
43
|
+
this.io = await connect(url)
|
|
44
|
+
this.connected = true
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
for (const event of EVENTS) this.io.diagnose(event, () => (this.events[event] = true))
|
|
44
47
|
|
|
45
|
-
|
|
48
|
+
this.io.diagnose('close', () => (this.connected = false))
|
|
49
|
+
this.io.diagnose('open', () => (this.connected = true))
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
52
|
|
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/features/steps/hooks.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const {
|
|
4
|
-
const { Context } = require('./context')
|
|
3
|
+
const { After } = require('@cucumber/cucumber')
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
After(
|
|
7
6
|
/**
|
|
8
7
|
* @this {comq.features.Context}
|
|
9
8
|
*/
|
|
10
9
|
async function () {
|
|
11
|
-
await
|
|
10
|
+
await this.disconnect()
|
|
12
11
|
})
|
package/lerna.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comq",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1-dev.0",
|
|
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
|
@@ -10,7 +10,7 @@ Node.js.
|
|
|
10
10
|
3. Events (pub/sub)
|
|
11
11
|
4. Content encoding
|
|
12
12
|
5. Flow control and back pressure handling
|
|
13
|
-
6. Consumer
|
|
13
|
+
6. Consumer acknowledgments and publisher confirms
|
|
14
14
|
7. Poison message handling
|
|
15
15
|
8. Connection tolerance
|
|
16
16
|
9. Broker restart resilience
|
|
@@ -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
|
|
@@ -222,7 +225,7 @@ requests and are expecting replies.
|
|
|
222
225
|
*persistent* ([delivery mode 2](https://www.rabbitmq.com/publishers.html#message-properties)),
|
|
223
226
|
while Requests and Replies are not (mode 1).
|
|
224
227
|
- Events and Requests are consumed using
|
|
225
|
-
manual [acknowledgment mode](https://www.rabbitmq.com/confirms.html#
|
|
228
|
+
manual [acknowledgment mode](https://www.rabbitmq.com/confirms.html#acknowledgment-modes),
|
|
226
229
|
and Replies are consumed using automatic mode.
|
|
227
230
|
|
|
228
231
|
If incoming message causes an exception, then it is "negative acknowledged" and requeued. If it
|
|
@@ -234,13 +237,13 @@ causes an exception again, it will be discarded.
|
|
|
234
237
|
|
|
235
238
|
See:
|
|
236
239
|
|
|
237
|
-
- [Consumer
|
|
238
|
-
- [Negative
|
|
240
|
+
- [Consumer Acknowledgments and Publisher Confirms](https://www.rabbitmq.com/confirms.html).
|
|
241
|
+
- [Negative Acknowledgment and Requeuing of Deliveries](https://www.rabbitmq.com/confirms.html#consumer-nacks-requeue)
|
|
239
242
|
- [Dead Letter Exchanges](https://www.rabbitmq.com/dlx.html)
|
|
240
243
|
|
|
241
244
|
### Cheatsheet
|
|
242
245
|
|
|
243
|
-
| Message | Prefetch | Confirms | Queue |
|
|
246
|
+
| Message | Prefetch | Confirms | Queue | Acknowledgment | Persistent |
|
|
244
247
|
|---------|-----------|----------|-----------|-----------------|------------|
|
|
245
248
|
| Request | limited | no | durable | manual | no |
|
|
246
249
|
| Reply | unlimited | no | exclusive | automatic | no |
|
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
|
|
@@ -201,7 +211,7 @@ class Channel {
|
|
|
201
211
|
/** @type {import('amqplib').Options.Consume} */
|
|
202
212
|
const options = {}
|
|
203
213
|
|
|
204
|
-
if (this.#topology.
|
|
214
|
+
if (this.#topology.acknowledgments) consumer = this.#getAcknowledgingConsumer(consumer)
|
|
205
215
|
else options.noAck = true
|
|
206
216
|
|
|
207
217
|
const response = await this.#channel.consume(queue, consumer, options)
|
|
@@ -220,6 +230,8 @@ class Channel {
|
|
|
220
230
|
|
|
221
231
|
this.#channel.ack(message)
|
|
222
232
|
} catch (exception) {
|
|
233
|
+
if (exception.message === 'Channel closed') return // message will be requeued by the broker
|
|
234
|
+
|
|
223
235
|
if (message.fields.redelivered) this.#discard(message, exception)
|
|
224
236
|
else this.#requeue(message)
|
|
225
237
|
}
|
package/source/connection.js
CHANGED
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.fixtures.js
CHANGED
package/test/channel.test.js
CHANGED
|
@@ -92,7 +92,7 @@ describe('consume', () => {
|
|
|
92
92
|
})
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
-
describe('
|
|
95
|
+
describe('acknowledgments', () => {
|
|
96
96
|
const consumer = /** @type {comq.channel.consumer} */ jest.fn(async () => undefined)
|
|
97
97
|
const queue = generate()
|
|
98
98
|
|
|
@@ -100,7 +100,7 @@ describe('acknowledgements', () => {
|
|
|
100
100
|
['', true],
|
|
101
101
|
['not ', false]
|
|
102
102
|
])('should %sack incoming messages', async (_, ack) => {
|
|
103
|
-
topology.
|
|
103
|
+
topology.acknowledgments = ack
|
|
104
104
|
channel = await create(connection, topology)
|
|
105
105
|
chan = await getCreatedChannel()
|
|
106
106
|
|
|
@@ -119,8 +119,8 @@ describe('acknowledgements', () => {
|
|
|
119
119
|
it.each([
|
|
120
120
|
['manual', true],
|
|
121
121
|
['automatic', false]
|
|
122
|
-
])('should create consumer with %s
|
|
123
|
-
topology.
|
|
122
|
+
])('should create consumer with %s acknowledgments', async (_, ack) => {
|
|
123
|
+
topology.acknowledgments = ack
|
|
124
124
|
channel = await create(connection, topology)
|
|
125
125
|
chan = await getCreatedChannel()
|
|
126
126
|
|
|
@@ -136,7 +136,7 @@ describe('acknowledgements', () => {
|
|
|
136
136
|
['nack', true],
|
|
137
137
|
['discard', false]
|
|
138
138
|
])('should %s the message caused an exception', async (_, requeue) => {
|
|
139
|
-
topology.
|
|
139
|
+
topology.acknowledgments = true
|
|
140
140
|
|
|
141
141
|
channel = await create(connection, topology)
|
|
142
142
|
chan = await getCreatedChannel()
|
|
@@ -157,6 +157,28 @@ describe('acknowledgements', () => {
|
|
|
157
157
|
|
|
158
158
|
expect(chan.nack).toHaveBeenCalledWith(message, false, requeue)
|
|
159
159
|
})
|
|
160
|
+
|
|
161
|
+
it('should ignore Channel ended exception', async () => {
|
|
162
|
+
topology.acknowledgments = true
|
|
163
|
+
|
|
164
|
+
channel = await create(connection, topology)
|
|
165
|
+
chan = await getCreatedChannel()
|
|
166
|
+
|
|
167
|
+
const consumer = /** @type {Function} */ jest.fn(async () => generate())
|
|
168
|
+
|
|
169
|
+
await channel.consume(queue, consumer)
|
|
170
|
+
|
|
171
|
+
const callback = chan.consume.mock.calls[0][1]
|
|
172
|
+
const content = randomBytes(8)
|
|
173
|
+
const properties = {}
|
|
174
|
+
const fields = {}
|
|
175
|
+
const message = /** @type {import('amqplib').ConsumeMessage} */ { content, properties, fields }
|
|
176
|
+
|
|
177
|
+
chan.ack.mockImplementation(() => { throw new Error('Channel closed') })
|
|
178
|
+
chan.nack.mockImplementation(() => { throw new Error('Channel closed') })
|
|
179
|
+
|
|
180
|
+
await expect(callback(message)).resolves.not.toThrow()
|
|
181
|
+
})
|
|
160
182
|
})
|
|
161
183
|
|
|
162
184
|
describe('send', () => {
|
|
@@ -293,9 +315,10 @@ describe('throw', () => {
|
|
|
293
315
|
})
|
|
294
316
|
})
|
|
295
317
|
|
|
296
|
-
describe('subscribe', () => {
|
|
318
|
+
describe.each(['group', 'exclusive'])('%s subscribe', (option) => {
|
|
319
|
+
let queue = option === 'group' ? generate() : undefined
|
|
320
|
+
|
|
297
321
|
const exchange = generate()
|
|
298
|
-
const queue = generate()
|
|
299
322
|
const consumer = /** @type {comq.channel.consumer} */ jest.fn(() => undefined)
|
|
300
323
|
|
|
301
324
|
beforeEach(async () => {
|
|
@@ -319,22 +342,42 @@ describe('subscribe', () => {
|
|
|
319
342
|
else expect(options).toMatchObject({ durable: false })
|
|
320
343
|
})
|
|
321
344
|
|
|
322
|
-
|
|
323
|
-
|
|
345
|
+
if (option === 'group') {
|
|
346
|
+
it('should assert queue', async () => {
|
|
347
|
+
const options = topology.durable ? { durable: true } : { exclusive: true }
|
|
324
348
|
|
|
325
|
-
|
|
326
|
-
|
|
349
|
+
expect(chan.assertQueue).toHaveBeenCalledWith(queue, expect.objectContaining(options))
|
|
350
|
+
})
|
|
351
|
+
} else {
|
|
352
|
+
it.each([true, false])('should assert exclusive queue (topology.durable: %s)', async (durable) => {
|
|
353
|
+
jest.clearAllMocks()
|
|
354
|
+
|
|
355
|
+
topology.durable = durable
|
|
356
|
+
|
|
357
|
+
channel = await create(connection, topology)
|
|
358
|
+
chan = await getCreatedChannel()
|
|
359
|
+
|
|
360
|
+
await channel.subscribe(exchange, undefined, consumer)
|
|
361
|
+
|
|
362
|
+
expect(chan.assertQueue).toHaveBeenCalledWith(
|
|
363
|
+
undefined,
|
|
364
|
+
expect.objectContaining({ exclusive: true })
|
|
365
|
+
)
|
|
366
|
+
})
|
|
367
|
+
}
|
|
327
368
|
|
|
328
369
|
it('should bind queue to exchange', async () => {
|
|
370
|
+
const { queue } = await chan.assertQueue.mock.results[0].value
|
|
371
|
+
|
|
329
372
|
expect(chan.bindQueue).toHaveBeenCalledTimes(1)
|
|
330
373
|
expect(chan.bindQueue).toHaveBeenCalledWith(queue, exchange, '')
|
|
331
374
|
})
|
|
332
375
|
|
|
333
376
|
it.each([
|
|
334
|
-
['with
|
|
335
|
-
['without
|
|
377
|
+
['with acknowledgments', true],
|
|
378
|
+
['without acknowledgments', false]
|
|
336
379
|
])('should start consuming %s', async (_, ack) => {
|
|
337
|
-
topology.
|
|
380
|
+
topology.acknowledgments = ack
|
|
338
381
|
|
|
339
382
|
jest.clearAllMocks()
|
|
340
383
|
|
|
@@ -345,6 +388,8 @@ describe('subscribe', () => {
|
|
|
345
388
|
|
|
346
389
|
const options = ack ? {} : { noAck: true }
|
|
347
390
|
|
|
391
|
+
queue = (await chan.assertQueue.mock.results[0].value).queue
|
|
392
|
+
|
|
348
393
|
expect(chan.consume).toHaveBeenCalledTimes(1)
|
|
349
394
|
expect(chan.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.objectContaining(options))
|
|
350
395
|
|
|
@@ -741,7 +786,7 @@ describe('diagnostics', () => {
|
|
|
741
786
|
])('should%s emit `discard` event', async (_, redelivered) => {
|
|
742
787
|
jest.clearAllMocks()
|
|
743
788
|
|
|
744
|
-
topology.
|
|
789
|
+
topology.acknowledgments = true
|
|
745
790
|
channel = await create(connection, topology)
|
|
746
791
|
chan = await getCreatedChannel()
|
|
747
792
|
|
package/test/connection.test.js
CHANGED
|
@@ -154,13 +154,26 @@ describe('create channel', () => {
|
|
|
154
154
|
})
|
|
155
155
|
})
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
describe('close', () => {
|
|
158
|
+
it('should close connection', async () => {
|
|
159
|
+
await connection.open()
|
|
160
|
+
await connection.close()
|
|
161
|
+
|
|
162
|
+
const amqp = await amqplib.connect.mock.results[0].value
|
|
163
|
+
|
|
164
|
+
expect(amqp.close).toHaveBeenCalled()
|
|
165
|
+
})
|
|
160
166
|
|
|
161
|
-
|
|
167
|
+
it('should ignore exceptions', async () => {
|
|
168
|
+
await connection.open()
|
|
169
|
+
|
|
170
|
+
/** @type {jest.MockedObject<import('amqplib').Connection>} */
|
|
171
|
+
const amqp = await amqplib.connect.mock.results[0].value
|
|
162
172
|
|
|
163
|
-
|
|
173
|
+
amqp.close.mockImplementation(async () => { throw new Error() })
|
|
174
|
+
|
|
175
|
+
await expect(connection.close()).resolves.not.toThrow()
|
|
176
|
+
})
|
|
164
177
|
})
|
|
165
178
|
|
|
166
179
|
describe('diagnostics', () => {
|
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/test/presets.test.js
CHANGED
|
@@ -7,7 +7,7 @@ it('should define request preset', async () => {
|
|
|
7
7
|
prefetch: 300,
|
|
8
8
|
confirms: false,
|
|
9
9
|
durable: true,
|
|
10
|
-
|
|
10
|
+
acknowledgments: true,
|
|
11
11
|
persistent: false
|
|
12
12
|
})
|
|
13
13
|
})
|
|
@@ -17,7 +17,7 @@ it('should define reply preset', async () => {
|
|
|
17
17
|
prefetch: 0,
|
|
18
18
|
confirms: false,
|
|
19
19
|
durable: false,
|
|
20
|
-
|
|
20
|
+
acknowledgments: false,
|
|
21
21
|
persistent: false
|
|
22
22
|
})
|
|
23
23
|
})
|
|
@@ -27,7 +27,7 @@ it('should define event preset', async () => {
|
|
|
27
27
|
prefetch: 300,
|
|
28
28
|
confirms: true,
|
|
29
29
|
durable: true,
|
|
30
|
-
|
|
30
|
+
acknowledgments: true,
|
|
31
31
|
persistent: true
|
|
32
32
|
})
|
|
33
33
|
})
|
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>
|