comq 0.15.8 → 0.16.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.
package/.releaserc CHANGED
@@ -3,8 +3,24 @@
3
3
  "release"
4
4
  ],
5
5
  "plugins": [
6
- "@semantic-release/commit-analyzer",
7
- "@semantic-release/release-notes-generator",
6
+ [
7
+ "@semantic-release/commit-analyzer",
8
+ {
9
+ "preset": "conventionalcommits",
10
+ "releaseRules": [
11
+ {
12
+ "breaking": true,
13
+ "release": "minor"
14
+ }
15
+ ]
16
+ }
17
+ ],
18
+ [
19
+ "@semantic-release/release-notes-generator",
20
+ {
21
+ "preset": "conventionalcommits"
22
+ }
23
+ ],
8
24
  [
9
25
  "@semantic-release/npm"
10
26
  ],
@@ -0,0 +1,14 @@
1
+ Feature: Channel exhaustion
2
+
3
+ A connection allocates channels from a range the broker negotiates, and once the range
4
+ is spent there is nothing a reconnection can do about it. The refusal is reported and
5
+ passed on rather than retried, so that whoever asked for a channel is told instead of
6
+ waiting for a recovery that is not coming.
7
+
8
+ Scenario: Reporting a connection with no channel left to allocate
9
+ Given the AMQP channel limit is set to 2
10
+ And an active connection to the broker
11
+ And a producer replying `echo` queue
12
+ When emitting an event to the `exhaustion` exchange is attempted
13
+ Then the exception is thrown: "No channels left to allocate"
14
+ And the `exhausted` event is emitted
@@ -0,0 +1,45 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const { randomBytes } = require('node:crypto')
5
+
6
+ const { Given, When, Then } = require('@cucumber/cucumber')
7
+
8
+ Given('the AMQP channel limit is set to {number}',
9
+ /**
10
+ * @param {number} limit
11
+ */
12
+ function (limit) {
13
+ global.COMQ_TESTING_AMQP_CHANNEL_MAX = limit
14
+ })
15
+
16
+ When('emitting an event to the {token} exchange is attempted',
17
+ /**
18
+ * @param {string} exchange
19
+ * @this {comq.features.Context}
20
+ */
21
+ async function (exchange) {
22
+ this.exception = undefined
23
+
24
+ await this.io.emit(exchange, randomBytes(8))
25
+ .catch((exception) => { this.exception = exception })
26
+ })
27
+
28
+ Then('the exception is thrown: {string}',
29
+ /**
30
+ * @param {string} message
31
+ * @this {comq.features.Context}
32
+ */
33
+ function (message) {
34
+ assert.notEqual(this.exception, undefined, 'no exception was thrown')
35
+ assert.equal(this.exception.message, message)
36
+ })
37
+
38
+ Then('the {token} event is emitted',
39
+ /**
40
+ * @param {comq.diagnostics.Event} event
41
+ * @this {comq.features.Context}
42
+ */
43
+ function (event) {
44
+ assert.equal(this.events[event], true, `the '${event}' event was not emitted`)
45
+ })
@@ -100,17 +100,27 @@ class Context extends World {
100
100
  #url (i, user, password) {
101
101
  const address = this.networks[i]?.address ?? getAddress(i)
102
102
  const url = PROTOCOL + user + ':' + password + '@' + address
103
- const heartbeat = global.COMQ_TESTING_AMQP_HEARTBEAT
103
+ const query = []
104
104
 
105
105
  // the watchdog measures silence, so it may only be shortened along with the
106
106
  // interval at which a healthy broker is expected to say something
107
- return heartbeat === undefined ? url : url + '?heartbeat=' + heartbeat
107
+ if (global.COMQ_TESTING_AMQP_HEARTBEAT !== undefined) {
108
+ query.push('heartbeat=' + global.COMQ_TESTING_AMQP_HEARTBEAT)
109
+ }
110
+
111
+ // a broker negotiates the channel limit down to what the client asks for,
112
+ // which is how exhaustion is reached without opening two thousand channels
113
+ if (global.COMQ_TESTING_AMQP_CHANNEL_MAX !== undefined) {
114
+ query.push('channelMax=' + global.COMQ_TESTING_AMQP_CHANNEL_MAX)
115
+ }
116
+
117
+ return query.length === 0 ? url : url + '?' + query.join('&')
108
118
  }
109
119
  }
110
120
 
111
121
  const PROTOCOL = 'amqp://'
112
122
 
113
123
  /** @type {comq.diagnostics.Event[]} */
114
- const EVENTS = ['open', 'close', 'flow', 'discard', 'pause', 'resume']
124
+ const EVENTS = ['open', 'close', 'flow', 'discard', 'pause', 'resume', 'exhausted']
115
125
 
116
126
  exports.Context = Context
@@ -14,6 +14,7 @@ After(
14
14
  async function () {
15
15
  delete global.COMQ_TESTING_WATCHDOG_INTERVAL
16
16
  delete global.COMQ_TESTING_AMQP_HEARTBEAT
17
+ delete global.COMQ_TESTING_AMQP_CHANNEL_MAX
17
18
 
18
19
  await Promise.all(
19
20
  Array.from({ length: BROKERS_AMOUNT }, (_, n) => actions.up(n))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.15.8",
3
+ "version": "0.16.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -33,15 +33,17 @@
33
33
  "@semantic-release/github": "^12.0.2",
34
34
  "@semantic-release/npm": "^13.1.2",
35
35
  "@semantic-release/release-notes-generator": "^14.1.0",
36
- "@testcontainers/rabbitmq": "^12.0.4",
36
+ "@testcontainers/rabbitmq": "^12.1.0",
37
37
  "@types/amqplib": "^0.10.8",
38
38
  "@types/jest": "^30.0.0",
39
+ "conventional-changelog-conventionalcommits": "^8.0.0",
39
40
  "fast-glob": "^3.3.3",
40
- "fuzzysort": "3.1.0",
41
+ "fuzzysort": "4.0.2",
41
42
  "husky": "^9.1.7",
42
43
  "jest": "^30.4.2",
43
- "js-yaml": "^5.2.3",
44
+ "js-yaml": "^5.4.1",
44
45
  "randomstring": "^1.3.1",
46
+ "semantic-release": "^25.0.9",
45
47
  "snazzy": "^9.0.0",
46
48
  "standard": "^17.1.2",
47
49
  "testcontainers": "^12.0.4"
package/readme.md CHANGED
@@ -498,6 +498,9 @@ Subscribe to one of the diagnostic events:
498
498
  - `error`: an attempt to restore the connection has failed. The exception is passed as an argument.
499
499
  Attempts continue until one succeeds.
500
500
  - `reconnect`: an attempt to restore the connection has started. Followed by `open` or `error`.
501
+ - `exhausted`: the connection has no channel left to allocate. The negotiated channel limit is
502
+ passed as an argument. Reconnecting does not free a channel, so the operation that asked for
503
+ one is rejected rather than retried.
501
504
  - `flow`: back pressure is applied to a channel. [Channel type](./types/topology.d.ts) is passed as
502
505
  an argument.
503
506
  - `drain`: back pressure is removed from a channel. Channel type is passed.
@@ -533,6 +536,7 @@ established, there is no way to capture the initial `open` event.
533
536
  io.diagnose('flow', (type) => console.log(`Back pressure was applied to the ${type} channel`))
534
537
 
535
538
  io.diagnose('reconnect', (shard) => console.log('AMQP reconnecting', { shard }))
539
+ io.diagnose('exhausted', (limit, shard) => console.log('AMQP channels exhausted', { limit, shard }))
536
540
  io.diagnose('open', (shard) => console.log('AMQP connection established', { shard }))
537
541
  io.diagnose('close', (error, shard) => console.log('AMQP connection closed', { message: error?.message, shard }))
538
542
  io.diagnose('error', (error, shard) => console.log('AMQP connection failed', { message: error.message, shard }))
package/source/channel.js CHANGED
@@ -42,6 +42,8 @@ class Channel {
42
42
 
43
43
  #diagnostics = emitter.create()
44
44
 
45
+ #closed = false
46
+
45
47
  /**
46
48
  * @param {comq.amqp.Connection} connection
47
49
  * @param {comq.Topology} topology
@@ -126,6 +128,21 @@ class Channel {
126
128
  }
127
129
  }
128
130
 
131
+ /**
132
+ * Gives the channel back. A connection has a limited number of them, and one whose
133
+ * IO is done with it would otherwise be held until the whole connection goes.
134
+ */
135
+ async close () {
136
+ if (this.#closed) return
137
+
138
+ this.#closed = true
139
+
140
+ await this.seal()
141
+
142
+ // a channel that went down with its connection is the outcome this asks for
143
+ await this.#channel?.close().catch(noop)
144
+ }
145
+
129
146
  async seal () {
130
147
  this.#sealed = true
131
148
 
@@ -142,6 +159,10 @@ class Channel {
142
159
  this.#diagnostics.off(event, listener)
143
160
  }
144
161
 
162
+ get closed () {
163
+ return this.#closed
164
+ }
165
+
145
166
  async recover (connection) {
146
167
  this.#connection = connection
147
168
 
@@ -99,6 +99,7 @@ class Connection {
99
99
  const topology = presets[type]
100
100
  const channel = await channels.create(this.#connection, topology, index)
101
101
 
102
+ this.#channels = this.#channels.filter((channel) => !channel.closed)
102
103
  this.#channels.push(channel)
103
104
 
104
105
  return channel
@@ -140,6 +141,8 @@ class Connection {
140
141
  this.#diagnostics.emit('open')
141
142
 
142
143
  try {
144
+ this.#channels = this.#channels.filter((channel) => !channel.closed)
145
+
143
146
  for (const channel of this.#channels) await channel.recover(connection)
144
147
  } catch (exception) {
145
148
  this.#diagnostics.emit('error', exception)
@@ -207,7 +210,20 @@ class Connection {
207
210
  connection.connection?.stream?.destroy(silence())
208
211
  }
209
212
 
210
- #recover () {
213
+ /**
214
+ * @param {Error} [exception]
215
+ * @return {Promise<void> | false}
216
+ */
217
+ #recover (exception) {
218
+ // a connection that has no channel left to give is not something reconnecting
219
+ // fixes, and `#recovery` on a connection that is perfectly well is a promise
220
+ // nobody ever resolves — returning `false` lets the caller see the refusal
221
+ if (exception?.message === EXHAUSTED) {
222
+ this.#diagnostics.emit('exhausted', this.#connection?.connection?.channelMax)
223
+
224
+ return false
225
+ }
226
+
211
227
  return this.#recovery
212
228
  }
213
229
 
@@ -333,6 +349,9 @@ const SOCKET_OPTIONS = {
333
349
 
334
350
  const HEARTBEAT_SET = /[?&]heartbeat=/
335
351
 
352
+ /** What amqplib says when the negotiated channel limit leaves no identifier free. */
353
+ const EXHAUSTED = 'No channels left to allocate'
354
+
336
355
  const TRANSIENT_CODES = new Set([
337
356
  'ECONNREFUSED',
338
357
  'EAI_AGAIN',
package/source/events.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  /** @type {comq.diagnostics.Event[]} */
4
- exports.connection = ['open', 'close', 'error', 'reconnect']
4
+ exports.connection = ['open', 'close', 'error', 'reconnect', 'exhausted']
5
5
 
6
6
  /** @type {comq.diagnostics.Event[]} */
7
7
  exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost', 'remove']
package/source/io.js CHANGED
@@ -163,6 +163,12 @@ class IO {
163
163
  await this.seal()
164
164
  await this.#destroyStreams(this.#replyPipes)
165
165
  await track(this)
166
+
167
+ // a connection is shared and outlives its IOs, so the channels are given back
168
+ // here — held to the end of the connection, they would run it out of them
169
+ await Promise.all([this.#requests, this.#replies, this.#events]
170
+ .map((channel) => channel?.close()))
171
+
166
172
  await this.#connection.close()
167
173
  })
168
174
 
@@ -77,6 +77,14 @@ class Channel {
77
77
  return await this.#one((channel) => channel.fire(queue, buffer, options))
78
78
  }
79
79
 
80
+ async close () {
81
+ await this.#all((channel) => channel.close())
82
+ }
83
+
84
+ get closed () {
85
+ return [...this.#channels].every((channel) => channel.closed)
86
+ }
87
+
80
88
  async seal () {
81
89
  await this.#all((channel) => channel.seal())
82
90
  }
@@ -15,6 +15,7 @@ class Channel extends EventEmitter {
15
15
  assertExchange = jest.fn(async () => undefined)
16
16
  bindQueue = jest.fn(async () => undefined)
17
17
  publish = jest.fn((_0, _1, _2, _3, resolve) => resolve?.(null))
18
+ close = jest.fn(async () => undefined)
18
19
  }
19
20
 
20
21
  class Connection extends EventEmitter {
@@ -37,7 +38,10 @@ class Connection extends EventEmitter {
37
38
  // a broker accepts the heartbeat the client asks for
38
39
  const heartbeat = Number(/[?&]heartbeat=(\d+)/.exec(url)?.[1] ?? 60)
39
40
 
40
- this.connection = { stream, heartbeat }
41
+ // and negotiates the channel limit down to whatever the client asked for
42
+ const channelMax = Number(/[?&]channelMax=(\d+)/.exec(url)?.[1] ?? 2047)
43
+
44
+ this.connection = { stream, heartbeat, channelMax }
41
45
 
42
46
  // noinspection JSValidateTypes
43
47
  this.removeAllListeners = jest.spyOn(this, 'removeAllListeners')
@@ -503,6 +503,49 @@ describe('seal', () => {
503
503
  })
504
504
  })
505
505
 
506
+ describe('close', () => {
507
+ const queue = generate()
508
+ const consumer = jest.fn()
509
+
510
+ beforeEach(async () => {
511
+ channel = await create(connection, topology)
512
+ chan = await getCreatedChannel()
513
+ })
514
+
515
+ it('should give the channel back', async () => {
516
+ await channel.close()
517
+
518
+ expect(chan.close).toHaveBeenCalled()
519
+ expect(channel.closed).toStrictEqual(true)
520
+ })
521
+
522
+ it('should stop consuming before giving it back', async () => {
523
+ await channel.consume(queue, consumer)
524
+
525
+ const { consumerTag: tag } = await chan.consume.mock.results[0].value
526
+
527
+ await channel.close()
528
+
529
+ expect(chan.cancel).toHaveBeenCalledWith(tag)
530
+ expect(chan.cancel.mock.invocationCallOrder[0])
531
+ .toBeLessThan(chan.close.mock.invocationCallOrder[0])
532
+ })
533
+
534
+ it('should give it back once', async () => {
535
+ await channel.close()
536
+ await channel.close()
537
+
538
+ expect(chan.close).toHaveBeenCalledTimes(1)
539
+ })
540
+
541
+ // one that went down with its connection is the outcome this asks for
542
+ it('should ignore one that is already gone', async () => {
543
+ chan.close.mockImplementationOnce(async () => { throw new Error() })
544
+
545
+ await expect(channel.close()).resolves.not.toThrow()
546
+ })
547
+ })
548
+
506
549
  describe('back pressure', () => {
507
550
  const exchange = generate()
508
551
  const queue = generate()
@@ -12,6 +12,8 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
12
12
  publish: jest.fn(async () => undefined),
13
13
  diagnose: jest.fn(async () => undefined),
14
14
  seal: jest.fn(async () => undefined),
15
+ close: jest.fn(async () => undefined),
16
+ closed: false,
15
17
  recover: jest.fn(async () => undefined)
16
18
  }))
17
19
 
@@ -398,6 +398,106 @@ describe('create channel', () => {
398
398
  })
399
399
  })
400
400
 
401
+ describe('closed channels', () => {
402
+ /** @type {jest.MockedObject<comq.amqp.Connection>} */
403
+ let conn
404
+
405
+ beforeEach(async () => {
406
+ // an earlier case leaves an implementation of its own behind
407
+ create.mockImplementation(async () => ({ recover: jest.fn(async () => undefined), closed: false }))
408
+
409
+ await connection.open()
410
+
411
+ conn = await amqplib.connect.mock.results[0].value
412
+ })
413
+
414
+ afterEach(async () => {
415
+ await connection.close()
416
+ })
417
+
418
+ // a channel given back is not one this connection has any more
419
+ it('should not recover one that was given back', async () => {
420
+ const channel = await connection.createChannel('event')
421
+
422
+ channel.closed = true
423
+
424
+ conn.emit('close', new Error())
425
+
426
+ await timeout(50)
427
+
428
+ expect(channel.recover).not.toHaveBeenCalled()
429
+ })
430
+
431
+ it('should recover one that is still in use', async () => {
432
+ const channel = await connection.createChannel('event')
433
+
434
+ conn.emit('close', new Error())
435
+
436
+ await timeout(50)
437
+
438
+ expect(channel.recover).toHaveBeenCalled()
439
+ })
440
+ })
441
+
442
+ describe('channel exhaustion', () => {
443
+ const EXHAUSTED = 'No channels left to allocate'
444
+
445
+ /** @type {jest.MockedObject<comq.amqp.Connection>} */
446
+ let conn
447
+
448
+ beforeEach(async () => {
449
+ await connection.open()
450
+
451
+ conn = await amqplib.connect.mock.results[0].value
452
+ })
453
+
454
+ it('should reject rather than wait for a recovery that is not coming', async () => {
455
+ create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
456
+
457
+ // the regression is a hang, so the assertion is that it settles at all
458
+ const settled = await Promise.race([
459
+ connection.createChannel('event').then(() => 'resolved', (e) => e.message),
460
+ timeout(100).then(() => 'pending')
461
+ ])
462
+
463
+ expect(settled).toStrictEqual(EXHAUSTED)
464
+ })
465
+
466
+ it('should not retry', async () => {
467
+ create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
468
+
469
+ await connection.createChannel('event').catch(() => undefined)
470
+
471
+ expect(create).toHaveBeenCalledTimes(1)
472
+ })
473
+
474
+ it('should report the limit the broker negotiated', async () => {
475
+ const listener = jest.fn()
476
+
477
+ await connection.diagnose('exhausted', listener)
478
+
479
+ create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
480
+
481
+ await connection.createChannel('event').catch(() => undefined)
482
+
483
+ expect(listener).toHaveBeenCalledWith(conn.connection.channelMax)
484
+ })
485
+
486
+ it('should leave every other failure its recovery', async () => {
487
+ create.mockImplementation(async () => { throw new Error() })
488
+
489
+ setTimeout(() => {
490
+ create.mockImplementation(async () => generate())
491
+
492
+ conn.emit('close', new Error())
493
+ }, 1)
494
+
495
+ const channel = await connection.createChannel('request')
496
+
497
+ expect(channel).toStrictEqual(await create.mock.results[1].value)
498
+ })
499
+ })
500
+
401
501
  describe('watchdog', () => {
402
502
  afterEach(() => {
403
503
  jest.useRealTimers()
@@ -93,6 +93,27 @@ describe('close', () => {
93
93
  await expect(io.close()).resolves.not.toThrow()
94
94
  })
95
95
 
96
+ // a connection is shared and outlives its IOs, and it has a limited number of channels
97
+ it('should give the channels back', async () => {
98
+ await reply()
99
+ await io.close()
100
+
101
+ expect(requests.close).toHaveBeenCalled()
102
+ expect(events.close).toHaveBeenCalled()
103
+ })
104
+
105
+ it('should give them back before letting go of the connection', async () => {
106
+ await reply()
107
+
108
+ connection.close.mockImplementation(async () => {
109
+ expect(requests.close).toHaveBeenCalled()
110
+ })
111
+
112
+ await io.close()
113
+
114
+ expect.assertions(1)
115
+ })
116
+
96
117
  it('should wait for event processing completion', async () => {
97
118
  const promise = /** @type {Promise<void>} */ new Promex()
98
119
  const queue = generate()
@@ -61,7 +61,7 @@ it('should not throw on error without listeners', async () => {
61
61
  expect(() => emit(new Error(generate()))).not.toThrow()
62
62
  })
63
63
 
64
- it.each(['open', 'close', 'error', 'reconnect'])('should re-emit %s from connection',
64
+ it.each(['open', 'close', 'error', 'reconnect', 'exhausted'])('should re-emit %s from connection',
65
65
  /**
66
66
  * @param {comq.diagnostics.Event} event
67
67
  */
@@ -27,6 +27,10 @@ declare namespace comq {
27
27
 
28
28
  seal (): Promise<void>
29
29
 
30
+ close (): Promise<void>
31
+
32
+ readonly closed: boolean
33
+
30
34
  diagnose (event: _diagnostics.Event, listener: Function): void
31
35
 
32
36
  forget (event: _diagnostics.Event, listener: Function): void
@@ -1,7 +1,7 @@
1
1
  declare namespace comq.diagnostics {
2
2
 
3
- type Event = 'open' | 'close' | 'error' | 'reconnect' | 'flow' | 'drain' | 'remove' | 'lost' |
4
- 'recover' | 'discard' | 'pause' | 'resume' | 'return'
3
+ type Event = 'open' | 'close' | 'error' | 'reconnect' | 'exhausted' | 'flow' | 'drain' |
4
+ 'remove' | 'lost' | 'recover' | 'discard' | 'pause' | 'resume' | 'return'
5
5
 
6
6
  interface Diagnosable {
7
7
  diagnose(event: Event, listener: Function): void
package/types/io.d.ts CHANGED
@@ -65,6 +65,8 @@ declare namespace comq {
65
65
 
66
66
  diagnose(event: 'reconnect', listener: (index?: number) => void): void
67
67
 
68
+ diagnose(event: 'exhausted', listener: (limit?: number, index?: number) => void): void
69
+
68
70
  diagnose(event: 'flow', listener: (channel: _topology.type, index?: number) => void): void
69
71
 
70
72
  diagnose(event: 'drain', listener: (channel: _topology.type, index?: number) => void): void