comq 0.24.0 → 0.25.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 (48) hide show
  1. package/.husky/commit-msg +5 -0
  2. package/benchmarks/run.js +66 -4
  3. package/benchmarks/server.js +14 -0
  4. package/commitlint.config.js +6 -0
  5. package/features/heartbeat.feature +16 -0
  6. package/features/rpc.call.recovery.feature +2 -2
  7. package/features/rpc.shards.retransmission.feature +46 -0
  8. package/features/rpc.streams.feature +28 -0
  9. package/features/shards.feature +1 -1
  10. package/features/silence.feature +3 -2
  11. package/features/steps/.types/context.d.ts +6 -1
  12. package/features/steps/brokers.js +1 -1
  13. package/features/steps/connection.js +23 -0
  14. package/features/steps/context.js +1 -2
  15. package/features/steps/hooks.js +0 -1
  16. package/features/steps/network.js +11 -2
  17. package/features/steps/networks.js +31 -2
  18. package/features/steps/retransmission.js +165 -0
  19. package/features/steps/rpc.js +31 -7
  20. package/package.json +31 -9
  21. package/readme.md +15 -4
  22. package/source/.io/ReplyPipe.js +130 -11
  23. package/source/.io/ReplyStream.js +41 -2
  24. package/source/.io/const.js +6 -0
  25. package/source/attributes/track.test.js +1 -1
  26. package/source/channel.js +5 -3
  27. package/source/connection.js +11 -88
  28. package/source/io.js +91 -16
  29. package/source/promex.js +47 -0
  30. package/source/promex.test.js +79 -0
  31. package/source/retry.js +42 -0
  32. package/source/retry.test.js +120 -0
  33. package/source/shards/channel.js +74 -13
  34. package/test/.types/jest.d.ts +22 -0
  35. package/test/ReplyPipe.test.js +71 -2
  36. package/test/ReplyStream.test.js +33 -0
  37. package/test/connection.mock.js +3 -2
  38. package/test/connection.test.js +16 -78
  39. package/test/globals.js +143 -0
  40. package/test/io.call.test.js +5 -3
  41. package/test/io.close.test.js +1 -1
  42. package/test/io.reply.test.js +5 -3
  43. package/test/io.request.test.js +264 -2
  44. package/test/memory.test.js +0 -2
  45. package/test/shards/channel.test.js +169 -3
  46. package/test/shards/connection.test.js +2 -3
  47. package/types/channel.d.ts +13 -4
  48. package/types/io.d.ts +9 -0
@@ -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
@@ -297,7 +291,37 @@ Then('the consumer receives the stream:',
297
291
 
298
292
  for await (const reply of this.stream) replies.push(reply)
299
293
 
300
- assert.equal(values.length, replies.length, `Stream length mismatch: expected ${values.length}, received ${replies.length}`)
294
+ assert.deepStrictEqual(replies, values)
295
+ })
296
+
297
+ Then('the consumer receives a stream of {number} values in order',
298
+ /**
299
+ * @param {number} count
300
+ * @this {comq.features.Context}
301
+ */
302
+ async function (count) {
303
+ const replies = []
304
+
305
+ for await (const reply of this.stream) replies.push(reply)
306
+
307
+ assert.deepStrictEqual(replies, Array.from({ length: count }, (_, i) => ({ i })))
308
+ })
309
+
310
+ Then('the consumer receives the stream of buffers:',
311
+ /**
312
+ * @param {string} yaml what each buffer holds, as text
313
+ * @this {comq.features.Context}
314
+ */
315
+ async function (yaml) {
316
+ const values = parse(yaml)
317
+ const replies = []
318
+
319
+ for await (const reply of this.stream) {
320
+ assert.ok(Buffer.isBuffer(reply), `Not a buffer: ${JSON.stringify(reply)}`)
321
+ replies.push(reply.toString())
322
+ }
323
+
324
+ assert.deepStrictEqual(replies, values)
301
325
  })
302
326
 
303
327
  Then('the consumer receives the stream',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -27,19 +27,22 @@
27
27
  "feat": "scripts/feat.js"
28
28
  },
29
29
  "devDependencies": {
30
+ "@commitlint/cli": "^21.2.3",
31
+ "@commitlint/config-conventional": "^21.2.3",
30
32
  "@cucumber/cucumber": "^13.2.1",
33
+ "@jest/fake-timers": "^30.5.2",
31
34
  "@semantic-release/commit-analyzer": "^13.0.1",
32
35
  "@semantic-release/git": "^11.0.1",
33
36
  "@semantic-release/github": "^12.0.2",
34
37
  "@semantic-release/npm": "^13.1.2",
35
38
  "@semantic-release/release-notes-generator": "^14.1.0",
36
39
  "@testcontainers/rabbitmq": "^12.1.0",
37
- "@types/jest": "^30.0.0",
38
40
  "conventional-changelog-conventionalcommits": "^8.0.0",
41
+ "expect": "^30.5.2",
39
42
  "fast-glob": "^3.3.3",
40
43
  "fuzzysort": "4.0.2",
41
44
  "husky": "^9.1.7",
42
- "jest": "^30.4.2",
45
+ "jest-mock": "^30.5.2",
43
46
  "js-yaml": "^5.4.1",
44
47
  "randomstring": "^1.3.1",
45
48
  "semantic-release": "^25.0.9",
@@ -48,24 +51,43 @@
48
51
  "testcontainers": "^12.0.4"
49
52
  },
50
53
  "dependencies": {
51
- "@toa.io/amqplib": "^0.1.0",
52
- "promex": "^0.1.1",
53
- "reretry": "^0.1.0"
54
+ "@toa.io/amqplib": "^0.1.0"
54
55
  },
55
56
  "engines": {
56
57
  "node": ">=24.0.0"
57
58
  },
58
59
  "scripts": {
59
60
  "prepare": "husky",
60
- "test": "standard && NODE_OPTIONS=--experimental-vm-modules jest",
61
+ "test": "standard && node --test --experimental-test-module-mocks --import ./test/globals.js 'test/**/*.test.js' 'source/**/*.test.js'",
61
62
  "features": "cucumber-js --fail-fast -t 'not @manual'",
62
63
  "features:fast": "cucumber-js --fail-fast -t 'not @manual and not @heavy'",
63
64
  "benchmark": "node benchmarks/run.js",
64
65
  "lint": "standard --fix --verbose | snazzy"
65
66
  },
66
67
  "standard": {
67
- "env": [
68
- "jest"
68
+ "globals": [
69
+ "afterAll",
70
+ "afterEach",
71
+ "beforeAll",
72
+ "beforeEach",
73
+ "describe",
74
+ "expect",
75
+ "it",
76
+ "jest",
77
+ "test"
69
78
  ]
79
+ },
80
+ "overrides": {
81
+ "rimraf": "^6.1.3",
82
+ "archiver-utils": {
83
+ "glob": "^13.0.6"
84
+ }
85
+ },
86
+ "allowScripts": {
87
+ "@parcel/watcher": false,
88
+ "cpu-features": false,
89
+ "protobufjs": false,
90
+ "ssh2": false,
91
+ "unrs-resolver": false
70
92
  }
71
93
  }
package/readme.md CHANGED
@@ -354,6 +354,12 @@ for await (const number of stream)
354
354
  console.log(number)
355
355
  ```
356
356
 
357
+ Values a producer yields together travel together: what the iterator yields before the event loop
358
+ turns is sent as one message, up to 128 values, and a value that has to be waited for is sent as
359
+ it comes. Buffers travel in messages of their own. The stream returned by `IO.request` yields each
360
+ value on its own either way. Values in an encoding other than `application/json` travel one to a
361
+ message.
362
+
357
363
  ### Stream topology
358
364
 
359
365
  <picture>
@@ -457,8 +463,9 @@ Reconnection attempts will be made indefinitely, with intervals increasing up to
457
463
  Unless the URL sets one, a 15 second heartbeat is requested, so that a connection that is gone
458
464
  without a word, such as after a machine wakes from sleep, is noticed within a minute instead of
459
465
  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.
466
+ A connection that has received nothing for two heartbeats is closed and restored regardless of what
467
+ the broker and the operating system have reported, since neither is guaranteed to report anything
468
+ at all. A connection that is merely idle receives the broker's heartbeats, and is kept.
462
469
  Requesting `heartbeat=0` disables both.
463
470
  If the broker rejects the connection, for example, due to access being denied, an exception will be thrown.
464
471
  Once reconnected, the topology will be recovered, and any unanswered requests and unconfirmed events will be
@@ -480,6 +487,10 @@ connection is re-established.
480
487
 
481
488
  Incoming messages are consumed from all shards.
482
489
 
490
+ A Reply goes back through the shard its Request arrived on, while that shard is reachable. When a
491
+ shard is lost, the Requests sent through it that are still unanswered are re-sent through the
492
+ others, since their Replies may be lost with it; a Request sent through another shard is not.
493
+
483
494
  `async connect(...shards: string[]): IO`
484
495
 
485
496
  Returns an instance of `IO` once connections to the shards are established.
@@ -795,8 +806,8 @@ Subscribe to one of the diagnostic events:
795
806
  an argument.
796
807
  - `drain`: back pressure is removed from a channel. Channel type is passed.
797
808
  - `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.
809
+ - `lost`: a shard has lost its connection, hence the requests sent through it that are awaiting
810
+ their replies are re-sent. Channel type is passed.
800
811
  - `recover`: channel's topology is recovered. Channel type is passed.
801
812
  - `discard`: message is [parked](#parked-messages), having run out of attempts. Channel type,
802
813
  raw [amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_consume)
@@ -1,8 +1,8 @@
1
1
  'use strict'
2
2
 
3
3
  const { EventEmitter } = require('node:events')
4
- const { Promex } = require('promex')
5
- const { control, FLOW_HEADER, HEARTBEAT_INTERVAL } = require('./const')
4
+ const { Promex } = require('../promex')
5
+ const { control, batch, FLOW_HEADER, HEARTBEAT_INTERVAL } = require('./const')
6
6
 
7
7
  /** @typedef {(message: any, properties?: comq.amqp.options.Publish) => Promise<void>} Reply */
8
8
 
@@ -36,6 +36,9 @@ class ReplyPipe extends EventEmitter {
36
36
  /** @type {Reply} */
37
37
  #reply
38
38
 
39
+ /** Whether the encoding can carry a list of values. */
40
+ #lists
41
+
39
42
  /**
40
43
  * @param {comq.amqp.Message} request
41
44
  * @param {stream.Readable} stream
@@ -54,8 +57,12 @@ class ReplyPipe extends EventEmitter {
54
57
  this.#reply = reply
55
58
  this.#replyTo = replyTo
56
59
 
60
+ this.#lists = request.properties.contentType === LISTS
61
+
57
62
  this.#properties = {
58
63
  chunk: { correlationId, ...CHUNK },
64
+ batch: { correlationId, ...CHUNK, type: batch.values },
65
+ buffers: { correlationId, ...CHUNK, type: batch.buffers },
59
66
  control: { correlationId, replyTo: feedback.queue, ...CONTROL },
60
67
  ok: { correlationId, replyTo: feedback.queue, ...CONTROL, headers: FLOW }
61
68
  }
@@ -99,21 +106,90 @@ class ReplyPipe extends EventEmitter {
99
106
  */
100
107
  async #pump () {
101
108
  try {
102
- for await (const chunk of this.#stream) {
103
- if (this.#gate !== null) await this.#gate
104
- if (this.#closed) break
109
+ await this.#pumpBatches()
110
+ } catch {
111
+ // the source has been destroyed, by this pipe or by whoever made it
112
+ }
113
+
114
+ this.#close()
115
+ }
116
+
117
+ /**
118
+ * Values go out together while they arrive together: what the source yields before the event
119
+ * loop turns joins the message, and the first value that has to be waited for leaves with the
120
+ * ones before it rather than waiting for company. A cursor yielding the documents of a batch it
121
+ * holds sends them as one message; a source yielding one value at a time sends each as it
122
+ * comes. Values and buffers travel in messages of their own, and values in an encoding that
123
+ * holds no list travel one to a message.
124
+ */
125
+ async #pumpBatches () {
126
+ const iterator = this.#stream[Symbol.asyncIterator]()
127
+
128
+ let pending = next(iterator)
129
+
130
+ while (true) {
131
+ const first = await pending
105
132
 
106
- await this.#transmit(chunk, this.#properties.chunk)
133
+ if (first.done === true) return
107
134
 
108
- if (this.#closed) break
135
+ const chunks = [first.value]
136
+ const buffers = Buffer.isBuffer(first.value)
137
+ const turn = immediate()
109
138
 
110
- this.#heartbeat()
139
+ let done = false
140
+
141
+ pending = next(iterator)
142
+
143
+ while (chunks.length < (buffers || this.#lists ? MAX_BATCH : 1)) {
144
+ const arrived = await Promise.race([pending, turn])
145
+
146
+ if (arrived === TURN) break
147
+
148
+ if (arrived.done === true) {
149
+ done = true
150
+
151
+ break
152
+ }
153
+
154
+ // of the other kind, it starts the next message
155
+ if (Buffer.isBuffer(arrived.value) !== buffers) {
156
+ pending = Promise.resolve(arrived)
157
+
158
+ break
159
+ }
160
+
161
+ chunks.push(arrived.value)
162
+ pending = next(iterator)
111
163
  }
112
- } catch {
113
- // the source has been destroyed, by this pipe or by whoever made it
164
+
165
+ if (!(await this.#send(...this.#message(chunks, buffers)))) {
166
+ await iterator.return?.()
167
+
168
+ return
169
+ }
170
+
171
+ if (done) return
114
172
  }
173
+ }
115
174
 
116
- this.#close()
175
+ #message (chunks, buffers) {
176
+ if (chunks.length === 1) return [chunks[0], this.#properties.chunk]
177
+ else if (buffers) return [prefixed(chunks), this.#properties.buffers]
178
+ else return [chunks, this.#properties.batch]
179
+ }
180
+
181
+ /** Answers whether the pipe goes on. */
182
+ async #send (data, properties) {
183
+ if (this.#gate !== null) await this.#gate
184
+ if (this.#closed) return false
185
+
186
+ await this.#transmit(data, properties)
187
+
188
+ if (this.#closed) return false
189
+
190
+ this.#heartbeat()
191
+
192
+ return true
117
193
  }
118
194
 
119
195
  async #transmit (data, properties) {
@@ -206,6 +282,49 @@ class ReplyPipe extends EventEmitter {
206
282
  /** @type {comq.amqp.options.Publish} */
207
283
  const CHUNK = { mandatory: true }
208
284
 
285
+ /** the most values a message carries */
286
+ const MAX_BATCH = 128
287
+
288
+ /** the encoding a batch of values is a list in */
289
+ const LISTS = 'application/json'
290
+
291
+ const TURN = Symbol('turn')
292
+
293
+ /**
294
+ * The next value, asked for ahead of when it is awaited. A source that fails while a message is
295
+ * being sent is found failed by the loop that awaits it next, or by nobody where the pipe has
296
+ * stopped — and a rejection nobody awaits is an unhandled one.
297
+ */
298
+ function next (iterator) {
299
+ const pending = iterator.next()
300
+
301
+ pending.catch(noop)
302
+
303
+ return pending
304
+ }
305
+
306
+ /** Settles once the event loop has turned, with what says so. */
307
+ function immediate () {
308
+ return new Promise((resolve) => setImmediate(resolve, TURN))
309
+ }
310
+
311
+ /**
312
+ * @param {Buffer[]} buffers
313
+ * @returns {Buffer}
314
+ */
315
+ function prefixed (buffers) {
316
+ const parts = []
317
+
318
+ for (const buffer of buffers) {
319
+ const length = Buffer.allocUnsafe(4)
320
+
321
+ length.writeUInt32BE(buffer.length)
322
+ parts.push(length, buffer)
323
+ }
324
+
325
+ return Buffer.concat(parts)
326
+ }
327
+
209
328
  /** @type {comq.amqp.options.Publish} */
210
329
  const CONTROL = { type: 'control', mandatory: true }
211
330
 
@@ -1,8 +1,8 @@
1
1
  'use strict'
2
2
 
3
3
  const { Readable } = require('node:stream')
4
- const { Promex } = require('promex')
5
- const { IDLE_INTERVAL, FLOW_HEADER, control } = require('./const')
4
+ const { Promex } = require('../promex')
5
+ const { IDLE_INTERVAL, FLOW_HEADER, batch, control } = require('./const')
6
6
  const { Interrupted } = require('../interrupted')
7
7
 
8
8
  class ReplyStream extends Readable {
@@ -133,10 +133,29 @@ class ReplyStream extends Readable {
133
133
 
134
134
  if (properties.type === 'control')
135
135
  this._control(payload, properties)
136
+ else if (properties.type === batch.values)
137
+ this._values(payload)
138
+ else if (properties.type === batch.buffers)
139
+ this._values(split(payload))
136
140
  else if (!this.push(payload))
137
141
  this._throttle()
138
142
  }
139
143
 
144
+ /**
145
+ * The values of one message, pushed whatever the consumer's room: they have arrived, and a
146
+ * pause asked for now holds back the messages after them.
147
+ *
148
+ * @param {unknown[]} values
149
+ * @private
150
+ */
151
+ _values (values) {
152
+ let room = true
153
+
154
+ for (const value of values) room = this.push(value) && room
155
+
156
+ if (!room) this._throttle()
157
+ }
158
+
140
159
  /**
141
160
  * Values arriving out of order are held until the gap is filled. The hold is
142
161
  * bounded by their number and, roughly, by their size: the size is that of
@@ -213,6 +232,26 @@ class ReplyStream extends Readable {
213
232
  }
214
233
  }
215
234
 
235
+ /**
236
+ * The buffers of a message that carries each prefixed with its length.
237
+ *
238
+ * @param {Buffer} buffer
239
+ * @returns {Buffer[]}
240
+ */
241
+ function split (buffer) {
242
+ const buffers = []
243
+
244
+ for (let offset = 0; offset < buffer.length;) {
245
+ const length = buffer.readUInt32BE(offset)
246
+ const start = offset + 4
247
+
248
+ buffers.push(buffer.subarray(start, start + length))
249
+ offset = start + length
250
+ }
251
+
252
+ return buffers
253
+ }
254
+
216
255
  const MAX_BUFFER_SIZE = 1000
217
256
 
218
257
  /** @type {number} */
@@ -14,5 +14,11 @@ exports.control = {
14
14
  */
15
15
  exports.FLOW_HEADER = 'x-flow'
16
16
 
17
+ /** The type of a message carrying values: a list of them, or buffers prefixed with their length. */
18
+ exports.batch = {
19
+ values: 'batch',
20
+ buffers: 'buffers'
21
+ }
22
+
17
23
  exports.HEARTBEAT_INTERVAL = 5_000
18
24
  exports.IDLE_INTERVAL = 12_000
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { generate } = require('randomstring')
4
- const { Promex } = require('promex')
4
+ const { Promex } = require('../promex')
5
5
 
6
6
  const { track } = require('./track')
7
7
 
package/source/channel.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
- const { Promex } = require('promex')
4
- const { retry } = require('reretry')
3
+ const { Promex } = require('./promex')
4
+ const { retry } = require('./retry')
5
5
  const { failsafe, lazy, recall } = require('./attributes')
6
6
  const { verdictOf, PARK } = require('./verdicts')
7
7
  const emitter = require('./emitter')
@@ -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 () {
@@ -2,8 +2,8 @@
2
2
 
3
3
  const { setTimeout: delay } = require('node:timers/promises')
4
4
  const amqp = require('@toa.io/amqplib')
5
- const { Promex } = require('promex')
6
- const { retry } = require('reretry')
5
+ const { Promex } = require('./promex')
6
+ const { retry } = require('./retry')
7
7
 
8
8
  const { failsafe } = require('./attributes')
9
9
  const presets = require('./topology')
@@ -35,18 +35,9 @@ class Connection {
35
35
  /** @type {boolean} */
36
36
  #closed = false
37
37
 
38
- /** @type {NodeJS.Timeout | null} */
39
- #heartbeatTimer = null
40
-
41
38
  /** @type {comq.topology.Overrides} */
42
39
  #overrides
43
40
 
44
- /** @type {import('node:net').Socket | null} */
45
- #heartbeatSocket = null
46
-
47
- /** @type {(() => void) | null} */
48
- #heartbeatReset = null
49
-
50
41
  #diagnostics = emitter.create()
51
42
 
52
43
  /**
@@ -147,7 +138,6 @@ class Connection {
147
138
  connection.on('close', (error) => this.#close(connection, error))
148
139
  this.#connection = connection
149
140
 
150
- this.#armWatchdog(connection)
151
141
  this.#diagnostics.emit('open')
152
142
 
153
143
  try {
@@ -177,11 +167,14 @@ class Connection {
177
167
  #close = (connection, error) => {
178
168
  if (this.#connection !== connection) return
179
169
 
180
- this.#disarmWatchdog()
181
170
  this.#diagnostics.emit('close', error)
182
171
  connection.removeAllListeners()
183
172
  this.#connection = undefined
184
173
 
174
+ // amqplib only ends the socket of a connection it has given up on, and a peer that has gone
175
+ // silent never ends its side, which would leave the socket to the kernel's keepalive
176
+ if (error !== undefined) connection.connection?.stream?.destroy()
177
+
185
178
  if (error !== undefined && !this.#closed) {
186
179
  this.#reopen().catch((exception) => this.#diagnostics.emit('error', exception))
187
180
  }
@@ -224,10 +217,7 @@ class Connection {
224
217
  * @param {comq.amqp.Connection} connection
225
218
  */
226
219
  #drop (connection) {
227
- if (this.#connection === connection) {
228
- this.#disarmWatchdog()
229
- this.#connection = undefined
230
- }
220
+ if (this.#connection === connection) this.#connection = undefined
231
221
 
232
222
  connection.removeAllListeners()
233
223
 
@@ -294,46 +284,6 @@ class Connection {
294
284
  }
295
285
  }
296
286
 
297
- /**
298
- * @param {comq.amqp.Connection} connection
299
- * @param {number} [timeoutMs]
300
- */
301
- #armWatchdog (connection, timeoutMs = tolerance(connection)) {
302
- this.#disarmWatchdog()
303
-
304
- const socket = connection.connection?.stream
305
-
306
- if (socket === undefined || timeoutMs === undefined) return
307
-
308
- const reset = () => {
309
- clearTimeout(this.#heartbeatTimer)
310
- // destroying a socket without an error tells amqplib nothing: it only
311
- // listens for 'error' and 'end', so a bare destroy() leaves the connection
312
- // silently dead — no 'close' event, no recovery, every pending operation
313
- // hanging forever
314
- this.#heartbeatTimer = setTimeout(() => socket.destroy(silence()), timeoutMs)
315
- this.#heartbeatTimer.unref()
316
- }
317
-
318
- this.#heartbeatSocket = socket
319
- this.#heartbeatReset = reset
320
-
321
- reset()
322
- socket.on('data', reset)
323
- }
324
-
325
- #disarmWatchdog () {
326
- clearTimeout(this.#heartbeatTimer)
327
- this.#heartbeatTimer = null
328
-
329
- // otherwise a byte arriving on a replaced socket rearms the watchdog of a
330
- // connection that is already gone, leaving the current one unguarded
331
- this.#heartbeatSocket?.off('data', this.#heartbeatReset)
332
-
333
- this.#heartbeatSocket = null
334
- this.#heartbeatReset = null
335
- }
336
-
337
287
  #transient (exception) {
338
288
  if (this.#running) return true
339
289
  if (TRANSIENT_CODES.has(exception.code)) return true
@@ -348,6 +298,10 @@ class Connection {
348
298
  * to suggest one that leaves a connection lost for minutes before anything
349
299
  * notices, and RabbitMQ suggests 60 by default.
350
300
  *
301
+ * It is amqplib that tells a connection gone silent: one that has received nothing
302
+ * for two heartbeats is closed with an error, whatever the broker and the operating
303
+ * system have reported, which is what restores it.
304
+ *
351
305
  * @type {number}
352
306
  */
353
307
  const HEARTBEAT_S = 15
@@ -355,15 +309,6 @@ const HEARTBEAT_S = 15
355
309
  /** @type {number} */
356
310
  const KEEPALIVE_MS = 10_000
357
311
 
358
- /** How many heartbeats a connection may miss before it is destroyed. */
359
- const MISSED_HEARTBEATS = 3
360
-
361
- /** @type {number} */
362
- const WATCHDOG_MIN_MS = 15_000
363
-
364
- /** The watchdog interval used when the negotiated heartbeat is unknown. */
365
- const WATCHDOG_MS = 60_000
366
-
367
312
  /** @type {number} */
368
313
  const CONNECT_MS = 30_000
369
314
 
@@ -422,28 +367,6 @@ function heartbeaten (url) {
422
367
  return url + (url.includes('?') ? '&' : '?') + 'heartbeat=' + HEARTBEAT_S
423
368
  }
424
369
 
425
- /**
426
- * The watchdog measures silence, so it cannot be shorter than the interval at
427
- * which a healthy broker is expected to say something, which is a heartbeat
428
- * frame every half a heartbeat. A connection that has agreed to no heartbeats
429
- * says nothing at all while it is idle, leaving nothing to measure.
430
- *
431
- * @param {comq.amqp.Connection} connection
432
- * @return {number | undefined}
433
- */
434
- function tolerance (connection) {
435
- const override = global.COMQ_TESTING_WATCHDOG_INTERVAL
436
-
437
- if (override !== undefined) return override
438
-
439
- const heartbeat = connection.connection?.heartbeat
440
-
441
- if (heartbeat === undefined) return WATCHDOG_MS
442
- if (heartbeat === 0) return undefined
443
-
444
- return Math.max(heartbeat * MISSED_HEARTBEATS * 1000, WATCHDOG_MIN_MS)
445
- }
446
-
447
370
  /**
448
371
  * @return {Error}
449
372
  */