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.
@@ -4,13 +4,13 @@ updates:
4
4
  directory: "/"
5
5
  schedule:
6
6
  interval: "weekly"
7
- target-branch: "dev"
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: "dev"
14
+ target-branch: "bumps"
15
15
  labels:
16
16
  - "Bump"
@@ -28,8 +28,8 @@ jobs:
28
28
  with:
29
29
  fetch-depth: 0
30
30
 
31
- - name: Run tests
32
- run: npm i && npm test
31
+ # - name: Run tests
32
+ # run: npm i && npm test
33
33
 
34
34
  - name: Import GPG key
35
35
  id: gpg
@@ -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
@@ -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 the connection is established
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 one of the brokers has crashed
17
- And an active sharded connection
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 the connection to both shards is established
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 the connection to both shards is established
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 the connection to both shards is established
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 the connection to both shards is established
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
 
@@ -25,6 +25,7 @@ declare namespace comq.features {
25
25
  publishing: any
26
26
 
27
27
  connect(user?: string, password?: string): Promise<void>
28
+ join(user?: string, password?: string): Promise<void>
28
29
  disconnect(): Promise<void>
29
30
  }
30
31
 
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const assert = require('node:assert')
4
- const { timeout, promex } = require('@toa.io/generic')
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('the connection to both shards is established',
29
+ Given('an active singleton connection',
30
30
  /**
31
31
  * @this {comq.features.Context}
32
32
  */
33
33
  async function () {
34
- const promise = promex()
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
- // second shard is connected
42
- this.io.diagnose('open', () => promise.resolve())
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
- return promise
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: global.COMQ_TESTING_CONNECTION_GAP_LOST ?? 10,
189
- restored: global.COMQ_TESTING_CONNECTION_GAP_RESTORED ?? 5000
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(...urls)
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 {...string} urls
49
+ * @param {string[]} urls
50
+ * @param {comq.connect} [method]
44
51
  * @return {Promise<void>}
45
52
  */
46
- async #connect (...urls) {
53
+ async #connect (urls, method = connect) {
47
54
  if (this.io !== undefined) await this.disconnect()
48
55
 
49
- this.io = await connect(...urls)
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
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "useWorkspaces": true,
3
- "version": "0.6.0",
3
+ "version": "0.8.0-dev.0",
4
4
  "conventionalCommits": true
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.6.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.0",
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.0",
36
- "husky": "8.0.3",
37
- "jest": "29.5.0",
38
- "randomstring": "1.2.3",
39
- "snazzy": "9.0.0",
40
- "standard": "17.0.0"
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.8.5"
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 Control
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 Tolerance
183
+ ## Connection tolerance
184
184
 
185
- When initially connecting to the broker or if the established connection is lost, connection
186
- attempts will be repeated indefinitely with intervals increasing up to 30 seconds. Once reconnected,
187
- the topology will be recovered, and unanswered Requests and unconfirmed Events will be
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
- If the broker rejects the connection (for example, due to access being denied), an exception will be
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
- lose their underlying connection or experience channel [back pressure](#flow-control) on
203
- corresponding channel are removed from the pool until the issue is resolved. Pending messages
204
- meeting these conditions are immediately routed among the remaining shards in the pool. If no shards
205
- are available, messages will wait until any shard's connection is re-established.
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 Queues
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 Shutdown
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, immediate } = require('@toa.io/generic')
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
- const connection = create(urls)
10
-
11
- await connection.open()
10
+ return create(urls, connectionConstructor(Connection))
11
+ }
12
12
 
13
- return new IO(connection)
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
- * @return {comq.Connection}
20
+ * @param {(urls: string[]) => comq.Connection} constructor
21
+ * @return {Promise<IO>}
19
22
  */
20
- const create = (urls) => {
21
- if (urls.length === 1) return single(urls[0])
22
- else return sharded(urls)
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
- * @param {string} url
27
- * @return {comq.Connection}
28
- */
29
- const single = (url) => new Connection(url)
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 sharded = (urls) => {
36
- const connections = urls.map(single)
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
@@ -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 crash, 'close' will be emitted next
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
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { connect } = require('./connect')
3
+ const { connect, join } = require('./connect')
4
4
 
5
5
  exports.connect = connect
6
+ exports.join = join
@@ -23,9 +23,13 @@ class Connection {
23
23
  }
24
24
 
25
25
  async open () {
26
- const opening = this.#connections.map((connection) => connection.open())
26
+ const openings = this.#connections.map((connection) => connection.open())
27
27
 
28
- await Promise.any(opening)
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
@@ -577,7 +577,7 @@ describe('recovery', () => {
577
577
  if (sealed) await channel.seal()
578
578
  })
579
579
 
580
- it('should re-assert queue after recovery', async () => {
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 after exception', async () => {
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 after exception', async () => {
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 after recovery`, async () => {
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 after exception `, async () => {
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 after recovery`, async () => {
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 after exception', async () => {
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 on recovery', async () => {
756
+ it('should unpause', async () => {
761
757
  jest.clearAllMocks()
762
758
 
763
759
  topology.confirms = false
@@ -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 {
@@ -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(TRANSIENT_ERRORS)('should reconnect on %s',
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('should throw if error is permanent', async () => {
58
- const error = new Error()
59
-
60
- amqplib.connect.mockImplementationOnce(async () => { throw error })
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.toThrow(error)
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 = { message: generate() }
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
- // emit 'flow'
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
- // emit 'drain'
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, sample } = require('@toa.io/generic')
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
- const connections = [mock.connection(), mock.connection()]
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 one of connections is established', async () => {
32
- expect.assertions(1)
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 connected = false
45
+ let resolved = false
45
46
 
46
- setImmediate(() => {
47
- expect(connected).toStrictEqual(false)
47
+ setImmediate(async () => {
48
+ expect(resolved).toStrictEqual(false)
48
49
 
49
- const any = sample(promises)
50
+ const first = promises.shift()
50
51
 
51
- any.resolve()
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
- connected = true
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
@@ -3,3 +3,4 @@ import { connect } from './connection'
3
3
  export type { IO, consumer, producer } from './io'
4
4
 
5
5
  export const connect: connect
6
+ export const join: connect