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/.github/workflows/dev.yaml +28 -0
- package/.github/workflows/release.yaml +3 -3
- package/.husky/pre-push +5 -0
- package/.releaserc +1 -1
- package/CHANGELOG.md +18 -0
- package/docker-compose.yaml +2 -2
- package/features/connection.feature +1 -0
- 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 +5 -3
- package/features/steps/backpressure.js +22 -4
- package/features/steps/brokers.js +1 -1
- package/features/steps/connection.js +15 -0
- package/features/steps/events.js +1 -1
- package/features/steps/rpc.js +1 -1
- package/features/steps/yaml.js +15 -0
- package/package.json +13 -7
- 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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Dev
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- dev
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
features-fast:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
env:
|
|
12
|
+
HUSKY: 0
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- name: Checkout
|
|
16
|
+
uses: actions/checkout@v7
|
|
17
|
+
|
|
18
|
+
- name: Setup Node
|
|
19
|
+
uses: actions/setup-node@v7
|
|
20
|
+
with:
|
|
21
|
+
node-version: "24"
|
|
22
|
+
package-manager-cache: false
|
|
23
|
+
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: npm ci
|
|
26
|
+
|
|
27
|
+
- name: Run fast feature tests
|
|
28
|
+
run: npm run features:fast
|
|
@@ -3,7 +3,7 @@ name: Release
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
5
|
branches:
|
|
6
|
-
-
|
|
6
|
+
- release
|
|
7
7
|
|
|
8
8
|
permissions:
|
|
9
9
|
contents: write
|
|
@@ -19,13 +19,13 @@ jobs:
|
|
|
19
19
|
|
|
20
20
|
steps:
|
|
21
21
|
- name: Checkout
|
|
22
|
-
uses: actions/checkout@
|
|
22
|
+
uses: actions/checkout@v7
|
|
23
23
|
with:
|
|
24
24
|
fetch-depth: 0
|
|
25
25
|
persist-credentials: false
|
|
26
26
|
|
|
27
27
|
- name: Setup Node
|
|
28
|
-
uses: actions/setup-node@
|
|
28
|
+
uses: actions/setup-node@v7
|
|
29
29
|
with:
|
|
30
30
|
node-version: "24"
|
|
31
31
|
registry-url: https://registry.npmjs.org
|
package/.husky/pre-push
ADDED
package/.releaserc
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
# [0.15.0](https://github.com/toa-io/comq/compare/v0.14.0...v0.15.0) (2026-08-01)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* avoid channel recover deadlock after broker restart ([8eeaf2e](https://github.com/toa-io/comq/commit/8eeaf2e12321be71b57913adb6a8fb04bd24d439)), closes [#recovery](https://github.com/toa-io/comq/issues/recovery)
|
|
7
|
+
* destroy half-open connections via socket idle watchdog ([9f7ed5f](https://github.com/toa-io/comq/commit/9f7ed5fe50e9ab1857547a6e908185b6b472162f))
|
|
8
|
+
* ignore close events from replaced AMQP connections ([c1070ab](https://github.com/toa-io/comq/commit/c1070abeca9eb30a1c0ed07925bc06e530c1d9ce))
|
|
9
|
+
* reconnect hang after broker restart ([#235](https://github.com/toa-io/comq/issues/235)) ([be07079](https://github.com/toa-io/comq/commit/be07079863194301d0279e2bdd551260196ce20a))
|
|
10
|
+
* retry transient network errors on initial connect ([c436a20](https://github.com/toa-io/comq/commit/c436a2017a2b52d102afa05bc694c9f22faf2065)), closes [#transient](https://github.com/toa-io/comq/issues/transient)
|
|
11
|
+
* tolerate empty YAML documents after js-yaml 5 bump ([0a05ecb](https://github.com/toa-io/comq/commit/0a05ecbf744822b3aa884382642ce51a93922dfc))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
* allow request() to time out pending replies ([ac9e2bd](https://github.com/toa-io/comq/commit/ac9e2bd7eb0834d19d404c84f24548bd5475017f))
|
|
17
|
+
* tag container-manipulation features [@heavy](https://github.com/heavy) and add features:fast script ([c0f52ec](https://github.com/toa-io/comq/commit/c0f52ecf72c010e387e853b3daed6884c1cef474))
|
|
18
|
+
|
|
1
19
|
# [0.14.0](https://github.com/toa-io/comq/compare/v0.13.0...v0.14.0) (2026-05-24)
|
|
2
20
|
|
|
3
21
|
|
package/docker-compose.yaml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
services:
|
|
2
2
|
rabbitmq-0:
|
|
3
|
-
image: rabbitmq:3
|
|
3
|
+
image: rabbitmq:4.3-management
|
|
4
4
|
container_name: comq-rmq-0
|
|
5
5
|
ports:
|
|
6
6
|
- '5673:5672'
|
|
@@ -18,7 +18,7 @@ services:
|
|
|
18
18
|
limits:
|
|
19
19
|
memory: 512MB
|
|
20
20
|
rabbitmq-1:
|
|
21
|
-
image: rabbitmq:3
|
|
21
|
+
image: rabbitmq:4.3-management
|
|
22
22
|
container_name: comq-rmq-1
|
|
23
23
|
ports:
|
|
24
24
|
- '5674:5672'
|
|
@@ -64,6 +64,7 @@ Feature: Reply streams
|
|
|
64
64
|
And after 200ms
|
|
65
65
|
Then the generator is destroyed
|
|
66
66
|
|
|
67
|
+
@heavy
|
|
67
68
|
Scenario: Broker crashes while streaming reply
|
|
68
69
|
Given a number generator with 90ms increasing delay replying `get_numbers` queue
|
|
69
70
|
When the consumer requests a stream with request to the `get_numbers` queue
|
|
@@ -21,6 +21,7 @@ Feature: Reply streams over shards
|
|
|
21
21
|
[0, 1, 2, 3, 4]
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
|
+
@heavy
|
|
24
25
|
Scenario: Broker crashes while fetching a stream
|
|
25
26
|
Given a number generator with 90ms increasing delay replying `get_numbers` queue
|
|
26
27
|
When the consumer requests a stream with request to the `get_numbers` queue
|
package/features/shards.feature
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
@heavy
|
|
1
2
|
Feature: Sharded Connection
|
|
2
3
|
|
|
3
4
|
Scenario: Establishing sharded connection
|
|
@@ -5,9 +6,10 @@ Feature: Sharded Connection
|
|
|
5
6
|
|
|
6
7
|
Scenario: Connecting while a shard is down
|
|
7
8
|
Given one of the brokers has crashed
|
|
8
|
-
When I attempt to establish sharded connection
|
|
9
|
-
Then
|
|
10
|
-
|
|
9
|
+
When I attempt to establish sharded connection for 1 second
|
|
10
|
+
Then the connection is not established
|
|
11
|
+
When the broker is up
|
|
12
|
+
Then the connection is established
|
|
11
13
|
|
|
12
14
|
Scenario: Connecting with wrong credentials
|
|
13
15
|
When I attempt to establish a sharded connection as "someone" with password "whatever"
|
|
@@ -6,8 +6,8 @@ const { quantity, timeout } = require('@toa.io/generic')
|
|
|
6
6
|
|
|
7
7
|
const { When } = require('@cucumber/cucumber')
|
|
8
8
|
|
|
9
|
-
const
|
|
10
|
-
const
|
|
9
|
+
const BASE_PAYLOAD = quantity('500k')
|
|
10
|
+
const BASE_BATCH = 20
|
|
11
11
|
const DEADLINE = 25_000
|
|
12
12
|
|
|
13
13
|
When('I\'m flooding the {token} queue until back pressure is applied',
|
|
@@ -16,15 +16,33 @@ When('I\'m flooding the {token} queue until back pressure is applied',
|
|
|
16
16
|
* @this {comq.features.Context}
|
|
17
17
|
*/
|
|
18
18
|
async function (queue) {
|
|
19
|
-
const buffer = randomBytes(PAYLOAD)
|
|
20
19
|
const deadline = Date.now() + DEADLINE
|
|
21
20
|
const pending = []
|
|
21
|
+
const errors = []
|
|
22
|
+
let iteration = 0
|
|
22
23
|
|
|
23
24
|
while (!this.events.flow && Date.now() < deadline) {
|
|
24
|
-
|
|
25
|
+
if (errors.length > 0) throw errors[0]
|
|
25
26
|
|
|
27
|
+
const payload = BASE_PAYLOAD * (iteration + 1)
|
|
28
|
+
const batch = BASE_BATCH * (iteration + 1)
|
|
29
|
+
const buffer = randomBytes(payload)
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < batch; i++) {
|
|
32
|
+
pending.push(
|
|
33
|
+
this.io.request(queue, buffer).catch((exception) => {
|
|
34
|
+
errors.push(exception)
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
iteration++
|
|
26
40
|
await timeout(0)
|
|
41
|
+
|
|
42
|
+
if (errors.length > 0) throw errors[0]
|
|
27
43
|
}
|
|
28
44
|
|
|
45
|
+
if (errors.length > 0) throw errors[0]
|
|
46
|
+
|
|
29
47
|
assert.equal(this.events.flow, true, 'Back pressure hasn\'t been applied')
|
|
30
48
|
})
|
|
@@ -8,7 +8,7 @@ const { getContainerRuntimeClient } = require('testcontainers')
|
|
|
8
8
|
|
|
9
9
|
const docker = promisify(execFile)
|
|
10
10
|
|
|
11
|
-
const IMAGE = 'rabbitmq:3
|
|
11
|
+
const IMAGE = 'rabbitmq:4.3-management'
|
|
12
12
|
const USER = 'developer'
|
|
13
13
|
const PASSWORD = 'secret'
|
|
14
14
|
const AMQP_PORT = 5672
|
|
@@ -88,6 +88,21 @@ When('I attempt to establish sharded connection',
|
|
|
88
88
|
await connect(this)
|
|
89
89
|
})
|
|
90
90
|
|
|
91
|
+
When('I attempt to establish sharded connection for {number} second(s)',
|
|
92
|
+
/**
|
|
93
|
+
* @param {number} interval
|
|
94
|
+
* @this {comq.features.Context}
|
|
95
|
+
*/
|
|
96
|
+
async function (interval) {
|
|
97
|
+
this.sharded = true
|
|
98
|
+
|
|
99
|
+
const gap = timeout(interval * 1000)
|
|
100
|
+
|
|
101
|
+
this.connecting = connect(this)
|
|
102
|
+
|
|
103
|
+
await Promise.any([this.connecting, gap])
|
|
104
|
+
})
|
|
105
|
+
|
|
91
106
|
When('I attempt to establish a sharded connection as {string} with password {string}',
|
|
92
107
|
/**
|
|
93
108
|
* @param {string} user
|
package/features/steps/events.js
CHANGED
|
@@ -4,7 +4,7 @@ const stream = require('node:stream')
|
|
|
4
4
|
const assert = require('node:assert')
|
|
5
5
|
const { randomBytes } = require('node:crypto')
|
|
6
6
|
const { timeout, quantity, match } = require('@toa.io/generic')
|
|
7
|
-
const {
|
|
7
|
+
const { parse } = require('./yaml')
|
|
8
8
|
const { Given, When, Then } = require('@cucumber/cucumber')
|
|
9
9
|
|
|
10
10
|
Given('(that ){token} is consuming events from the {token} exchange',
|
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,29 +23,34 @@
|
|
|
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
|
-
"@
|
|
30
|
+
"@cucumber/cucumber": "^13.2.0",
|
|
31
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
28
32
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
29
33
|
"@semantic-release/git": "^10.0.1",
|
|
30
34
|
"@semantic-release/github": "^12.0.2",
|
|
31
35
|
"@semantic-release/npm": "^13.1.2",
|
|
32
36
|
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
33
|
-
"@
|
|
34
|
-
"@testcontainers/rabbitmq": "^12.0.0",
|
|
37
|
+
"@testcontainers/rabbitmq": "^12.0.4",
|
|
35
38
|
"@toa.io/generic": "^1.0.0-alpha.208",
|
|
36
39
|
"@types/amqplib": "^0.10.8",
|
|
37
40
|
"@types/jest": "^30.0.0",
|
|
41
|
+
"fast-glob": "^3.3.3",
|
|
42
|
+
"fuzzysort": "3.1.0",
|
|
38
43
|
"husky": "^9.1.7",
|
|
39
44
|
"jest": "^30.4.2",
|
|
40
|
-
"js-yaml": "^
|
|
45
|
+
"js-yaml": "^5.2.3",
|
|
41
46
|
"randomstring": "^1.3.1",
|
|
42
47
|
"snazzy": "^9.0.0",
|
|
43
48
|
"standard": "^17.1.2",
|
|
44
|
-
"testcontainers": "^12.0.
|
|
49
|
+
"testcontainers": "^12.0.4"
|
|
45
50
|
},
|
|
46
51
|
"dependencies": {
|
|
47
52
|
"amqplib": "^2.0.1",
|
|
48
|
-
"msgpackr": "^2.0.
|
|
53
|
+
"msgpackr": "^2.0.5",
|
|
49
54
|
"promex": "^0.1.1",
|
|
50
55
|
"reretry": "^0.1.0"
|
|
51
56
|
},
|
|
@@ -53,6 +58,7 @@
|
|
|
53
58
|
"prepare": "husky",
|
|
54
59
|
"test": "standard && jest",
|
|
55
60
|
"features": "cucumber-js --fail-fast -t 'not @manual'",
|
|
61
|
+
"features:fast": "cucumber-js --fail-fast -t 'not @manual and not @heavy'",
|
|
56
62
|
"lint": "standard --fix --verbose | snazzy"
|
|
57
63
|
},
|
|
58
64
|
"standard": {
|
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']
|