comq 0.6.0 → 0.8.0-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/.github/dependabot.yml +2 -2
- package/.github/workflows/release.yaml +2 -2
- package/examples/readme.md +0 -4
- package/features/connection.feature +0 -8
- package/features/shards.feature +11 -14
- package/features/singleton.feature +23 -0
- package/features/steps/.test/connection.test.js +0 -6
- package/features/steps/.types/context.d.ts +1 -0
- package/features/steps/connection.js +14 -11
- package/features/steps/context.js +12 -5
- package/lerna.json +1 -1
- package/package.json +15 -15
- package/readme.md +27 -18
- package/source/channel.js +2 -3
- package/source/connect.js +27 -15
- package/source/connection.js +13 -13
- package/source/index.js +2 -1
- package/source/shards/connection.js +21 -2
- package/source/singleton.js +68 -0
- package/test/channel.test.js +8 -12
- package/test/connect.test.js +1 -2
- package/test/connection.test.js +10 -13
- package/test/join.test.js +58 -0
- package/test/shards/channel.test.js +6 -5
- package/test/shards/connection.test.js +39 -12
- package/test/singleton.fixtures.js +21 -0
- package/test/singleton.test.js +113 -0
- package/types/index.d.ts +1 -0
package/.github/dependabot.yml
CHANGED
|
@@ -4,13 +4,13 @@ updates:
|
|
|
4
4
|
directory: "/"
|
|
5
5
|
schedule:
|
|
6
6
|
interval: "weekly"
|
|
7
|
-
target-branch: "
|
|
7
|
+
target-branch: "bumps"
|
|
8
8
|
labels:
|
|
9
9
|
- "Bump"
|
|
10
10
|
- package-ecosystem: "npm"
|
|
11
11
|
directory: "/"
|
|
12
12
|
schedule:
|
|
13
13
|
interval: "daily"
|
|
14
|
-
target-branch: "
|
|
14
|
+
target-branch: "bumps"
|
|
15
15
|
labels:
|
|
16
16
|
- "Bump"
|
package/examples/readme.md
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
To run examples please start RabbitMQ server with `docker compose up -d` in the package root.
|
|
4
4
|
|
|
5
|
-
> Try to run example first.
|
|
6
|
-
|
|
7
5
|
## RPC
|
|
8
6
|
|
|
9
7
|
Run in two terminals:
|
|
@@ -20,8 +18,6 @@ $ node exampels/rpc/consumer
|
|
|
20
18
|
|
|
21
19
|
[source](rpc/consumer.js)
|
|
22
20
|
|
|
23
|
-
> Try to run consumer first.
|
|
24
|
-
|
|
25
21
|
## Events
|
|
26
22
|
|
|
27
23
|
Run in multiple terminals:
|
|
@@ -3,14 +3,6 @@ Feature: Connection Tolerance
|
|
|
3
3
|
Scenario: Connect
|
|
4
4
|
Given an active connection to the broker
|
|
5
5
|
|
|
6
|
-
Scenario: Connecting to a broker before it was started
|
|
7
|
-
Given the broker is down
|
|
8
|
-
When I attempt to connect to the broker for 0.2 seconds
|
|
9
|
-
Then the connection is not established
|
|
10
|
-
And no exceptions are thrown
|
|
11
|
-
When the broker is up
|
|
12
|
-
Then the connection is established
|
|
13
|
-
|
|
14
6
|
Scenario: Restoring connection after broker restart
|
|
15
7
|
Given an active connection to the broker
|
|
16
8
|
When the broker is down
|
package/features/shards.feature
CHANGED
|
@@ -3,25 +3,26 @@ Feature: Sharded Connection
|
|
|
3
3
|
Scenario: Establishing sharded connection
|
|
4
4
|
Given an active sharded connection
|
|
5
5
|
|
|
6
|
-
Scenario: Connecting and waiting for 'open' event
|
|
7
|
-
Given the connection to both shards is established
|
|
8
|
-
|
|
9
6
|
Scenario: Connecting while a shard is down
|
|
10
7
|
Given one of the brokers has crashed
|
|
11
8
|
When I attempt to establish sharded connection
|
|
12
|
-
Then
|
|
9
|
+
Then an exception is thrown: "ECONNREFUSED"
|
|
13
10
|
Then the broker is up
|
|
14
11
|
|
|
12
|
+
Scenario: Connecting with wrong credentials
|
|
13
|
+
When I attempt to establish a sharded connection as "someone" with password "whatever"
|
|
14
|
+
Then an exception is thrown: "Handshake terminated by server: 403 (ACCESS-REFUSED)"
|
|
15
|
+
|
|
15
16
|
Scenario: Sending a request to a sharded connection while one broker is down
|
|
16
|
-
Given
|
|
17
|
-
And
|
|
17
|
+
Given an active sharded connection
|
|
18
|
+
And one of the brokers has crashed
|
|
18
19
|
And a producer replying `add_numbers` queue
|
|
19
20
|
When the consumer sends a request to the `add_numbers` queue
|
|
20
21
|
Then the consumer receives the reply
|
|
21
22
|
Then the broker is up
|
|
22
23
|
|
|
23
24
|
Scenario: Publishing an event to a sharded connection missing one broker
|
|
24
|
-
Given
|
|
25
|
+
Given an active sharded connection
|
|
25
26
|
And events are exclusively consumed from the `numbers_added` exchange
|
|
26
27
|
And one of the brokers has crashed
|
|
27
28
|
When an event is emitted to the `numbers_added` exchange
|
|
@@ -29,7 +30,7 @@ Feature: Sharded Connection
|
|
|
29
30
|
Then the broker is up
|
|
30
31
|
|
|
31
32
|
Scenario: Shard crashes while sending requests
|
|
32
|
-
Given
|
|
33
|
+
Given an active sharded connection
|
|
33
34
|
And a producer replying `flood` queue
|
|
34
35
|
And I'm sending 1kB requests to the `flood` queue at 1kHz
|
|
35
36
|
When one of the brokers has crashed
|
|
@@ -38,7 +39,7 @@ Feature: Sharded Connection
|
|
|
38
39
|
Then the broker is up
|
|
39
40
|
|
|
40
41
|
Scenario: Shard crashes while publishing events
|
|
41
|
-
Given
|
|
42
|
+
Given an active sharded connection
|
|
42
43
|
And events are exclusively consumed from the `flood` exchange
|
|
43
44
|
And I'm publishing 1kB events to the `flood` exchange at 100Hz
|
|
44
45
|
When one of the brokers has crashed
|
|
@@ -47,7 +48,7 @@ Feature: Sharded Connection
|
|
|
47
48
|
Then the broker is up
|
|
48
49
|
|
|
49
50
|
Scenario: Sealing sharded connection while one broker is down
|
|
50
|
-
Given
|
|
51
|
+
Given an active sharded connection
|
|
51
52
|
And a producer replying `echo` queue
|
|
52
53
|
And one of the brokers has crashed
|
|
53
54
|
And the connection has started sealing
|
|
@@ -55,7 +56,3 @@ Feature: Sharded Connection
|
|
|
55
56
|
And the broker is up
|
|
56
57
|
Then the connection is sealed
|
|
57
58
|
And the consumer does not receive the reply
|
|
58
|
-
|
|
59
|
-
Scenario: Connecting with wrong credentials
|
|
60
|
-
When I attempt to establish a sharded connection as "someone" with password "whatever"
|
|
61
|
-
Then an exception is thrown: "Handshake terminated by server: 403 (ACCESS-REFUSED)"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Feature: Singleton connection
|
|
2
|
+
|
|
3
|
+
Scenario: Establishing singleton connection
|
|
4
|
+
Given an active singleton connection
|
|
5
|
+
|
|
6
|
+
Scenario: Establishing sharded singleton connection
|
|
7
|
+
Given an active sharded singleton connection
|
|
8
|
+
|
|
9
|
+
Scenario: Sending request and getting reply
|
|
10
|
+
Given an active singleton connection
|
|
11
|
+
And function replying `add_numbers` queue:
|
|
12
|
+
"""
|
|
13
|
+
({ a, b }) => { return a + b }
|
|
14
|
+
"""
|
|
15
|
+
When the consumer sends the following request to the `add_numbers` queue:
|
|
16
|
+
"""yaml
|
|
17
|
+
a: 1
|
|
18
|
+
b: 2
|
|
19
|
+
"""
|
|
20
|
+
Then the consumer receives the reply:
|
|
21
|
+
"""yaml
|
|
22
|
+
3
|
|
23
|
+
"""
|
|
@@ -62,12 +62,6 @@ describe('Given an active sharded connection', () => {
|
|
|
62
62
|
})
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
describe('Given the connection to both shards is established', () => {
|
|
66
|
-
tomato.steps.Gi('the connection to both shards is established')
|
|
67
|
-
|
|
68
|
-
it('should be', async () => undefined)
|
|
69
|
-
})
|
|
70
|
-
|
|
71
65
|
describe('When I attempt to connect to the broker for {number} second(s)', () => {
|
|
72
66
|
const step = tomato.steps.Wh('I attempt to connect to the broker for {number} second(s)')
|
|
73
67
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const assert = require('node:assert')
|
|
4
|
-
const { timeout
|
|
4
|
+
const { timeout } = require('@toa.io/generic')
|
|
5
5
|
|
|
6
6
|
const { Given, When, Then } = require('@cucumber/cucumber')
|
|
7
7
|
|
|
@@ -26,22 +26,25 @@ Given('an active sharded connection',
|
|
|
26
26
|
await this.connecting
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
-
Given('
|
|
29
|
+
Given('an active singleton connection',
|
|
30
30
|
/**
|
|
31
31
|
* @this {comq.features.Context}
|
|
32
32
|
*/
|
|
33
33
|
async function () {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
this.sharded = true
|
|
37
|
-
this.connecting = this.connect()
|
|
34
|
+
this.connecting = this.join()
|
|
38
35
|
|
|
39
36
|
await this.connecting
|
|
37
|
+
})
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
Given('an active sharded singleton connection',
|
|
40
|
+
/**
|
|
41
|
+
* @this {comq.features.Context}
|
|
42
|
+
*/
|
|
43
|
+
async function () {
|
|
44
|
+
this.sharded = true
|
|
45
|
+
this.connecting = this.join()
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
await this.connecting
|
|
45
48
|
})
|
|
46
49
|
|
|
47
50
|
When('I attempt to connect to the broker for {number} second(s)',
|
|
@@ -185,6 +188,6 @@ const CONNECTION_EVENTS = {
|
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
const CONNECTION_GAPS = {
|
|
188
|
-
lost:
|
|
189
|
-
restored:
|
|
191
|
+
lost: 10,
|
|
192
|
+
restored: 5000
|
|
190
193
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { World } = require('@cucumber/cucumber')
|
|
4
|
-
const { connect } = require('../../')
|
|
4
|
+
const { connect, join } = require('../../')
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* @implements {comq.features.Context}
|
|
@@ -26,7 +26,13 @@ class Context extends World {
|
|
|
26
26
|
async connect (user, password) {
|
|
27
27
|
const urls = this.#urls(user, password)
|
|
28
28
|
|
|
29
|
-
await this.#connect(
|
|
29
|
+
await this.#connect(urls)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async join (user, password) {
|
|
33
|
+
const urls = this.#urls(user, password)
|
|
34
|
+
|
|
35
|
+
await this.#connect(urls, join)
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
async disconnect () {
|
|
@@ -40,13 +46,14 @@ class Context extends World {
|
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
/**
|
|
43
|
-
* @param {
|
|
49
|
+
* @param {string[]} urls
|
|
50
|
+
* @param {comq.connect} [method]
|
|
44
51
|
* @return {Promise<void>}
|
|
45
52
|
*/
|
|
46
|
-
async #connect (
|
|
53
|
+
async #connect (urls, method = connect) {
|
|
47
54
|
if (this.io !== undefined) await this.disconnect()
|
|
48
55
|
|
|
49
|
-
this.io = await
|
|
56
|
+
this.io = await method(...urls)
|
|
50
57
|
this.connected = true
|
|
51
58
|
|
|
52
59
|
for (const event of EVENTS) this.io.diagnose(event, () => (this.events[event] = true))
|
package/lerna.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0-dev.0",
|
|
4
4
|
"description": "ComQ",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Artem Gurtovoi",
|
|
@@ -27,22 +27,22 @@
|
|
|
27
27
|
"main": "source/index.js",
|
|
28
28
|
"types": "types/index.d.ts",
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@cucumber/cucumber": "9.1.
|
|
31
|
-
"@toa.io/command": "0.4.0",
|
|
32
|
-
"@toa.io/tomato": "0.4.0",
|
|
33
|
-
"@toa.io/yaml": "0.4.0",
|
|
34
|
-
"@types/amqplib": "0.10.1",
|
|
35
|
-
"@types/jest": "29.5.
|
|
36
|
-
"husky": "8.0.3",
|
|
37
|
-
"jest": "29.5.0",
|
|
38
|
-
"randomstring": "1.
|
|
39
|
-
"snazzy": "9.0.0",
|
|
40
|
-
"standard": "17.
|
|
30
|
+
"@cucumber/cucumber": "^9.1.2",
|
|
31
|
+
"@toa.io/command": "^0.4.0",
|
|
32
|
+
"@toa.io/tomato": "^0.4.0",
|
|
33
|
+
"@toa.io/yaml": "^0.4.0",
|
|
34
|
+
"@types/amqplib": "^0.10.1",
|
|
35
|
+
"@types/jest": "^29.5.2",
|
|
36
|
+
"husky": "^8.0.3",
|
|
37
|
+
"jest": "^29.5.0",
|
|
38
|
+
"randomstring": "^1.3.0",
|
|
39
|
+
"snazzy": "^9.0.0",
|
|
40
|
+
"standard": "^17.1.0"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@toa.io/generic": "0.4.0",
|
|
44
|
-
"amqplib": "0.10.3",
|
|
45
|
-
"msgpackr": "1.
|
|
43
|
+
"@toa.io/generic": "^0.4.0",
|
|
44
|
+
"amqplib": "^0.10.3",
|
|
45
|
+
"msgpackr": "^1.9.5"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
48
|
"prepare": "husky install",
|
package/readme.md
CHANGED
|
@@ -173,24 +173,22 @@ The following encoding formats are supported:
|
|
|
173
173
|
- `application/octet-stream`
|
|
174
174
|
- `text/plain`
|
|
175
175
|
|
|
176
|
-
## Flow
|
|
176
|
+
## Flow control
|
|
177
177
|
|
|
178
178
|
When [back pressure](https://www.rabbitmq.com/flow-control.html) is applied to a channel or the
|
|
179
179
|
underlying broker connection is lost, any current and future outgoing messages will be paused.
|
|
180
180
|
Corresponding returned promises will remain in a `pending` state until the pressure is removed or
|
|
181
181
|
the connection is restored.
|
|
182
182
|
|
|
183
|
-
## Connection
|
|
183
|
+
## Connection tolerance
|
|
184
184
|
|
|
185
|
-
When
|
|
186
|
-
attempts will be
|
|
187
|
-
the
|
|
185
|
+
When the established connection is lost, it will be automatically restored.
|
|
186
|
+
Reconnection attempts will be made indefinitely, with intervals increasing up to 30 seconds.
|
|
187
|
+
If the broker rejects the connection, for example, due to access being denied, an exception will be thrown.
|
|
188
|
+
Once reconnected, the topology will be recovered, and any unanswered requests and unconfirmed events will be
|
|
188
189
|
retransmitted.
|
|
189
190
|
|
|
190
|
-
|
|
191
|
-
thrown.
|
|
192
|
-
|
|
193
|
-
## Sharded Connection
|
|
191
|
+
## Sharded connection
|
|
194
192
|
|
|
195
193
|
*Send to one, receive from all.*
|
|
196
194
|
|
|
@@ -198,11 +196,11 @@ A sharded connection is a mechanism that uses multiple connections simultaneousl
|
|
|
198
196
|
balancing and mitigate failover scenarios, utilizing a set of broker instances that are **not**
|
|
199
197
|
combined into a cluster.
|
|
200
198
|
|
|
201
|
-
Outgoing messages are sent to a single connection chosen at random from the shard pool. Shards that
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
199
|
+
Outgoing messages are sent to a single connection chosen at random from the shard pool. Shards that lose their
|
|
200
|
+
underlying connection or experience channel [back pressure](#flow-control) on a corresponding channel are removed from
|
|
201
|
+
the pool until the issue is resolved. Pending messages meeting these conditions are immediately routed among the
|
|
202
|
+
remaining shards in the pool. If no shards are available, messages will wait until a shard's connection is
|
|
203
|
+
re-established.
|
|
206
204
|
|
|
207
205
|
Incoming messages are consumed from all shards.
|
|
208
206
|
|
|
@@ -213,7 +211,6 @@ Returns an instance of `IO` once a successful connection to one of the shards is
|
|
|
213
211
|
### Example
|
|
214
212
|
|
|
215
213
|
```javascript
|
|
216
|
-
|
|
217
214
|
const shard0 = 'amqp://developer:secret@localhost:5673'
|
|
218
215
|
const shard1 = 'amqp://developer:secret@localhost:5674'
|
|
219
216
|
|
|
@@ -222,9 +219,21 @@ const io = await connect(shard0, shard1)
|
|
|
222
219
|
// ...
|
|
223
220
|
|
|
224
221
|
await io.close()
|
|
225
|
-
|
|
226
222
|
```
|
|
227
223
|
|
|
224
|
+
## Singleton connection
|
|
225
|
+
|
|
226
|
+
`async join(url: string): IO`
|
|
227
|
+
|
|
228
|
+
Similar to [`connect`](#connect), but it utilizes shared underlying connections.
|
|
229
|
+
|
|
230
|
+
The connection is established once per unique `url` among instances of `IO` created with `join`,
|
|
231
|
+
and it will be closed when the last instance of `IO` using that connection is [disconnected](#disconnection).
|
|
232
|
+
|
|
233
|
+
[Sharded connections](#sharded-connection) are also supported.
|
|
234
|
+
|
|
235
|
+
`async join(...shards: string[]): IO`
|
|
236
|
+
|
|
228
237
|
## Topology
|
|
229
238
|
|
|
230
239
|
Topology is designed to deliver maximum performance while ensuring that the **at least once**
|
|
@@ -258,7 +267,7 @@ when using a single channel or channel pool.
|
|
|
258
267
|
[^1]: The maximum number of messages has been consumed while handlers of those messages have sent
|
|
259
268
|
requests and are expecting replies.
|
|
260
269
|
|
|
261
|
-
### Exchanges and
|
|
270
|
+
### Exchanges and queues
|
|
262
271
|
|
|
263
272
|
- Exchanges and queues for Events, and queues for Requests
|
|
264
273
|
are [durable](https://amqp-node.github.io/amqplib/channel_api.html#channel_assertQueue).
|
|
@@ -294,7 +303,7 @@ See:
|
|
|
294
303
|
| Reply | unlimited | no | exclusive | automatic | no |
|
|
295
304
|
| Event | limited | yes | durable | manual | yes |
|
|
296
305
|
|
|
297
|
-
## Graceful
|
|
306
|
+
## Graceful shutdown
|
|
298
307
|
|
|
299
308
|
### Sealing
|
|
300
309
|
|
package/source/channel.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { EventEmitter } = require('node:events')
|
|
4
|
-
const { lazy, recall, promex, failsafe,
|
|
4
|
+
const { lazy, recall, promex, failsafe, timeout } = require('@toa.io/generic')
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* @implements {comq.Channel}
|
|
@@ -137,8 +137,7 @@ class Channel {
|
|
|
137
137
|
|
|
138
138
|
for (const confirmation of this.#confirmations) confirmation.reject(INTERRUPTION)
|
|
139
139
|
|
|
140
|
-
// let unpause and confirmation interruptions be handled
|
|
141
|
-
await immediate()
|
|
140
|
+
await timeout(0) // let unpause and confirmation interruptions be handled
|
|
142
141
|
|
|
143
142
|
this.#recovery.resolve()
|
|
144
143
|
this.#recovery = promex()
|
package/source/connect.js
CHANGED
|
@@ -2,40 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
const { IO } = require('./io')
|
|
4
4
|
const { Connection } = require('./connection')
|
|
5
|
+
const { SingletonConnection } = require('./singleton')
|
|
5
6
|
const shards = require('./shards')
|
|
6
7
|
|
|
7
8
|
/** @type {comq.connect} */
|
|
8
9
|
const connect = async (...urls) => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
await connection.open()
|
|
10
|
+
return create(urls, connectionConstructor(Connection))
|
|
11
|
+
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/** @type {comq.connect} */
|
|
14
|
+
const join = async (...urls) => {
|
|
15
|
+
return create(urls, connectionConstructor(SingletonConnection))
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* @param {string[]} urls
|
|
18
|
-
* @
|
|
20
|
+
* @param {(urls: string[]) => comq.Connection} constructor
|
|
21
|
+
* @return {Promise<IO>}
|
|
19
22
|
*/
|
|
20
|
-
const create = (urls) => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
const create = async (urls, constructor) => {
|
|
24
|
+
const connection = constructor(urls)
|
|
25
|
+
|
|
26
|
+
await connection.open()
|
|
27
|
+
|
|
28
|
+
return new IO(connection)
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
const connectionConstructor = (ConnectionClass) =>
|
|
32
|
+
/**
|
|
33
|
+
* @param {string[]} urls
|
|
34
|
+
* @return {comq.Connection}
|
|
35
|
+
*/
|
|
36
|
+
(urls) => {
|
|
37
|
+
if (urls.length === 1) return new ConnectionClass(urls[0])
|
|
38
|
+
else return shardedConnection(urls, ConnectionClass)
|
|
39
|
+
}
|
|
30
40
|
|
|
31
41
|
/**
|
|
32
42
|
* @param {string[]} urls
|
|
43
|
+
* @param {function} ConnectionClass
|
|
33
44
|
* @return {comq.Connection}
|
|
34
45
|
*/
|
|
35
|
-
const
|
|
36
|
-
const connections = urls.map(
|
|
46
|
+
const shardedConnection = (urls, ConnectionClass) => {
|
|
47
|
+
const connections = urls.map((url) => new ConnectionClass(url))
|
|
37
48
|
|
|
38
49
|
return new shards.Connection(connections)
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
exports.connect = connect
|
|
53
|
+
exports.join = join
|
package/source/connection.js
CHANGED
|
@@ -23,6 +23,9 @@ class Connection {
|
|
|
23
23
|
/** @type {toa.generic.Promex} */
|
|
24
24
|
#recovery = promex()
|
|
25
25
|
|
|
26
|
+
/** @type {boolean} */
|
|
27
|
+
#running = false
|
|
28
|
+
|
|
26
29
|
#diagnostics = new EventEmitter()
|
|
27
30
|
|
|
28
31
|
/**
|
|
@@ -34,6 +37,8 @@ class Connection {
|
|
|
34
37
|
|
|
35
38
|
async open () {
|
|
36
39
|
await retry(this.#open, RETRY)
|
|
40
|
+
|
|
41
|
+
this.#running = true
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
async close () {
|
|
@@ -70,11 +75,11 @@ class Connection {
|
|
|
70
75
|
try {
|
|
71
76
|
this.#connection = await amqp.connect(this.#url)
|
|
72
77
|
} catch (exception) {
|
|
73
|
-
if (transient(exception)) return retry()
|
|
78
|
+
if (this.#transient(exception)) return retry()
|
|
74
79
|
else throw exception
|
|
75
80
|
}
|
|
76
81
|
|
|
77
|
-
// prevents process
|
|
82
|
+
// This prevents the process from crashing; 'close' will be emitted next.
|
|
78
83
|
// https://amqp-node.github.io/amqplib/channel_api.html#model_events
|
|
79
84
|
this.#connection.on('error', noop)
|
|
80
85
|
|
|
@@ -101,6 +106,12 @@ class Connection {
|
|
|
101
106
|
#recover () {
|
|
102
107
|
return this.#recovery
|
|
103
108
|
}
|
|
109
|
+
|
|
110
|
+
#transient (exception) {
|
|
111
|
+
const abruptly = exception.message === 'Socket closed abruptly during opening handshake'
|
|
112
|
+
|
|
113
|
+
return this.#running || abruptly
|
|
114
|
+
}
|
|
104
115
|
}
|
|
105
116
|
|
|
106
117
|
/** @type {toa.generic.retry.Options} */
|
|
@@ -108,17 +119,6 @@ const RETRY = {
|
|
|
108
119
|
retries: Infinity
|
|
109
120
|
}
|
|
110
121
|
|
|
111
|
-
/**
|
|
112
|
-
* @param {Error} exception
|
|
113
|
-
* @returns {boolean}
|
|
114
|
-
*/
|
|
115
|
-
const transient = (exception) => {
|
|
116
|
-
const refused = exception.code === 'ECONNREFUSED'
|
|
117
|
-
const handshake = exception.message === 'Socket closed abruptly during opening handshake'
|
|
118
|
-
|
|
119
|
-
return refused || handshake
|
|
120
|
-
}
|
|
121
|
-
|
|
122
122
|
function noop () {}
|
|
123
123
|
|
|
124
124
|
exports.Connection = Connection
|
package/source/index.js
CHANGED
|
@@ -23,9 +23,13 @@ class Connection {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
async open () {
|
|
26
|
-
const
|
|
26
|
+
const openings = this.#connections.map((connection) => connection.open())
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
try {
|
|
29
|
+
await Promise.all(openings)
|
|
30
|
+
} catch (exception) {
|
|
31
|
+
await this.#cancel(openings, exception)
|
|
32
|
+
}
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
async close () {
|
|
@@ -51,6 +55,21 @@ class Connection {
|
|
|
51
55
|
connection.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args, index))
|
|
52
56
|
}
|
|
53
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {Promise<any>[]} openings
|
|
61
|
+
* @param {Error} exception
|
|
62
|
+
*/
|
|
63
|
+
async #cancel (openings, exception) {
|
|
64
|
+
const settled = await Promise.allSettled(openings)
|
|
65
|
+
const indexes = settled.map((result, index) => result.status === 'fulfilled' ? index : -1)
|
|
66
|
+
const opened = indexes.filter((index) => index !== -1)
|
|
67
|
+
const closings = opened.map((index) => this.#connections[index].close())
|
|
68
|
+
|
|
69
|
+
await Promise.all(closings)
|
|
70
|
+
|
|
71
|
+
throw exception
|
|
72
|
+
}
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
exports.Connection = Connection
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Connection } = require('./connection')
|
|
4
|
+
|
|
5
|
+
class SingletonConnection extends Connection {
|
|
6
|
+
/** @type {string} */
|
|
7
|
+
#url
|
|
8
|
+
|
|
9
|
+
/** @type {Promise<void>} */
|
|
10
|
+
#opened = null
|
|
11
|
+
|
|
12
|
+
constructor (url) {
|
|
13
|
+
if (instances.has(url)) return instances.get(url)
|
|
14
|
+
|
|
15
|
+
super(url)
|
|
16
|
+
|
|
17
|
+
this.#url = url
|
|
18
|
+
|
|
19
|
+
instances.set(url, this)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async open () {
|
|
23
|
+
increment(this.#url)
|
|
24
|
+
|
|
25
|
+
if (this.#opened === null) this.#opened = super.open()
|
|
26
|
+
|
|
27
|
+
await this.#opened
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async close () {
|
|
31
|
+
const remainder = decrement(this.#url)
|
|
32
|
+
|
|
33
|
+
if (remainder === 0) await this.#close()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async #close () {
|
|
37
|
+
this.#opened = null
|
|
38
|
+
await super.close()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Singletons are evil.
|
|
42
|
+
static __lets_pretend_this_method_doesnt_exist () {
|
|
43
|
+
instances.clear()
|
|
44
|
+
counters.clear()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function increment (url) {
|
|
49
|
+
const value = (counters.get(url) ?? 0) + 1
|
|
50
|
+
|
|
51
|
+
counters.set(url, value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function decrement (url) {
|
|
55
|
+
const value = counters.get(url) - 1
|
|
56
|
+
|
|
57
|
+
counters.set(url, value)
|
|
58
|
+
|
|
59
|
+
return value
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @type {Map<string, SingletonConnection>} */
|
|
63
|
+
const instances = new Map()
|
|
64
|
+
|
|
65
|
+
/** @type {Map<string, number>} */
|
|
66
|
+
const counters = new Map()
|
|
67
|
+
|
|
68
|
+
exports.SingletonConnection = SingletonConnection
|
package/test/channel.test.js
CHANGED
|
@@ -577,7 +577,7 @@ describe('recovery', () => {
|
|
|
577
577
|
if (sealed) await channel.seal()
|
|
578
578
|
})
|
|
579
579
|
|
|
580
|
-
it('should re-assert queue
|
|
580
|
+
it('should re-assert queue', async () => {
|
|
581
581
|
chan.assertQueue.mockImplementation(async () => { throw new Error('Channel closed') })
|
|
582
582
|
|
|
583
583
|
/** @type {comq.amqp.Connection} */
|
|
@@ -598,7 +598,7 @@ describe('recovery', () => {
|
|
|
598
598
|
expect(repl.assertQueue).toHaveBeenCalled()
|
|
599
599
|
})
|
|
600
600
|
|
|
601
|
-
it('should re-assert exchange
|
|
601
|
+
it('should re-assert exchange', async () => {
|
|
602
602
|
chan.assertExchange.mockImplementation(async () => { throw new Error('Channel ended, no reply will be forthcoming') })
|
|
603
603
|
|
|
604
604
|
/** @type {comq.amqp.Connection} */
|
|
@@ -619,7 +619,7 @@ describe('recovery', () => {
|
|
|
619
619
|
expect(repl.assertExchange).toHaveBeenCalled()
|
|
620
620
|
})
|
|
621
621
|
|
|
622
|
-
it('should re-bind queue
|
|
622
|
+
it('should re-bind queue', async () => {
|
|
623
623
|
chan.bindQueue.mockImplementation(async () => { throw new Error('Channel ended, no reply will be forthcoming') })
|
|
624
624
|
|
|
625
625
|
/** @type {comq.amqp.Connection} */
|
|
@@ -642,7 +642,7 @@ describe('recovery', () => {
|
|
|
642
642
|
expect(repl.bindQueue).toHaveBeenCalled()
|
|
643
643
|
})
|
|
644
644
|
|
|
645
|
-
it(`should${not} re-consume
|
|
645
|
+
it(`should${not} re-consume`, async () => {
|
|
646
646
|
await channel.consume(queue, consumer)
|
|
647
647
|
|
|
648
648
|
const replacement = await amqplib.connect()
|
|
@@ -655,7 +655,7 @@ describe('recovery', () => {
|
|
|
655
655
|
else expect(chan.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.anything())
|
|
656
656
|
})
|
|
657
657
|
|
|
658
|
-
it(`should${not} re-consume
|
|
658
|
+
it(`should${not} re-consume`, async () => {
|
|
659
659
|
chan.consume.mockImplementation(async () => { throw new Error('Channel ended, no reply will be forthcoming') })
|
|
660
660
|
|
|
661
661
|
/** @type {comq.amqp.Connection} */
|
|
@@ -677,7 +677,7 @@ describe('recovery', () => {
|
|
|
677
677
|
else expect(repl.consume).toHaveBeenCalled()
|
|
678
678
|
})
|
|
679
679
|
|
|
680
|
-
it(`should${not} re-subscribe
|
|
680
|
+
it(`should${not} re-subscribe`, async () => {
|
|
681
681
|
await channel.subscribe(exchange, queue, consumer)
|
|
682
682
|
|
|
683
683
|
const replacement = await amqplib.connect()
|
|
@@ -690,7 +690,7 @@ describe('recovery', () => {
|
|
|
690
690
|
else expect(repl.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.anything())
|
|
691
691
|
})
|
|
692
692
|
|
|
693
|
-
it('should re-send
|
|
693
|
+
it('should re-send', async () => {
|
|
694
694
|
const queue = generate()
|
|
695
695
|
const buffer = Buffer.from(generate())
|
|
696
696
|
|
|
@@ -702,7 +702,6 @@ describe('recovery', () => {
|
|
|
702
702
|
setImmediate(async () => {
|
|
703
703
|
expect(chan.publish).toHaveBeenCalled()
|
|
704
704
|
|
|
705
|
-
chan.publish.mockImplementation(async () => {})
|
|
706
705
|
replacement = await amqplib.connect()
|
|
707
706
|
|
|
708
707
|
await channel.recover(replacement)
|
|
@@ -712,9 +711,6 @@ describe('recovery', () => {
|
|
|
712
711
|
|
|
713
712
|
const repl = await getCreatedChannel(replacement)
|
|
714
713
|
|
|
715
|
-
// await recovery effect
|
|
716
|
-
await timeout(5)
|
|
717
|
-
|
|
718
714
|
expect(repl.publish).toHaveBeenCalled()
|
|
719
715
|
})
|
|
720
716
|
|
|
@@ -757,7 +753,7 @@ describe('recovery', () => {
|
|
|
757
753
|
)
|
|
758
754
|
})
|
|
759
755
|
|
|
760
|
-
it('should unpause
|
|
756
|
+
it('should unpause', async () => {
|
|
761
757
|
jest.clearAllMocks()
|
|
762
758
|
|
|
763
759
|
topology.confirms = false
|
package/test/connect.test.js
CHANGED
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
const { generate } = require('randomstring')
|
|
4
4
|
|
|
5
|
-
const { connect } = require('../')
|
|
6
|
-
|
|
7
5
|
jest.mock('../source/io')
|
|
8
6
|
jest.mock('../source/connection')
|
|
9
7
|
jest.mock('../source/shards/connection')
|
|
10
8
|
|
|
9
|
+
const { connect } = require('../')
|
|
11
10
|
const { IO } = require('../source/io')
|
|
12
11
|
|
|
13
12
|
const {
|
package/test/connection.test.js
CHANGED
|
@@ -30,12 +30,6 @@ beforeEach(() => {
|
|
|
30
30
|
connection = new Connection(url)
|
|
31
31
|
})
|
|
32
32
|
|
|
33
|
-
/** @type {[string, Partial<Error>][]} */
|
|
34
|
-
const TRANSIENT_ERRORS = [
|
|
35
|
-
['ECONNREFUSED', { code: 'ECONNREFUSED' }],
|
|
36
|
-
['Socket closed', { message: 'Socket closed abruptly during opening handshake' }]
|
|
37
|
-
]
|
|
38
|
-
|
|
39
33
|
// endregion
|
|
40
34
|
|
|
41
35
|
describe('initial connection', () => {
|
|
@@ -45,7 +39,9 @@ describe('initial connection', () => {
|
|
|
45
39
|
expect(amqplib.connect).toHaveBeenCalledWith(url)
|
|
46
40
|
})
|
|
47
41
|
|
|
48
|
-
it.each(
|
|
42
|
+
it.each(/** @type {[string, Partial<Error>][]} */[
|
|
43
|
+
['Socket closed', { message: 'Socket closed abruptly during opening handshake' }]
|
|
44
|
+
])('should reconnect on %s',
|
|
49
45
|
async (_, error) => {
|
|
50
46
|
amqplib.connect.mockImplementationOnce(async () => { throw error })
|
|
51
47
|
|
|
@@ -54,12 +50,13 @@ describe('initial connection', () => {
|
|
|
54
50
|
expect(amqplib.connect).toHaveBeenCalledTimes(2)
|
|
55
51
|
})
|
|
56
52
|
|
|
57
|
-
it(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
it.each(/** @type {[string, Partial<Error>][]} */[
|
|
54
|
+
['ECONNREFUSED', { code: 'ECONNREFUSED' }],
|
|
55
|
+
['any exception', new Error(generate())]
|
|
56
|
+
])('should throw if error is %s', async (_, exception) => {
|
|
57
|
+
amqplib.connect.mockImplementationOnce(async () => { throw exception })
|
|
61
58
|
|
|
62
|
-
await expect(connection.open()).rejects.
|
|
59
|
+
await expect(connection.open()).rejects.toStrictEqual(exception)
|
|
63
60
|
})
|
|
64
61
|
})
|
|
65
62
|
|
|
@@ -77,7 +74,7 @@ describe('reconnection', () => {
|
|
|
77
74
|
expect(amqplib.connect).toHaveBeenCalledTimes(1)
|
|
78
75
|
|
|
79
76
|
// const clear = jest.spyOn(conn, 'removeAllListeners')
|
|
80
|
-
const error = {
|
|
77
|
+
const error = { code: 'ECONNREFUSED' }
|
|
81
78
|
|
|
82
79
|
conn.emit('close', error)
|
|
83
80
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { generate } = require('randomstring')
|
|
4
|
+
|
|
5
|
+
jest.mock('../source/io')
|
|
6
|
+
jest.mock('../source/singleton')
|
|
7
|
+
jest.mock('../source/shards/connection')
|
|
8
|
+
|
|
9
|
+
const { /** @type {jest.MockedClass<comq.IO>} */ IO } = require('../source/io')
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
/** @type {jest.MockedClass<comq.Connection>} */
|
|
13
|
+
SingletonConnection
|
|
14
|
+
} = require('../source/singleton')
|
|
15
|
+
|
|
16
|
+
const shards = /** @type {{ Connection: jest.MockedClass<comq.Connection>}} */
|
|
17
|
+
require('../source/shards')
|
|
18
|
+
|
|
19
|
+
const { join } = require('../')
|
|
20
|
+
|
|
21
|
+
const url = generate()
|
|
22
|
+
|
|
23
|
+
/** @type {comq.IO} */
|
|
24
|
+
let io
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
jest.clearAllMocks()
|
|
28
|
+
|
|
29
|
+
io = await join(url)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should be', async () => {
|
|
33
|
+
expect(join).toBeInstanceOf(Function)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should return instance of IO', async () => {
|
|
37
|
+
expect(io).toStrictEqual(IO.mock.instances[0])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should create singleton connection', async () => {
|
|
41
|
+
expect(SingletonConnection).toHaveBeenCalled()
|
|
42
|
+
expect(SingletonConnection).toHaveBeenCalledWith(url)
|
|
43
|
+
expect(IO).toHaveBeenCalledWith(SingletonConnection.mock.instances[0])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should create sharded connection', async () => {
|
|
47
|
+
jest.clearAllMocks()
|
|
48
|
+
|
|
49
|
+
const urls = [generate(), generate()]
|
|
50
|
+
|
|
51
|
+
io = await join(...urls)
|
|
52
|
+
|
|
53
|
+
for (const url of urls) expect(SingletonConnection).toHaveBeenCalledWith(url)
|
|
54
|
+
|
|
55
|
+
expect(shards.Connection).toHaveBeenCalledWith(SingletonConnection.mock.instances)
|
|
56
|
+
expect(IO).toHaveBeenCalledWith(shards.Connection.mock.instances[0])
|
|
57
|
+
expect(io).toStrictEqual(IO.mock.instances[0])
|
|
58
|
+
})
|
|
@@ -374,20 +374,21 @@ it('should remove channel with back pressure from the pool', async () => {
|
|
|
374
374
|
}
|
|
375
375
|
|
|
376
376
|
await send()
|
|
377
|
+
|
|
377
378
|
expect(chan.send).toHaveBeenCalled()
|
|
379
|
+
|
|
378
380
|
chan.send.mockClear()
|
|
379
381
|
|
|
380
|
-
|
|
381
|
-
for (const listener of flowListeners) listener()
|
|
382
|
+
for (const flow of flowListeners) flow()
|
|
382
383
|
|
|
383
384
|
await send()
|
|
385
|
+
|
|
384
386
|
expect(chan.send).not.toHaveBeenCalled()
|
|
385
|
-
chan.send.mockClear()
|
|
386
387
|
|
|
387
|
-
|
|
388
|
-
for (const listener of drainListeners) listener()
|
|
388
|
+
for (const drain of drainListeners) drain()
|
|
389
389
|
|
|
390
390
|
await send()
|
|
391
|
+
|
|
391
392
|
expect(chan.send).toHaveBeenCalled()
|
|
392
393
|
})
|
|
393
394
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { generate } = require('randomstring')
|
|
4
|
-
const { random, promex,
|
|
4
|
+
const { random, promex, immediate } = require('@toa.io/generic')
|
|
5
5
|
|
|
6
6
|
const { Connection } = require('../../source/shards')
|
|
7
7
|
|
|
@@ -16,7 +16,7 @@ it('should be', async () => {
|
|
|
16
16
|
})
|
|
17
17
|
|
|
18
18
|
/** @type {jest.MockedObject<comq.Connection>[]} */
|
|
19
|
-
|
|
19
|
+
let connections
|
|
20
20
|
|
|
21
21
|
/** @type {comq.Connection} */
|
|
22
22
|
let connection
|
|
@@ -24,12 +24,13 @@ let connection
|
|
|
24
24
|
beforeEach(() => {
|
|
25
25
|
jest.clearAllMocks()
|
|
26
26
|
|
|
27
|
+
connections = [mock.connection(), mock.connection()]
|
|
27
28
|
connection = new Connection(connections)
|
|
28
29
|
})
|
|
29
30
|
|
|
30
31
|
describe('open', () => {
|
|
31
|
-
it('should resolve when
|
|
32
|
-
expect.assertions(
|
|
32
|
+
it('should resolve when all of the connections are established', async () => {
|
|
33
|
+
expect.assertions(2)
|
|
33
34
|
|
|
34
35
|
/** @type {toa.generic.Promex[]} */
|
|
35
36
|
const promises = []
|
|
@@ -41,19 +42,45 @@ describe('open', () => {
|
|
|
41
42
|
promises.push(promise)
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
let
|
|
45
|
+
let resolved = false
|
|
45
46
|
|
|
46
|
-
setImmediate(() => {
|
|
47
|
-
expect(
|
|
47
|
+
setImmediate(async () => {
|
|
48
|
+
expect(resolved).toStrictEqual(false)
|
|
48
49
|
|
|
49
|
-
const
|
|
50
|
+
const first = promises.shift()
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
first.resolve()
|
|
53
|
+
|
|
54
|
+
await immediate()
|
|
55
|
+
|
|
56
|
+
promises.forEach((promise) => promise.resolve())
|
|
57
|
+
|
|
58
|
+
resolved = true
|
|
52
59
|
})
|
|
53
60
|
|
|
54
61
|
await connection.open()
|
|
55
62
|
|
|
56
|
-
|
|
63
|
+
expect(resolved).toStrictEqual(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should close opened connection if one fails', async () => {
|
|
67
|
+
connections.push(mock.connection())
|
|
68
|
+
|
|
69
|
+
const [one, bad, two] = connections
|
|
70
|
+
const exception = new Error(generate())
|
|
71
|
+
|
|
72
|
+
one.open.mockImplementation(() => Promise.resolve())
|
|
73
|
+
two.open.mockImplementation(() => Promise.resolve())
|
|
74
|
+
|
|
75
|
+
bad.open.mockImplementation(async () => {
|
|
76
|
+
await immediate()
|
|
77
|
+
throw exception
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
await expect(connection.open()).rejects.toThrow(exception)
|
|
81
|
+
|
|
82
|
+
expect(one.close).toHaveBeenCalled()
|
|
83
|
+
expect(two.close).toHaveBeenCalled()
|
|
57
84
|
})
|
|
58
85
|
})
|
|
59
86
|
|
|
@@ -81,9 +108,9 @@ describe('createChannel', () => {
|
|
|
81
108
|
|
|
82
109
|
describe.each(/** @type {comq.diagnostics.event[]} */ ['open', 'close'])('diagnose %s event',
|
|
83
110
|
(event) => {
|
|
84
|
-
const index = random(connections.length)
|
|
85
|
-
|
|
86
111
|
it('should re-emit event', async () => {
|
|
112
|
+
const index = random(connections.length)
|
|
113
|
+
|
|
87
114
|
for (const conn of connections) {
|
|
88
115
|
expect(conn.diagnose).toHaveBeenCalledWith(event, expect.any(Function))
|
|
89
116
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
class Connection {
|
|
4
|
+
static ctor = jest.fn(async () => undefined)
|
|
5
|
+
static open = jest.fn(async () => undefined)
|
|
6
|
+
static close = jest.fn(async () => undefined)
|
|
7
|
+
|
|
8
|
+
constructor (...args) {
|
|
9
|
+
Connection.ctor(...args)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async open () {
|
|
13
|
+
await Connection.open()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async close () {
|
|
17
|
+
await Connection.close()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
exports.mock = { Connection }
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { generate } = require('randomstring')
|
|
4
|
+
|
|
5
|
+
const { mock } = require('./singleton.fixtures')
|
|
6
|
+
|
|
7
|
+
jest.mock('../source/connection', () => ({ Connection: mock.Connection }))
|
|
8
|
+
|
|
9
|
+
const { SingletonConnection } = require('../source/singleton')
|
|
10
|
+
|
|
11
|
+
it('should be', async () => {
|
|
12
|
+
expect(SingletonConnection).toBeInstanceOf(Function)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/** @type {SingletonConnection} */
|
|
16
|
+
let connection
|
|
17
|
+
|
|
18
|
+
const url = generate()
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks()
|
|
22
|
+
|
|
23
|
+
SingletonConnection.__lets_pretend_this_method_doesnt_exist()
|
|
24
|
+
|
|
25
|
+
connection = new SingletonConnection(url)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should extend Connection', async () => {
|
|
29
|
+
expect(connection).toBeInstanceOf(mock.Connection)
|
|
30
|
+
expect(mock.Connection.ctor).toHaveBeenCalledWith(url)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('connection', () => {
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
await connection.open()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
await connection.close()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should open connection', async () => {
|
|
43
|
+
expect(mock.Connection.open).toHaveBeenCalled()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should open connection once per url across instances', async () => {
|
|
47
|
+
jest.clearAllMocks()
|
|
48
|
+
|
|
49
|
+
const url = generate()
|
|
50
|
+
const second = new SingletonConnection(url)
|
|
51
|
+
const third = new SingletonConnection(url)
|
|
52
|
+
|
|
53
|
+
await Promise.all([second.open(), third.open()])
|
|
54
|
+
|
|
55
|
+
expect(mock.Connection.open).toHaveBeenCalledTimes(1)
|
|
56
|
+
|
|
57
|
+
const fourth = new SingletonConnection(generate())
|
|
58
|
+
|
|
59
|
+
await fourth.open()
|
|
60
|
+
|
|
61
|
+
expect(mock.Connection.open).toHaveBeenCalledTimes(2)
|
|
62
|
+
|
|
63
|
+
await second.close()
|
|
64
|
+
await third.close()
|
|
65
|
+
await fourth.close()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should close connection', async () => {
|
|
69
|
+
await connection.close()
|
|
70
|
+
|
|
71
|
+
expect(mock.Connection.close).toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should close connection once', async () => {
|
|
75
|
+
const url = generate()
|
|
76
|
+
const one = new SingletonConnection(url)
|
|
77
|
+
const two = new SingletonConnection(url)
|
|
78
|
+
|
|
79
|
+
await Promise.all([one.open(), two.open()])
|
|
80
|
+
await Promise.all([one.close(), two.close()])
|
|
81
|
+
|
|
82
|
+
expect(mock.Connection.close).toHaveBeenCalledTimes(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should not close connection if other instances connected', async () => {
|
|
86
|
+
jest.clearAllMocks()
|
|
87
|
+
const second = new SingletonConnection(url)
|
|
88
|
+
|
|
89
|
+
await second.open()
|
|
90
|
+
await second.close()
|
|
91
|
+
|
|
92
|
+
expect(mock.Connection.close).not.toHaveBeenCalled()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should close connection when all instances are disconnected', async () => {
|
|
96
|
+
const second = new SingletonConnection(url)
|
|
97
|
+
|
|
98
|
+
await second.open()
|
|
99
|
+
await second.close()
|
|
100
|
+
await connection.close()
|
|
101
|
+
|
|
102
|
+
expect(mock.Connection.close).toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should reopen connection', async () => {
|
|
106
|
+
const one = new SingletonConnection(url)
|
|
107
|
+
|
|
108
|
+
await connection.close()
|
|
109
|
+
await one.open()
|
|
110
|
+
|
|
111
|
+
expect(mock.Connection.open).toHaveBeenCalledTimes(2)
|
|
112
|
+
})
|
|
113
|
+
})
|
package/types/index.d.ts
CHANGED