comq 0.6.0-dev.2 → 0.7.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.
@@ -3,14 +3,6 @@ Feature: Connection Tolerance
3
3
  Scenario: Connect
4
4
  Given an active connection to the broker
5
5
 
6
- Scenario: Connecting to a broker before it was started
7
- Given the broker is down
8
- When I attempt to connect to the broker for 0.2 seconds
9
- Then the connection is not established
10
- And no exceptions are thrown
11
- When the broker is up
12
- Then the connection is established
13
-
14
6
  Scenario: Restoring connection after broker restart
15
7
  Given an active connection to the broker
16
8
  When the broker is down
@@ -3,25 +3,26 @@ Feature: Sharded Connection
3
3
  Scenario: Establishing sharded connection
4
4
  Given an active sharded connection
5
5
 
6
- Scenario: Connecting and waiting for 'open' event
7
- Given the connection to both shards is established
8
-
9
6
  Scenario: Connecting while a shard is down
10
7
  Given one of the brokers has crashed
11
8
  When I attempt to establish sharded connection
12
- Then the connection is established
9
+ Then an exception is thrown: "ECONNREFUSED"
13
10
  Then the broker is up
14
11
 
12
+ Scenario: Connecting with wrong credentials
13
+ When I attempt to establish a sharded connection as "someone" with password "whatever"
14
+ Then an exception is thrown: "Handshake terminated by server: 403 (ACCESS-REFUSED)"
15
+
15
16
  Scenario: Sending a request to a sharded connection while one broker is down
16
- Given one of the brokers has crashed
17
- And an active sharded connection
17
+ Given an active sharded connection
18
+ And one of the brokers has crashed
18
19
  And a producer replying `add_numbers` queue
19
20
  When the consumer sends a request to the `add_numbers` queue
20
21
  Then the consumer receives the reply
21
22
  Then the broker is up
22
23
 
23
24
  Scenario: Publishing an event to a sharded connection missing one broker
24
- Given the connection to both shards is established
25
+ Given an active sharded connection
25
26
  And events are exclusively consumed from the `numbers_added` exchange
26
27
  And one of the brokers has crashed
27
28
  When an event is emitted to the `numbers_added` exchange
@@ -29,7 +30,7 @@ Feature: Sharded Connection
29
30
  Then the broker is up
30
31
 
31
32
  Scenario: Shard crashes while sending requests
32
- Given the connection to both shards is established
33
+ Given an active sharded connection
33
34
  And a producer replying `flood` queue
34
35
  And I'm sending 1kB requests to the `flood` queue at 1kHz
35
36
  When one of the brokers has crashed
@@ -38,7 +39,7 @@ Feature: Sharded Connection
38
39
  Then the broker is up
39
40
 
40
41
  Scenario: Shard crashes while publishing events
41
- Given the connection to both shards is established
42
+ Given an active sharded connection
42
43
  And events are exclusively consumed from the `flood` exchange
43
44
  And I'm publishing 1kB events to the `flood` exchange at 100Hz
44
45
  When one of the brokers has crashed
@@ -47,7 +48,7 @@ Feature: Sharded Connection
47
48
  Then the broker is up
48
49
 
49
50
  Scenario: Sealing sharded connection while one broker is down
50
- Given the connection to both shards is established
51
+ Given an active sharded connection
51
52
  And a producer replying `echo` queue
52
53
  And one of the brokers has crashed
53
54
  And the connection has started sealing
@@ -55,7 +56,3 @@ Feature: Sharded Connection
55
56
  And the broker is up
56
57
  Then the connection is sealed
57
58
  And the consumer does not receive the reply
58
-
59
- Scenario: Connecting with wrong credentials
60
- When I attempt to establish a sharded connection as "someone" with password "whatever"
61
- Then an exception is thrown: "Handshake terminated by server: 403 (ACCESS-REFUSED)"
@@ -62,12 +62,6 @@ describe('Given an active sharded connection', () => {
62
62
  })
63
63
  })
64
64
 
65
- describe('Given the connection to both shards is established', () => {
66
- tomato.steps.Gi('the connection to both shards is established')
67
-
68
- it('should be', async () => undefined)
69
- })
70
-
71
65
  describe('When I attempt to connect to the broker for {number} second(s)', () => {
72
66
  const step = tomato.steps.Wh('I attempt to connect to the broker for {number} second(s)')
73
67
 
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const assert = require('node:assert')
4
- const { timeout, promex } = require('@toa.io/generic')
4
+ const { timeout } = require('@toa.io/generic')
5
5
 
6
6
  const { Given, When, Then } = require('@cucumber/cucumber')
7
7
 
@@ -26,24 +26,6 @@ Given('an active sharded connection',
26
26
  await this.connecting
27
27
  })
28
28
 
29
- Given('the connection to both shards is established',
30
- /**
31
- * @this {comq.features.Context}
32
- */
33
- async function () {
34
- const promise = promex()
35
-
36
- this.sharded = true
37
- this.connecting = this.connect()
38
-
39
- await this.connecting
40
-
41
- // second shard is connected
42
- this.io.diagnose('open', () => promise.resolve())
43
-
44
- return promise
45
- })
46
-
47
29
  When('I attempt to connect to the broker for {number} second(s)',
48
30
  /**
49
31
  * @param {number} interval
@@ -185,6 +167,6 @@ const CONNECTION_EVENTS = {
185
167
  }
186
168
 
187
169
  const CONNECTION_GAPS = {
188
- lost: global.COMQ_TESTING_CONNECTION_GAP_LOST ?? 10,
189
- restored: global.COMQ_TESTING_CONNECTION_GAP_RESTORED ?? 5000
170
+ lost: 10,
171
+ restored: 5000
190
172
  }
package/lerna.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "useWorkspaces": true,
3
- "version": "0.6.0-dev.2",
3
+ "version": "0.7.0",
4
4
  "conventionalCommits": true
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.6.0-dev.2",
3
+ "version": "0.7.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
package/source/channel.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { EventEmitter } = require('node:events')
4
- const { lazy, recall, promex, failsafe, immediate } = require('@toa.io/generic')
4
+ const { lazy, recall, promex, failsafe, timeout } = require('@toa.io/generic')
5
5
 
6
6
  /**
7
7
  * @implements {comq.Channel}
@@ -137,8 +137,7 @@ class Channel {
137
137
 
138
138
  for (const confirmation of this.#confirmations) confirmation.reject(INTERRUPTION)
139
139
 
140
- // let unpause and confirmation interruptions be handled
141
- await immediate()
140
+ await timeout(0) // let unpause and confirmation interruptions be handled
142
141
 
143
142
  this.#recovery.resolve()
144
143
  this.#recovery = promex()
@@ -23,6 +23,9 @@ class Connection {
23
23
  /** @type {toa.generic.Promex} */
24
24
  #recovery = promex()
25
25
 
26
+ /** @type {boolean} */
27
+ #running = false
28
+
26
29
  #diagnostics = new EventEmitter()
27
30
 
28
31
  /**
@@ -34,6 +37,8 @@ class Connection {
34
37
 
35
38
  async open () {
36
39
  await retry(this.#open, RETRY)
40
+
41
+ this.#running = true
37
42
  }
38
43
 
39
44
  async close () {
@@ -70,11 +75,11 @@ class Connection {
70
75
  try {
71
76
  this.#connection = await amqp.connect(this.#url)
72
77
  } catch (exception) {
73
- if (transient(exception)) return retry()
78
+ if (this.#transient(exception)) return retry()
74
79
  else throw exception
75
80
  }
76
81
 
77
- // prevents process crash, 'close' will be emitted next
82
+ // prevents the process crash, 'close' will be emitted next
78
83
  // https://amqp-node.github.io/amqplib/channel_api.html#model_events
79
84
  this.#connection.on('error', noop)
80
85
 
@@ -101,6 +106,12 @@ class Connection {
101
106
  #recover () {
102
107
  return this.#recovery
103
108
  }
109
+
110
+ #transient (exception) {
111
+ const abruptly = exception.message === 'Socket closed abruptly during opening handshake'
112
+
113
+ return this.#running || abruptly
114
+ }
104
115
  }
105
116
 
106
117
  /** @type {toa.generic.retry.Options} */
@@ -108,17 +119,6 @@ const RETRY = {
108
119
  retries: Infinity
109
120
  }
110
121
 
111
- /**
112
- * @param {Error} exception
113
- * @returns {boolean}
114
- */
115
- const transient = (exception) => {
116
- const refused = exception.code === 'ECONNREFUSED'
117
- const handshake = exception.message === 'Socket closed abruptly during opening handshake'
118
-
119
- return refused || handshake
120
- }
121
-
122
122
  function noop () {}
123
123
 
124
124
  exports.Connection = Connection
@@ -23,9 +23,13 @@ class Connection {
23
23
  }
24
24
 
25
25
  async open () {
26
- const opening = this.#connections.map((connection) => connection.open())
26
+ const openings = this.#connections.map((connection) => connection.open())
27
27
 
28
- await Promise.any(opening)
28
+ try {
29
+ await Promise.all(openings)
30
+ } catch (exception) {
31
+ await this.#cancel(openings, exception)
32
+ }
29
33
  }
30
34
 
31
35
  async close () {
@@ -51,6 +55,21 @@ class Connection {
51
55
  connection.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args, index))
52
56
  }
53
57
  }
58
+
59
+ /**
60
+ * @param {Promise<any>[]} openings
61
+ * @param {Error} exception
62
+ */
63
+ async #cancel (openings, exception) {
64
+ const settled = await Promise.allSettled(openings)
65
+ const indexes = settled.map((result, index) => result.status === 'fulfilled' ? index : -1)
66
+ const opened = indexes.filter((index) => index !== -1)
67
+ const closings = opened.map((index) => this.#connections[index].close())
68
+
69
+ await Promise.all(closings)
70
+
71
+ throw exception
72
+ }
54
73
  }
55
74
 
56
75
  exports.Connection = Connection
@@ -577,7 +577,7 @@ describe('recovery', () => {
577
577
  if (sealed) await channel.seal()
578
578
  })
579
579
 
580
- it('should re-assert queue after recovery', async () => {
580
+ it('should re-assert queue', async () => {
581
581
  chan.assertQueue.mockImplementation(async () => { throw new Error('Channel closed') })
582
582
 
583
583
  /** @type {comq.amqp.Connection} */
@@ -598,7 +598,7 @@ describe('recovery', () => {
598
598
  expect(repl.assertQueue).toHaveBeenCalled()
599
599
  })
600
600
 
601
- it('should re-assert exchange after exception', async () => {
601
+ it('should re-assert exchange', async () => {
602
602
  chan.assertExchange.mockImplementation(async () => { throw new Error('Channel ended, no reply will be forthcoming') })
603
603
 
604
604
  /** @type {comq.amqp.Connection} */
@@ -619,7 +619,7 @@ describe('recovery', () => {
619
619
  expect(repl.assertExchange).toHaveBeenCalled()
620
620
  })
621
621
 
622
- it('should re-bind queue after exception', async () => {
622
+ it('should re-bind queue', async () => {
623
623
  chan.bindQueue.mockImplementation(async () => { throw new Error('Channel ended, no reply will be forthcoming') })
624
624
 
625
625
  /** @type {comq.amqp.Connection} */
@@ -642,7 +642,7 @@ describe('recovery', () => {
642
642
  expect(repl.bindQueue).toHaveBeenCalled()
643
643
  })
644
644
 
645
- it(`should${not} re-consume after recovery`, async () => {
645
+ it(`should${not} re-consume`, async () => {
646
646
  await channel.consume(queue, consumer)
647
647
 
648
648
  const replacement = await amqplib.connect()
@@ -655,7 +655,7 @@ describe('recovery', () => {
655
655
  else expect(chan.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.anything())
656
656
  })
657
657
 
658
- it(`should${not} re-consume after exception `, async () => {
658
+ it(`should${not} re-consume`, async () => {
659
659
  chan.consume.mockImplementation(async () => { throw new Error('Channel ended, no reply will be forthcoming') })
660
660
 
661
661
  /** @type {comq.amqp.Connection} */
@@ -677,7 +677,7 @@ describe('recovery', () => {
677
677
  else expect(repl.consume).toHaveBeenCalled()
678
678
  })
679
679
 
680
- it(`should${not} re-subscribe after recovery`, async () => {
680
+ it(`should${not} re-subscribe`, async () => {
681
681
  await channel.subscribe(exchange, queue, consumer)
682
682
 
683
683
  const replacement = await amqplib.connect()
@@ -690,7 +690,7 @@ describe('recovery', () => {
690
690
  else expect(repl.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.anything())
691
691
  })
692
692
 
693
- it('should re-send after exception', async () => {
693
+ it('should re-send', async () => {
694
694
  const queue = generate()
695
695
  const buffer = Buffer.from(generate())
696
696
 
@@ -702,7 +702,6 @@ describe('recovery', () => {
702
702
  setImmediate(async () => {
703
703
  expect(chan.publish).toHaveBeenCalled()
704
704
 
705
- chan.publish.mockImplementation(async () => {})
706
705
  replacement = await amqplib.connect()
707
706
 
708
707
  await channel.recover(replacement)
@@ -712,9 +711,6 @@ describe('recovery', () => {
712
711
 
713
712
  const repl = await getCreatedChannel(replacement)
714
713
 
715
- // await recovery effect
716
- await timeout(5)
717
-
718
714
  expect(repl.publish).toHaveBeenCalled()
719
715
  })
720
716
 
@@ -757,7 +753,7 @@ describe('recovery', () => {
757
753
  )
758
754
  })
759
755
 
760
- it('should unpause on recovery', async () => {
756
+ it('should unpause', async () => {
761
757
  jest.clearAllMocks()
762
758
 
763
759
  topology.confirms = false
@@ -30,12 +30,6 @@ beforeEach(() => {
30
30
  connection = new Connection(url)
31
31
  })
32
32
 
33
- /** @type {[string, Partial<Error>][]} */
34
- const TRANSIENT_ERRORS = [
35
- ['ECONNREFUSED', { code: 'ECONNREFUSED' }],
36
- ['Socket closed', { message: 'Socket closed abruptly during opening handshake' }]
37
- ]
38
-
39
33
  // endregion
40
34
 
41
35
  describe('initial connection', () => {
@@ -45,7 +39,9 @@ describe('initial connection', () => {
45
39
  expect(amqplib.connect).toHaveBeenCalledWith(url)
46
40
  })
47
41
 
48
- it.each(TRANSIENT_ERRORS)('should reconnect on %s',
42
+ it.each(/** @type {[string, Partial<Error>][]} */[
43
+ ['Socket closed', { message: 'Socket closed abruptly during opening handshake' }]
44
+ ])('should reconnect on %s',
49
45
  async (_, error) => {
50
46
  amqplib.connect.mockImplementationOnce(async () => { throw error })
51
47
 
@@ -54,12 +50,13 @@ describe('initial connection', () => {
54
50
  expect(amqplib.connect).toHaveBeenCalledTimes(2)
55
51
  })
56
52
 
57
- it('should throw if error is permanent', async () => {
58
- const error = new Error()
59
-
60
- amqplib.connect.mockImplementationOnce(async () => { throw error })
53
+ it.each(/** @type {[string, Partial<Error>][]} */[
54
+ ['ECONNREFUSED', { code: 'ECONNREFUSED' }],
55
+ ['any exception', new Error(generate())]
56
+ ])('should throw if error is %s', async (_, exception) => {
57
+ amqplib.connect.mockImplementationOnce(async () => { throw exception })
61
58
 
62
- await expect(connection.open()).rejects.toThrow(error)
59
+ await expect(connection.open()).rejects.toStrictEqual(exception)
63
60
  })
64
61
  })
65
62
 
@@ -77,7 +74,7 @@ describe('reconnection', () => {
77
74
  expect(amqplib.connect).toHaveBeenCalledTimes(1)
78
75
 
79
76
  // const clear = jest.spyOn(conn, 'removeAllListeners')
80
- const error = { message: generate() }
77
+ const error = { code: 'ECONNREFUSED' }
81
78
 
82
79
  conn.emit('close', error)
83
80
 
@@ -374,20 +374,21 @@ it('should remove channel with back pressure from the pool', async () => {
374
374
  }
375
375
 
376
376
  await send()
377
+
377
378
  expect(chan.send).toHaveBeenCalled()
379
+
378
380
  chan.send.mockClear()
379
381
 
380
- // emit 'flow'
381
- for (const listener of flowListeners) listener()
382
+ for (const flow of flowListeners) flow()
382
383
 
383
384
  await send()
385
+
384
386
  expect(chan.send).not.toHaveBeenCalled()
385
- chan.send.mockClear()
386
387
 
387
- // emit 'drain'
388
- for (const listener of drainListeners) listener()
388
+ for (const drain of drainListeners) drain()
389
389
 
390
390
  await send()
391
+
391
392
  expect(chan.send).toHaveBeenCalled()
392
393
  })
393
394
 
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { generate } = require('randomstring')
4
- const { random, promex, sample } = require('@toa.io/generic')
4
+ const { random, promex, immediate } = require('@toa.io/generic')
5
5
 
6
6
  const { Connection } = require('../../source/shards')
7
7
 
@@ -16,7 +16,7 @@ it('should be', async () => {
16
16
  })
17
17
 
18
18
  /** @type {jest.MockedObject<comq.Connection>[]} */
19
- const connections = [mock.connection(), mock.connection()]
19
+ let connections
20
20
 
21
21
  /** @type {comq.Connection} */
22
22
  let connection
@@ -24,12 +24,13 @@ let connection
24
24
  beforeEach(() => {
25
25
  jest.clearAllMocks()
26
26
 
27
+ connections = [mock.connection(), mock.connection()]
27
28
  connection = new Connection(connections)
28
29
  })
29
30
 
30
31
  describe('open', () => {
31
- it('should resolve when one of connections is established', async () => {
32
- expect.assertions(1)
32
+ it('should resolve when all of the connections are established', async () => {
33
+ expect.assertions(2)
33
34
 
34
35
  /** @type {toa.generic.Promex[]} */
35
36
  const promises = []
@@ -41,19 +42,45 @@ describe('open', () => {
41
42
  promises.push(promise)
42
43
  }
43
44
 
44
- let connected = false
45
+ let resolved = false
45
46
 
46
- setImmediate(() => {
47
- expect(connected).toStrictEqual(false)
47
+ setImmediate(async () => {
48
+ expect(resolved).toStrictEqual(false)
48
49
 
49
- const any = sample(promises)
50
+ const first = promises.shift()
50
51
 
51
- any.resolve()
52
+ first.resolve()
53
+
54
+ await immediate()
55
+
56
+ promises.forEach((promise) => promise.resolve())
57
+
58
+ resolved = true
52
59
  })
53
60
 
54
61
  await connection.open()
55
62
 
56
- connected = true
63
+ expect(resolved).toStrictEqual(true)
64
+ })
65
+
66
+ it('should close opened connection if one fails', async () => {
67
+ connections.push(mock.connection())
68
+
69
+ const [one, bad, two] = connections
70
+ const exception = new Error(generate())
71
+
72
+ one.open.mockImplementation(() => Promise.resolve())
73
+ two.open.mockImplementation(() => Promise.resolve())
74
+
75
+ bad.open.mockImplementation(async () => {
76
+ await immediate()
77
+ throw exception
78
+ })
79
+
80
+ await expect(connection.open()).rejects.toThrow(exception)
81
+
82
+ expect(one.close).toHaveBeenCalled()
83
+ expect(two.close).toHaveBeenCalled()
57
84
  })
58
85
  })
59
86
 
@@ -81,9 +108,9 @@ describe('createChannel', () => {
81
108
 
82
109
  describe.each(/** @type {comq.diagnostics.event[]} */ ['open', 'close'])('diagnose %s event',
83
110
  (event) => {
84
- const index = random(connections.length)
85
-
86
111
  it('should re-emit event', async () => {
112
+ const index = random(connections.length)
113
+
87
114
  for (const conn of connections) {
88
115
  expect(conn.diagnose).toHaveBeenCalledWith(event, expect.any(Function))
89
116
  }