comq 0.7.0 → 0.8.0-dev.1

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:
@@ -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
+ """
@@ -25,6 +25,7 @@ declare namespace comq.features {
25
25
  publishing: any
26
26
 
27
27
  connect(user?: string, password?: string): Promise<void>
28
+ assert(user?: string, password?: string): Promise<void>
28
29
  disconnect(): Promise<void>
29
30
  }
30
31
 
@@ -26,6 +26,27 @@ Given('an active sharded connection',
26
26
  await this.connecting
27
27
  })
28
28
 
29
+ Given('an active singleton connection',
30
+ /**
31
+ * @this {comq.features.Context}
32
+ */
33
+ async function () {
34
+ this.connecting = this.assert()
35
+
36
+ await this.connecting
37
+ })
38
+
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.assert()
46
+
47
+ await this.connecting
48
+ })
49
+
29
50
  When('I attempt to connect to the broker for {number} second(s)',
30
51
  /**
31
52
  * @param {number} interval
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { World } = require('@cucumber/cucumber')
4
- const { connect } = require('../../')
4
+ const { connect, assert } = 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 assert (user, password) {
33
+ const urls = this.#urls(user, password)
34
+
35
+ await this.#connect(urls, assert)
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.7.0",
3
+ "version": "0.8.0-dev.1",
4
4
  "conventionalCommits": true
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.7.0",
3
+ "version": "0.8.0-dev.1",
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 assert(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 `assert`,
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 assert(...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/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 assert = 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.assert = assert
@@ -26,7 +26,7 @@ class Connection {
26
26
  /** @type {boolean} */
27
27
  #running = false
28
28
 
29
- #diagnostics = new EventEmitter()
29
+ #diagnostics = createEmitter()
30
30
 
31
31
  /**
32
32
  * @param {string} url
@@ -79,7 +79,7 @@ class Connection {
79
79
  else throw exception
80
80
  }
81
81
 
82
- // prevents the process crash, 'close' will be emitted next
82
+ // This prevents the process from crashing; 'close' will be emitted next.
83
83
  // https://amqp-node.github.io/amqplib/channel_api.html#model_events
84
84
  this.#connection.on('error', noop)
85
85
 
@@ -114,11 +114,19 @@ class Connection {
114
114
  }
115
115
  }
116
116
 
117
+ function createEmitter () {
118
+ const emitter = new EventEmitter()
119
+
120
+ emitter.setMaxListeners(10000)
121
+
122
+ return emitter
123
+ }
124
+
125
+ function noop () {}
126
+
117
127
  /** @type {toa.generic.retry.Options} */
118
128
  const RETRY = {
119
129
  retries: Infinity
120
130
  }
121
131
 
122
- function noop () {}
123
-
124
132
  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, assert } = require('./connect')
4
4
 
5
5
  exports.connect = connect
6
+ exports.assert = assert
@@ -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
@@ -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 { assert } = 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 assert(url)
30
+ })
31
+
32
+ it('should be', async () => {
33
+ expect(assert).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 assert(...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
+ })
@@ -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 {
@@ -253,4 +253,10 @@ describe('diagnostics', () => {
253
253
 
254
254
  expect(captured).toStrictEqual(error)
255
255
  })
256
+
257
+ it('should handle max listeners', async () => {
258
+ for (let i = 0; i < 100; i++) {
259
+ connection.diagnose('close', () => undefined)
260
+ }
261
+ })
256
262
  })
@@ -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 assert: connect