comq 0.14.0 → 0.15.1

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.
@@ -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
- - dev
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@v6
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@v6
28
+ uses: actions/setup-node@v7
29
29
  with:
30
30
  node-version: "24"
31
31
  registry-url: https://registry.npmjs.org
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env sh
2
+
3
+ [ -n "$CI" ] && exit 0
4
+
5
+ npm run features:fast
package/.releaserc CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "branches": [
3
- "dev"
3
+ "release"
4
4
  ],
5
5
  "plugins": [
6
6
  "@semantic-release/commit-analyzer",
package/CHANGELOG.md CHANGED
@@ -1,3 +1,29 @@
1
+ ## [0.15.1](https://github.com/toa-io/comq/compare/v0.15.0...v0.15.1) (2026-08-02)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * skip end control when reply stream buffer overflows ([654e046](https://github.com/toa-io/comq/commit/654e046bdcbfbbbbbb789e5bb032bc16564221d1)), closes [#control](https://github.com/toa-io/comq/issues/control) [#reply](https://github.com/toa-io/comq/issues/reply)
7
+ * skip end control when reply stream buffer overflows ([#237](https://github.com/toa-io/comq/issues/237)) ([c34d87f](https://github.com/toa-io/comq/commit/c34d87f6d219ce6c8509d989117eadd55a33ca86))
8
+
9
+ # [0.15.0](https://github.com/toa-io/comq/compare/v0.14.0...v0.15.0) (2026-08-01)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * 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)
15
+ * destroy half-open connections via socket idle watchdog ([9f7ed5f](https://github.com/toa-io/comq/commit/9f7ed5fe50e9ab1857547a6e908185b6b472162f))
16
+ * ignore close events from replaced AMQP connections ([c1070ab](https://github.com/toa-io/comq/commit/c1070abeca9eb30a1c0ed07925bc06e530c1d9ce))
17
+ * reconnect hang after broker restart ([#235](https://github.com/toa-io/comq/issues/235)) ([be07079](https://github.com/toa-io/comq/commit/be07079863194301d0279e2bdd551260196ce20a))
18
+ * 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)
19
+ * tolerate empty YAML documents after js-yaml 5 bump ([0a05ecb](https://github.com/toa-io/comq/commit/0a05ecbf744822b3aa884382642ce51a93922dfc))
20
+
21
+
22
+ ### Features
23
+
24
+ * allow request() to time out pending replies ([ac9e2bd](https://github.com/toa-io/comq/commit/ac9e2bd7eb0834d19d404c84f24548bd5475017f))
25
+ * tag container-manipulation features [@heavy](https://github.com/heavy) and add features:fast script ([c0f52ec](https://github.com/toa-io/comq/commit/c0f52ecf72c010e387e853b3daed6884c1cef474))
26
+
1
27
  # [0.14.0](https://github.com/toa-io/comq/compare/v0.13.0...v0.14.0) (2026-05-24)
2
28
 
3
29
 
@@ -1,6 +1,6 @@
1
1
  services:
2
2
  rabbitmq-0:
3
- image: rabbitmq:3.10.0-management
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.10.0-management
21
+ image: rabbitmq:4.3-management
22
22
  container_name: comq-rmq-1
23
23
  ports:
24
24
  - '5674:5672'
@@ -1,3 +1,4 @@
1
+ @heavy
1
2
  Feature: Connection Tolerance
2
3
 
3
4
  Scenario: Connect
@@ -1,3 +1,4 @@
1
+ @heavy
1
2
  Feature: Request-Reply Topology Recovery
2
3
 
3
4
  Background:
@@ -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
@@ -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 an exception is thrown: "ECONNREFUSED"
10
- Then the broker is up
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 PAYLOAD = quantity('500k')
10
- const BATCH = 20
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
- for (let i = 0; i < BATCH; i++) { pending.push(this.io.request(queue, buffer).catch(() => {})) }
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.10.0-management'
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
@@ -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 { load: parse } = require('js-yaml')
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',
@@ -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 { load: parse } = require('js-yaml')
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.14.0",
3
+ "version": "0.15.1",
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
- "@semantic-release/changelog": "^6.0.3",
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
- "@cucumber/cucumber": "^12.9.0",
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": "^4.1.1",
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.0"
49
+ "testcontainers": "^12.0.4"
45
50
  },
46
51
  "dependencies": {
47
52
  "amqplib": "^2.0.1",
48
- "msgpackr": "^2.0.1",
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, messages will wait until a shard's connection is
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 a successful connection to one of the shards is established.
342
+ Returns an instance of `IO` once connections to the shards are established.
340
343
 
341
344
  ### Example
342
345
 
@@ -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
+ })
@@ -26,6 +26,8 @@ class ReplyStream extends Readable {
26
26
 
27
27
  #buffered = 0
28
28
 
29
+ #maxBufferSize
30
+
29
31
  /** @type {Map<number, unknown>} */
30
32
  #queue = new Map()
31
33
 
@@ -39,6 +41,7 @@ class ReplyStream extends Readable {
39
41
  this.#emitter = request.emitter
40
42
  this.#correlationId = request.properties.correlationId
41
43
  this.#idleInterval = global['COMQ_TESTING_IDLE_INTERVAL'] || IDLE_INTERVAL
44
+ this.#maxBufferSize = global['COMQ_TESTING_MAX_BUFFER_SIZE'] || MAX_BUFFER_SIZE
42
45
  this.#reply = reply
43
46
 
44
47
  this.#emitter.on(this.#correlationId, this.arrange.bind(this))
@@ -48,7 +51,9 @@ class ReplyStream extends Readable {
48
51
  this._clear()
49
52
  this.push(null)
50
53
 
51
- void this.#reply(this.#control, control.end)
54
+ if (this.#control !== undefined)
55
+ void this.#reply(this.#control, control.end)
56
+
52
57
  super._destroy(error, callback)
53
58
  }
54
59
 
@@ -95,7 +100,11 @@ class ReplyStream extends Readable {
95
100
  }
96
101
 
97
102
  _buffer (payload, properties) {
98
- if (this.#buffered > MAX_BUFFER_SIZE) this.destroy()
103
+ if (this.#buffered > this.#maxBufferSize) {
104
+ this.destroy()
105
+
106
+ return
107
+ }
99
108
 
100
109
  this.#buffered++
101
110
  this.#queue.set(properties.headers.index, { payload, properties })
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
- await recall(this)
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
  }