comq 0.14.0 → 0.15.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/source/io.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const stream = require('node:stream')
4
4
  const { EventEmitter } = require('node:events')
5
5
  const { randomBytes } = require('node:crypto')
6
- const { setTimeout } = require('node:timers/promises')
6
+ const { setTimeout: delay } = require('node:timers/promises')
7
7
  const { Promex } = require('promex')
8
8
  const { memo, failsafe, lazy, track } = require('./attributes')
9
9
 
@@ -75,24 +75,45 @@ class IO {
75
75
  /**
76
76
  * @param {string} queue
77
77
  * @param {any | Readable} payload
78
- * @param {comq.Encoding} [encoding]
78
+ * @param {comq.Encoding | number} [encoding]
79
+ * @param {number} [timeout]
79
80
  * @returns {Promise<any | Readable>}
80
81
  */
81
- async (queue, payload, encoding) => {
82
+ async (queue, payload, encoding, timeout) => {
83
+ if (typeof encoding === 'number') {
84
+ timeout = encoding
85
+ encoding = undefined
86
+ }
87
+
82
88
  if (payload instanceof stream.Readable) {
83
89
  return pipeline(
84
90
  payload,
85
- (payload) => this.request(queue, payload, encoding),
91
+ (payload) => this.request(queue, payload, encoding, timeout),
86
92
  this.#requests
87
93
  )
88
94
  }
89
95
 
90
- const request = this.#createRequest(queue, payload, encoding)
96
+ const request = this.#createRequest(queue, payload, /** @type {comq.Encoding} */ encoding)
91
97
  const reply = this.#createReply(request)
92
98
 
93
- await this.#requests.send(queue, request.buffer, request.properties)
99
+ const done = this.#requests.send(queue, request.buffer, request.properties)
100
+ .then(() => reply)
101
+
102
+ if (typeof timeout !== 'number') return done
94
103
 
95
- return reply
104
+ try {
105
+ return await Promise.race([
106
+ done,
107
+ delay(timeout).then(() => Promise.reject(timeoutError(timeout)))
108
+ ])
109
+ } catch (exception) {
110
+ if (exception.code === 'ETIMEDOUT') {
111
+ request.emitter.removeAllListeners(request.properties.correlationId)
112
+ reply.reject(exception)
113
+ }
114
+
115
+ throw exception
116
+ }
96
117
  }))
97
118
 
98
119
  consume = lazy(this, this.#createEventChannel,
@@ -396,7 +417,7 @@ class IO {
396
417
  Even if these messages are lost, the reply stream will be closed anyway,
397
418
  either due to missing heartbeat or the deletion of the stream queue.
398
419
  */
399
- await setTimeout(50)
420
+ await delay(50)
400
421
  }
401
422
 
402
423
  /**
@@ -456,6 +477,19 @@ const DEFAULT = 'application/json'
456
477
 
457
478
  const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
458
479
 
480
+ /**
481
+ * @param {number} timeout
482
+ * @returns {Error}
483
+ */
484
+ function timeoutError (timeout) {
485
+ const error = /** @type {Error & { code: string }} */ new Error(
486
+ `Request timed out after ${timeout}ms`)
487
+
488
+ error.code = 'ETIMEDOUT'
489
+
490
+ return error
491
+ }
492
+
459
493
  function noop () {}
460
494
 
461
495
  exports.IO = IO
@@ -21,6 +21,12 @@ class Connection extends EventEmitter {
21
21
  constructor () {
22
22
  super()
23
23
 
24
+ const stream = new EventEmitter()
25
+
26
+ stream.destroy = jest.fn()
27
+
28
+ this.connection = { stream }
29
+
24
30
  // noinspection JSValidateTypes
25
31
  this.removeAllListeners = jest.spyOn(this, 'removeAllListeners')
26
32
 
@@ -552,6 +552,29 @@ describe('recovery', () => {
552
552
  await expect(channel.consume(queue, consumer)).rejects.toStrictEqual(exception)
553
553
  })
554
554
 
555
+ it('should not deadlock when recall hits a transient error during recover', async () => {
556
+ await channel.consume(queue, consumer)
557
+
558
+ const replacement = await amqplib.connect()
559
+ const method = `create${topology.confirms ? 'Confirm' : ''}Channel`
560
+
561
+ replacement[method].mockImplementationOnce(async () => {
562
+ const transient = await amqplib.connect()
563
+ const bad = await transient[method]()
564
+
565
+ bad.consume.mockImplementation(async () => { throw new Error('Channel closed') })
566
+
567
+ return bad
568
+ })
569
+
570
+ const deadlock = timeout(200).then(() => {
571
+ throw new Error('recover hung (deadlock)')
572
+ })
573
+
574
+ await expect(Promise.race([channel.recover(replacement), deadlock]))
575
+ .rejects.toThrow('Channel closed')
576
+ })
577
+
555
578
  describe.each(/** @type {[string, boolean][]} */ [
556
579
  ['', false],
557
580
  [' not', true]
@@ -40,7 +40,15 @@ describe('initial connection', () => {
40
40
  })
41
41
 
42
42
  it.each(/** @type {[string, Partial<Error>][]} */[
43
- ['Socket closed', { message: 'Socket closed abruptly during opening handshake' }]
43
+ ['Socket closed', { message: 'Socket closed abruptly during opening handshake' }],
44
+ ['TLS disconnect', { message: 'Client network socket disconnected before secure TLS connection was established' }],
45
+ ['ECONNREFUSED', { code: 'ECONNREFUSED' }],
46
+ ['EAI_AGAIN', { code: 'EAI_AGAIN' }],
47
+ ['ENOTFOUND', { code: 'ENOTFOUND' }],
48
+ ['ETIMEDOUT', { code: 'ETIMEDOUT' }],
49
+ ['ECONNRESET', { code: 'ECONNRESET' }],
50
+ ['EHOSTUNREACH', { code: 'EHOSTUNREACH' }],
51
+ ['ENETUNREACH', { code: 'ENETUNREACH' }]
44
52
  ])('should reconnect on %s',
45
53
  async (_, error) => {
46
54
  amqplib.connect.mockImplementationOnce(async () => { throw error })
@@ -50,10 +58,9 @@ describe('initial connection', () => {
50
58
  expect(amqplib.connect).toHaveBeenCalledTimes(2)
51
59
  })
52
60
 
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) => {
61
+ it('should throw if error is permanent', async () => {
62
+ const exception = new Error(generate())
63
+
57
64
  amqplib.connect.mockImplementationOnce(async () => { throw exception })
58
65
 
59
66
  await expect(connection.open()).rejects.toStrictEqual(exception)
@@ -109,6 +116,68 @@ describe('reconnection', () => {
109
116
 
110
117
  expect(channel.recover).toHaveBeenCalledWith(replacement)
111
118
  })
119
+
120
+ it('should retry when channel recover fails', async () => {
121
+ const channel = await connection.createChannel('request')
122
+
123
+ channel.recover
124
+ .mockRejectedValueOnce(new Error('Channel closed'))
125
+ .mockResolvedValue(undefined)
126
+
127
+ conn.emit('close', new Error())
128
+
129
+ const start = Date.now()
130
+
131
+ while (channel.recover.mock.calls.length < 2 && Date.now() - start < 10000) {
132
+ await timeout(50)
133
+ }
134
+
135
+ expect(channel.recover).toHaveBeenCalledTimes(2)
136
+ expect(amqplib.connect.mock.calls.length).toBeGreaterThanOrEqual(3)
137
+ }, 15000)
138
+
139
+ it('should emit error when reconnect open fails', async () => {
140
+ const errors = []
141
+ const unhandled = jest.fn()
142
+
143
+ connection.diagnose('error', (exception) => errors.push(exception))
144
+ process.on('unhandledRejection', unhandled)
145
+
146
+ const boom = new Error('reconnect failed')
147
+
148
+ connection.open = jest.fn(async () => { throw boom })
149
+
150
+ conn.emit('close', new Error('broker down'))
151
+
152
+ await timeout(10)
153
+
154
+ process.off('unhandledRejection', unhandled)
155
+
156
+ expect(errors).toContain(boom)
157
+ expect(unhandled).not.toHaveBeenCalled()
158
+ })
159
+
160
+ it('should ignore close from a stale connection', async () => {
161
+ const stale = conn
162
+ const closeHandler = stale.on.mock.calls.find(([event]) => event === 'close')[1]
163
+
164
+ stale.emit('close', new Error('gone'))
165
+
166
+ await timeout(50)
167
+
168
+ const live = await amqplib.connect.mock.results[1].value
169
+ const connects = amqplib.connect.mock.calls.length
170
+
171
+ expect(live).not.toBe(stale)
172
+
173
+ live.removeAllListeners.mockClear()
174
+ closeHandler(new Error('late'))
175
+
176
+ await timeout(10)
177
+
178
+ expect(amqplib.connect).toHaveBeenCalledTimes(connects)
179
+ expect(live.removeAllListeners).not.toHaveBeenCalled()
180
+ })
112
181
  })
113
182
 
114
183
  describe('create channel', () => {
@@ -203,6 +272,44 @@ describe('create channel', () => {
203
272
  })
204
273
  })
205
274
 
275
+ describe('watchdog', () => {
276
+ afterEach(() => {
277
+ jest.useRealTimers()
278
+ })
279
+
280
+ it('should destroy silent connection when watchdog expires', async () => {
281
+ jest.useFakeTimers()
282
+
283
+ await connection.open()
284
+
285
+ const conn = await amqplib.connect.mock.results[0].value
286
+
287
+ await jest.advanceTimersByTimeAsync(60_000)
288
+
289
+ expect(conn.connection.stream.destroy).toHaveBeenCalled()
290
+ })
291
+
292
+ it('should reset watchdog on socket data', async () => {
293
+ jest.useFakeTimers()
294
+
295
+ await connection.open()
296
+
297
+ const conn = await amqplib.connect.mock.results[0].value
298
+
299
+ await jest.advanceTimersByTimeAsync(50_000)
300
+
301
+ conn.connection.stream.emit('data', Buffer.alloc(0))
302
+
303
+ await jest.advanceTimersByTimeAsync(50_000)
304
+
305
+ expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
306
+
307
+ await jest.advanceTimersByTimeAsync(10_000)
308
+
309
+ expect(conn.connection.stream.destroy).toHaveBeenCalled()
310
+ })
311
+ })
312
+
206
313
  describe('close', () => {
207
314
  it('should close connection', async () => {
208
315
  await connection.open()
@@ -0,0 +1,61 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+
5
+ const mock = require('./connection.mock')
6
+ const { IO } = require('../source/io')
7
+
8
+ /** @type {comq.IO} */
9
+ let io
10
+
11
+ /** @type {jest.MockedObject<comq.Connection>} */
12
+ let connection
13
+
14
+ const queue = generate()
15
+ const payload = { [generate()]: generate() }
16
+
17
+ beforeEach(() => {
18
+ jest.clearAllMocks()
19
+
20
+ connection = mock.connection()
21
+ io = new IO(connection)
22
+ })
23
+
24
+ it('should reject when request times out', async () => {
25
+ await expect(io.request(queue, payload, 50)).rejects.toMatchObject({
26
+ message: expect.stringContaining('timed out'),
27
+ code: 'ETIMEDOUT'
28
+ })
29
+ })
30
+
31
+ it('should reject when request times out with encoding', async () => {
32
+ await expect(io.request(queue, payload, 'application/json', 50)).rejects.toMatchObject({
33
+ code: 'ETIMEDOUT'
34
+ })
35
+ })
36
+
37
+ it('should time out while send is still pending', async () => {
38
+ connection.createChannel.mockImplementation(async () => hangingChannel())
39
+
40
+ await expect(io.request(queue, payload, 50)).rejects.toMatchObject({
41
+ code: 'ETIMEDOUT'
42
+ })
43
+ })
44
+
45
+ /**
46
+ * @returns {jest.MockedObject<comq.Channel>}
47
+ */
48
+ function hangingChannel () {
49
+ return /** @type {jest.MockedObject<comq.Channel>} */ ({
50
+ sharded: false,
51
+ consume: jest.fn(async () => undefined),
52
+ send: jest.fn(() => new Promise(() => {})),
53
+ deliver: jest.fn(async () => undefined),
54
+ fire: jest.fn(async () => undefined),
55
+ subscribe: jest.fn(async () => undefined),
56
+ publish: jest.fn(async () => undefined),
57
+ diagnose: jest.fn(),
58
+ seal: jest.fn(async () => undefined),
59
+ recover: jest.fn(async () => undefined)
60
+ })
61
+ }
@@ -1,6 +1,6 @@
1
1
  declare namespace comq.diagnostics {
2
2
 
3
- type Event = 'open' | 'close' | 'flow' | 'drain' | 'remove' | 'recover' | 'discard' |
3
+ type Event = 'open' | 'close' | 'error' | 'flow' | 'drain' | 'remove' | 'recover' | 'discard' |
4
4
  'pause' | 'resume' | 'return'
5
5
 
6
6
  interface Diagnosable {
package/types/io.d.ts CHANGED
@@ -27,9 +27,11 @@ declare namespace comq {
27
27
  interface IO extends _diagnostics.Diagnosable {
28
28
  reply(queue: string, produce: Producer): Promise<void>
29
29
 
30
- request<Reply = any, Request = any>(queue: string, payload: Request, encoding?: _encoding.Encoding): Promise<Reply> | Promise<Readable>
30
+ request<Reply = any, Request = any>(queue: string, payload: Request, encoding?: _encoding.Encoding, timeout?: number): Promise<Reply> | Promise<Readable>
31
31
 
32
- request(queue: string, stream: Readable, encoding?: _encoding.Encoding): Promise<Readable>
32
+ request<Reply = any, Request = any>(queue: string, payload: Request, timeout: number): Promise<Reply> | Promise<Readable>
33
+
34
+ request(queue: string, stream: Readable, encoding?: _encoding.Encoding, timeout?: number): Promise<Readable>
33
35
 
34
36
  consume<T = any>(exchange: string, group: string, consumer: Consumer<T>): Promise<void>
35
37
 
@@ -61,6 +63,8 @@ declare namespace comq {
61
63
 
62
64
  diagnose(event: 'close', listener: (index?: number) => void): void
63
65
 
66
+ diagnose(event: 'error', listener: (error: Error, index?: number) => void): void
67
+
64
68
  diagnose(event: 'flow', listener: (channel: _topology.type, index?: number) => void): void
65
69
 
66
70
  diagnose(event: 'drain', listener: (channel: _topology.type, index?: number) => void): void
@@ -1,78 +0,0 @@
1
- # For most projects, this workflow file will not need changing; you simply need
2
- # to commit it to your repository.
3
- #
4
- # You may wish to alter this file to override the set of languages analyzed,
5
- # or to provide custom queries or build logic.
6
- #
7
- # ******** NOTE ********
8
- # We have attempted to detect the languages in your repository. Please check
9
- # the `language` matrix defined below to confirm you have the correct set of
10
- # supported CodeQL languages.
11
- #
12
- name: "CodeQL"
13
-
14
- on:
15
- push:
16
- branches: [ "main" ]
17
- paths: [ "source/**" ]
18
- pull_request:
19
- # The branches below must be a subset of the branches above
20
- branches: [ "main" ]
21
- paths: [ "source/**" ]
22
- schedule:
23
- - cron: '15 8 * * 3'
24
-
25
- jobs:
26
- analyze:
27
- name: Analyze
28
- runs-on: ubuntu-latest
29
- permissions:
30
- actions: read
31
- contents: read
32
- security-events: write
33
-
34
- strategy:
35
- fail-fast: false
36
- matrix:
37
- language: [ 'javascript' ]
38
- # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
39
- # Use only 'java' to analyze code written in Java, Kotlin or both
40
- # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
41
- # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
42
-
43
- steps:
44
- - name: Checkout repository
45
- uses: actions/checkout@v6
46
-
47
- # Initializes the CodeQL tools for scanning.
48
- - name: Initialize CodeQL
49
- uses: github/codeql-action/init@v4
50
- with:
51
- languages: ${{ matrix.language }}
52
- # If you wish to specify custom queries, you can do so here or in a config file.
53
- # By default, queries listed here will override any specified in a config file.
54
- # Prefix the list here with "+" to use these queries and those in the config file.
55
-
56
- # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
57
- # queries: security-extended,security-and-quality
58
-
59
-
60
- # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
61
- # If this step fails, then you should remove it and run the build manually (see below)
62
- - name: Autobuild
63
- uses: github/codeql-action/autobuild@v4
64
-
65
- # ℹ️ Command-line programs to run using the OS shell.
66
- # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
67
-
68
- # If the Autobuild fails above, remove it and uncomment the following three lines.
69
- # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
70
-
71
- # - run: |
72
- # echo "Run, Build Application using script"
73
- # ./location_of_script_within_repo/buildscript.sh
74
-
75
- - name: Perform CodeQL Analysis
76
- uses: github/codeql-action/analyze@v4
77
- with:
78
- category: "/language:${{matrix.language}}"