comq 0.13.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/.github/workflows/dev.yaml +28 -0
- package/.github/workflows/release.yaml +24 -54
- package/.husky/pre-push +5 -0
- package/.releaserc +35 -0
- package/AGENTS.md +35 -0
- package/CHANGELOG.md +29 -0
- package/docker-compose.yaml +2 -2
- package/features/backpressure.feature +1 -2
- package/features/connection.feature +1 -0
- package/features/readme.md +9 -2
- package/features/recovery.feature +1 -0
- package/features/rpc.streams.feature +1 -0
- package/features/rpc.streams.shards.feature +1 -0
- package/features/shards.feature +6 -4
- package/features/steps/backpressure.js +32 -30
- package/features/steps/broker.js +4 -30
- package/features/steps/brokers.js +125 -0
- package/features/steps/connection.js +15 -0
- package/features/steps/context.js +2 -4
- package/features/steps/events.js +1 -1
- package/features/steps/hooks.js +14 -1
- package/features/steps/rpc.js +1 -1
- package/features/steps/yaml.js +15 -0
- package/package.json +27 -17
- package/readme.md +8 -5
- package/scripts/feat.js +67 -0
- package/source/channel.js +12 -1
- package/source/connection.js +92 -12
- package/source/events.js +1 -1
- package/source/io.js +42 -8
- package/test/amqplib.mock.js +6 -0
- package/test/channel.test.js +23 -0
- package/test/connection.test.js +112 -5
- package/test/io.request.timeout.test.js +61 -0
- package/types/diagnostic.d.ts +1 -1
- package/types/io.d.ts +6 -2
- package/.github/workflows/codeql.yml +0 -78
- package/fixplan.md +0 -507
- package/lerna.json +0 -4
package/features/steps/rpc.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const stream = require('node:stream')
|
|
4
4
|
const assert = require('node:assert')
|
|
5
5
|
const { randomBytes } = require('node:crypto')
|
|
6
|
-
const {
|
|
6
|
+
const { parse } = require('./yaml')
|
|
7
7
|
const { match, timeout, quantity } = require('@toa.io/generic')
|
|
8
8
|
const { Given, When, Then } = require('@cucumber/cucumber')
|
|
9
9
|
const { Readable } = require('node:stream')
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { load } = require('js-yaml')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} [yaml]
|
|
7
|
+
* @returns {any}
|
|
8
|
+
*/
|
|
9
|
+
function parse (yaml) {
|
|
10
|
+
if (yaml === undefined || yaml.trim() === '') return undefined
|
|
11
|
+
|
|
12
|
+
return load(yaml)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
exports.parse = parse
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "ComQ",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Artem Gurtovoi",
|
|
@@ -23,37 +23,47 @@
|
|
|
23
23
|
},
|
|
24
24
|
"main": "source/index.js",
|
|
25
25
|
"types": "types/index.d.ts",
|
|
26
|
+
"bin": {
|
|
27
|
+
"feat": "scripts/feat.js"
|
|
28
|
+
},
|
|
26
29
|
"devDependencies": {
|
|
27
|
-
"@cucumber/cucumber": "^
|
|
28
|
-
"@
|
|
29
|
-
"@
|
|
30
|
-
"@
|
|
31
|
-
"@
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
30
|
+
"@cucumber/cucumber": "^13.2.0",
|
|
31
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
32
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
33
|
+
"@semantic-release/git": "^10.0.1",
|
|
34
|
+
"@semantic-release/github": "^12.0.2",
|
|
35
|
+
"@semantic-release/npm": "^13.1.2",
|
|
36
|
+
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
37
|
+
"@testcontainers/rabbitmq": "^12.0.4",
|
|
38
|
+
"@toa.io/generic": "^1.0.0-alpha.208",
|
|
39
|
+
"@types/amqplib": "^0.10.8",
|
|
40
|
+
"@types/jest": "^30.0.0",
|
|
41
|
+
"fast-glob": "^3.3.3",
|
|
42
|
+
"fuzzysort": "3.1.0",
|
|
43
|
+
"husky": "^9.1.7",
|
|
44
|
+
"jest": "^30.4.2",
|
|
45
|
+
"js-yaml": "^5.2.3",
|
|
46
|
+
"randomstring": "^1.3.1",
|
|
36
47
|
"snazzy": "^9.0.0",
|
|
37
|
-
"standard": "^17.1.
|
|
48
|
+
"standard": "^17.1.2",
|
|
49
|
+
"testcontainers": "^12.0.4"
|
|
38
50
|
},
|
|
39
51
|
"dependencies": {
|
|
40
52
|
"amqplib": "^2.0.1",
|
|
41
|
-
"msgpackr": "^2.0.
|
|
53
|
+
"msgpackr": "^2.0.5",
|
|
42
54
|
"promex": "^0.1.1",
|
|
43
55
|
"reretry": "^0.1.0"
|
|
44
56
|
},
|
|
45
57
|
"scripts": {
|
|
46
|
-
"prepare": "
|
|
58
|
+
"prepare": "husky",
|
|
47
59
|
"test": "standard && jest",
|
|
48
60
|
"features": "cucumber-js --fail-fast -t 'not @manual'",
|
|
61
|
+
"features:fast": "cucumber-js --fail-fast -t 'not @manual and not @heavy'",
|
|
49
62
|
"lint": "standard --fix --verbose | snazzy"
|
|
50
63
|
},
|
|
51
64
|
"standard": {
|
|
52
65
|
"env": [
|
|
53
66
|
"jest"
|
|
54
67
|
]
|
|
55
|
-
}
|
|
56
|
-
"workspaces": [
|
|
57
|
-
"."
|
|
58
|
-
]
|
|
68
|
+
}
|
|
59
69
|
}
|
package/readme.md
CHANGED
|
@@ -103,10 +103,11 @@ await io.reply('add_numbers', ({ a, b }) => (a + b))
|
|
|
103
103
|
|
|
104
104
|
## Request
|
|
105
105
|
|
|
106
|
-
`async IO.request(queue: string, payload: any, encoding?: string): any`
|
|
106
|
+
`async IO.request(queue: string, payload: any, encoding?: string, timeout?: number): any`
|
|
107
107
|
|
|
108
108
|
Send encoded Request message with `replyTo` and `correlationId` properties set and
|
|
109
|
-
return decoded Reply content.
|
|
109
|
+
return decoded Reply content. Optional `timeout` (ms) rejects with `ETIMEDOUT` if no
|
|
110
|
+
Reply arrives in time; encoding may be omitted when only `timeout` is passed.
|
|
110
111
|
|
|
111
112
|
On the initial call, queues for Requests and Replies are asserted.
|
|
112
113
|
|
|
@@ -114,6 +115,7 @@ On the initial call, queues for Requests and Replies are asserted.
|
|
|
114
115
|
|
|
115
116
|
```javascript
|
|
116
117
|
const sum = await io.request('add_numbers', { a: 1, b: 2 })
|
|
118
|
+
const sumOrTimeout = await io.request('add_numbers', { a: 1, b: 2 }, 5000)
|
|
117
119
|
```
|
|
118
120
|
|
|
119
121
|
## Consumption
|
|
@@ -329,14 +331,15 @@ combined into a cluster.
|
|
|
329
331
|
Outgoing messages are sent to a single connection chosen at random from the shard pool. Shards that lose their
|
|
330
332
|
underlying connection or experience channel [back pressure](#flow-control) on a corresponding channel are removed from
|
|
331
333
|
the pool until the issue is resolved. Pending messages meeting these conditions are immediately routed among the
|
|
332
|
-
remaining shards in the pool. If no shards are available,
|
|
333
|
-
re-established.
|
|
334
|
+
remaining shards in the pool. If no shards are available, `send` / `publish` / `request` wait until a shard's
|
|
335
|
+
connection is re-established (unbounded). For RPC, pass a [`request` timeout](#request) so callers fail with
|
|
336
|
+
`ETIMEDOUT` instead of hanging while the pool is empty or a Reply never arrives.
|
|
334
337
|
|
|
335
338
|
Incoming messages are consumed from all shards.
|
|
336
339
|
|
|
337
340
|
`async connect(...shards: string[]): IO`
|
|
338
341
|
|
|
339
|
-
Returns an instance of `IO` once
|
|
342
|
+
Returns an instance of `IO` once connections to the shards are established.
|
|
340
343
|
|
|
341
344
|
### Example
|
|
342
345
|
|
package/scripts/feat.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
const { spawnSync } = require('node:child_process')
|
|
5
|
+
const path = require('node:path')
|
|
6
|
+
const fg = require('fast-glob')
|
|
7
|
+
const fuzzysort = require('fuzzysort')
|
|
8
|
+
|
|
9
|
+
const ROOT = path.resolve(__dirname, '..')
|
|
10
|
+
const USAGE = 'Usage: feat <query> [name] [cucumber-args...]'
|
|
11
|
+
|
|
12
|
+
function toKey (file) {
|
|
13
|
+
return path.relative(ROOT, file)
|
|
14
|
+
.replace(/^features\//, '')
|
|
15
|
+
.replace(/\.feature$/, '')
|
|
16
|
+
.replace(/\./g, '/')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeQuery (query) {
|
|
20
|
+
return query.replace(/\./g, '/').replace(/\\/g, '/')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function main () {
|
|
24
|
+
const args = process.argv.slice(2)
|
|
25
|
+
const dashIndex = args.findIndex((arg) => arg.startsWith('-'))
|
|
26
|
+
const positional = dashIndex === -1 ? args : args.slice(0, dashIndex)
|
|
27
|
+
const query = positional[0]
|
|
28
|
+
const name = positional[1]
|
|
29
|
+
|
|
30
|
+
const cucumberArgs = [
|
|
31
|
+
...(name ? ['--name', name] : []),
|
|
32
|
+
...positional.slice(2),
|
|
33
|
+
...(dashIndex === -1 ? [] : args.slice(dashIndex))
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
if (!query) {
|
|
37
|
+
console.error(USAGE)
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const files = await fg('features/**/*.feature', { cwd: ROOT, absolute: true })
|
|
42
|
+
const items = files.map((file) => ({ file, key: toKey(file) }))
|
|
43
|
+
const result = fuzzysort.go(normalizeQuery(query), items, { key: 'key', limit: 1 })
|
|
44
|
+
|
|
45
|
+
if (result.length === 0) {
|
|
46
|
+
console.error(`No feature files match "${query}"`)
|
|
47
|
+
process.exit(1)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const selected = result.map(({ obj }) => obj.file)
|
|
51
|
+
|
|
52
|
+
for (const file of selected) { console.error(path.relative(ROOT, file)) }
|
|
53
|
+
|
|
54
|
+
const cucumberBin = path.join(ROOT, 'node_modules', '.bin', 'cucumber-js')
|
|
55
|
+
|
|
56
|
+
const run = spawnSync(cucumberBin, [...selected, ...cucumberArgs], {
|
|
57
|
+
cwd: ROOT,
|
|
58
|
+
stdio: 'inherit'
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
process.exit(run.status ?? 1)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
main().catch((error) => {
|
|
65
|
+
console.error(error.message)
|
|
66
|
+
process.exit(1)
|
|
67
|
+
})
|
package/source/channel.js
CHANGED
|
@@ -34,6 +34,9 @@ class Channel {
|
|
|
34
34
|
/** @type {Promex} */
|
|
35
35
|
#recovery = new Promex()
|
|
36
36
|
|
|
37
|
+
/** @type {boolean} */
|
|
38
|
+
#recovering = false
|
|
39
|
+
|
|
37
40
|
/** @type {Set<Promex>} */
|
|
38
41
|
#confirmations = new Set()
|
|
39
42
|
|
|
@@ -145,7 +148,14 @@ class Channel {
|
|
|
145
148
|
await this.create()
|
|
146
149
|
|
|
147
150
|
lazy.reset(this)
|
|
148
|
-
|
|
151
|
+
|
|
152
|
+
this.#recovering = true
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
await recall(this)
|
|
156
|
+
} finally {
|
|
157
|
+
this.#recovering = false
|
|
158
|
+
}
|
|
149
159
|
|
|
150
160
|
this.#unpause(INTERRUPTION)
|
|
151
161
|
|
|
@@ -332,6 +342,7 @@ class Channel {
|
|
|
332
342
|
|
|
333
343
|
async #recover (exception) {
|
|
334
344
|
if (permanent(exception)) return false
|
|
345
|
+
if (this.#recovering) return false
|
|
335
346
|
else await this.#recovery
|
|
336
347
|
}
|
|
337
348
|
}
|
package/source/connection.js
CHANGED
|
@@ -25,9 +25,15 @@ class Connection {
|
|
|
25
25
|
/** @type {Promex} */
|
|
26
26
|
#recovery = new Promex()
|
|
27
27
|
|
|
28
|
+
/** @type {Promise<void> | null} */
|
|
29
|
+
#opening = null
|
|
30
|
+
|
|
28
31
|
/** @type {boolean} */
|
|
29
32
|
#running = false
|
|
30
33
|
|
|
34
|
+
/** @type {NodeJS.Timeout | null} */
|
|
35
|
+
#heartbeatTimer = null
|
|
36
|
+
|
|
31
37
|
#diagnostics = emitter.create()
|
|
32
38
|
|
|
33
39
|
/**
|
|
@@ -35,10 +41,17 @@ class Connection {
|
|
|
35
41
|
*/
|
|
36
42
|
constructor (url) {
|
|
37
43
|
this.#url = url
|
|
44
|
+
|
|
45
|
+
// EventEmitter throws on 'error' with no listeners
|
|
46
|
+
this.#diagnostics.on('error', noop)
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
async open () {
|
|
41
|
-
|
|
50
|
+
if (this.#opening !== null) return this.#opening
|
|
51
|
+
|
|
52
|
+
this.#opening = retry(this.#open).finally(() => { this.#opening = null })
|
|
53
|
+
|
|
54
|
+
await this.#opening
|
|
42
55
|
|
|
43
56
|
this.#running = true
|
|
44
57
|
}
|
|
@@ -71,8 +84,11 @@ class Connection {
|
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
#open = async (retry) => {
|
|
87
|
+
/** @type {comq.amqp.Connection} */
|
|
88
|
+
let connection
|
|
89
|
+
|
|
74
90
|
try {
|
|
75
|
-
|
|
91
|
+
connection = await amqp.connect(this.#url)
|
|
76
92
|
} catch (exception) {
|
|
77
93
|
if (this.#transient(exception)) return retry
|
|
78
94
|
else throw exception
|
|
@@ -80,40 +96,104 @@ class Connection {
|
|
|
80
96
|
|
|
81
97
|
// This prevents the process from crashing; 'close' will be emitted next.
|
|
82
98
|
// https://amqp-node.github.io/amqplib/channel_api.html#model_events
|
|
83
|
-
|
|
99
|
+
connection.on('error', noop)
|
|
84
100
|
|
|
85
|
-
|
|
101
|
+
connection.on('close', (error) => this.#close(connection, error))
|
|
102
|
+
this.#connection = connection
|
|
103
|
+
this.#armWatchdog(connection)
|
|
86
104
|
this.#diagnostics.emit('open')
|
|
87
105
|
|
|
88
|
-
|
|
106
|
+
try {
|
|
107
|
+
for (const channel of this.#channels) await channel.recover(connection)
|
|
108
|
+
} catch (exception) {
|
|
109
|
+
this.#disarmWatchdog()
|
|
110
|
+
this.#diagnostics.emit('error', exception)
|
|
111
|
+
connection.removeAllListeners()
|
|
112
|
+
|
|
113
|
+
await connection.close().catch(noop)
|
|
114
|
+
|
|
115
|
+
if (this.#connection === connection) this.#connection = undefined
|
|
116
|
+
|
|
117
|
+
return retry
|
|
118
|
+
}
|
|
89
119
|
|
|
90
120
|
this.#recovery.resolve()
|
|
91
121
|
this.#recovery = new Promex()
|
|
92
122
|
}
|
|
93
123
|
|
|
94
124
|
/**
|
|
95
|
-
* @param {
|
|
125
|
+
* @param {comq.amqp.Connection} connection
|
|
126
|
+
* @param {Error} [error]
|
|
96
127
|
*/
|
|
97
|
-
#close =
|
|
128
|
+
#close = (connection, error) => {
|
|
129
|
+
this.#disarmWatchdog()
|
|
130
|
+
|
|
131
|
+
if (this.#connection !== connection) return
|
|
132
|
+
|
|
98
133
|
this.#diagnostics.emit('close', error)
|
|
99
|
-
|
|
134
|
+
connection.removeAllListeners()
|
|
100
135
|
this.#connection = undefined
|
|
101
136
|
|
|
102
|
-
if (error !== undefined)
|
|
137
|
+
if (error !== undefined) {
|
|
138
|
+
this.open().catch((exception) => this.#diagnostics.emit('error', exception))
|
|
139
|
+
}
|
|
103
140
|
}
|
|
104
141
|
|
|
105
142
|
#recover () {
|
|
106
143
|
return this.#recovery
|
|
107
144
|
}
|
|
108
145
|
|
|
146
|
+
/**
|
|
147
|
+
* @param {comq.amqp.Connection} connection
|
|
148
|
+
* @param {number} [timeoutMs]
|
|
149
|
+
*/
|
|
150
|
+
#armWatchdog (connection, timeoutMs = WATCHDOG_MS) {
|
|
151
|
+
const socket = connection.connection?.stream
|
|
152
|
+
|
|
153
|
+
if (socket === undefined) return
|
|
154
|
+
|
|
155
|
+
const reset = () => {
|
|
156
|
+
clearTimeout(this.#heartbeatTimer)
|
|
157
|
+
this.#heartbeatTimer = setTimeout(() => socket.destroy(), timeoutMs)
|
|
158
|
+
this.#heartbeatTimer.unref()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
reset()
|
|
162
|
+
socket.on('data', reset)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#disarmWatchdog () {
|
|
166
|
+
clearTimeout(this.#heartbeatTimer)
|
|
167
|
+
this.#heartbeatTimer = null
|
|
168
|
+
}
|
|
169
|
+
|
|
109
170
|
#transient (exception) {
|
|
110
|
-
|
|
111
|
-
|
|
171
|
+
if (this.#running) return true
|
|
172
|
+
if (TRANSIENT_CODES.has(exception.code)) return true
|
|
173
|
+
if (TRANSIENT_MESSAGES.has(exception.message)) return true
|
|
112
174
|
|
|
113
|
-
return
|
|
175
|
+
return false
|
|
114
176
|
}
|
|
115
177
|
}
|
|
116
178
|
|
|
179
|
+
/** @type {number} */
|
|
180
|
+
const WATCHDOG_MS = 60_000
|
|
181
|
+
|
|
182
|
+
const TRANSIENT_CODES = new Set([
|
|
183
|
+
'ECONNREFUSED',
|
|
184
|
+
'EAI_AGAIN',
|
|
185
|
+
'ENOTFOUND',
|
|
186
|
+
'ETIMEDOUT',
|
|
187
|
+
'ECONNRESET',
|
|
188
|
+
'EHOSTUNREACH',
|
|
189
|
+
'ENETUNREACH'
|
|
190
|
+
])
|
|
191
|
+
|
|
192
|
+
const TRANSIENT_MESSAGES = new Set([
|
|
193
|
+
'Socket closed abruptly during opening handshake',
|
|
194
|
+
'Client network socket disconnected before secure TLS connection was established'
|
|
195
|
+
])
|
|
196
|
+
|
|
117
197
|
function noop () {}
|
|
118
198
|
|
|
119
199
|
exports.Connection = Connection
|
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']
|
|
4
|
+
exports.connection = ['open', 'close', 'error']
|
|
5
5
|
|
|
6
6
|
/** @type {comq.diagnostics.Event[]} */
|
|
7
7
|
exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume']
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/test/amqplib.mock.js
CHANGED
|
@@ -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
|
|
package/test/channel.test.js
CHANGED
|
@@ -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]
|
package/test/connection.test.js
CHANGED
|
@@ -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
|
|
54
|
-
|
|
55
|
-
|
|
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()
|