comq 0.12.0 → 0.14.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/codeql.yml +4 -4
- package/.github/workflows/release.yaml +25 -55
- package/.releaserc +35 -0
- package/AGENTS.md +35 -0
- package/CHANGELOG.md +11 -0
- package/examples/streams/consumer.js +3 -3
- package/examples/streams/producer.js +2 -0
- package/features/backpressure.feature +1 -2
- package/features/readme.md +9 -2
- package/features/shards.feature +1 -1
- package/features/steps/backpressure.js +13 -29
- package/features/steps/broker.js +4 -30
- package/features/steps/brokers.js +125 -0
- package/features/steps/context.js +2 -4
- package/features/steps/hooks.js +14 -1
- package/fixplan.md +507 -0
- package/package.json +22 -21
- package/types/io.d.ts +28 -24
- package/lerna.json +0 -4
|
@@ -42,11 +42,11 @@ jobs:
|
|
|
42
42
|
|
|
43
43
|
steps:
|
|
44
44
|
- name: Checkout repository
|
|
45
|
-
uses: actions/checkout@
|
|
45
|
+
uses: actions/checkout@v6
|
|
46
46
|
|
|
47
47
|
# Initializes the CodeQL tools for scanning.
|
|
48
48
|
- name: Initialize CodeQL
|
|
49
|
-
uses: github/codeql-action/init@
|
|
49
|
+
uses: github/codeql-action/init@v4
|
|
50
50
|
with:
|
|
51
51
|
languages: ${{ matrix.language }}
|
|
52
52
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
|
@@ -60,7 +60,7 @@ jobs:
|
|
|
60
60
|
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
|
61
61
|
# If this step fails, then you should remove it and run the build manually (see below)
|
|
62
62
|
- name: Autobuild
|
|
63
|
-
uses: github/codeql-action/autobuild@
|
|
63
|
+
uses: github/codeql-action/autobuild@v4
|
|
64
64
|
|
|
65
65
|
# ℹ️ Command-line programs to run using the OS shell.
|
|
66
66
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
|
@@ -73,6 +73,6 @@ jobs:
|
|
|
73
73
|
# ./location_of_script_within_repo/buildscript.sh
|
|
74
74
|
|
|
75
75
|
- name: Perform CodeQL Analysis
|
|
76
|
-
uses: github/codeql-action/analyze@
|
|
76
|
+
uses: github/codeql-action/analyze@v4
|
|
77
77
|
with:
|
|
78
78
|
category: "/language:${{matrix.language}}"
|
|
@@ -1,73 +1,43 @@
|
|
|
1
1
|
name: Release
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
|
-
|
|
5
|
-
types: [closed]
|
|
4
|
+
push:
|
|
6
5
|
branches:
|
|
7
|
-
-
|
|
8
|
-
- alpha
|
|
9
|
-
paths:
|
|
10
|
-
- 'source/**'
|
|
11
|
-
- 'types/**'
|
|
12
|
-
- 'package-lock.json'
|
|
6
|
+
- dev
|
|
13
7
|
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
issues: write
|
|
11
|
+
pull-requests: write
|
|
12
|
+
id-token: write
|
|
16
13
|
|
|
17
14
|
jobs:
|
|
18
|
-
|
|
19
|
-
if: github.event.pull_request.merged == true
|
|
15
|
+
release:
|
|
20
16
|
runs-on: ubuntu-latest
|
|
17
|
+
env:
|
|
18
|
+
HUSKY: 0
|
|
21
19
|
|
|
22
20
|
steps:
|
|
23
|
-
- name: Setup Node
|
|
24
|
-
uses: actions/setup-node@v4
|
|
25
|
-
with:
|
|
26
|
-
node-version: 20
|
|
27
|
-
registry-url: 'https://registry.npmjs.org'
|
|
28
|
-
|
|
29
21
|
- name: Checkout
|
|
30
|
-
uses: actions/checkout@
|
|
22
|
+
uses: actions/checkout@v6
|
|
31
23
|
with:
|
|
32
24
|
fetch-depth: 0
|
|
25
|
+
persist-credentials: false
|
|
33
26
|
|
|
34
|
-
- name:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
- name: Import GPG key
|
|
38
|
-
id: gpg
|
|
39
|
-
uses: crazy-max/ghaction-import-gpg@v6
|
|
27
|
+
- name: Setup Node
|
|
28
|
+
uses: actions/setup-node@v6
|
|
40
29
|
with:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
git_commit_gpgsign: true
|
|
30
|
+
node-version: "24"
|
|
31
|
+
registry-url: https://registry.npmjs.org
|
|
32
|
+
package-manager-cache: false
|
|
45
33
|
|
|
46
|
-
- name:
|
|
47
|
-
run:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
npx lerna@7.3.0 version --conventional-prerelease --preid alpha --dist-tag alpha --no-commit-hooks --no-changelog --yes --message "ci: %s" --git-tag-command "echo %s" --sign-git-commit
|
|
52
|
-
fi
|
|
53
|
-
|
|
54
|
-
git fetch
|
|
55
|
-
git checkout dev
|
|
56
|
-
git merge ${{ github.base_ref }}
|
|
57
|
-
git push --all origin --no-verify
|
|
58
|
-
env:
|
|
59
|
-
GITHUB_TOKEN: ${{ secrets.TOABOT_GITHUB_TOKEN }}
|
|
60
|
-
GIT_AUTHOR_NAME: ${{ steps.gpg.outputs.name }}
|
|
61
|
-
GIT_AUTHOR_EMAIL: ${{ steps.gpg.outputs.email }}
|
|
62
|
-
GIT_COMMITTER_NAME: ${{ steps.gpg.outputs.name }}
|
|
63
|
-
GIT_COMMITTER_EMAIL: ${{ steps.gpg.outputs.email }}
|
|
34
|
+
- name: Install dependencies
|
|
35
|
+
run: npm ci
|
|
36
|
+
|
|
37
|
+
- name: Run tests
|
|
38
|
+
run: npm test
|
|
64
39
|
|
|
65
|
-
- name:
|
|
40
|
+
- name: Semantic release
|
|
66
41
|
env:
|
|
67
|
-
|
|
68
|
-
run:
|
|
69
|
-
if [ ${{ github.base_ref }} = release ]; then
|
|
70
|
-
npm publish
|
|
71
|
-
else
|
|
72
|
-
npm publish --tag alpha
|
|
73
|
-
fi
|
|
42
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
43
|
+
run: npx semantic-release
|
package/.releaserc
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"branches": [
|
|
3
|
+
"dev"
|
|
4
|
+
],
|
|
5
|
+
"plugins": [
|
|
6
|
+
"@semantic-release/commit-analyzer",
|
|
7
|
+
"@semantic-release/release-notes-generator",
|
|
8
|
+
[
|
|
9
|
+
"@semantic-release/changelog",
|
|
10
|
+
{
|
|
11
|
+
"changelogFile": "CHANGELOG.md"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
[
|
|
15
|
+
"@semantic-release/npm"
|
|
16
|
+
],
|
|
17
|
+
[
|
|
18
|
+
"@semantic-release/git",
|
|
19
|
+
{
|
|
20
|
+
"assets": [
|
|
21
|
+
"package.json",
|
|
22
|
+
"package-lock.json",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
"@semantic-release/github",
|
|
30
|
+
{
|
|
31
|
+
"owner": "toa-io"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
]
|
|
35
|
+
}
|
package/AGENTS.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
## Cursor Cloud specific instructions
|
|
4
|
+
|
|
5
|
+
### Project Overview
|
|
6
|
+
|
|
7
|
+
ComQ is a Node.js communication library over AMQP (RabbitMQ). It provides RPC, pub/sub, task queues, pipelines, and sharded connections.
|
|
8
|
+
|
|
9
|
+
### Services
|
|
10
|
+
|
|
11
|
+
Feature tests start two RabbitMQ instances automatically via Testcontainers (`features/steps/brokers.js`). Credentials: `developer` / `secret`. AMQP ports are assigned dynamically on the host.
|
|
12
|
+
|
|
13
|
+
### Docker
|
|
14
|
+
|
|
15
|
+
Feature tests require a running Docker daemon. In nested container environments, start Docker with:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
sudo dockerd --exec-opt native.cgroupdriver=cgroupfs &
|
|
19
|
+
sudo chmod 666 /var/run/docker.sock
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Do not use `docker compose up` for feature tests — Testcontainers manages broker lifecycle. `docker compose` may fail here because `deploy.resources.limits.memory` requires cgroupv2 memory limits that are unavailable in nested setups.
|
|
23
|
+
|
|
24
|
+
### Commands
|
|
25
|
+
|
|
26
|
+
- `npm test` — runs `standard` (lint) + `jest` (300 unit tests, no RabbitMQ required)
|
|
27
|
+
- `npm run features` — runs all Cucumber feature tests (starts RabbitMQ via Testcontainers)
|
|
28
|
+
- `npm run lint` — runs `standard --fix --verbose | snazzy`
|
|
29
|
+
|
|
30
|
+
### Testing Notes
|
|
31
|
+
|
|
32
|
+
- Unit tests (`jest`) use mocks and do NOT require running RabbitMQ.
|
|
33
|
+
- Feature tests (`cucumber-js`) start brokers in `BeforeAll` and stop them in `AfterAll`.
|
|
34
|
+
- Recovery/connection tests (in `features/connection.feature`, `features/recovery.feature`, `features/shards.feature`) stop/kill broker containers and take 5-15 minutes to complete.
|
|
35
|
+
- To run only fast feature tests (no broker stop/start): `npx cucumber-js --fail-fast -t 'not @manual' features/events.feature features/rpc.feature features/tasks.feature features/singleton.feature features/properties.feature features/tasks.shards.feature`
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# [0.14.0](https://github.com/toa-io/comq/compare/v0.13.0...v0.14.0) (2026-05-24)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **features:** manage RabbitMQ brokers with Testcontainers ([1d9ca59](https://github.com/toa-io/comq/commit/1d9ca591ff458c75caf87ab63224b15942ba7d07))
|
|
7
|
+
* **features:** use Testcontainers for RabbitMQ in Gherkin tests ([#219](https://github.com/toa-io/comq/issues/219)) ([a33e66b](https://github.com/toa-io/comq/commit/a33e66bfc357f0f089c728bc60c08f6f24b2c8d1))
|
|
8
|
+
|
|
9
|
+
# Changelog
|
|
10
|
+
|
|
11
|
+
All notable changes to this project are documented in this file by [semantic-release](https://github.com/semantic-release/semantic-release).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { connect } from 'comq'
|
|
2
2
|
import { url } from '../const.js'
|
|
3
3
|
|
|
4
|
-
const limit = Number(process.argv[2])
|
|
4
|
+
const limit = Number(process.argv[2]) ?? Math.round(Math.random() * 8 + 2)
|
|
5
5
|
|
|
6
6
|
let io
|
|
7
7
|
let stream
|
|
@@ -19,9 +19,9 @@ async function run () {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
async function fetch () {
|
|
22
|
-
console.log(`Fetching stream with ${limit} numbers`)
|
|
22
|
+
console.log(`Fetching stream with ${limit === 0 ? 'infinite' : limit} numbers`)
|
|
23
23
|
|
|
24
|
-
stream = await io.
|
|
24
|
+
stream = await io.request('get_numbers', { limit })
|
|
25
25
|
|
|
26
26
|
for await (const number of stream) console.log('Received:', number)
|
|
27
27
|
|
package/features/readme.md
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
# Features
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Feature tests start two RabbitMQ instances automatically via [Testcontainers](https://node.testcontainers.org/modules/rabbitmq/).
|
|
4
|
+
|
|
5
|
+
Run them with:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm run features
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Docker must be available on the host. No manual `docker compose` setup is required.
|
package/features/shards.feature
CHANGED
|
@@ -41,7 +41,7 @@ Feature: Sharded Connection
|
|
|
41
41
|
Scenario: Shard crashes while publishing events
|
|
42
42
|
Given an active sharded connection
|
|
43
43
|
And events are exclusively consumed from the `flood` exchange
|
|
44
|
-
And I'm publishing 1kB events to the `flood` exchange at
|
|
44
|
+
And I'm publishing 1kB events to the `flood` exchange at 10Hz
|
|
45
45
|
When one of the brokers has crashed
|
|
46
46
|
Then no exceptions are thrown
|
|
47
47
|
And all events have been received
|
|
@@ -4,43 +4,27 @@ const assert = require('node:assert')
|
|
|
4
4
|
const { randomBytes } = require('node:crypto')
|
|
5
5
|
const { quantity, timeout } = require('@toa.io/generic')
|
|
6
6
|
|
|
7
|
-
const { When
|
|
7
|
+
const { When } = require('@cucumber/cucumber')
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
const PAYLOAD = quantity('500k')
|
|
10
|
+
const BATCH = 20
|
|
11
|
+
const DEADLINE = 25_000
|
|
12
|
+
|
|
13
|
+
When('I\'m flooding the {token} queue until back pressure is applied',
|
|
10
14
|
/**
|
|
11
|
-
* @param {string} bytesQ
|
|
12
15
|
* @param {string} queue
|
|
13
|
-
* @param {string} frequencyQ
|
|
14
|
-
* @param {number} seconds
|
|
15
16
|
* @this {comq.features.Context}
|
|
16
17
|
*/
|
|
17
|
-
async function (
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const times = seconds * frequency
|
|
22
|
-
|
|
23
|
-
// intervals less than 1ms are not accurate
|
|
24
|
-
const interval = Math.max((1000 / frequency), 1)
|
|
25
|
-
const each = Math.max(1 / (1000 / frequency), 1)
|
|
26
|
-
|
|
27
|
-
const promises = []
|
|
18
|
+
async function (queue) {
|
|
19
|
+
const buffer = randomBytes(PAYLOAD)
|
|
20
|
+
const deadline = Date.now() + DEADLINE
|
|
21
|
+
const pending = []
|
|
28
22
|
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
while (!this.events.flow && Date.now() < deadline) {
|
|
24
|
+
for (let i = 0; i < BATCH; i++) { pending.push(this.io.request(queue, buffer).catch(() => {})) }
|
|
31
25
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if ((i + 1) % each === 0) await timeout(interval)
|
|
26
|
+
await timeout(0)
|
|
35
27
|
}
|
|
36
28
|
|
|
37
|
-
await Promise.all(promises)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
Then('back pressure was applied',
|
|
41
|
-
/**
|
|
42
|
-
* @this {comq.features.Context}
|
|
43
|
-
*/
|
|
44
|
-
function () {
|
|
45
29
|
assert.equal(this.events.flow, true, 'Back pressure hasn\'t been applied')
|
|
46
30
|
})
|
package/features/steps/broker.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const {
|
|
4
|
-
const { execute } = require('@toa.io/command')
|
|
3
|
+
const { random } = require('@toa.io/generic')
|
|
5
4
|
|
|
6
5
|
const { Given } = require('@cucumber/cucumber')
|
|
6
|
+
const { BROKERS_AMOUNT, actions } = require('./brokers')
|
|
7
7
|
|
|
8
8
|
Given('the broker is/has {status}',
|
|
9
9
|
/**
|
|
10
|
-
* @param {'up' | 'down'} status
|
|
10
|
+
* @param {'up' | 'down' | 'crashed'} status
|
|
11
11
|
* @this {comq.features.Context}
|
|
12
12
|
*/
|
|
13
13
|
async function (status) {
|
|
@@ -18,7 +18,7 @@ Given('the broker is/has {status}',
|
|
|
18
18
|
|
|
19
19
|
Given('one of the brokers is/has {status}',
|
|
20
20
|
/**
|
|
21
|
-
* @param {'up' | 'down'} status
|
|
21
|
+
* @param {'up' | 'down' | 'crashed'} status
|
|
22
22
|
* @this {comq.features.Context}
|
|
23
23
|
*/
|
|
24
24
|
async function (status) {
|
|
@@ -28,29 +28,3 @@ Given('one of the brokers is/has {status}',
|
|
|
28
28
|
|
|
29
29
|
await actions[status](shard)
|
|
30
30
|
})
|
|
31
|
-
|
|
32
|
-
const actions = {
|
|
33
|
-
up: async (n = 0) => {
|
|
34
|
-
await execute('docker start comq-rmq-' + n)
|
|
35
|
-
await healthy(n)
|
|
36
|
-
},
|
|
37
|
-
down: async (n = 0) => {
|
|
38
|
-
await execute('docker stop comq-rmq-' + n)
|
|
39
|
-
},
|
|
40
|
-
crashed: async (n = 0) => {
|
|
41
|
-
await execute('docker kill comq-rmq-' + n)
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function healthy (n) {
|
|
46
|
-
let process
|
|
47
|
-
|
|
48
|
-
do {
|
|
49
|
-
await timeout(HEALTHCHECK_INTERVAL)
|
|
50
|
-
|
|
51
|
-
process = await execute('docker inspect -f {{.State.Health.Status}} comq-rmq-' + n)
|
|
52
|
-
} while (process.output !== 'healthy')
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const BROKERS_AMOUNT = 2
|
|
56
|
-
const HEALTHCHECK_INTERVAL = global.COMQ_TESTING_HEALTHCHECK_INTERVAL ?? 1000
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { execFile } = require('node:child_process')
|
|
4
|
+
const { promisify } = require('node:util')
|
|
5
|
+
const { timeout } = require('@toa.io/generic')
|
|
6
|
+
const { RabbitMQContainer } = require('@testcontainers/rabbitmq')
|
|
7
|
+
const { getContainerRuntimeClient } = require('testcontainers')
|
|
8
|
+
|
|
9
|
+
const docker = promisify(execFile)
|
|
10
|
+
|
|
11
|
+
const IMAGE = 'rabbitmq:3.10.0-management'
|
|
12
|
+
const USER = 'developer'
|
|
13
|
+
const PASSWORD = 'secret'
|
|
14
|
+
const AMQP_PORT = 5672
|
|
15
|
+
const BROKERS_AMOUNT = 2
|
|
16
|
+
const HOST_PORTS = [5673, 5674]
|
|
17
|
+
const HEALTHCHECK_INTERVAL = global.COMQ_TESTING_HEALTHCHECK_INTERVAL ?? 1000
|
|
18
|
+
|
|
19
|
+
/** @type {import('@testcontainers/rabbitmq').StartedRabbitMQContainer[]} */
|
|
20
|
+
const brokers = []
|
|
21
|
+
|
|
22
|
+
async function startBrokers () {
|
|
23
|
+
if (brokers.length > 0) return
|
|
24
|
+
|
|
25
|
+
for (let n = 0; n < BROKERS_AMOUNT; n++) await removeStale(`comq-rmq-${n}`)
|
|
26
|
+
|
|
27
|
+
const started = await Promise.all(
|
|
28
|
+
HOST_PORTS.map((hostPort, n) =>
|
|
29
|
+
new RabbitMQContainer(IMAGE)
|
|
30
|
+
.withEnvironment({
|
|
31
|
+
RABBITMQ_DEFAULT_USER: USER,
|
|
32
|
+
RABBITMQ_DEFAULT_PASS: PASSWORD
|
|
33
|
+
})
|
|
34
|
+
.withAutoRemove(false)
|
|
35
|
+
.withName(`comq-rmq-${n}`)
|
|
36
|
+
.withExposedPorts({ container: AMQP_PORT, host: hostPort })
|
|
37
|
+
.withHealthCheck({
|
|
38
|
+
test: ['CMD', 'rabbitmq-diagnostics', '-q', 'ping'],
|
|
39
|
+
interval: 5000,
|
|
40
|
+
timeout: 1000,
|
|
41
|
+
retries: 5
|
|
42
|
+
})
|
|
43
|
+
.start()
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
brokers.push(...started)
|
|
48
|
+
|
|
49
|
+
await Promise.all(
|
|
50
|
+
Array.from({ length: BROKERS_AMOUNT }, (_, n) => healthy(n))
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function stopBrokers () {
|
|
55
|
+
await Promise.all(brokers.map((broker) => broker.stop({ remove: true })))
|
|
56
|
+
brokers.length = 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} name
|
|
61
|
+
*/
|
|
62
|
+
async function removeStale (name) {
|
|
63
|
+
const client = await getContainerRuntimeClient()
|
|
64
|
+
const listed = await client.container.list()
|
|
65
|
+
|
|
66
|
+
for (const info of listed) {
|
|
67
|
+
if (!info.Names.some((entry) => entry === `/${name}`)) continue
|
|
68
|
+
|
|
69
|
+
const container = client.container.getById(info.Id)
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await client.container.stop(container, { timeout: 0 })
|
|
73
|
+
} catch {
|
|
74
|
+
// already stopped
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await container.remove({ force: true })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {number} [n]
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function getAddress (n = 0) {
|
|
86
|
+
if (brokers[n] === undefined) throw new Error(`Broker ${n} is not started`)
|
|
87
|
+
|
|
88
|
+
return `localhost:${HOST_PORTS[n]}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {number} [n]
|
|
93
|
+
*/
|
|
94
|
+
async function healthy (n = 0) {
|
|
95
|
+
do {
|
|
96
|
+
await timeout(HEALTHCHECK_INTERVAL)
|
|
97
|
+
|
|
98
|
+
const { stdout } = await docker('docker', ['inspect', '-f', '{{.State.Health.Status}}', `comq-rmq-${n}`])
|
|
99
|
+
|
|
100
|
+
if (stdout.trim() === 'healthy') return
|
|
101
|
+
} while (true)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const actions = {
|
|
105
|
+
up: async (n = 0) => {
|
|
106
|
+
await docker('docker', ['start', `comq-rmq-${n}`])
|
|
107
|
+
await healthy(n)
|
|
108
|
+
},
|
|
109
|
+
down: async (n = 0) => {
|
|
110
|
+
await docker('docker', ['stop', `comq-rmq-${n}`])
|
|
111
|
+
},
|
|
112
|
+
crashed: async (n = 0) => {
|
|
113
|
+
await docker('docker', ['kill', `comq-rmq-${n}`])
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
BROKERS_AMOUNT,
|
|
119
|
+
USER,
|
|
120
|
+
PASSWORD,
|
|
121
|
+
startBrokers,
|
|
122
|
+
stopBrokers,
|
|
123
|
+
getAddress,
|
|
124
|
+
actions
|
|
125
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { World } = require('@cucumber/cucumber')
|
|
4
4
|
const { connect, assert } = require('../../')
|
|
5
|
+
const { getAddress, USER, PASSWORD } = require('./brokers')
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* @implements {comq.features.Context}
|
|
@@ -88,14 +89,11 @@ class Context extends World {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
#url (i, user, password) {
|
|
91
|
-
return PROTOCOL + user + ':' + password + '@' +
|
|
92
|
+
return PROTOCOL + user + ':' + password + '@' + getAddress(i)
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
const PROTOCOL = 'amqp://'
|
|
96
|
-
const SHARDS = ['localhost:5673', 'localhost:5674']
|
|
97
|
-
const USER = 'developer'
|
|
98
|
-
const PASSWORD = 'secret'
|
|
99
97
|
|
|
100
98
|
/** @type {comq.diagnostics.Event[]} */
|
|
101
99
|
const EVENTS = ['open', 'close', 'flow', 'discard']
|
package/features/steps/hooks.js
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { After } = require('@cucumber/cucumber')
|
|
3
|
+
const { BeforeAll, After, AfterAll } = require('@cucumber/cucumber')
|
|
4
|
+
const { startBrokers, stopBrokers, actions, BROKERS_AMOUNT } = require('./brokers')
|
|
5
|
+
|
|
6
|
+
BeforeAll({ timeout: 120_000 }, async function () {
|
|
7
|
+
await startBrokers()
|
|
8
|
+
})
|
|
4
9
|
|
|
5
10
|
After(
|
|
6
11
|
/**
|
|
7
12
|
* @this {comq.features.Context}
|
|
8
13
|
*/
|
|
9
14
|
async function () {
|
|
15
|
+
await Promise.all(
|
|
16
|
+
Array.from({ length: BROKERS_AMOUNT }, (_, n) => actions.up(n))
|
|
17
|
+
)
|
|
18
|
+
|
|
10
19
|
await this.disconnect()
|
|
11
20
|
})
|
|
21
|
+
|
|
22
|
+
AfterAll(async function () {
|
|
23
|
+
await stopBrokers()
|
|
24
|
+
})
|
package/fixplan.md
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
# comq — reconnection hang fix plan
|
|
2
|
+
|
|
3
|
+
This document describes the defects identified in the single-connection
|
|
4
|
+
reconnection path (`source/connection.js`, `source/channel.js`,
|
|
5
|
+
`source/attributes/failsafe.js`) and concrete, minimally-invasive fixes for
|
|
6
|
+
each one. Problems are ordered by their likelihood of producing the observed
|
|
7
|
+
symptom: *"after RabbitMQ connection is lost, the application is sometimes
|
|
8
|
+
never recovered."*
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Problem 1 — Deadlock in `Channel.recover()` via `recall()` + `failsafe`
|
|
13
|
+
|
|
14
|
+
### Symptom
|
|
15
|
+
|
|
16
|
+
During reconnect, `Channel.recover()` rebuilds the AMQP channel and then
|
|
17
|
+
replays previously recorded `consume` / `subscribe` calls via `recall(this)`.
|
|
18
|
+
`#recovery` is resolved only *after* `recall(this)` completes:
|
|
19
|
+
|
|
20
|
+
```142:160:source/channel.js
|
|
21
|
+
async recover (connection) {
|
|
22
|
+
this.#connection = connection
|
|
23
|
+
|
|
24
|
+
await this.create()
|
|
25
|
+
|
|
26
|
+
lazy.reset(this)
|
|
27
|
+
await recall(this)
|
|
28
|
+
|
|
29
|
+
this.#unpause(INTERRUPTION)
|
|
30
|
+
|
|
31
|
+
for (const confirmation of this.#confirmations) confirmation.reject(INTERRUPTION)
|
|
32
|
+
|
|
33
|
+
// handle interruptions
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
35
|
+
|
|
36
|
+
this.#recovery.resolve()
|
|
37
|
+
this.#recovery = new Promex()
|
|
38
|
+
this.#diagnostics.emit('recover')
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The replayed methods are wrapped in `failsafe(this, this.#recover, ...)`. If
|
|
43
|
+
any replayed operation throws a *non-permanent* exception (`Channel closed`,
|
|
44
|
+
`Channel ended, no reply will be forthcoming`, or the internal
|
|
45
|
+
`INTERRUPTION`), `failsafe` calls:
|
|
46
|
+
|
|
47
|
+
```333:336:source/channel.js
|
|
48
|
+
async #recover (exception) {
|
|
49
|
+
if (permanent(exception)) return false
|
|
50
|
+
else await this.#recovery
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`failsafe` now awaits `this.#recovery`, which is the same Promex the enclosing
|
|
55
|
+
`recover()` is supposed to resolve further down. `recall(this)` cannot
|
|
56
|
+
finish, so `#recovery` is never resolved — the channel is stuck, and every
|
|
57
|
+
`send`/`publish`/`consume` parked on `failsafe → #recovery` hangs **forever**.
|
|
58
|
+
|
|
59
|
+
This reliably happens when the freshly created channel dies mid-setup, which
|
|
60
|
+
is common after a broker restart while heartbeats are still flapping, or
|
|
61
|
+
when `#assertQueue`/`#assertExchange` hits a transient amqplib "Channel
|
|
62
|
+
closed" race.
|
|
63
|
+
|
|
64
|
+
### Fix
|
|
65
|
+
|
|
66
|
+
Add a reentrancy guard so that `failsafe.#recover` refuses to wait on
|
|
67
|
+
`#recovery` while `recover()` is itself running — it must propagate instead,
|
|
68
|
+
letting `recall()` surface the failure to `Connection.#open`, which (after
|
|
69
|
+
Problem 2 is fixed) will ask `retry()` for another reopen attempt.
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
// source/channel.js
|
|
73
|
+
#recovering = false
|
|
74
|
+
|
|
75
|
+
async recover (connection) {
|
|
76
|
+
this.#recovering = true
|
|
77
|
+
try {
|
|
78
|
+
this.#connection = connection
|
|
79
|
+
await this.create()
|
|
80
|
+
lazy.reset(this)
|
|
81
|
+
await recall(this)
|
|
82
|
+
this.#unpause(INTERRUPTION)
|
|
83
|
+
for (const confirmation of this.#confirmations) confirmation.reject(INTERRUPTION)
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
85
|
+
this.#recovery.resolve()
|
|
86
|
+
this.#recovery = new Promex()
|
|
87
|
+
this.#diagnostics.emit('recover')
|
|
88
|
+
} finally {
|
|
89
|
+
this.#recovering = false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async #recover (exception) {
|
|
94
|
+
if (permanent(exception)) return false
|
|
95
|
+
if (this.#recovering) return false // never wait on #recovery from inside recover()
|
|
96
|
+
await this.#recovery
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Alternative: wrap `recall(this)` in try/catch inside `recover()`, and on
|
|
101
|
+
failure, reject the *old* `#recovery` (with a sentinel), create a fresh one,
|
|
102
|
+
and rethrow. The reentrancy guard is simpler and strictly local.
|
|
103
|
+
|
|
104
|
+
### Test
|
|
105
|
+
|
|
106
|
+
Add a unit test in `test/channel.test.js` that:
|
|
107
|
+
|
|
108
|
+
1. Creates a channel, records a `consume` call.
|
|
109
|
+
2. Triggers `recover()` but makes the mocked `channel.consume` throw a
|
|
110
|
+
`new Error('Channel closed')` on the replay.
|
|
111
|
+
3. Asserts `recover()` rejects (rather than hanging) within a short timeout.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Problem 2 — `channel.recover()` errors escape the `retry()` loop in `Connection.#open`
|
|
116
|
+
|
|
117
|
+
### Symptom
|
|
118
|
+
|
|
119
|
+
Only `amqp.connect` is guarded by try/catch in `#open`; channel recovery
|
|
120
|
+
runs *after* the catch block:
|
|
121
|
+
|
|
122
|
+
```73:92:source/connection.js
|
|
123
|
+
#open = async (retry) => {
|
|
124
|
+
try {
|
|
125
|
+
this.#connection = await amqp.connect(this.#url)
|
|
126
|
+
} catch (exception) {
|
|
127
|
+
if (this.#transient(exception)) return retry
|
|
128
|
+
else throw exception
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// This prevents the process from crashing; 'close' will be emitted next.
|
|
132
|
+
// https://amqp-node.github.io/amqplib/channel_api.html#model_events
|
|
133
|
+
this.#connection.on('error', noop)
|
|
134
|
+
|
|
135
|
+
this.#connection.on('close', this.#close)
|
|
136
|
+
this.#diagnostics.emit('open')
|
|
137
|
+
|
|
138
|
+
for (const channel of this.#channels) await channel.recover(this.#connection)
|
|
139
|
+
|
|
140
|
+
this.#recovery.resolve()
|
|
141
|
+
this.#recovery = new Promex()
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
If any `channel.recover()` throws (permanent topology error such as
|
|
146
|
+
`PRECONDITION_FAILED`, or the freshly opened connection dies before channel
|
|
147
|
+
creation completes, or the deadlock from Problem 1 is broken by rejection),
|
|
148
|
+
`#open` rejects → `retry()` rethrows → `open()` rejects. And `open()` was
|
|
149
|
+
called from:
|
|
150
|
+
|
|
151
|
+
```97:103:source/connection.js
|
|
152
|
+
#close = async (error) => {
|
|
153
|
+
this.#diagnostics.emit('close', error)
|
|
154
|
+
this.#connection.removeAllListeners()
|
|
155
|
+
this.#connection = undefined
|
|
156
|
+
|
|
157
|
+
if (error !== undefined) await this.open()
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
…which has no error handler — rejection becomes an `unhandledRejection`, and
|
|
162
|
+
the Connection stops trying to reconnect. Whether the broken amqplib
|
|
163
|
+
`Connection` eventually emits another `close` to re-arm the flow is not
|
|
164
|
+
guaranteed (TCP half-open, missing heartbeat). The process ends up with a
|
|
165
|
+
live event loop but no broker connectivity.
|
|
166
|
+
|
|
167
|
+
### Fix
|
|
168
|
+
|
|
169
|
+
Keep channel recovery inside the `retry()` contract: on any failure, close
|
|
170
|
+
the half-baked connection and ask `retry` to try again.
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
#open = async (retry) => {
|
|
174
|
+
try {
|
|
175
|
+
this.#connection = await amqp.connect(this.#url)
|
|
176
|
+
} catch (exception) {
|
|
177
|
+
if (this.#transient(exception)) return retry
|
|
178
|
+
else throw exception
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.#connection.on('error', noop)
|
|
182
|
+
this.#connection.on('close', this.#close)
|
|
183
|
+
this.#diagnostics.emit('open')
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
for (const channel of this.#channels) {
|
|
187
|
+
await channel.recover(this.#connection)
|
|
188
|
+
}
|
|
189
|
+
} catch (exception) {
|
|
190
|
+
this.#diagnostics.emit('error', exception)
|
|
191
|
+
// prevent close re-entry from this broken connection
|
|
192
|
+
this.#connection.removeAllListeners()
|
|
193
|
+
try { await this.#connection.close() } catch {}
|
|
194
|
+
this.#connection = undefined
|
|
195
|
+
return retry
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.#recovery.resolve()
|
|
199
|
+
this.#recovery = new Promex()
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
This keeps `retry()` in charge, honours its backoff, and never surfaces an
|
|
204
|
+
unhandled rejection to `#close`.
|
|
205
|
+
|
|
206
|
+
### Test
|
|
207
|
+
|
|
208
|
+
In `test/connection.test.js`, mock `channel.recover` to throw once, then
|
|
209
|
+
succeed; assert `amqplib.connect` is called at least twice and `createChannel`
|
|
210
|
+
eventually resolves.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Problem 3 — Unhandled rejection from `#close`
|
|
215
|
+
|
|
216
|
+
### Symptom
|
|
217
|
+
|
|
218
|
+
`#close` is an event handler attached to the amqplib `Connection`'s `close`
|
|
219
|
+
event. It `await this.open()` with no surrounding `try/catch`. If `open()`
|
|
220
|
+
rejects (see Problem 2), the rejection becomes an `unhandledRejection`. On
|
|
221
|
+
processes that exit on unhandled rejections, this kills the app; on others,
|
|
222
|
+
it silently leaves the library without a working connection.
|
|
223
|
+
|
|
224
|
+
### Fix
|
|
225
|
+
|
|
226
|
+
Detach lifecycle from the event emitter so that the rejection is either
|
|
227
|
+
swallowed on purpose (with diagnostics) or restarted:
|
|
228
|
+
|
|
229
|
+
```js
|
|
230
|
+
#close = (error) => {
|
|
231
|
+
this.#diagnostics.emit('close', error)
|
|
232
|
+
this.#connection.removeAllListeners()
|
|
233
|
+
this.#connection = undefined
|
|
234
|
+
|
|
235
|
+
if (error !== undefined) {
|
|
236
|
+
this.open().catch((exception) => this.#diagnostics.emit('error', exception))
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Users that observe diagnostics get a signal (the existing `close` diagnostic
|
|
242
|
+
plus a new `error` diagnostic) without the process being destabilised.
|
|
243
|
+
|
|
244
|
+
### Test
|
|
245
|
+
|
|
246
|
+
Subscribe to the `error` diagnostic, force `open()` to reject, emit `close`
|
|
247
|
+
with an error on the mocked amqp connection, assert the diagnostic fires and
|
|
248
|
+
the process does not register an unhandled rejection.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Problem 4 — `#transient` is too narrow on the initial connect
|
|
253
|
+
|
|
254
|
+
### Symptom
|
|
255
|
+
|
|
256
|
+
```109:114:source/connection.js
|
|
257
|
+
#transient (exception) {
|
|
258
|
+
const abruptly = exception.message === 'Socket closed abruptly during opening handshake'
|
|
259
|
+
const tls = exception.message === 'Client network socket disconnected before secure TLS connection was established'
|
|
260
|
+
|
|
261
|
+
return this.#running || abruptly || tls
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Before `#running` flips to `true` (i.e. on the *first* `open()`), only two
|
|
266
|
+
exact error messages are considered transient. Everyday transient failures
|
|
267
|
+
(`ECONNREFUSED`, `EAI_AGAIN`, `ENOTFOUND`, `ETIMEDOUT`, `ECONNRESET`) cause
|
|
268
|
+
the boot to throw instead of retrying, forcing process supervisors to do the
|
|
269
|
+
retry. After first connect, `#running` is `true` forever, so *everything*
|
|
270
|
+
becomes transient — the asymmetry between "before first open" and "after
|
|
271
|
+
first open" is surprising.
|
|
272
|
+
|
|
273
|
+
### Fix
|
|
274
|
+
|
|
275
|
+
Introduce an explicit whitelist of transient failures that applies in both
|
|
276
|
+
phases:
|
|
277
|
+
|
|
278
|
+
```js
|
|
279
|
+
#transient (exception) {
|
|
280
|
+
const transientCodes = new Set([
|
|
281
|
+
'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET',
|
|
282
|
+
'EHOSTUNREACH', 'ENETUNREACH'
|
|
283
|
+
])
|
|
284
|
+
const transientMessages = new Set([
|
|
285
|
+
'Socket closed abruptly during opening handshake',
|
|
286
|
+
'Client network socket disconnected before secure TLS connection was established'
|
|
287
|
+
])
|
|
288
|
+
|
|
289
|
+
if (this.#running) return true
|
|
290
|
+
if (transientCodes.has(exception.code)) return true
|
|
291
|
+
if (transientMessages.has(exception.message)) return true
|
|
292
|
+
return false
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Optionally gate the `#running` shortcut behind a maximum attempt count or a
|
|
297
|
+
"permanent" classifier for things like `ACCESS_REFUSED`, so a misconfigured
|
|
298
|
+
credential doesn't retry forever — but that is a separate behaviour change
|
|
299
|
+
and should be opt-in.
|
|
300
|
+
|
|
301
|
+
### Test
|
|
302
|
+
|
|
303
|
+
Parametrised test in `test/connection.test.js` for each listed error code,
|
|
304
|
+
asserting `open()` resolves after one failing `amqplib.connect`.
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## Problem 5 — Reentrant `#close` can run two overlapping `#open` loops
|
|
309
|
+
|
|
310
|
+
### Symptom
|
|
311
|
+
|
|
312
|
+
If the new connection dies *while* `#open` is still iterating
|
|
313
|
+
`channel.recover()`, amqplib fires `close` on that connection. The handler
|
|
314
|
+
executes:
|
|
315
|
+
|
|
316
|
+
```js
|
|
317
|
+
this.#connection.removeAllListeners()
|
|
318
|
+
this.#connection = undefined
|
|
319
|
+
if (error !== undefined) await this.open()
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
While the outer `#open` is still running and will, on its next loop
|
|
323
|
+
iteration, pass the now-`undefined` `this.#connection` to `channel.recover`
|
|
324
|
+
(crash → see Problem 2). You now have two concurrent `open()` retry loops
|
|
325
|
+
racing; whichever one wins may leave the other with a live amqp connection
|
|
326
|
+
that's unreferenced.
|
|
327
|
+
|
|
328
|
+
### Fix
|
|
329
|
+
|
|
330
|
+
Serialise `open()` with a single in-flight promise, and stop reading
|
|
331
|
+
`this.#connection` as a shared variable mid-loop:
|
|
332
|
+
|
|
333
|
+
```js
|
|
334
|
+
/** @type {Promise<void> | null} */
|
|
335
|
+
#opening = null
|
|
336
|
+
|
|
337
|
+
async open () {
|
|
338
|
+
if (this.#opening !== null) return this.#opening
|
|
339
|
+
this.#opening = retry(this.#open).finally(() => { this.#opening = null })
|
|
340
|
+
await this.#opening
|
|
341
|
+
this.#running = true
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#open = async (retry) => {
|
|
345
|
+
let connection
|
|
346
|
+
try {
|
|
347
|
+
connection = await amqp.connect(this.#url)
|
|
348
|
+
} catch (exception) {
|
|
349
|
+
if (this.#transient(exception)) return retry
|
|
350
|
+
else throw exception
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
connection.on('error', noop)
|
|
354
|
+
connection.on('close', this.#close)
|
|
355
|
+
this.#connection = connection
|
|
356
|
+
this.#diagnostics.emit('open')
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
for (const channel of this.#channels) await channel.recover(connection)
|
|
360
|
+
} catch (exception) {
|
|
361
|
+
this.#diagnostics.emit('error', exception)
|
|
362
|
+
connection.removeAllListeners()
|
|
363
|
+
try { await connection.close() } catch {}
|
|
364
|
+
if (this.#connection === connection) this.#connection = undefined
|
|
365
|
+
return retry
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
this.#recovery.resolve()
|
|
369
|
+
this.#recovery = new Promex()
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Two wins:
|
|
374
|
+
|
|
375
|
+
1. Channel recovery uses a local `connection` reference, so a concurrent
|
|
376
|
+
`#close` can't swap it out mid-loop.
|
|
377
|
+
2. Concurrent `open()` calls converge on a single retry loop.
|
|
378
|
+
|
|
379
|
+
### Test
|
|
380
|
+
|
|
381
|
+
Simulate two `close` events in quick succession (before the first recovery
|
|
382
|
+
finishes) and assert only one `amqp.connect` is *in progress* at any time and
|
|
383
|
+
the final state has exactly one live connection.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Problem 6 — Partial `Channel.recover()` leaves the channel wedged
|
|
388
|
+
|
|
389
|
+
### Symptom
|
|
390
|
+
|
|
391
|
+
If `recover()` throws between `await this.create()` and the final
|
|
392
|
+
`#recovery.resolve()`, the Channel has already:
|
|
393
|
+
|
|
394
|
+
- replaced `this.#channel` with a new one,
|
|
395
|
+
- reset lazy locks,
|
|
396
|
+
- possibly pushed new consumer tags,
|
|
397
|
+
|
|
398
|
+
but has **not**:
|
|
399
|
+
|
|
400
|
+
- rejected pending confirmations,
|
|
401
|
+
- unpaused `#paused`,
|
|
402
|
+
- resolved `#recovery`.
|
|
403
|
+
|
|
404
|
+
Every user operation currently parked on `#recovery` stays parked forever
|
|
405
|
+
unless a subsequent recover succeeds. Combined with Problem 1, this is the
|
|
406
|
+
mechanism by which single failures persist.
|
|
407
|
+
|
|
408
|
+
### Fix
|
|
409
|
+
|
|
410
|
+
Make `recover()` structurally transactional — always release waiters on
|
|
411
|
+
failure by rejecting `#recovery` (with a sentinel that `failsafe.#recover`
|
|
412
|
+
classifies as permanent, so callers see a real error instead of hanging):
|
|
413
|
+
|
|
414
|
+
```js
|
|
415
|
+
async recover (connection) {
|
|
416
|
+
this.#recovering = true
|
|
417
|
+
try {
|
|
418
|
+
this.#connection = connection
|
|
419
|
+
await this.create()
|
|
420
|
+
lazy.reset(this)
|
|
421
|
+
await recall(this)
|
|
422
|
+
this.#unpause(INTERRUPTION)
|
|
423
|
+
for (const confirmation of this.#confirmations) confirmation.reject(INTERRUPTION)
|
|
424
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
425
|
+
this.#recovery.resolve()
|
|
426
|
+
this.#recovery = new Promex()
|
|
427
|
+
this.#diagnostics.emit('recover')
|
|
428
|
+
} catch (exception) {
|
|
429
|
+
// release everyone parked on #recovery; they will propagate the error
|
|
430
|
+
this.#unpause(exception)
|
|
431
|
+
for (const confirmation of this.#confirmations) confirmation.reject(exception)
|
|
432
|
+
this.#recovery.reject(exception)
|
|
433
|
+
this.#recovery = new Promex()
|
|
434
|
+
throw exception
|
|
435
|
+
} finally {
|
|
436
|
+
this.#recovering = false
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Combined with Problem 2's fix, the Connection retry loop will catch this,
|
|
442
|
+
close the broken connection, and retry — instead of wedging callers.
|
|
443
|
+
|
|
444
|
+
### Test
|
|
445
|
+
|
|
446
|
+
Force `create()` to throw, call `recover()`, assert that a previously
|
|
447
|
+
`await`-ing `send()` rejects (rather than hanging) with the propagated
|
|
448
|
+
exception.
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## Problem 7 — No liveness/heartbeat watchdog
|
|
453
|
+
|
|
454
|
+
### Symptom (optional but recommended)
|
|
455
|
+
|
|
456
|
+
amqplib's `Connection` relies on AMQP heartbeats and the underlying TCP
|
|
457
|
+
socket. In the half-open TCP case (NAT drop, cloud NLB idle timeout,
|
|
458
|
+
broker OOM without RST), neither `close` nor `error` may be emitted for a
|
|
459
|
+
long time — enough to look "stuck" even though the retry machinery is
|
|
460
|
+
healthy.
|
|
461
|
+
|
|
462
|
+
### Fix
|
|
463
|
+
|
|
464
|
+
Add a watchdog that forcibly destroys the underlying amqp `Connection` if no
|
|
465
|
+
progress has been observed within a configurable window. The simplest
|
|
466
|
+
implementation:
|
|
467
|
+
|
|
468
|
+
```js
|
|
469
|
+
// source/connection.js
|
|
470
|
+
#heartbeatTimer = null
|
|
471
|
+
|
|
472
|
+
#armWatchdog (connection, timeoutMs = 60_000) {
|
|
473
|
+
const reset = () => {
|
|
474
|
+
clearTimeout(this.#heartbeatTimer)
|
|
475
|
+
this.#heartbeatTimer = setTimeout(() => {
|
|
476
|
+
try { connection.connection?.stream?.destroy() } catch {}
|
|
477
|
+
}, timeoutMs)
|
|
478
|
+
}
|
|
479
|
+
reset()
|
|
480
|
+
connection.on('heartbeat', reset) // amqplib emits on each heartbeat
|
|
481
|
+
connection.on('close', () => clearTimeout(this.#heartbeatTimer))
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Call `#armWatchdog(this.#connection)` from `#open`. Any truly silent
|
|
486
|
+
connection will be destroyed, emitting `close`, and the normal retry loop
|
|
487
|
+
will take over.
|
|
488
|
+
|
|
489
|
+
### Test
|
|
490
|
+
|
|
491
|
+
Integration test (can be skipped by default) that blocks RabbitMQ traffic
|
|
492
|
+
with `iptables` / network namespaces in CI, asserting the library recovers
|
|
493
|
+
within watchdog + retry window.
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## Rollout order
|
|
498
|
+
|
|
499
|
+
1. Problem 1 and Problem 2 together — they remove the deadlock and keep
|
|
500
|
+
recovery inside the retry loop. These are the minimum fix for the hang.
|
|
501
|
+
2. Problem 3 — guard `#close`'s rejection.
|
|
502
|
+
3. Problem 6 — transactional `Channel.recover()`.
|
|
503
|
+
4. Problem 5 — serialise `open()` / local connection reference.
|
|
504
|
+
5. Problem 4 — broaden transient classifier.
|
|
505
|
+
6. Problem 7 — optional watchdog.
|
|
506
|
+
|
|
507
|
+
Each step is independently deployable and testable.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "ComQ",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Artem Gurtovoi",
|
|
@@ -18,35 +18,39 @@
|
|
|
18
18
|
"AMQP",
|
|
19
19
|
"RabbitMQ"
|
|
20
20
|
],
|
|
21
|
-
"engines": {
|
|
22
|
-
"node": ">= 12.0.0"
|
|
23
|
-
},
|
|
24
21
|
"publishConfig": {
|
|
25
22
|
"access": "public"
|
|
26
23
|
},
|
|
27
24
|
"main": "source/index.js",
|
|
28
25
|
"types": "types/index.d.ts",
|
|
29
26
|
"devDependencies": {
|
|
30
|
-
"@
|
|
31
|
-
"@
|
|
32
|
-
"@
|
|
33
|
-
"@
|
|
34
|
-
"@
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
27
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
28
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
29
|
+
"@semantic-release/git": "^10.0.1",
|
|
30
|
+
"@semantic-release/github": "^12.0.2",
|
|
31
|
+
"@semantic-release/npm": "^13.1.2",
|
|
32
|
+
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
33
|
+
"@cucumber/cucumber": "^12.9.0",
|
|
34
|
+
"@testcontainers/rabbitmq": "^12.0.0",
|
|
35
|
+
"@toa.io/generic": "^1.0.0-alpha.208",
|
|
36
|
+
"@types/amqplib": "^0.10.8",
|
|
37
|
+
"@types/jest": "^30.0.0",
|
|
38
|
+
"husky": "^9.1.7",
|
|
39
|
+
"jest": "^30.4.2",
|
|
40
|
+
"js-yaml": "^4.1.1",
|
|
41
|
+
"randomstring": "^1.3.1",
|
|
39
42
|
"snazzy": "^9.0.0",
|
|
40
|
-
"standard": "^17.1.
|
|
43
|
+
"standard": "^17.1.2",
|
|
44
|
+
"testcontainers": "^12.0.0"
|
|
41
45
|
},
|
|
42
46
|
"dependencies": {
|
|
43
|
-
"amqplib": "^0.
|
|
44
|
-
"msgpackr": "^
|
|
47
|
+
"amqplib": "^2.0.1",
|
|
48
|
+
"msgpackr": "^2.0.1",
|
|
45
49
|
"promex": "^0.1.1",
|
|
46
50
|
"reretry": "^0.1.0"
|
|
47
51
|
},
|
|
48
52
|
"scripts": {
|
|
49
|
-
"prepare": "
|
|
53
|
+
"prepare": "husky",
|
|
50
54
|
"test": "standard && jest",
|
|
51
55
|
"features": "cucumber-js --fail-fast -t 'not @manual'",
|
|
52
56
|
"lint": "standard --fix --verbose | snazzy"
|
|
@@ -55,8 +59,5 @@
|
|
|
55
59
|
"env": [
|
|
56
60
|
"jest"
|
|
57
61
|
]
|
|
58
|
-
}
|
|
59
|
-
"workspaces": [
|
|
60
|
-
"."
|
|
61
|
-
]
|
|
62
|
+
}
|
|
62
63
|
}
|
package/types/io.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ declare namespace comq {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
interface Destroyable extends EventEmitter {
|
|
18
|
-
destroy
|
|
18
|
+
destroy(): void
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
interface Request {
|
|
@@ -25,51 +25,55 @@ declare namespace comq {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
interface IO extends _diagnostics.Diagnosable {
|
|
28
|
-
reply
|
|
28
|
+
reply(queue: string, produce: Producer): Promise<void>
|
|
29
29
|
|
|
30
|
-
request<Reply = any, Request = any>
|
|
30
|
+
request<Reply = any, Request = any>(queue: string, payload: Request, encoding?: _encoding.Encoding): Promise<Reply> | Promise<Readable>
|
|
31
31
|
|
|
32
|
-
request
|
|
32
|
+
request(queue: string, stream: Readable, encoding?: _encoding.Encoding): Promise<Readable>
|
|
33
33
|
|
|
34
|
-
consume<T = any>
|
|
34
|
+
consume<T = any>(exchange: string, group: string, consumer: Consumer<T>): Promise<void>
|
|
35
35
|
|
|
36
|
-
consume<T = any>
|
|
36
|
+
consume<T = any>(exchange: string, consumer: Consumer<T>): Promise<void>
|
|
37
37
|
|
|
38
|
-
emit
|
|
38
|
+
emit(exchange: string, payload: any, encoding?: _encoding.Encoding): Promise<void>
|
|
39
39
|
|
|
40
|
-
emit
|
|
40
|
+
emit(exchange: string, payload: any, properties?: _amqp.Properties): Promise<void>
|
|
41
41
|
|
|
42
|
-
emit
|
|
42
|
+
emit(exchange: string, stream: Readable, encoding?: _encoding.Encoding): Promise<void>
|
|
43
43
|
|
|
44
|
-
emit
|
|
44
|
+
emit(exchange: string, stream: Readable, properties?: _amqp.Properties): Promise<void>
|
|
45
45
|
|
|
46
|
-
enqueue
|
|
46
|
+
enqueue(exchange: string, payload: any, encoding?: _encoding.Encoding): Promise<void>
|
|
47
47
|
|
|
48
|
-
enqueue
|
|
48
|
+
enqueue(exchange: string, payload: any, properties?: _amqp.Properties): Promise<void>
|
|
49
49
|
|
|
50
|
-
enqueue
|
|
50
|
+
enqueue(exchange: string, stream: Readable, encoding?: _encoding.Encoding): Promise<void>
|
|
51
51
|
|
|
52
|
-
enqueue
|
|
52
|
+
enqueue(exchange: string, stream: Readable, properties?: _amqp.Properties): Promise<void>
|
|
53
53
|
|
|
54
|
-
process<T = any>
|
|
54
|
+
process<T = any>(queue: string, processor: Consumer<T>): Promise<void>
|
|
55
55
|
|
|
56
|
-
seal
|
|
56
|
+
seal(): Promise<void>
|
|
57
57
|
|
|
58
|
-
close
|
|
58
|
+
close(): Promise<void>
|
|
59
59
|
|
|
60
|
-
diagnose
|
|
60
|
+
diagnose(event: 'open', listener: (index?: number) => void): void
|
|
61
61
|
|
|
62
|
-
diagnose
|
|
62
|
+
diagnose(event: 'close', listener: (index?: number) => void): void
|
|
63
63
|
|
|
64
|
-
diagnose
|
|
64
|
+
diagnose(event: 'flow', listener: (channel: _topology.type, index?: number) => void): void
|
|
65
65
|
|
|
66
|
-
diagnose
|
|
66
|
+
diagnose(event: 'drain', listener: (channel: _topology.type, index?: number) => void): void
|
|
67
67
|
|
|
68
|
-
diagnose
|
|
68
|
+
diagnose(event: 'remove', listener: (index?: number) => void): void
|
|
69
69
|
|
|
70
|
-
diagnose
|
|
70
|
+
diagnose(event: 'recover', listener: (channel: _topology.type, index?: number) => void): void
|
|
71
71
|
|
|
72
|
-
diagnose
|
|
72
|
+
diagnose(event: 'discard', listener: (channel: _topology.type, message: any, index?: number) => void): void
|
|
73
|
+
|
|
74
|
+
diagnose(event: 'pause', listener: (channel: _topology.type) => void): void
|
|
75
|
+
|
|
76
|
+
diagnose(event: 'resume', listener: (channel: _topology.type) => void): void
|
|
73
77
|
}
|
|
74
78
|
}
|
|
75
79
|
|
package/lerna.json
DELETED