comq 0.25.1 → 0.27.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.
Files changed (41) hide show
  1. package/docs/headers.md +16 -1
  2. package/features/properties.feature +39 -0
  3. package/features/shards.virtual.feature +84 -0
  4. package/features/steps/context.js +29 -2
  5. package/features/steps/hooks.js +1 -0
  6. package/features/steps/names.js +235 -0
  7. package/features/steps/networks.js +23 -5
  8. package/features/steps/rpc.js +14 -2
  9. package/package.json +1 -1
  10. package/readme.md +90 -4
  11. package/source/channel.js +147 -2
  12. package/source/connect.js +18 -15
  13. package/source/connection.js +8 -5
  14. package/source/events.js +7 -0
  15. package/source/io.js +31 -9
  16. package/source/shards/channel.js +289 -19
  17. package/source/shards/connection.js +404 -18
  18. package/source/shards/resolve.js +134 -0
  19. package/source/shards/watch.js +257 -0
  20. package/source/singleton.js +3 -2
  21. package/test/amqplib.mock.js +1 -0
  22. package/test/assert.test.js +7 -2
  23. package/test/channel.quiet.test.js +301 -0
  24. package/test/connect.test.js +32 -8
  25. package/test/connection.mock.js +3 -0
  26. package/test/connection.test.js +15 -0
  27. package/test/io.call.test.js +1 -1
  28. package/test/io.diagnostics.test.js +1 -1
  29. package/test/io.reply.test.js +12 -1
  30. package/test/io.request.test.js +18 -0
  31. package/test/io.shards.test.js +85 -0
  32. package/test/shards/channel.test.js +411 -0
  33. package/test/shards/connection.test.js +372 -17
  34. package/test/shards/resolve.test.js +73 -0
  35. package/test/shards/watch.test.js +264 -0
  36. package/test/singleton.test.js +1 -1
  37. package/types/channel.d.ts +23 -0
  38. package/types/connection.d.ts +12 -2
  39. package/types/diagnostic.d.ts +2 -1
  40. package/types/io.d.ts +17 -1
  41. package/types/shards.d.ts +58 -0
package/docs/headers.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # Message Properties
2
2
 
3
- Events may be emitted and consumed with custom properties using an optional `properties` argument.
3
+ Events and Requests may be sent and received with custom properties using an optional `properties`
4
+ argument.
4
5
 
5
6
  ## Emission
6
7
 
@@ -16,6 +17,20 @@ to [amqplib.publish](https://amqp-node.github.io/amqplib/channel_api.html#channe
16
17
  `consumer` function's signature
17
18
  is `async? (payload: any, [properties: comq.amqp.Properties]): void`
18
19
 
20
+ ## Request
21
+
22
+ `async IO.request(queue: string, payload: any, [properties: comq.amqp.Properties]): any`
23
+
24
+ `replyTo` and `correlationId` are set by comq whatever is passed, and `contentType` is the
25
+ encoding of the Request. A Request takes no `timeout` and no `signal`.
26
+
27
+ ## Reply
28
+
29
+ `async IO.reply(queue: string, producer): void`
30
+
31
+ `producer` function's signature
32
+ is `async? (payload: any, [properties: comq.amqp.Properties]): any`
33
+
19
34
  ## Headers set by comq
20
35
 
21
36
  A message that failed at least once carries headers comq wrote, and a consumer sees them among
@@ -27,3 +27,42 @@ Feature: Message properties
27
27
  """yaml
28
28
  appId: test
29
29
  """
30
+
31
+ Scenario: Sending a request with headers
32
+ Given function replying `greet` queue:
33
+ """
34
+ ({ name }, properties) => properties.headers.greeting + ' ' + name
35
+ """
36
+ When the consumer sends the following request to the `greet` queue with properties:
37
+ """yaml
38
+ payload:
39
+ name: world
40
+ properties:
41
+ headers:
42
+ greeting: hello
43
+ """
44
+ Then the consumer receives the reply:
45
+ """yaml
46
+ hello world
47
+ """
48
+
49
+ Scenario: A retried request keeps its headers
50
+ Given function replying `greet` queue:
51
+ """
52
+ (_, properties) => {
53
+ if (properties.headers['x-comq-attempt'] === undefined) throw new Error('once')
54
+
55
+ return properties.headers.greeting
56
+ }
57
+ """
58
+ When the consumer sends the following request to the `greet` queue with properties:
59
+ """yaml
60
+ payload: {}
61
+ properties:
62
+ headers:
63
+ greeting: hello
64
+ """
65
+ Then the consumer receives the reply:
66
+ """yaml
67
+ hello
68
+ """
@@ -0,0 +1,84 @@
1
+ Feature: Virtual shards
2
+
3
+ The URLs of a sharded connection are names, of which several may stand for one broker.
4
+ A connection is made to each broker, and the brokers are followed as the names move.
5
+
6
+ Scenario: Names sharing a broker share a connection
7
+ Given 4 names, half of them standing for each broker
8
+ When I connect to the names
9
+ Then 1 connection is open to each broker
10
+
11
+ Scenario: Requests over the names of one broker
12
+ Given 4 names standing for the first broker
13
+ And an active connection to the names
14
+ And a producer replying `virtual_one` queue
15
+ When the consumer sends 20 requests to the `virtual_one` queue
16
+ Then all replies have been received
17
+ And 1 connection is open to the first broker
18
+ And 0 connections are open to the second broker
19
+
20
+ Scenario: A name moves to another broker
21
+ Given 4 names standing for the first broker
22
+ And an active connection to the names
23
+ And a producer replying `virtual_moved` queue
24
+ And tasks from the `virtual_moved_tasks` queue are being processed
25
+ When half of the names move to the second broker
26
+ Then the second broker has joined
27
+ And 1 connection is open to each broker
28
+ When the consumer sends 50 requests to the `virtual_moved` queue
29
+ Then all replies have been received
30
+ When a task is enqueued to the `virtual_moved_tasks` queue of the second broker by another connection
31
+ Then the task is processed
32
+
33
+ Scenario: A broker no name stands for is drained before it is left
34
+ Given 4 names standing for the first broker
35
+ And a broker lingers for 300ms
36
+ And an active connection to the names
37
+ And tasks from the `virtual_left_behind` queue are being processed
38
+ And a producer replying `virtual_slow` queue in 2000ms
39
+ When the consumer sends 5 requests to the `virtual_slow` queue
40
+ And the names move to the second broker
41
+ Then the second broker has joined
42
+ And the first broker is retired
43
+ When a task is enqueued to the `virtual_left_behind` queue of the first broker by another connection
44
+ Then the task is processed
45
+ When after 1000ms
46
+ Then the first broker is not left
47
+ And all replies have been received
48
+ And the first broker is left
49
+ And 0 connections are open to the first broker
50
+ When the consumer sends 20 requests to the `virtual_slow` queue
51
+ Then all replies have been received
52
+
53
+ Scenario: Names observed while they are moving are not followed
54
+ Given 4 names standing for the first broker
55
+ And an active connection to the names
56
+ When the names keep moving between the brokers for 1500ms
57
+ And after 500ms
58
+ Then no broker has joined or retired
59
+ And 0 connections are open to the second broker
60
+
61
+ Scenario: A broker that is out of reach is followed at once
62
+ Given 4 names standing for the first broker
63
+ And the names settle in 60000ms
64
+ And an active connection to the names
65
+ And a producer replying `virtual_followed` queue
66
+ When the first broker is out of reach
67
+ And the names move to the second broker
68
+ Then the second broker has joined
69
+ When the consumer sends 20 requests to the `virtual_followed` queue
70
+ Then all replies have been received
71
+
72
+ Scenario: A retiring broker that is named again is back
73
+ Given 4 names standing for the first broker
74
+ And a broker lingers for 5000ms
75
+ And an active connection to the names
76
+ And a producer replying `virtual_back` queue
77
+ When the names move to the second broker
78
+ Then the first broker is retired
79
+ When the names move to the first broker
80
+ Then the first broker has joined
81
+ And the second broker is retired
82
+ And the first broker is not left
83
+ When the consumer sends 20 requests to the `virtual_back` queue
84
+ Then all replies have been received
@@ -42,6 +42,15 @@ class Context extends World {
42
42
  /** @type {comq.features.Network[]} */
43
43
  networks = []
44
44
 
45
+ /** @type {Record<string, string>} the address each name of a shard resolves to */
46
+ names = {}
47
+
48
+ /** @type {comq.shards.Timing} */
49
+ resolution = { interval: 50, settle: 300, linger: 600 }
50
+
51
+ /** @type {Record<'join' | 'retire' | 'leave', string[]>} the addresses of the brokers that did */
52
+ brokers = { join: [], retire: [], leave: [] }
53
+
45
54
  async connect (user, password) {
46
55
  const urls = this.#urls(user, password)
47
56
 
@@ -54,6 +63,23 @@ class Context extends World {
54
63
  await this.#connect(urls, assert)
55
64
  }
56
65
 
66
+ /**
67
+ * Connects to the names of the shards, which a broker is found behind at whatever port the
68
+ * names are given: see `names.js`.
69
+ *
70
+ * @param {string[]} hosts
71
+ * @param {number} port
72
+ */
73
+ async connectNames (hosts, port) {
74
+ const urls = hosts.map((host) => PROTOCOL + USER + ':' + PASSWORD + '@' + host + ':' + port)
75
+
76
+ await this.#connect(urls, connect, { ...TOPOLOGY, resolution: this.resolution })
77
+
78
+ for (const event of Object.keys(this.brokers)) {
79
+ this.io.diagnose(event, (_index, address) => this.brokers[event].push(address))
80
+ }
81
+ }
82
+
57
83
  async unplug () {
58
84
  await Promise.all(this.networks.map((network) => network.close()))
59
85
 
@@ -78,14 +104,15 @@ class Context extends World {
78
104
  /**
79
105
  * @param {string[]} urls
80
106
  * @param {comq.Connect} [method]
107
+ * @param {comq.Options} [options]
81
108
  * @return {Promise<void>}
82
109
  */
83
- async #connect (urls, method = connect) {
110
+ async #connect (urls, method = connect, options = TOPOLOGY) {
84
111
  if (this.io !== undefined) await this.disconnect()
85
112
 
86
113
  // the retry delay is a topology setting, so the suite need not wait out a
87
114
  // production one to see the mechanism work
88
- this.io = await method(...urls, TOPOLOGY)
115
+ this.io = await method(...urls, options)
89
116
  this.connected = true
90
117
 
91
118
  for (const event of EVENTS) this.io.diagnose(event, () => (this.events[event] = true))
@@ -14,6 +14,7 @@ After(
14
14
  async function () {
15
15
  delete global.COMQ_TESTING_AMQP_HEARTBEAT
16
16
  delete global.COMQ_TESTING_AMQP_CHANNEL_MAX
17
+ delete global.COMQ_TESTING_LOOKUP
17
18
 
18
19
  await Promise.all(
19
20
  Array.from({ length: BROKERS_AMOUNT }, (_, n) => actions.up(n))
@@ -0,0 +1,235 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const { Given, When, Then, defineParameterType } = require('@cucumber/cucumber')
5
+ const { connect } = require('../../')
6
+ const { timeout, until } = require('../../test/helpers')
7
+ const { BROKERS_AMOUNT, getAddress, USER, PASSWORD } = require('./brokers')
8
+ const { Network } = require('./networks')
9
+
10
+ defineParameterType({
11
+ name: 'broker',
12
+ regexp: /(first|second)/,
13
+ transformer: (value) => ORDINALS.indexOf(value)
14
+ })
15
+
16
+ Given('{number} names standing for the {broker} broker',
17
+ /**
18
+ * @param {number} amount
19
+ * @param {number} n
20
+ * @this {comq.features.Context}
21
+ */
22
+ async function (amount, n) {
23
+ await expose.call(this)
24
+
25
+ for (let i = 0; i < amount; i++) this.names[name(i)] = ADDRESSES[n]
26
+ })
27
+
28
+ Given('{number} names, half of them standing for each broker',
29
+ /**
30
+ * @param {number} amount
31
+ * @this {comq.features.Context}
32
+ */
33
+ async function (amount) {
34
+ await expose.call(this)
35
+
36
+ for (let i = 0; i < amount; i++) this.names[name(i)] = ADDRESSES[i < amount / 2 ? 0 : 1]
37
+ })
38
+
39
+ Given('the names settle in {number}ms',
40
+ /**
41
+ * @param {number} settle
42
+ * @this {comq.features.Context}
43
+ */
44
+ function (settle) {
45
+ this.resolution.settle = settle
46
+ })
47
+
48
+ Given('a broker lingers for {number}ms',
49
+ /**
50
+ * @param {number} linger
51
+ * @this {comq.features.Context}
52
+ */
53
+ function (linger) {
54
+ this.resolution.linger = linger
55
+ })
56
+
57
+ Given('an active connection to the names', connectNames)
58
+
59
+ When('I connect to the names', connectNames)
60
+
61
+ When('half of the names move to the {broker} broker',
62
+ /**
63
+ * @param {number} n
64
+ * @this {comq.features.Context}
65
+ */
66
+ function (n) {
67
+ const names = Object.keys(this.names)
68
+
69
+ for (const name of names.slice(names.length / 2)) this.names[name] = ADDRESSES[n]
70
+ })
71
+
72
+ When('the names move to the {broker} broker', move)
73
+
74
+ When('the names keep moving between the brokers for {number}ms',
75
+ /**
76
+ * @param {number} duration
77
+ * @this {comq.features.Context}
78
+ */
79
+ async function (duration) {
80
+ const flips = Math.floor(duration / FLIP_MS)
81
+
82
+ // an odd number of moves would leave the names moved
83
+ for (let i = 0; i < flips + flips % 2; i++) {
84
+ move.call(this, (i + 1) % 2)
85
+
86
+ await timeout(FLIP_MS)
87
+ }
88
+ })
89
+
90
+ When('the {broker} broker is out of reach',
91
+ /**
92
+ * @param {number} n
93
+ * @this {comq.features.Context}
94
+ */
95
+ function (n) {
96
+ this.networks[n].cut()
97
+ })
98
+
99
+ When('a task is enqueued to the {token} queue of the {broker} broker by another connection',
100
+ /**
101
+ * @param {string} queue
102
+ * @param {number} n
103
+ * @this {comq.features.Context}
104
+ */
105
+ async function (queue, n) {
106
+ const io = await connect('amqp://' + USER + ':' + PASSWORD + '@' + getAddress(n))
107
+
108
+ this.enqueued = 'left behind'
109
+
110
+ await io.enqueue(queue, this.enqueued)
111
+ await io.close()
112
+ })
113
+
114
+ Then('the task is processed',
115
+ /**
116
+ * @this {comq.features.Context}
117
+ */
118
+ async function () {
119
+ const processed = await until(() => this.processed === this.enqueued)
120
+
121
+ assert.equal(processed, true, 'the task was not processed')
122
+ })
123
+
124
+ Then('the {broker} broker has joined', heard('join'))
125
+
126
+ Then('the {broker} broker is retired', heard('retire'))
127
+
128
+ Then('the {broker} broker is left', heard('leave'))
129
+
130
+ Then('the {broker} broker is not left',
131
+ /**
132
+ * @param {number} n
133
+ * @this {comq.features.Context}
134
+ */
135
+ function (n) {
136
+ assert.equal(this.brokers.leave.includes(ADDRESSES[n]), false, 'the broker is left')
137
+ })
138
+
139
+ Then('no broker has joined or retired',
140
+ /**
141
+ * @this {comq.features.Context}
142
+ */
143
+ function () {
144
+ assert.deepEqual(this.brokers, { join: [], retire: [], leave: [] })
145
+ })
146
+
147
+ Then('{number} connection(s) is/are open to the {broker} broker', open)
148
+
149
+ Then('{number} connection is open to each broker',
150
+ /**
151
+ * @param {number} amount
152
+ * @this {comq.features.Context}
153
+ */
154
+ async function (amount) {
155
+ for (let n = 0; n < BROKERS_AMOUNT; n++) await open.call(this, amount, n)
156
+ })
157
+
158
+ /**
159
+ * Each broker is given an address of its own, at one port: a name resolves to an address and
160
+ * says nothing of a port, and the brokers of the suite differ in nothing but theirs.
161
+ *
162
+ * @this {comq.features.Context}
163
+ */
164
+ async function expose () {
165
+ if (this.networks.length > 0) return
166
+
167
+ for (let n = 0; n < BROKERS_AMOUNT; n++) {
168
+ const network = new Network(n, ADDRESSES[n], PORT)
169
+
170
+ await network.open()
171
+
172
+ this.networks[n] = network
173
+ }
174
+
175
+ global.COMQ_TESTING_LOOKUP = async (host) => ({ addresses: [this.names[host]], ttl: 0 })
176
+ }
177
+
178
+ /**
179
+ * @this {comq.features.Context}
180
+ */
181
+ async function connectNames () {
182
+ await this.connectNames(Object.keys(this.names), PORT)
183
+ }
184
+
185
+ /**
186
+ * @param {number} n
187
+ * @this {comq.features.Context}
188
+ */
189
+ function move (n) {
190
+ for (const name of Object.keys(this.names)) this.names[name] = ADDRESSES[n]
191
+ }
192
+
193
+ /**
194
+ * @param {number} amount
195
+ * @param {number} n
196
+ * @this {comq.features.Context}
197
+ */
198
+ async function open (amount, n) {
199
+ await until(() => this.networks[n].connections === amount)
200
+
201
+ assert.equal(this.networks[n].connections, amount)
202
+ }
203
+
204
+ /**
205
+ * @param {'join' | 'retire' | 'leave'} event
206
+ */
207
+ function heard (event) {
208
+ /**
209
+ * @param {number} n
210
+ * @this {comq.features.Context}
211
+ */
212
+ return async function (n) {
213
+ const happened = await until(() => this.brokers[event].includes(ADDRESSES[n]), 10_000)
214
+
215
+ assert.equal(happened, true, `the broker did not ${event}`)
216
+ }
217
+ }
218
+
219
+ /**
220
+ * @param {number | string} i
221
+ * @return {string}
222
+ */
223
+ function name (i) {
224
+ return `shard-${i}.test`
225
+ }
226
+
227
+ const ORDINALS = ['first', 'second']
228
+
229
+ /** Loopback addresses, which every one of 127.0.0.0/8 is. */
230
+ const ADDRESSES = ['127.0.0.2', '127.0.0.3']
231
+
232
+ const PORT = 56790
233
+
234
+ /** Sooner than the names settle. */
235
+ const FLIP_MS = 100
@@ -25,24 +25,42 @@ class Network {
25
25
 
26
26
  #refusing = false
27
27
 
28
+ /** @type {string} */
29
+ #listenHost
30
+
31
+ /** @type {number} */
32
+ #listenPort
33
+
28
34
  /**
29
35
  * @param {number} [n] broker index
36
+ * @param {string} [host] the address the broker is found at through this network
37
+ * @param {number} [port] any that is free unless given
30
38
  */
31
- constructor (n = 0) {
32
- const [host, port] = getAddress(n).split(':')
39
+ constructor (n = 0, host = '127.0.0.1', port = 0) {
40
+ const [brokerHost, brokerPort] = getAddress(n).split(':')
33
41
 
34
- this.#host = host
35
- this.#port = Number(port)
42
+ this.#host = brokerHost
43
+ this.#port = Number(brokerPort)
44
+ this.#listenHost = host
45
+ this.#listenPort = port
36
46
  }
37
47
 
38
48
  get address () {
39
49
  return 'localhost:' + this.#server.address().port
40
50
  }
41
51
 
52
+ /** How many connections are open through this network. */
53
+ get connections () {
54
+ return this.#tunnels.size
55
+ }
56
+
42
57
  async open () {
43
58
  this.#server = net.createServer(this.#tunnel)
44
59
 
45
- await new Promise((resolve) => this.#server.listen(0, '127.0.0.1', resolve))
60
+ await new Promise((resolve, reject) => {
61
+ this.#server.once('error', reject)
62
+ this.#server.listen(this.#listenPort, this.#listenHost, resolve)
63
+ })
46
64
  }
47
65
 
48
66
  /**
@@ -144,6 +144,18 @@ When('the consumer sends the following request to the {token} queue:',
144
144
  await send.call(this, queue, payload)
145
145
  })
146
146
 
147
+ When('the consumer sends the following request to the {token} queue with properties:',
148
+ /**
149
+ * @param {string} queue
150
+ * @param {string} yaml
151
+ * @this {comq.features.Context}
152
+ */
153
+ async function (queue, yaml) {
154
+ const { payload, properties } = parse(yaml)
155
+
156
+ await send.call(this, queue, payload, properties)
157
+ })
158
+
147
159
  When('the consumer sends a request to the {token} queue',
148
160
  /**
149
161
  * @param {string} queue
@@ -567,10 +579,10 @@ function sendMany (amount, queue) {
567
579
  }
568
580
  }
569
581
 
570
- async function send (queue, payload) {
582
+ async function send (queue, payload, properties) {
571
583
  if (this.expected) await this.expected
572
584
 
573
- this.reply = this.io.request(queue, payload)
585
+ this.reply = this.io.request(queue, payload, properties)
574
586
  }
575
587
 
576
588
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.25.1",
3
+ "version": "0.27.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",