comq 0.24.0 → 0.24.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.
@@ -0,0 +1,16 @@
1
+ @heavy
2
+ Feature: Idle connection tolerance
3
+
4
+ A connection that says nothing but heartbeats is as healthy as a busy one, and is kept for as
5
+ long as the broker goes on answering them, however long that is.
6
+
7
+ Background:
8
+ Given 1s AMQP heartbeat
9
+
10
+ Scenario: Keeping an idle connection
11
+ Given an active connection to the broker
12
+ Then the connection is not lost for 20 seconds
13
+
14
+ Scenario: Keeping an idle sharded connection
15
+ Given an active sharded connection
16
+ Then the connection is not lost for 20 seconds
@@ -17,14 +17,14 @@ Feature: Calls under a key across failures
17
17
  it the key. The holder finds the key taken until the broker lets that connection go, and
18
18
  claims it then.
19
19
 
20
- Given watchdog interval is set to 2000ms with 60s AMQP heartbeat
20
+ Given 1s AMQP heartbeat
21
21
  And a network that can go silent
22
22
  And an active connection to the broker
23
23
  And a holder answering `echo` under the `a` key:
24
24
  """
25
25
  (payload) => payload
26
26
  """
27
- When the network goes silent
27
+ When the network goes silent, with the broker still hearing heartbeats
28
28
  Then the connection is lost within 10 seconds
29
29
  And the holder finds its key taken while the broker holds the silent connection
30
30
  When the silent connection is let go
@@ -0,0 +1,46 @@
1
+ @heavy
2
+ Feature: Re-sending the requests a lost shard held
3
+
4
+ A Request is re-sent when the shard it went through is lost, since its Reply may be lost with it.
5
+ One that went through a shard that stays is answered through that shard, and is never re-sent
6
+ because another one was lost: a Request is not assumed to be idempotent.
7
+
8
+ Each scenario first takes one broker away from the consumer, so that the Requests it sends go
9
+ through the other one, and gives the broker back before losing one of them again.
10
+
11
+ Background:
12
+ Given 1s AMQP heartbeat
13
+ And a network that can go silent
14
+ And an active sharded connection
15
+ And a producer replying `echo` queue
16
+ # the consumer's channels exist on both brokers before one of them is taken away
17
+ And the consumer sends a request to the `echo` queue
18
+ And the consumer receives the reply
19
+ And a producer on another connection counting the requests to the `numbered` queue, answering when told
20
+
21
+ Scenario: Requests through the broker that stays are not re-sent when the other one is lost
22
+ Given the consumer's connection to broker 1 is cut
23
+ When the consumer sends 10 numbered requests to the `numbered` queue
24
+ And the consumer's connection to broker 1 is restored
25
+ And the consumer's connection to broker 1 is cut
26
+ And the producer answers
27
+ Then every numbered request is answered within 10 seconds
28
+ And every numbered request has been executed once
29
+
30
+ Scenario: Replies to the requests through the broker that stays are not lost with the other one
31
+ Given the consumer's connection to broker 1 is cut
32
+ When the consumer sends 10 numbered requests to the `numbered` queue
33
+ And the consumer's connection to broker 1 is restored
34
+ And the consumer's network to broker 1 goes silent
35
+ And the producer answers
36
+ Then every numbered request is answered within 15 seconds
37
+ And every numbered request has been executed once
38
+
39
+ Scenario: Requests through the broker that is lost are re-sent and answered
40
+ Given the consumer's connection to broker 0 is cut
41
+ When the consumer sends 10 numbered requests to the `numbered` queue
42
+ And the consumer's connection to broker 0 is restored
43
+ And the consumer's network to broker 1 goes silent
44
+ And the producer answers
45
+ Then every numbered request is answered within 15 seconds
46
+ And every numbered request has been executed twice
@@ -49,7 +49,7 @@ Feature: Sharded Connection
49
49
  Then the broker is up
50
50
 
51
51
  Scenario: Shard stops responding while requests are sent
52
- Given watchdog interval is set to 3000ms with 1s AMQP heartbeat
52
+ Given 1s AMQP heartbeat
53
53
  And an active sharded connection
54
54
  And a producer replying `stalled` queue
55
55
  When the consumer sends 10 requests to the `stalled` queue as a broker freezes
@@ -2,10 +2,11 @@ Feature: Silent Connection Tolerance
2
2
 
3
3
  A connection that has gone silent is what a machine that woke from sleep is
4
4
  left with: the socket is open, neither the broker nor the operating system
5
- reports anything, and nothing but the watchdog tells it from an idle connection.
5
+ reports anything, and nothing but the missing heartbeats tell it from an idle
6
+ connection.
6
7
 
7
8
  Background:
8
- Given watchdog interval is set to 2000ms with 60s AMQP heartbeat
9
+ Given 1s AMQP heartbeat
9
10
  And a network that can go silent
10
11
 
11
12
  Scenario: Restoring a connection that has gone silent
@@ -49,7 +49,11 @@ declare namespace comq.features {
49
49
  readonly address: string
50
50
 
51
51
  open(): Promise<void>
52
- silence(): void
52
+ silence(heard?: boolean): void
53
+ refuse(): void
54
+ admit(): void
55
+ release(): void
56
+ cut(): void
53
57
  close(): Promise<void>
54
58
  }
55
59
 
@@ -57,6 +61,7 @@ declare namespace comq.features {
57
61
  client: Socket
58
62
  upstream: Socket
59
63
  silent: boolean
64
+ heartbeat?: NodeJS.Timeout
60
65
  }
61
66
 
62
67
  }
@@ -139,7 +139,7 @@ const actions = {
139
139
  await docker('docker', ['kill', `comq-rmq-${n}`])
140
140
  },
141
141
  // a frozen broker keeps its connections open while answering nothing, which is
142
- // what a publisher cannot tell from a working one until the watchdog fires
142
+ // what a publisher cannot tell from a working one until its heartbeats go missing
143
143
  frozen: async (n = 0) => {
144
144
  await docker('docker', ['pause', `comq-rmq-${n}`])
145
145
  }
@@ -5,6 +5,16 @@ const { timeout } = require('../../test/helpers')
5
5
 
6
6
  const { Given, When, Then } = require('@cucumber/cucumber')
7
7
 
8
+ Given('{number}s AMQP heartbeat',
9
+ /**
10
+ * A connection that has received nothing for two heartbeats is taken for lost.
11
+ *
12
+ * @param {number} heartbeat
13
+ */
14
+ function (heartbeat) {
15
+ global.COMQ_TESTING_AMQP_HEARTBEAT = heartbeat
16
+ })
17
+
8
18
  Given('an active connection to the broker',
9
19
  /**
10
20
  * @this {comq.features.Context}
@@ -176,6 +186,19 @@ Then('the connection is lost within {number} second(s)',
176
186
  await emitted.call(this, 'close', seconds, 'connection was not lost')
177
187
  })
178
188
 
189
+ Then('the connection is not lost for {number} seconds',
190
+ /**
191
+ * @param {number} seconds
192
+ * @this {comq.features.Context}
193
+ */
194
+ async function (seconds) {
195
+ delete this.events.close
196
+
197
+ await timeout(seconds * 1000)
198
+
199
+ assert.equal(this.events.close, undefined, 'The connection was lost')
200
+ })
201
+
179
202
  Then('publishing is {paused-event} within {number} second(s)',
180
203
  /**
181
204
  * @param {'paused' | 'resumed'} key
@@ -114,8 +114,7 @@ class Context extends World {
114
114
  const url = PROTOCOL + user + ':' + password + '@' + address
115
115
  const query = []
116
116
 
117
- // the watchdog measures silence, so it may only be shortened along with the
118
- // interval at which a healthy broker is expected to say something
117
+ // a connection gone silent is told by its missing heartbeats, so a short one tells it soon
119
118
  if (global.COMQ_TESTING_AMQP_HEARTBEAT !== undefined) {
120
119
  query.push('heartbeat=' + global.COMQ_TESTING_AMQP_HEARTBEAT)
121
120
  }
@@ -12,7 +12,6 @@ After(
12
12
  * @this {comq.features.Context}
13
13
  */
14
14
  async function () {
15
- delete global.COMQ_TESTING_WATCHDOG_INTERVAL
16
15
  delete global.COMQ_TESTING_AMQP_HEARTBEAT
17
16
  delete global.COMQ_TESTING_AMQP_CHANNEL_MAX
18
17
 
@@ -26,6 +26,14 @@ When('the network goes silent',
26
26
  silence.call(this)
27
27
  })
28
28
 
29
+ When('the network goes silent, with the broker still hearing heartbeats',
30
+ /**
31
+ * @this {comq.features.Context}
32
+ */
33
+ function () {
34
+ silence.call(this, true)
35
+ })
36
+
29
37
  When('the network goes silent and refuses new connections',
30
38
  /**
31
39
  * @this {comq.features.Context}
@@ -66,11 +74,12 @@ When('the consumer sends a request to the {token} queue as the network goes sile
66
74
  })
67
75
 
68
76
  /**
77
+ * @param {boolean} [heard]
69
78
  * @this {comq.features.Context}
70
79
  */
71
- function silence () {
80
+ function silence (heard) {
72
81
  // only a connection lost from now on counts as lost
73
82
  delete this.events.close
74
83
 
75
- for (const network of this.networks) network.silence()
84
+ for (const network of this.networks) network.silence(heard)
76
85
  }
@@ -48,9 +48,19 @@ class Network {
48
48
  /**
49
49
  * Only the tunnels that are already open go silent: a connection established
50
50
  * afterwards is forwarded as usual, just as it is once a machine is awake.
51
+ *
52
+ * The broker notices a silent connection by its heartbeat as soon as the client does. One
53
+ * that is to be held by the broker for as long as the scenario needs is kept heard from:
54
+ * heartbeats are sent to the broker in the client's stead, until the network lets it go.
55
+ *
56
+ * @param {boolean} [heard] whether the broker goes on hearing heartbeats
51
57
  */
52
- silence () {
53
- for (const tunnel of this.#tunnels) tunnel.silent = true
58
+ silence (heard = false) {
59
+ for (const tunnel of this.#tunnels) {
60
+ tunnel.silent = true
61
+
62
+ if (heard) tunnel.heartbeat ??= setInterval(() => tunnel.upstream.write(HEARTBEAT), HEARTBEAT_MS)
63
+ }
54
64
  }
55
65
 
56
66
  /**
@@ -65,6 +75,17 @@ class Network {
65
75
  this.#refusing = false
66
76
  }
67
77
 
78
+ /**
79
+ * Takes the broker away from whoever is connected through this network: the tunnels that are
80
+ * open are closed at both ends, and a connection attempted from now on is refused until the
81
+ * network admits it.
82
+ */
83
+ cut () {
84
+ this.refuse()
85
+
86
+ for (const tunnel of this.#tunnels) this.#collapse(tunnel)
87
+ }
88
+
68
89
  /**
69
90
  * Closes the tunnels that went silent, at both ends, so the broker learns that the
70
91
  * connection it was still holding is gone — the moment its heartbeat would otherwise tell it.
@@ -117,10 +138,18 @@ class Network {
117
138
  * @param {comq.features.Tunnel} tunnel
118
139
  */
119
140
  #collapse (tunnel) {
141
+ clearInterval(tunnel.heartbeat)
142
+
120
143
  this.#tunnels.delete(tunnel)
121
144
  tunnel.client.destroy()
122
145
  tunnel.upstream.destroy()
123
146
  }
124
147
  }
125
148
 
149
+ /** An AMQP heartbeat frame: type 8, channel 0, no payload, and the frame end. */
150
+ const HEARTBEAT = Buffer.from([8, 0, 0, 0, 0, 0, 0, 0xCE])
151
+
152
+ /** Often enough for the shortest heartbeat a scenario asks for. */
153
+ const HEARTBEAT_MS = 250
154
+
126
155
  exports.Network = Network
@@ -0,0 +1,165 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const { Promex } = require('promex')
5
+ const { Given, When, Then, After } = require('@cucumber/cucumber')
6
+
7
+ const { connect } = require('../../')
8
+ const { getAddress, USER, PASSWORD, BROKERS_AMOUNT } = require('./brokers')
9
+ const { timeout, until } = require('../../test/helpers')
10
+
11
+ Given('a producer on another connection counting the requests to the {token} queue, answering when told',
12
+ /**
13
+ * Connected to every broker directly, so that nothing done to the consumer's network reaches it.
14
+ * It takes every Request as it comes, counts how many times it has been handed each of them, and
15
+ * holds the replies back until it is told to answer: whatever the consumer re-sends by then is
16
+ * counted, however quickly the first attempt would have been answered.
17
+ *
18
+ * @param {string} queue
19
+ * @this {comq.features.Context}
20
+ */
21
+ async function (queue) {
22
+ const urls = Array.from({ length: BROKERS_AMOUNT }, (_, n) =>
23
+ `amqp://${USER}:${PASSWORD}@${getAddress(n)}`)
24
+
25
+ this.producer = await connect(...urls)
26
+ this.executions = new Map()
27
+ this.answering = new Promex()
28
+
29
+ await this.producer.reply(queue, async ({ id }) => {
30
+ this.executions.set(id, (this.executions.get(id) ?? 0) + 1)
31
+
32
+ await this.answering
33
+
34
+ return id
35
+ })
36
+ })
37
+
38
+ When('the producer answers',
39
+ /**
40
+ * @this {comq.features.Context}
41
+ */
42
+ function () {
43
+ this.answering.resolve()
44
+ })
45
+
46
+ When('the consumer sends {number} numbered requests to the {token} queue',
47
+ /**
48
+ * @param {number} amount
49
+ * @param {string} queue
50
+ * @this {comq.features.Context}
51
+ */
52
+ function (amount, queue) {
53
+ this.numbered = Array.from({ length: amount }, (_, id) => {
54
+ const reply = this.io.request(queue, { id })
55
+
56
+ // asserted on by a later step, and an unhandled rejection would end the run before it
57
+ reply.catch(() => undefined)
58
+
59
+ return reply
60
+ })
61
+ })
62
+
63
+ When('the consumer\'s connection to broker {number} is cut',
64
+ /**
65
+ * The broker is taken away from the consumer alone: the connection closes at both ends, and the
66
+ * consumer is refused until it is restored. Returns once the consumer has noticed, so that what
67
+ * it sends next goes to the other broker.
68
+ *
69
+ * @param {number} broker
70
+ * @this {comq.features.Context}
71
+ */
72
+ async function (broker) {
73
+ let lost = false
74
+
75
+ this.io.diagnose('close', (_error, index) => { if (index === broker) lost = true })
76
+ this.networks[broker].cut()
77
+
78
+ assert.equal(await until(() => lost, 10_000), true,
79
+ `The consumer's connection to broker ${broker} was not lost`)
80
+ })
81
+
82
+ When('the consumer\'s connection to broker {number} is restored',
83
+ /**
84
+ * Returns once the consumer's channels to that broker have recovered, which is when the
85
+ * requests it may have lost with it are re-sent.
86
+ *
87
+ * @param {number} broker
88
+ * @this {comq.features.Context}
89
+ */
90
+ async function (broker) {
91
+ const recovered = new Set()
92
+
93
+ this.io.diagnose('recover', (type, index) => { if (index === broker) recovered.add(type) })
94
+ this.networks[broker].admit()
95
+
96
+ const restored = () => recovered.has('request') && recovered.has('reply')
97
+
98
+ assert.equal(await until(restored, 20_000), true,
99
+ `The consumer's channels to broker ${broker} have not recovered`)
100
+ })
101
+
102
+ When('the consumer\'s network to broker {number} goes silent',
103
+ /**
104
+ * @param {number} broker
105
+ * @this {comq.features.Context}
106
+ */
107
+ function (broker) {
108
+ this.networks[broker].silence()
109
+ })
110
+
111
+ Then('every numbered request is answered within {number} seconds',
112
+ /**
113
+ * @param {number} seconds
114
+ * @this {comq.features.Context}
115
+ */
116
+ async function (seconds) {
117
+ const answered = new Array(this.numbered.length).fill(false)
118
+
119
+ this.numbered.forEach((reply, id) => reply.then(() => { answered[id] = true }, () => undefined))
120
+
121
+ await until(() => answered.every(Boolean), seconds * 1000)
122
+
123
+ const unanswered = answered.flatMap((yes, id) => yes ? [] : [id])
124
+
125
+ assert.deepEqual(unanswered, [], `Requests ${unanswered.join(', ')} are not answered`)
126
+
127
+ for (const [id, reply] of this.numbered.entries()) assert.equal(await reply, id, 'Reply mismatch')
128
+ })
129
+
130
+ Then('every numbered request has been executed {word}',
131
+ /**
132
+ * A re-sent Request is taken as soon as it arrives, so a moment is given for any copy still on
133
+ * its way to the producer to be counted.
134
+ *
135
+ * @param {'once' | 'twice'} times
136
+ * @this {comq.features.Context}
137
+ */
138
+ async function (times) {
139
+ const expected = TIMES[times]
140
+
141
+ await timeout(SETTLE_MS)
142
+
143
+ const executed = this.numbered.map((_, id) => this.executions.get(id) ?? 0)
144
+ const wrong = executed.flatMap((count, id) => count === expected ? [] : [`${id}: ${count}`])
145
+
146
+ assert.deepEqual(wrong, [], `Requests executed other than ${times} (id: times): ${wrong.join(', ')}`)
147
+ })
148
+
149
+ After(
150
+ /**
151
+ * @this {comq.features.Context}
152
+ */
153
+ async function () {
154
+ this.answering?.resolve()
155
+ await this.producer?.close()
156
+
157
+ this.producer = undefined
158
+ this.executions = undefined
159
+ this.answering = undefined
160
+ this.numbered = undefined
161
+ })
162
+
163
+ const TIMES = { once: 1, twice: 2 }
164
+
165
+ const SETTLE_MS = 1000
@@ -88,12 +88,6 @@ Given('idle timeout is set to {number}ms',
88
88
  global.COMQ_TESTING_IDLE_INTERVAL = value
89
89
  })
90
90
 
91
- Given('watchdog interval is set to {number}ms with {number}s AMQP heartbeat',
92
- function (watchdog, heartbeat) {
93
- global.COMQ_TESTING_WATCHDOG_INTERVAL = watchdog
94
- global.COMQ_TESTING_AMQP_HEARTBEAT = heartbeat
95
- })
96
-
97
91
  Given('a producer replying {token} queue',
98
92
  /**
99
93
  * @param {string} queue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -67,5 +67,19 @@
67
67
  "env": [
68
68
  "jest"
69
69
  ]
70
+ },
71
+ "overrides": {
72
+ "test-exclude": "^8.0.0",
73
+ "rimraf": "^6.1.3",
74
+ "archiver-utils": {
75
+ "glob": "^13.0.6"
76
+ }
77
+ },
78
+ "allowScripts": {
79
+ "@parcel/watcher": false,
80
+ "cpu-features": false,
81
+ "protobufjs": false,
82
+ "ssh2": false,
83
+ "unrs-resolver": false
70
84
  }
71
85
  }
package/readme.md CHANGED
@@ -457,8 +457,9 @@ Reconnection attempts will be made indefinitely, with intervals increasing up to
457
457
  Unless the URL sets one, a 15 second heartbeat is requested, so that a connection that is gone
458
458
  without a word, such as after a machine wakes from sleep, is noticed within a minute instead of
459
459
  being left to whatever the broker suggests.
460
- A connection that stays silent for three heartbeats is destroyed regardless of what the broker and
461
- the operating system have reported, since neither is guaranteed to report anything at all.
460
+ A connection that has received nothing for two heartbeats is closed and restored regardless of what
461
+ the broker and the operating system have reported, since neither is guaranteed to report anything
462
+ at all. A connection that is merely idle receives the broker's heartbeats, and is kept.
462
463
  Requesting `heartbeat=0` disables both.
463
464
  If the broker rejects the connection, for example, due to access being denied, an exception will be thrown.
464
465
  Once reconnected, the topology will be recovered, and any unanswered requests and unconfirmed events will be
@@ -480,6 +481,10 @@ connection is re-established.
480
481
 
481
482
  Incoming messages are consumed from all shards.
482
483
 
484
+ A Reply goes back through the shard its Request arrived on, while that shard is reachable. When a
485
+ shard is lost, the Requests sent through it that are still unanswered are re-sent through the
486
+ others, since their Replies may be lost with it; a Request sent through another shard is not.
487
+
483
488
  `async connect(...shards: string[]): IO`
484
489
 
485
490
  Returns an instance of `IO` once connections to the shards are established.
@@ -795,8 +800,8 @@ Subscribe to one of the diagnostic events:
795
800
  an argument.
796
801
  - `drain`: back pressure is removed from a channel. Channel type is passed.
797
802
  - `remove`: channel is removed from the [pool](#sharded-connection), having failed to publish.
798
- - `lost`: a shard has lost its connection, hence the requests awaiting their replies on it are
799
- re-sent. Channel type is passed.
803
+ - `lost`: a shard has lost its connection, hence the requests sent through it that are awaiting
804
+ their replies are re-sent. Channel type is passed.
800
805
  - `recover`: channel's topology is recovered. Channel type is passed.
801
806
  - `discard`: message is [parked](#parked-messages), having run out of attempts. Channel type,
802
807
  raw [amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_consume)
package/source/channel.js CHANGED
@@ -70,7 +70,9 @@ class Channel {
70
70
  this.#failfast = index !== undefined
71
71
  this.#release = release
72
72
 
73
- if (this.#failfast) failsafe.disable(this.send, this.publish)
73
+ // a shard's publish fails rather than waits for its connection to come back: the pool
74
+ // fails it over to another shard, and whoever published it is told where it went
75
+ if (this.#failfast) failsafe.disable(this.send, this.publish, this.route)
74
76
  }
75
77
 
76
78
  async create () {