comq 0.4.0-dev.7 → 0.4.1-dev.3
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/.github/workflows/release.yaml +5 -1
- 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/.types/context.d.ts +3 -2
- package/features/steps/connection.js +3 -3
- package/features/steps/context.js +20 -16
- package/features/steps/hooks.js +3 -4
- package/lerna.json +1 -1
- package/package.json +1 -1
- package/readme.md +5 -5
- package/source/channel.js +3 -1
- package/source/connection.js +5 -1
- package/source/presets/event.json +1 -1
- package/source/presets/reply.json +1 -1
- package/source/presets/request.json +1 -1
- package/test/channel.fixtures.js +1 -1
- package/test/channel.test.js +31 -9
- package/test/connection.test.js +18 -5
- package/test/presets.test.js +3 -3
- package/types/topology.d.ts +1 -1
|
@@ -27,6 +27,10 @@ jobs:
|
|
|
27
27
|
- name: Run tests
|
|
28
28
|
run: npm i && npm test
|
|
29
29
|
|
|
30
|
+
- name: Run features
|
|
31
|
+
if: github.ref == 'refs/heads/main'
|
|
32
|
+
run: docker-compose up -d && npx cucumber-js --fail-fast --exit --format summary --publish-quiet
|
|
33
|
+
|
|
30
34
|
- name: Bump version
|
|
31
35
|
run: |
|
|
32
36
|
git config user.name "toabot"
|
|
@@ -39,7 +43,7 @@ jobs:
|
|
|
39
43
|
git merge main
|
|
40
44
|
git push --all origin --no-verify
|
|
41
45
|
else
|
|
42
|
-
npx lerna@6.5.1 version --conventional-prerelease --preid dev --no-commit-hooks --no-changelog --yes --message "chore(prerelease): %s"
|
|
46
|
+
npx lerna@6.5.1 version --conventional-prerelease --preid dev --no-commit-hooks --no-changelog --yes --message "chore(prerelease): %s" --git-tag-command "echo %s"
|
|
43
47
|
fi
|
|
44
48
|
|
|
45
49
|
- name: Publish packages
|
|
@@ -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
|
|
|
@@ -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/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
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
|
|
@@ -225,7 +225,7 @@ requests and are expecting replies.
|
|
|
225
225
|
*persistent* ([delivery mode 2](https://www.rabbitmq.com/publishers.html#message-properties)),
|
|
226
226
|
while Requests and Replies are not (mode 1).
|
|
227
227
|
- Events and Requests are consumed using
|
|
228
|
-
manual [acknowledgment mode](https://www.rabbitmq.com/confirms.html#
|
|
228
|
+
manual [acknowledgment mode](https://www.rabbitmq.com/confirms.html#acknowledgment-modes),
|
|
229
229
|
and Replies are consumed using automatic mode.
|
|
230
230
|
|
|
231
231
|
If incoming message causes an exception, then it is "negative acknowledged" and requeued. If it
|
|
@@ -237,13 +237,13 @@ causes an exception again, it will be discarded.
|
|
|
237
237
|
|
|
238
238
|
See:
|
|
239
239
|
|
|
240
|
-
- [Consumer
|
|
241
|
-
- [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)
|
|
242
242
|
- [Dead Letter Exchanges](https://www.rabbitmq.com/dlx.html)
|
|
243
243
|
|
|
244
244
|
### Cheatsheet
|
|
245
245
|
|
|
246
|
-
| Message | Prefetch | Confirms | Queue |
|
|
246
|
+
| Message | Prefetch | Confirms | Queue | Acknowledgment | Persistent |
|
|
247
247
|
|---------|-----------|----------|-----------|-----------------|------------|
|
|
248
248
|
| Request | limited | no | durable | manual | no |
|
|
249
249
|
| Reply | unlimited | no | exclusive | automatic | no |
|
package/source/channel.js
CHANGED
|
@@ -211,7 +211,7 @@ class Channel {
|
|
|
211
211
|
/** @type {import('amqplib').Options.Consume} */
|
|
212
212
|
const options = {}
|
|
213
213
|
|
|
214
|
-
if (this.#topology.
|
|
214
|
+
if (this.#topology.acknowledgments) consumer = this.#getAcknowledgingConsumer(consumer)
|
|
215
215
|
else options.noAck = true
|
|
216
216
|
|
|
217
217
|
const response = await this.#channel.consume(queue, consumer, options)
|
|
@@ -230,6 +230,8 @@ class Channel {
|
|
|
230
230
|
|
|
231
231
|
this.#channel.ack(message)
|
|
232
232
|
} catch (exception) {
|
|
233
|
+
if (exception.message === 'Channel closed') return // message will be requeued by the broker
|
|
234
|
+
|
|
233
235
|
if (message.fields.redelivered) this.#discard(message, exception)
|
|
234
236
|
else this.#requeue(message)
|
|
235
237
|
}
|
package/source/connection.js
CHANGED
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', () => {
|
|
@@ -352,10 +374,10 @@ describe.each(['group', 'exclusive'])('%s subscribe', (option) => {
|
|
|
352
374
|
})
|
|
353
375
|
|
|
354
376
|
it.each([
|
|
355
|
-
['with
|
|
356
|
-
['without
|
|
377
|
+
['with acknowledgments', true],
|
|
378
|
+
['without acknowledgments', false]
|
|
357
379
|
])('should start consuming %s', async (_, ack) => {
|
|
358
|
-
topology.
|
|
380
|
+
topology.acknowledgments = ack
|
|
359
381
|
|
|
360
382
|
jest.clearAllMocks()
|
|
361
383
|
|
|
@@ -764,7 +786,7 @@ describe('diagnostics', () => {
|
|
|
764
786
|
])('should%s emit `discard` event', async (_, redelivered) => {
|
|
765
787
|
jest.clearAllMocks()
|
|
766
788
|
|
|
767
|
-
topology.
|
|
789
|
+
topology.acknowledgments = true
|
|
768
790
|
channel = await create(connection, topology)
|
|
769
791
|
chan = await getCreatedChannel()
|
|
770
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/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
|
})
|