comq 0.25.1 → 0.26.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 (44) hide show
  1. package/docs/headers.md +16 -1
  2. package/features/properties.feature +39 -0
  3. package/features/shards.virtual.feature +89 -0
  4. package/features/steps/context.js +29 -2
  5. package/features/steps/hooks.js +1 -0
  6. package/features/steps/names.js +245 -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 +102 -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/expand.js +57 -0
  19. package/source/shards/index.js +2 -0
  20. package/source/shards/resolve.js +134 -0
  21. package/source/shards/watch.js +257 -0
  22. package/source/singleton.js +3 -2
  23. package/test/amqplib.mock.js +1 -0
  24. package/test/assert.test.js +7 -2
  25. package/test/channel.quiet.test.js +301 -0
  26. package/test/connect.test.js +49 -8
  27. package/test/connection.mock.js +3 -0
  28. package/test/connection.test.js +15 -0
  29. package/test/io.call.test.js +1 -1
  30. package/test/io.diagnostics.test.js +1 -1
  31. package/test/io.reply.test.js +12 -1
  32. package/test/io.request.test.js +18 -0
  33. package/test/io.shards.test.js +85 -0
  34. package/test/shards/channel.test.js +411 -0
  35. package/test/shards/connection.test.js +372 -17
  36. package/test/shards/expand.test.js +58 -0
  37. package/test/shards/resolve.test.js +73 -0
  38. package/test/shards/watch.test.js +264 -0
  39. package/test/singleton.test.js +1 -1
  40. package/types/channel.d.ts +23 -0
  41. package/types/connection.d.ts +13 -2
  42. package/types/diagnostic.d.ts +2 -1
  43. package/types/io.d.ts +17 -1
  44. 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,89 @@
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: Names given as a range
12
+ Given 4 names, half of them standing for each broker
13
+ When I connect to the range of the names
14
+ Then 1 connection is open to each broker
15
+
16
+ Scenario: Requests over the names of one broker
17
+ Given 4 names standing for the first broker
18
+ And an active connection to the names
19
+ And a producer replying `virtual_one` queue
20
+ When the consumer sends 20 requests to the `virtual_one` queue
21
+ Then all replies have been received
22
+ And 1 connection is open to the first broker
23
+ And 0 connections are open to the second broker
24
+
25
+ Scenario: A name moves to another broker
26
+ Given 4 names standing for the first broker
27
+ And an active connection to the names
28
+ And a producer replying `virtual_moved` queue
29
+ And tasks from the `virtual_moved_tasks` queue are being processed
30
+ When half of the names move to the second broker
31
+ Then the second broker has joined
32
+ And 1 connection is open to each broker
33
+ When the consumer sends 50 requests to the `virtual_moved` queue
34
+ Then all replies have been received
35
+ When a task is enqueued to the `virtual_moved_tasks` queue of the second broker by another connection
36
+ Then the task is processed
37
+
38
+ Scenario: A broker no name stands for is drained before it is left
39
+ Given 4 names standing for the first broker
40
+ And a broker lingers for 300ms
41
+ And an active connection to the names
42
+ And tasks from the `virtual_left_behind` queue are being processed
43
+ And a producer replying `virtual_slow` queue in 2000ms
44
+ When the consumer sends 5 requests to the `virtual_slow` queue
45
+ And the names move to the second broker
46
+ Then the second broker has joined
47
+ And the first broker is retired
48
+ When a task is enqueued to the `virtual_left_behind` queue of the first broker by another connection
49
+ Then the task is processed
50
+ When after 1000ms
51
+ Then the first broker is not left
52
+ And all replies have been received
53
+ And the first broker is left
54
+ And 0 connections are open to the first broker
55
+ When the consumer sends 20 requests to the `virtual_slow` queue
56
+ Then all replies have been received
57
+
58
+ Scenario: Names observed while they are moving are not followed
59
+ Given 4 names standing for the first broker
60
+ And an active connection to the names
61
+ When the names keep moving between the brokers for 1500ms
62
+ And after 500ms
63
+ Then no broker has joined or retired
64
+ And 0 connections are open to the second broker
65
+
66
+ Scenario: A broker that is out of reach is followed at once
67
+ Given 4 names standing for the first broker
68
+ And the names settle in 60000ms
69
+ And an active connection to the names
70
+ And a producer replying `virtual_followed` queue
71
+ When the first broker is out of reach
72
+ And the names move to the second broker
73
+ Then the second broker has joined
74
+ When the consumer sends 20 requests to the `virtual_followed` queue
75
+ Then all replies have been received
76
+
77
+ Scenario: A retiring broker that is named again is back
78
+ Given 4 names standing for the first broker
79
+ And a broker lingers for 5000ms
80
+ And an active connection to the names
81
+ And a producer replying `virtual_back` queue
82
+ When the names move to the second broker
83
+ Then the first broker is retired
84
+ When the names move to the first broker
85
+ Then the first broker has joined
86
+ And the second broker is retired
87
+ And the first broker is not left
88
+ When the consumer sends 20 requests to the `virtual_back` queue
89
+ 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,245 @@
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('I connect to the range of the names',
62
+ /**
63
+ * @this {comq.features.Context}
64
+ */
65
+ async function () {
66
+ const amount = Object.keys(this.names).length
67
+
68
+ await this.connectNames([name(`[0..${amount}]`)], PORT)
69
+ })
70
+
71
+ When('half of the names move to the {broker} broker',
72
+ /**
73
+ * @param {number} n
74
+ * @this {comq.features.Context}
75
+ */
76
+ function (n) {
77
+ const names = Object.keys(this.names)
78
+
79
+ for (const name of names.slice(names.length / 2)) this.names[name] = ADDRESSES[n]
80
+ })
81
+
82
+ When('the names move to the {broker} broker', move)
83
+
84
+ When('the names keep moving between the brokers for {number}ms',
85
+ /**
86
+ * @param {number} duration
87
+ * @this {comq.features.Context}
88
+ */
89
+ async function (duration) {
90
+ const flips = Math.floor(duration / FLIP_MS)
91
+
92
+ // an odd number of moves would leave the names moved
93
+ for (let i = 0; i < flips + flips % 2; i++) {
94
+ move.call(this, (i + 1) % 2)
95
+
96
+ await timeout(FLIP_MS)
97
+ }
98
+ })
99
+
100
+ When('the {broker} broker is out of reach',
101
+ /**
102
+ * @param {number} n
103
+ * @this {comq.features.Context}
104
+ */
105
+ function (n) {
106
+ this.networks[n].cut()
107
+ })
108
+
109
+ When('a task is enqueued to the {token} queue of the {broker} broker by another connection',
110
+ /**
111
+ * @param {string} queue
112
+ * @param {number} n
113
+ * @this {comq.features.Context}
114
+ */
115
+ async function (queue, n) {
116
+ const io = await connect('amqp://' + USER + ':' + PASSWORD + '@' + getAddress(n))
117
+
118
+ this.enqueued = 'left behind'
119
+
120
+ await io.enqueue(queue, this.enqueued)
121
+ await io.close()
122
+ })
123
+
124
+ Then('the task is processed',
125
+ /**
126
+ * @this {comq.features.Context}
127
+ */
128
+ async function () {
129
+ const processed = await until(() => this.processed === this.enqueued)
130
+
131
+ assert.equal(processed, true, 'the task was not processed')
132
+ })
133
+
134
+ Then('the {broker} broker has joined', heard('join'))
135
+
136
+ Then('the {broker} broker is retired', heard('retire'))
137
+
138
+ Then('the {broker} broker is left', heard('leave'))
139
+
140
+ Then('the {broker} broker is not left',
141
+ /**
142
+ * @param {number} n
143
+ * @this {comq.features.Context}
144
+ */
145
+ function (n) {
146
+ assert.equal(this.brokers.leave.includes(ADDRESSES[n]), false, 'the broker is left')
147
+ })
148
+
149
+ Then('no broker has joined or retired',
150
+ /**
151
+ * @this {comq.features.Context}
152
+ */
153
+ function () {
154
+ assert.deepEqual(this.brokers, { join: [], retire: [], leave: [] })
155
+ })
156
+
157
+ Then('{number} connection(s) is/are open to the {broker} broker', open)
158
+
159
+ Then('{number} connection is open to each broker',
160
+ /**
161
+ * @param {number} amount
162
+ * @this {comq.features.Context}
163
+ */
164
+ async function (amount) {
165
+ for (let n = 0; n < BROKERS_AMOUNT; n++) await open.call(this, amount, n)
166
+ })
167
+
168
+ /**
169
+ * Each broker is given an address of its own, at one port: a name resolves to an address and
170
+ * says nothing of a port, and the brokers of the suite differ in nothing but theirs.
171
+ *
172
+ * @this {comq.features.Context}
173
+ */
174
+ async function expose () {
175
+ if (this.networks.length > 0) return
176
+
177
+ for (let n = 0; n < BROKERS_AMOUNT; n++) {
178
+ const network = new Network(n, ADDRESSES[n], PORT)
179
+
180
+ await network.open()
181
+
182
+ this.networks[n] = network
183
+ }
184
+
185
+ global.COMQ_TESTING_LOOKUP = async (host) => ({ addresses: [this.names[host]], ttl: 0 })
186
+ }
187
+
188
+ /**
189
+ * @this {comq.features.Context}
190
+ */
191
+ async function connectNames () {
192
+ await this.connectNames(Object.keys(this.names), PORT)
193
+ }
194
+
195
+ /**
196
+ * @param {number} n
197
+ * @this {comq.features.Context}
198
+ */
199
+ function move (n) {
200
+ for (const name of Object.keys(this.names)) this.names[name] = ADDRESSES[n]
201
+ }
202
+
203
+ /**
204
+ * @param {number} amount
205
+ * @param {number} n
206
+ * @this {comq.features.Context}
207
+ */
208
+ async function open (amount, n) {
209
+ await until(() => this.networks[n].connections === amount)
210
+
211
+ assert.equal(this.networks[n].connections, amount)
212
+ }
213
+
214
+ /**
215
+ * @param {'join' | 'retire' | 'leave'} event
216
+ */
217
+ function heard (event) {
218
+ /**
219
+ * @param {number} n
220
+ * @this {comq.features.Context}
221
+ */
222
+ return async function (n) {
223
+ const happened = await until(() => this.brokers[event].includes(ADDRESSES[n]), 10_000)
224
+
225
+ assert.equal(happened, true, `the broker did not ${event}`)
226
+ }
227
+ }
228
+
229
+ /**
230
+ * @param {number | string} i
231
+ * @return {string}
232
+ */
233
+ function name (i) {
234
+ return `shard-${i}.test`
235
+ }
236
+
237
+ const ORDINALS = ['first', 'second']
238
+
239
+ /** Loopback addresses, which every one of 127.0.0.0/8 is. */
240
+ const ADDRESSES = ['127.0.0.2', '127.0.0.3']
241
+
242
+ const PORT = 56790
243
+
244
+ /** Sooner than the names settle. */
245
+ 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.26.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",