comq 0.15.1 → 0.15.3

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/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [0.15.3](https://github.com/toa-io/comq/compare/v0.15.2...v0.15.3) (2026-08-04)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * re-send requests left unanswered by a lost shard ([c534a01](https://github.com/toa-io/comq/commit/c534a0136e5f0189cdb6976cd9cffb9a2ca5a148))
7
+ * re-send requests left unanswered by a lost shard ([#242](https://github.com/toa-io/comq/issues/242)) ([c821da8](https://github.com/toa-io/comq/commit/c821da895049bd78a907c6705477ebe74c160e1b))
8
+
9
+ ## [0.15.2](https://github.com/toa-io/comq/compare/v0.15.1...v0.15.2) (2026-08-04)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * report and recover undeliverable replies ([b2d2d0f](https://github.com/toa-io/comq/commit/b2d2d0f11d7714c2d32d3d85894fa78e2203f8c6))
15
+ * report and recover undeliverable replies ([#241](https://github.com/toa-io/comq/issues/241)) ([73e5592](https://github.com/toa-io/comq/commit/73e5592762634fbead29d825be68a3da3136729b))
16
+
1
17
  ## [0.15.1](https://github.com/toa-io/comq/compare/v0.15.0...v0.15.1) (2026-08-02)
2
18
 
3
19
 
@@ -40,6 +40,22 @@ Feature: Sharded Connection
40
40
  And all replies have been received
41
41
  Then the broker is up
42
42
 
43
+ Scenario: Shard crashes while requests await their replies
44
+ Given an active sharded connection
45
+ And a producer replying `slow` queue in 3000ms
46
+ When the consumer sends 10 requests to the `slow` queue
47
+ And one of the brokers has crashed
48
+ Then all replies have been received
49
+ Then the broker is up
50
+
51
+ Scenario: Shard stops responding while requests are sent
52
+ Given watchdog interval is set to 3000ms with 1s AMQP heartbeat
53
+ And an active sharded connection
54
+ And a producer replying `stalled` queue
55
+ When the consumer sends 10 requests to the `stalled` queue as a broker freezes
56
+ Then all replies have been received
57
+ Then the broker is up
58
+
43
59
  Scenario: Shard crashes while publishing events
44
60
  Given an active sharded connection
45
61
  And events are exclusively consumed from the `flood` exchange
@@ -0,0 +1,25 @@
1
+ Feature: Sharded subscriptions
2
+
3
+ A subscription is only complete once every shard has it, otherwise a message
4
+ published to a randomly picked shard has nowhere to be routed.
5
+
6
+ Background:
7
+ Given an active sharded connection
8
+
9
+ Scenario: Events published right after subscribing
10
+ Given events are exclusively consumed from the `numbers_added` exchange
11
+ When a stream of 100 events is emitted to the `numbers_added` exchange
12
+ Then 100 events have been received
13
+
14
+ Scenario: Requesting streams repeatedly
15
+ Given a generator replying `get_numbers` queue:
16
+ """
17
+ function * ({ amount }) {
18
+ for (let i = 0; i < amount; i++) yield i
19
+ }
20
+ """
21
+ When the consumer requests 20 streams with the following request to the `get_numbers` queue:
22
+ """yaml
23
+ amount: 5
24
+ """
25
+ Then every stream has been received
@@ -103,6 +103,9 @@ async function healthy (n = 0) {
103
103
 
104
104
  const actions = {
105
105
  up: async (n = 0) => {
106
+ // a paused broker is neither stopped nor usable, and the hooks bring every
107
+ // broker up between scenarios, so unpausing belongs here
108
+ await docker('docker', ['unpause', `comq-rmq-${n}`]).catch(() => undefined)
106
109
  await docker('docker', ['start', `comq-rmq-${n}`])
107
110
  await healthy(n)
108
111
  },
@@ -111,6 +114,11 @@ const actions = {
111
114
  },
112
115
  crashed: async (n = 0) => {
113
116
  await docker('docker', ['kill', `comq-rmq-${n}`])
117
+ },
118
+ // a frozen broker keeps its connections open while answering nothing, which is
119
+ // what a publisher cannot tell from a working one until the watchdog fires
120
+ frozen: async (n = 0) => {
121
+ await docker('docker', ['pause', `comq-rmq-${n}`])
114
122
  }
115
123
  }
116
124
 
@@ -89,7 +89,12 @@ class Context extends World {
89
89
  }
90
90
 
91
91
  #url (i, user, password) {
92
- return PROTOCOL + user + ':' + password + '@' + getAddress(i)
92
+ const url = PROTOCOL + user + ':' + password + '@' + getAddress(i)
93
+ const heartbeat = global.COMQ_TESTING_AMQP_HEARTBEAT
94
+
95
+ // the watchdog measures silence, so it may only be shortened along with the
96
+ // interval at which a healthy broker is expected to say something
97
+ return heartbeat === undefined ? url : url + '?heartbeat=' + heartbeat
93
98
  }
94
99
  }
95
100
 
@@ -12,6 +12,9 @@ After(
12
12
  * @this {comq.features.Context}
13
13
  */
14
14
  async function () {
15
+ delete global.COMQ_TESTING_WATCHDOG_INTERVAL
16
+ delete global.COMQ_TESTING_AMQP_HEARTBEAT
17
+
15
18
  await Promise.all(
16
19
  Array.from({ length: BROKERS_AMOUNT }, (_, n) => actions.up(n))
17
20
  )
@@ -28,7 +28,7 @@ defineParameterType({
28
28
 
29
29
  defineParameterType({
30
30
  name: 'status',
31
- regexp: /(up|down|crashed)/,
31
+ regexp: /(up|down|crashed|frozen)/,
32
32
  transformer: (value) => value
33
33
  })
34
34
 
@@ -4,9 +4,10 @@ const stream = require('node:stream')
4
4
  const assert = require('node:assert')
5
5
  const { randomBytes } = require('node:crypto')
6
6
  const { parse } = require('./yaml')
7
- const { match, timeout, quantity } = require('@toa.io/generic')
7
+ const { match, timeout, quantity, random } = require('@toa.io/generic')
8
8
  const { Given, When, Then } = require('@cucumber/cucumber')
9
9
  const { Readable } = require('node:stream')
10
+ const { actions, BROKERS_AMOUNT } = require('./brokers')
10
11
 
11
12
  Given('function replying {token} queue:',
12
13
  /**
@@ -85,6 +86,12 @@ Given('idle timeout is set to {number}ms',
85
86
  global.COMQ_TESTING_IDLE_INTERVAL = value
86
87
  })
87
88
 
89
+ Given('watchdog interval is set to {number}ms with {number}s AMQP heartbeat',
90
+ function (watchdog, heartbeat) {
91
+ global.COMQ_TESTING_WATCHDOG_INTERVAL = watchdog
92
+ global.COMQ_TESTING_AMQP_HEARTBEAT = heartbeat
93
+ })
94
+
88
95
  Given('a producer replying {token} queue',
89
96
  /**
90
97
  * @param {string} queue
@@ -100,6 +107,22 @@ Given('a producer replying {token} queue',
100
107
  await this.io.reply(queue, producer)
101
108
  })
102
109
 
110
+ Given('a producer replying {token} queue in {number}ms',
111
+ /**
112
+ * @param {string} queue
113
+ * @param {number} delay
114
+ * @this {comq.features.Context}
115
+ */
116
+ async function (queue, delay) {
117
+ const producer = async (request) => {
118
+ await timeout(delay)
119
+
120
+ return request
121
+ }
122
+
123
+ await this.io.reply(queue, producer)
124
+ })
125
+
103
126
  Given('function replying {token} queue is expected:',
104
127
  /**
105
128
  * @param {string} queue
@@ -136,6 +159,32 @@ When('the consumer sends a request to the {token} queue',
136
159
  await send.call(this, queue, payload)
137
160
  })
138
161
 
162
+ When('the consumer sends {number} requests to the {token} queue',
163
+ /**
164
+ * @param {number} amount
165
+ * @param {string} queue
166
+ * @this {comq.features.Context}
167
+ */
168
+ async function (amount, queue) {
169
+ sendMany.call(this, amount, queue)
170
+ })
171
+
172
+ // the requests must be published while the frozen shard still looks reachable,
173
+ // which is a window too short to spend on a step boundary
174
+ When('the consumer sends {number} requests to the {token} queue as a broker freezes',
175
+ /**
176
+ * @param {number} amount
177
+ * @param {string} queue
178
+ * @this {comq.features.Context}
179
+ */
180
+ async function (amount, queue) {
181
+ this.shard = random(BROKERS_AMOUNT)
182
+
183
+ await actions.frozen(this.shard)
184
+
185
+ sendMany.call(this, amount, queue)
186
+ })
187
+
139
188
  When('the consumer requests a stream with the following request to the {token} queue:',
140
189
  /**
141
190
  * @param {string} queue
@@ -167,6 +216,38 @@ When('the consumer{number} requests a stream with request to the {token} queue',
167
216
  await fetch.call(this, queue, null, number)
168
217
  })
169
218
 
219
+ When('the consumer requests {number} streams with the following request to the {token} queue:',
220
+ /**
221
+ * @param {number} amount
222
+ * @param {string} queue
223
+ * @param {string} yaml
224
+ * @this {comq.features.Context}
225
+ */
226
+ async function (amount, queue, yaml) {
227
+ const payload = parse(yaml)
228
+
229
+ for (let number = 0; number < amount; number++) {
230
+ await fetch.call(this, queue, payload, number)
231
+
232
+ for await (const value of this.streams[number]) this.streamsValues[number].push(value)
233
+
234
+ this.streamsEnded[number] = true
235
+ }
236
+ })
237
+
238
+ Then('every stream has been received',
239
+ /**
240
+ * @this {comq.features.Context}
241
+ */
242
+ async function () {
243
+ for (const number of Object.keys(this.streams)) {
244
+ assert.equal(this.streamsEnded[number], true, `Stream ${number} was not closed`)
245
+
246
+ assert.notEqual(this.streamsValues[number].length, 0,
247
+ `Stream ${number} is empty`)
248
+ }
249
+ })
250
+
170
251
  Then('the consumer receives the reply:',
171
252
  /**
172
253
  * @param {string} yaml
@@ -393,6 +474,17 @@ Then('all replies have been received',
393
474
  await Promise.all(this.requestsSent)
394
475
  })
395
476
 
477
+ /**
478
+ * @param {number} amount
479
+ * @param {string} queue
480
+ * @this {comq.features.Context}
481
+ */
482
+ function sendMany (amount, queue) {
483
+ for (let i = 0; i < amount; i++) {
484
+ this.requestsSent.push(this.io.request(queue, randomBytes(8)))
485
+ }
486
+ }
487
+
396
488
  async function send (queue, payload) {
397
489
  if (this.expected) await this.expected
398
490
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -44,13 +44,21 @@ class ReplyStream extends Readable {
44
44
  this.#maxBufferSize = global['COMQ_TESTING_MAX_BUFFER_SIZE'] || MAX_BUFFER_SIZE
45
45
  this.#reply = reply
46
46
 
47
+ this.confirmation.catch(noop) // it is not awaited until the stream is handed over
48
+
47
49
  this.#emitter.on(this.#correlationId, this.arrange.bind(this))
50
+
51
+ // control.ok may never arrive, hence the watchdog is armed before the first message
52
+ this._heartbeat()
48
53
  }
49
54
 
50
55
  _destroy (error, callback) {
51
56
  this._clear()
52
57
  this.push(null)
53
58
 
59
+ // a no-op once control.ok has been received
60
+ this.confirmation.reject(error ?? new Error(UNCONFIRMED))
61
+
54
62
  if (this.#control !== undefined)
55
63
  void this.#reply(this.#control, control.end)
56
64
 
@@ -145,4 +153,9 @@ class ReplyStream extends Readable {
145
153
 
146
154
  const MAX_BUFFER_SIZE = 1000
147
155
 
156
+ const UNCONFIRMED = 'Reply stream has been destroyed before confirmation'
157
+
158
+ function noop () {}
159
+
148
160
  exports.ReplyStream = ReplyStream
161
+ exports.UNCONFIRMED = UNCONFIRMED
@@ -46,6 +46,10 @@ class Connection {
46
46
  this.#diagnostics.on('error', noop)
47
47
  }
48
48
 
49
+ get connected () {
50
+ return this.#connection !== undefined
51
+ }
52
+
49
53
  async open () {
50
54
  if (this.#opening !== null) return this.#opening
51
55
 
@@ -147,7 +151,7 @@ class Connection {
147
151
  * @param {comq.amqp.Connection} connection
148
152
  * @param {number} [timeoutMs]
149
153
  */
150
- #armWatchdog (connection, timeoutMs = WATCHDOG_MS) {
154
+ #armWatchdog (connection, timeoutMs = global.COMQ_TESTING_WATCHDOG_INTERVAL ?? WATCHDOG_MS) {
151
155
  const socket = connection.connection?.stream
152
156
 
153
157
  if (socket === undefined) return
package/source/events.js CHANGED
@@ -4,4 +4,4 @@
4
4
  exports.connection = ['open', 'close', 'error']
5
5
 
6
6
  /** @type {comq.diagnostics.Event[]} */
7
- exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume']
7
+ exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost']
package/source/io.js CHANGED
@@ -35,8 +35,8 @@ class IO {
35
35
  /** @type {comq.ReplyEmitter | null} */
36
36
  #control = null
37
37
 
38
- /** @type {Set<Promex>} */
39
- #pendingReplies = new Set()
38
+ /** @type {Map<Promex, comq.Request>} */
39
+ #pendingReplies = new Map()
40
40
 
41
41
  /** @type {Set<comq.Destroyable>} */
42
42
  #replyStreams = new Set()
@@ -230,9 +230,14 @@ class IO {
230
230
  }
231
231
 
232
232
  #setupRetransmission () {
233
- const event = (this.#requests.sharded === true) ? 'remove' : 'recover'
234
-
235
- this.#requests.diagnose(event, this.#retransmit)
233
+ if (this.#requests.sharded === true) {
234
+ // a shard leaves the pool when it rejects a publish, and is lost when its
235
+ // connection drops, which leaves an already sent request unanswered
236
+ this.#requests.diagnose('remove', this.#retransmit)
237
+ this.#requests.diagnose('lost', this.#retransmit)
238
+ } else {
239
+ this.#requests.diagnose('recover', this.#retransmit)
240
+ }
236
241
  }
237
242
 
238
243
  /**
@@ -316,7 +321,7 @@ class IO {
316
321
  * @return {Promex<any>}
317
322
  */
318
323
  #createReply (request) {
319
- const reply = this.#createPendingReply()
324
+ const reply = this.#createPendingReply(request)
320
325
 
321
326
  request.emitter.once(request.properties.correlationId, this.#getReplyResolver(request, reply))
322
327
 
@@ -324,12 +329,13 @@ class IO {
324
329
  }
325
330
 
326
331
  /**
332
+ * @param {comq.Request} request
327
333
  * @return {Promex}
328
334
  */
329
- #createPendingReply () {
335
+ #createPendingReply (request) {
330
336
  const reply = new Promex()
331
337
 
332
- this.#pendingReplies.add(reply)
338
+ this.#pendingReplies.set(reply, request)
333
339
 
334
340
  reply
335
341
  .catch(noop)
@@ -349,7 +355,12 @@ class IO {
349
355
  if (isStream) {
350
356
  const stream = this.#createReplyStream(request, payload, properties)
351
357
 
352
- await stream.confirmation
358
+ try {
359
+ await stream.confirmation
360
+ } catch {
361
+ // the stream has never started, hence the request is re-sent
362
+ return reply.reject(RETRANSMISSION)
363
+ }
353
364
 
354
365
  reply.resolve(stream)
355
366
  } else {
@@ -439,6 +450,9 @@ class IO {
439
450
  properties.contentType = contentType
440
451
  properties.correlationId = request.properties.correlationId
441
452
 
453
+ // an unroutable reply must be returned by the broker rather than dropped
454
+ properties.mandatory = true
455
+
442
456
  return await this.#replies.fire(replyTo, buffer, properties)
443
457
  }
444
458
 
@@ -447,10 +461,14 @@ class IO {
447
461
  }
448
462
 
449
463
  #retransmit = () => {
450
- for (const emitter of this.#emitters.values()) emitter.removeAllListeners()
464
+ for (const [reply, request] of this.#pendingReplies) {
465
+ // detaching this attempt alone leaves the listeners of the reply streams
466
+ // that are still flowing over the other shards in place
467
+ request.emitter.removeAllListeners(request.properties.correlationId)
451
468
 
452
- // trigger failsafe attribute
453
- for (const reply of this.#pendingReplies) reply.reject(RETRANSMISSION)
469
+ // trigger failsafe attribute
470
+ reply.reject(RETRANSMISSION)
471
+ }
454
472
  }
455
473
 
456
474
  /**
@@ -19,8 +19,14 @@ class Channel {
19
19
  /** @type {comq.Channel[]} */
20
20
  #pool
21
21
 
22
- /** @type {Set<Promise<comq.Channel>>} */
23
- #pending = new Set()
22
+ /** @type {Map<Promise<comq.Channel>, number>} */
23
+ #pending = new Map()
24
+
25
+ /** @type {Promex[]} */
26
+ #down = []
27
+
28
+ /** @type {boolean[]} */
29
+ #alive = []
24
30
 
25
31
  /** @type {Map<comq.Channel, Promex>} */
26
32
  #bench = new Map()
@@ -48,11 +54,11 @@ class Channel {
48
54
  }
49
55
 
50
56
  async consume (queue, consumer) {
51
- return await this.#any((channel) => channel.consume(queue, consumer))
57
+ return await this.#every((channel) => channel.consume(queue, consumer))
52
58
  }
53
59
 
54
60
  async subscribe (queue, group, consumer) {
55
- await this.#any((channel) => channel.subscribe(queue, group, consumer))
61
+ await this.#every((channel) => channel.subscribe(queue, group, consumer))
56
62
  }
57
63
 
58
64
  async send (queue, buffer, options) {
@@ -86,8 +92,10 @@ class Channel {
86
92
  * @return {Promise<void>}
87
93
  */
88
94
  #create = async (connection, index) => {
95
+ this.#watch(connection, index)
96
+
89
97
  const pending = connection.createChannel(this.#type, index)
90
- const channel = await this.#pend(pending)
98
+ const channel = await this.#pend(pending, index)
91
99
 
92
100
  this.#add(channel)
93
101
  this.#pipe(channel)
@@ -97,12 +105,41 @@ class Channel {
97
105
  channel.diagnose('recover', () => this.#recover(channel))
98
106
  }
99
107
 
108
+ /**
109
+ * Tracks whether a shard is reachable, so that subscribing does not wait for
110
+ * one that is not. The pool itself is left alone: a channel is only benched
111
+ * when it actually fails to publish, otherwise a lost connection would
112
+ * interrupt the streams it is carrying. Losing a shard is reported instead,
113
+ * since a request awaiting its reply on it will never be answered.
114
+ *
115
+ * @param {comq.Connection} connection
116
+ * @param {number} index
117
+ */
118
+ #watch (connection, index) {
119
+ this.#down[index] = new Promex()
120
+ this.#alive[index] = connection.connected !== false
121
+
122
+ if (connection.connected === false) this.#down[index].resolve()
123
+
124
+ connection.diagnose('close', () => {
125
+ this.#alive[index] = false
126
+ this.#down[index].resolve()
127
+ this.#diagnostics.emit(LOST, index)
128
+ })
129
+
130
+ connection.diagnose('open', () => {
131
+ this.#alive[index] = true
132
+ this.#down[index] = new Promex()
133
+ })
134
+ }
135
+
100
136
  /**
101
137
  * @param {Promise<comq.Channel>} pending
138
+ * @param {number} index
102
139
  * @return {Promise<comq.Channel>}
103
140
  */
104
- async #pend (pending) {
105
- this.#pending.add(pending)
141
+ async #pend (pending, index) {
142
+ this.#pending.set(pending, index)
106
143
 
107
144
  const channel = await pending
108
145
 
@@ -116,8 +153,44 @@ class Channel {
116
153
  */
117
154
  #pipe (channel) {
118
155
  for (const event of events.channel) {
156
+ if (event === RETURN) continue // returns are retried before being reported
157
+ if (event === LOST) continue // a shard is lost with its connection, not its channel
158
+
119
159
  channel.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args, channel.index))
120
160
  }
161
+
162
+ channel.diagnose(RETURN, (message) => this.#returned(message, channel))
163
+ }
164
+
165
+ /**
166
+ * An unroutable message is retried on the shards that have not seen it yet,
167
+ * since a queue may be declared on some of them only. The return is reported
168
+ * once every shard has rejected the message.
169
+ *
170
+ * @param {comq.amqp.Message} message
171
+ * @param {comq.Channel} channel
172
+ */
173
+ #returned (message, channel) {
174
+ const report = () => this.#diagnostics.emit(RETURN, message, channel.index)
175
+ const attempt = (message.properties.headers?.[RETURN_HEADER] ?? 0) + 1
176
+ const rest = this.#pool.filter((one) => one !== channel)
177
+
178
+ // only replies are published to the default exchange, and only they are mandatory
179
+ const exhausted = message.fields.exchange !== DEFAULT ||
180
+ attempt >= this.#connections.length ||
181
+ rest.length === 0
182
+
183
+ if (exhausted) return report()
184
+
185
+ const properties = {
186
+ ...message.properties,
187
+ mandatory: true,
188
+ headers: { ...message.properties.headers, [RETURN_HEADER]: attempt }
189
+ }
190
+
191
+ const next = rest[Math.floor(Math.random() * rest.length)]
192
+
193
+ next.fire(message.fields.routingKey, message.content, properties).catch(report)
121
194
  }
122
195
 
123
196
  /**
@@ -171,12 +244,49 @@ class Channel {
171
244
  }
172
245
 
173
246
  /**
247
+ * Resolves once every available shard has settled, requiring at least one to
248
+ * succeed. Benched shards are applied when they come back, which must not
249
+ * hold up the caller.
250
+ *
174
251
  * @param {(channel: comq.Channel) => void} fn
252
+ * @return {Promise<any>}
175
253
  */
176
- async #any (fn) {
177
- const promises = this.#apply(fn)
254
+ async #every (fn) {
255
+ const promises = []
178
256
 
179
- return await Promise.any(promises)
257
+ for (const channel of this.#channels) {
258
+ promises.push(this.#unless(fn(channel), channel.index))
259
+ }
260
+
261
+ for (const [pending, index] of this.#pending) {
262
+ promises.push(this.#unless(pending.then(fn), index))
263
+ }
264
+
265
+ for (const recover of this.#bench.values()) recover.then(fn).catch(noop)
266
+
267
+ const results = await Promise.allSettled(promises)
268
+ const fulfilled = results.find((result) => result.status === 'fulfilled')
269
+
270
+ if (fulfilled === undefined) {
271
+ const reasons = results.map((result) => result.reason)
272
+
273
+ throw new AggregateError(reasons, 'No shard is available')
274
+ }
275
+
276
+ return fulfilled.value
277
+ }
278
+
279
+ /**
280
+ * Gives up on a shard as soon as its connection is lost.
281
+ *
282
+ * @param {Promise<any>} promise
283
+ * @param {number} index
284
+ * @return {Promise<any>}
285
+ */
286
+ async #unless (promise, index) {
287
+ const down = this.#down[index]
288
+
289
+ return down === undefined ? await promise : await Promise.race([promise, down])
180
290
  }
181
291
 
182
292
  /**
@@ -197,19 +307,34 @@ class Channel {
197
307
  const promises = []
198
308
 
199
309
  for (const channel of this.#channels) promises.push(fn(channel))
200
- for (const pending of this.#pending) promises.push(pending.then(fn))
310
+ for (const pending of this.#pending.keys()) promises.push(pending.then(fn))
201
311
  for (const recover of this.#bench.values()) promises.push(recover.then(fn))
202
312
 
203
313
  return promises
204
314
  }
205
315
 
316
+ /**
317
+ * The channels of the shards that are known to be connected, falling back to the
318
+ * whole pool while none is, so that a publish is attempted rather than dropped.
319
+ *
320
+ * @return {comq.Channel[]}
321
+ */
322
+ #reachable () {
323
+ const reachable = this.#pool.filter((channel) => this.#alive[channel.index] !== false)
324
+
325
+ return reachable.length === 0 ? this.#pool : reachable
326
+ }
327
+
206
328
  /**
207
329
  * @param {(channel: comq.Channel) => void} fn
208
330
  */
209
331
  async #one (fn) {
210
332
  if (this.#pool.length === 0) await this.#recovery
211
333
 
212
- const channel = this.#pool[Math.floor(Math.random() * this.#pool.length)]
334
+ // publishing to a lost shard is silently accepted by the destroyed socket, so
335
+ // it must be avoided rather than retried: nothing would report the loss twice
336
+ const pool = this.#reachable()
337
+ const channel = pool[Math.floor(Math.random() * pool.length)]
213
338
 
214
339
  try {
215
340
  return await fn(channel)
@@ -234,4 +359,11 @@ async function create (connections, type) {
234
359
  return channel
235
360
  }
236
361
 
362
+ const DEFAULT = ''
363
+ const RETURN = 'return'
364
+ const RETURN_HEADER = 'x-return'
365
+ const LOST = 'lost'
366
+
367
+ function noop () {}
368
+
237
369
  exports.create = create
@@ -2,7 +2,8 @@
2
2
 
3
3
  const { EventEmitter } = require('node:events')
4
4
  const { once } = require('node:events')
5
- const { ReplyStream } = require('../source/.io/ReplyStream')
5
+ const { ReplyStream, UNCONFIRMED } = require('../source/.io/ReplyStream')
6
+ const { control } = require('../source/.io/const')
6
7
 
7
8
  /** @type {jest.Mock} */
8
9
  let reply
@@ -43,3 +44,40 @@ it('should destroy without calling reply when buffer overflows before control.ok
43
44
  expect(stream.destroyed).toBe(true)
44
45
  expect(reply).not.toHaveBeenCalled()
45
46
  })
47
+
48
+ it('should reject confirmation when buffer overflows before control.ok', async () => {
49
+ for (let index = 1; index <= 5; index++) {
50
+ stream.arrange(index, { headers: { index } })
51
+ }
52
+
53
+ await expect(stream.confirmation).rejects.toThrow(UNCONFIRMED)
54
+ })
55
+
56
+ it('should reject confirmation when destroyed before control.ok', async () => {
57
+ stream.destroy()
58
+
59
+ await expect(stream.confirmation).rejects.toThrow(UNCONFIRMED)
60
+ })
61
+
62
+ it('should reject confirmation when nothing arrives within the idle interval', async () => {
63
+ global.COMQ_TESTING_IDLE_INTERVAL = 10
64
+
65
+ const request = {
66
+ emitter: new EventEmitter(),
67
+ properties: { correlationId: 'idle-correlation' }
68
+ }
69
+
70
+ const idle = new ReplyStream(request, reply)
71
+
72
+ await expect(idle.confirmation).rejects.toThrow(UNCONFIRMED)
73
+ })
74
+
75
+ it('should keep confirmation resolved after control.ok', async () => {
76
+ stream.arrange(control.ok, { headers: { index: 0 }, type: 'control' })
77
+
78
+ await expect(stream.confirmation).resolves.toBeUndefined()
79
+
80
+ stream.destroy()
81
+
82
+ await expect(stream.confirmation).resolves.toBeUndefined()
83
+ })
@@ -19,6 +19,7 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
19
19
  * @returns {jest.MockedObject<comq.Connection>}
20
20
  */
21
21
  const connection = (sharded = false) => (/** @type {jest.MockedObject<comq.Connection>} */ {
22
+ connected: true,
22
23
  createChannel: jest.fn(async (type, index) => channel(sharded, index)),
23
24
  open: jest.fn(async () => undefined),
24
25
  close: jest.fn(async () => undefined),
@@ -185,7 +185,8 @@ describe('encoding', () => {
185
185
 
186
186
  const props = {
187
187
  correlationId: properties.correlationId,
188
- contentType: properties.contentType
188
+ contentType: properties.contentType,
189
+ mandatory: true
189
190
  }
190
191
 
191
192
  expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, reply, props)
@@ -207,8 +207,125 @@ describe('send', () => {
207
207
 
208
208
  expect(requests.send).toHaveBeenCalledTimes(2)
209
209
  })
210
+
211
+ it('should resend unanswered Requests when a shard is lost', async () => {
212
+ jest.clearAllMocks()
213
+
214
+ connection = mock.connection(true)
215
+ io = new IO(connection)
216
+
217
+ promise = io.request(queue, payload)
218
+
219
+ // allows initializers to run
220
+ await immediate()
221
+
222
+ requests = await findChannel('request')
223
+
224
+ expect(requests.diagnose).toHaveBeenCalledWith('lost', expect.any(Function))
225
+
226
+ await lose()
227
+
228
+ expect(requests.send).toHaveBeenCalledTimes(2)
229
+ })
230
+
231
+ it('should not interrupt a confirmed reply stream when a shard is lost', async () => {
232
+ jest.clearAllMocks()
233
+
234
+ connection = mock.connection(true)
235
+ io = new IO(connection)
236
+
237
+ promise = io.request(queue, payload)
238
+
239
+ await immediate()
240
+
241
+ requests = await findChannel('request')
242
+ replies = await findChannel('reply')
243
+
244
+ const correlationId = requests.send.mock.calls[0][2].correlationId
245
+ const callback = replies.consume.mock.calls[0][1]
246
+
247
+ await callback(message(correlationId, 0, 'ok', 'control'))
248
+
249
+ const output = await promise
250
+
251
+ expect(output).toBeInstanceOf(stream.Readable)
252
+
253
+ await lose()
254
+
255
+ // the stream is carried by whichever shard replied, so it keeps flowing
256
+ const chunk = randomBytes(8)
257
+
258
+ await callback(message(correlationId, 1, chunk))
259
+
260
+ expect(output.read()).toStrictEqual(chunk)
261
+
262
+ output.destroy()
263
+ })
264
+
265
+ it('should not resend an answered Request when a shard is lost', async () => {
266
+ jest.clearAllMocks()
267
+
268
+ connection = mock.connection(true)
269
+ io = new IO(connection)
270
+
271
+ promise = io.request(queue, payload)
272
+
273
+ await immediate()
274
+
275
+ requests = await findChannel('request')
276
+ replies = await findChannel('reply')
277
+
278
+ const correlationId = requests.send.mock.calls[0][2].correlationId
279
+ const callback = replies.consume.mock.calls[0][1]
280
+
281
+ const answer = /** @type {comq.amqp.Message} */
282
+ { content: randomBytes(8), properties: { correlationId } }
283
+
284
+ await callback(answer)
285
+ await promise
286
+
287
+ await lose()
288
+
289
+ expect(requests.send).toHaveBeenCalledTimes(1)
290
+ })
210
291
  })
211
292
 
293
+ /**
294
+ * Fires the listeners the IO has attached to the shard loss diagnostic.
295
+ *
296
+ * @return {Promise<void>}
297
+ */
298
+ async function lose () {
299
+ const calls = requests.diagnose.mock.calls.filter((call) => call[0] === 'lost')
300
+
301
+ for (const [, listener] of calls) listener(0)
302
+
303
+ await immediate()
304
+ await immediate()
305
+ }
306
+
307
+ /**
308
+ * @param {string} correlationId
309
+ * @param {number} index
310
+ * @param {any} content
311
+ * @param {string} [type]
312
+ * @return {comq.amqp.Message}
313
+ */
314
+ function message (correlationId, index, content, type) {
315
+ const raw = Buffer.isBuffer(content)
316
+
317
+ return /** @type {comq.amqp.Message} */ {
318
+ content: raw ? content : encode(content, 'application/json'),
319
+ properties: {
320
+ correlationId,
321
+ type,
322
+ replyTo: queue,
323
+ contentType: raw ? 'application/octet-stream' : 'application/json',
324
+ headers: { index }
325
+ }
326
+ }
327
+ }
328
+
212
329
  describe('reply', () => {
213
330
  it.each([undefined, 'application/octet-stream'])('should return raw content if encoding is %s',
214
331
  async (contentType) => {
@@ -237,6 +354,30 @@ describe('reply', () => {
237
354
 
238
355
  expect(output).toStrictEqual(value)
239
356
  })
357
+
358
+ it('should re-send the Request if a reply stream is never confirmed', async () => {
359
+ global.COMQ_TESTING_MAX_BUFFER_SIZE = 3
360
+
361
+ const correlationId = requests.send.mock.calls[0][2].correlationId
362
+ const callback = replies.consume.mock.calls[0][1]
363
+
364
+ try {
365
+ // out of order chunks overflow the buffer, so the stream dies before control.ok
366
+ for (let index = 1; index <= 5; index++) {
367
+ const properties = { correlationId, headers: { index } }
368
+ const message = /** @type {comq.amqp.Message} */ { content: randomBytes(8), properties }
369
+
370
+ await callback(message)
371
+ }
372
+
373
+ await immediate()
374
+ await immediate()
375
+
376
+ expect(requests.send).toHaveBeenCalledTimes(2)
377
+ } finally {
378
+ delete global.COMQ_TESTING_MAX_BUFFER_SIZE
379
+ }
380
+ })
240
381
  })
241
382
 
242
383
  const reply = async (content = randomBytes(8), contentType = undefined) => {
@@ -29,6 +29,58 @@ it('should expose `sharded`', async () => {
29
29
  expect(channel.sharded).toStrictEqual(true)
30
30
  })
31
31
 
32
+ it('should report a lost shard', async () => {
33
+ channel = await create(connections, type)
34
+
35
+ const lost = /** @type {jest.MockedFunction} */ jest.fn()
36
+
37
+ channel.diagnose('lost', lost)
38
+
39
+ const calls = connections[1].diagnose.mock.calls.filter((call) => call[0] === 'close')
40
+
41
+ expect(calls.length).toBeGreaterThan(0)
42
+
43
+ for (const call of calls) call[1]()
44
+
45
+ expect(lost).toHaveBeenCalledWith(1)
46
+ })
47
+
48
+ it('should not publish to a lost shard', async () => {
49
+ channel = await create(connections, type)
50
+
51
+ const channels = await getCreatedChannels()
52
+ const calls = connections[1].diagnose.mock.calls.filter((call) => call[0] === 'close')
53
+
54
+ for (const call of calls) call[1]()
55
+
56
+ const buffer = randomBytes(8)
57
+ const label = generate()
58
+
59
+ for (let i = 0; i < 20; i++) await channel.send(label, buffer)
60
+
61
+ expect(channels[0].send).toHaveBeenCalledTimes(20)
62
+ expect(channels[1].send).not.toHaveBeenCalled()
63
+ })
64
+
65
+ // a lost shard is not benched, so it is used again as soon as it reconnects
66
+ it('should publish to a recovered shard', async () => {
67
+ channel = await create(connections, type)
68
+
69
+ const channels = await getCreatedChannels()
70
+ const close = connections[1].diagnose.mock.calls.filter((call) => call[0] === 'close')
71
+ const open = connections[1].diagnose.mock.calls.filter((call) => call[0] === 'open')
72
+
73
+ for (const call of close) call[1]()
74
+ for (const call of open) call[1]()
75
+
76
+ const buffer = randomBytes(8)
77
+ const label = generate()
78
+
79
+ for (let i = 0; i < 20; i++) await channel.send(label, buffer)
80
+
81
+ expect(channels[1].send).toHaveBeenCalled()
82
+ })
83
+
32
84
  it('should resolve when one of the connections has created a channel', async () => {
33
85
  /** @type {toa.generic.Promex[]} */
34
86
  const promises = []
@@ -87,28 +139,75 @@ describe.each(['consume', 'subscribe'])('%s', (method) => {
87
139
  for (const chan of channels) expect(chan[method]).toHaveBeenCalledWith(...args)
88
140
  })
89
141
 
90
- it(`should should ${method} using available and pending channels`, async () => {
142
+ it(`should not resolve ${method} until pending channels are created`, async () => {
91
143
  const promise = promex()
92
144
 
93
145
  connections[0].createChannel.mockImplementationOnce(() => promise)
94
146
 
95
147
  channel = await create(connections, type)
96
148
 
97
- await channel[method](...args)
149
+ let resolved = false
150
+
151
+ const subscription = channel[method](...args).then(() => { resolved = true })
152
+
153
+ await immediate()
98
154
 
99
155
  /** @type {jest.MockedObject<comq.Channel>} */
100
156
  const chan1 = await connections[1].createChannel.mock.results[0].value
101
157
 
102
158
  expect(chan1[method]).toHaveBeenCalled()
159
+ expect(resolved).toStrictEqual(false)
103
160
 
104
161
  /** @type {jest.MockedObject<comq.Channel>} */
105
162
  const chan0 = mock.channel()
106
163
 
107
164
  promise.resolve(chan0)
108
165
 
109
- await immediate()
166
+ await subscription
110
167
 
111
168
  expect(chan0[method]).toHaveBeenCalled()
169
+ expect(resolved).toStrictEqual(true)
170
+ })
171
+
172
+ it(`should resolve ${method} without a disconnected shard`, async () => {
173
+ const promise = promex()
174
+
175
+ connections[0].createChannel.mockImplementationOnce(() => promise)
176
+ connections[0].connected = false
177
+
178
+ try {
179
+ channel = await create(connections, type)
180
+
181
+ // must not wait for the channel that is never going to be created
182
+ await channel[method](...args)
183
+
184
+ /** @type {jest.MockedObject<comq.Channel>} */
185
+ const chan1 = await connections[1].createChannel.mock.results[0].value
186
+
187
+ expect(chan1[method]).toHaveBeenCalled()
188
+ } finally {
189
+ connections[0].connected = true
190
+ promise.resolve(mock.channel())
191
+ }
192
+ })
193
+
194
+ it(`should resolve ${method} when a shard disconnects`, async () => {
195
+ const promise = promex()
196
+
197
+ connections[0].createChannel.mockImplementationOnce(() => promise)
198
+
199
+ channel = await create(connections, type)
200
+
201
+ const subscription = channel[method](...args)
202
+ const calls = connections[0].diagnose.mock.calls.filter((call) => call[0] === 'close')
203
+
204
+ expect(calls.length).toBeGreaterThan(0)
205
+
206
+ for (const call of calls) call[1]()
207
+
208
+ await subscription
209
+
210
+ promise.resolve(mock.channel())
112
211
  })
113
212
 
114
213
  it(`should ${method} using a channel removed from the pool once it is recovered`, async () => {
@@ -339,6 +438,43 @@ describe('diagnose', () => {
339
438
  expect(listener).toHaveBeenCalledWith(expect.any(Number))
340
439
  })
341
440
 
441
+ it('should re-fire a returned message on another shard', async () => {
442
+ const listener = /** @type {jest.MockedFunction} */ jest.fn()
443
+ const index = random(channels.length)
444
+ const chan = channels[index]
445
+ const message = returned()
446
+
447
+ channel.diagnose('return', listener)
448
+
449
+ emitReturn(chan, message)
450
+
451
+ const rest = channels.filter((one) => one !== chan)
452
+
453
+ const properties = expect.objectContaining(
454
+ { mandatory: true, headers: { 'x-return': 1 } })
455
+
456
+ for (const one of rest) {
457
+ expect(one.fire).toHaveBeenCalledWith(message.fields.routingKey, message.content, properties)
458
+ }
459
+
460
+ expect(listener).not.toHaveBeenCalled()
461
+ })
462
+
463
+ it('should emit `return` event once every shard has returned the message', async () => {
464
+ const listener = /** @type {jest.MockedFunction} */ jest.fn()
465
+ const index = random(channels.length)
466
+ const chan = channels[index]
467
+ const message = returned({ 'x-return': channels.length - 1 })
468
+
469
+ channel.diagnose('return', listener)
470
+
471
+ emitReturn(chan, message)
472
+
473
+ for (const one of channels) expect(one.fire).not.toHaveBeenCalled()
474
+
475
+ expect(listener).toHaveBeenCalledWith(message, index)
476
+ })
477
+
342
478
  it('should emit `pause` and `unpause` events', async () => {
343
479
  const queue = generate()
344
480
  const buffer = randomBytes(8)
@@ -428,6 +564,30 @@ it('should remove channel with back pressure from the pool', async () => {
428
564
  expect(chan.send).toHaveBeenCalled()
429
565
  })
430
566
 
567
+ /**
568
+ * @param {Record<string, any>} [headers]
569
+ * @return {comq.amqp.Message}
570
+ */
571
+ function returned (headers = {}) {
572
+ return /** @type {comq.amqp.Message} */ {
573
+ content: randomBytes(8),
574
+ fields: { exchange: '', routingKey: generate() },
575
+ properties: { correlationId: generate(), headers }
576
+ }
577
+ }
578
+
579
+ /**
580
+ * @param {jest.MockedObject<comq.Channel>} channel
581
+ * @param {comq.amqp.Message} message
582
+ */
583
+ function emitReturn (channel, message) {
584
+ const calls = channel.diagnose.mock.calls.filter((call) => call[0] === 'return')
585
+
586
+ expect(calls.length).toBeGreaterThan(0)
587
+
588
+ for (const call of calls) call[1](message)
589
+ }
590
+
431
591
  /**
432
592
  * @return {Promise<jest.MockedObject<comq.Channel>[]>}
433
593
  */
@@ -6,6 +6,8 @@ import * as _topology from './topology'
6
6
  declare namespace comq {
7
7
 
8
8
  interface Connection {
9
+ readonly connected?: boolean
10
+
9
11
  open(): Promise<void>
10
12
 
11
13
  close(): Promise<void>