comq 0.15.1 → 0.15.2
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 +8 -0
- package/features/shards.subscriptions.feature +25 -0
- package/features/steps/rpc.js +32 -0
- package/package.json +1 -1
- package/source/.io/ReplyStream.js +13 -0
- package/source/connection.js +4 -0
- package/source/events.js +1 -1
- package/source/io.js +9 -1
- package/source/shards/channel.js +113 -11
- package/test/ReplyStream.test.js +39 -1
- package/test/connection.mock.js +1 -0
- package/test/io.reply.test.js +2 -1
- package/test/io.request.test.js +24 -0
- package/test/shards/channel.test.js +111 -3
- package/types/connection.d.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## [0.15.2](https://github.com/toa-io/comq/compare/v0.15.1...v0.15.2) (2026-08-04)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* report and recover undeliverable replies ([b2d2d0f](https://github.com/toa-io/comq/commit/b2d2d0f11d7714c2d32d3d85894fa78e2203f8c6))
|
|
7
|
+
* report and recover undeliverable replies ([#241](https://github.com/toa-io/comq/issues/241)) ([73e5592](https://github.com/toa-io/comq/commit/73e5592762634fbead29d825be68a3da3136729b))
|
|
8
|
+
|
|
1
9
|
## [0.15.1](https://github.com/toa-io/comq/compare/v0.15.0...v0.15.1) (2026-08-02)
|
|
2
10
|
|
|
3
11
|
|
|
@@ -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
|
package/features/steps/rpc.js
CHANGED
|
@@ -167,6 +167,38 @@ When('the consumer{number} requests a stream with request to the {token} queue',
|
|
|
167
167
|
await fetch.call(this, queue, null, number)
|
|
168
168
|
})
|
|
169
169
|
|
|
170
|
+
When('the consumer requests {number} streams with the following request to the {token} queue:',
|
|
171
|
+
/**
|
|
172
|
+
* @param {number} amount
|
|
173
|
+
* @param {string} queue
|
|
174
|
+
* @param {string} yaml
|
|
175
|
+
* @this {comq.features.Context}
|
|
176
|
+
*/
|
|
177
|
+
async function (amount, queue, yaml) {
|
|
178
|
+
const payload = parse(yaml)
|
|
179
|
+
|
|
180
|
+
for (let number = 0; number < amount; number++) {
|
|
181
|
+
await fetch.call(this, queue, payload, number)
|
|
182
|
+
|
|
183
|
+
for await (const value of this.streams[number]) this.streamsValues[number].push(value)
|
|
184
|
+
|
|
185
|
+
this.streamsEnded[number] = true
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
Then('every stream has been received',
|
|
190
|
+
/**
|
|
191
|
+
* @this {comq.features.Context}
|
|
192
|
+
*/
|
|
193
|
+
async function () {
|
|
194
|
+
for (const number of Object.keys(this.streams)) {
|
|
195
|
+
assert.equal(this.streamsEnded[number], true, `Stream ${number} was not closed`)
|
|
196
|
+
|
|
197
|
+
assert.notEqual(this.streamsValues[number].length, 0,
|
|
198
|
+
`Stream ${number} is empty`)
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
170
202
|
Then('the consumer receives the reply:',
|
|
171
203
|
/**
|
|
172
204
|
* @param {string} yaml
|
package/package.json
CHANGED
|
@@ -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
|
package/source/connection.js
CHANGED
package/source/events.js
CHANGED
package/source/io.js
CHANGED
|
@@ -349,7 +349,12 @@ class IO {
|
|
|
349
349
|
if (isStream) {
|
|
350
350
|
const stream = this.#createReplyStream(request, payload, properties)
|
|
351
351
|
|
|
352
|
-
|
|
352
|
+
try {
|
|
353
|
+
await stream.confirmation
|
|
354
|
+
} catch {
|
|
355
|
+
// the stream has never started, hence the request is re-sent
|
|
356
|
+
return reply.reject(RETRANSMISSION)
|
|
357
|
+
}
|
|
353
358
|
|
|
354
359
|
reply.resolve(stream)
|
|
355
360
|
} else {
|
|
@@ -439,6 +444,9 @@ class IO {
|
|
|
439
444
|
properties.contentType = contentType
|
|
440
445
|
properties.correlationId = request.properties.correlationId
|
|
441
446
|
|
|
447
|
+
// an unroutable reply must be returned by the broker rather than dropped
|
|
448
|
+
properties.mandatory = true
|
|
449
|
+
|
|
442
450
|
return await this.#replies.fire(replyTo, buffer, properties)
|
|
443
451
|
}
|
|
444
452
|
|
package/source/shards/channel.js
CHANGED
|
@@ -19,8 +19,11 @@ class Channel {
|
|
|
19
19
|
/** @type {comq.Channel[]} */
|
|
20
20
|
#pool
|
|
21
21
|
|
|
22
|
-
/** @type {
|
|
23
|
-
#pending = new
|
|
22
|
+
/** @type {Map<Promise<comq.Channel>, number>} */
|
|
23
|
+
#pending = new Map()
|
|
24
|
+
|
|
25
|
+
/** @type {Promex[]} */
|
|
26
|
+
#down = []
|
|
24
27
|
|
|
25
28
|
/** @type {Map<comq.Channel, Promex>} */
|
|
26
29
|
#bench = new Map()
|
|
@@ -48,11 +51,11 @@ class Channel {
|
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
async consume (queue, consumer) {
|
|
51
|
-
return await this.#
|
|
54
|
+
return await this.#every((channel) => channel.consume(queue, consumer))
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
async subscribe (queue, group, consumer) {
|
|
55
|
-
await this.#
|
|
58
|
+
await this.#every((channel) => channel.subscribe(queue, group, consumer))
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
async send (queue, buffer, options) {
|
|
@@ -86,8 +89,10 @@ class Channel {
|
|
|
86
89
|
* @return {Promise<void>}
|
|
87
90
|
*/
|
|
88
91
|
#create = async (connection, index) => {
|
|
92
|
+
this.#watch(connection, index)
|
|
93
|
+
|
|
89
94
|
const pending = connection.createChannel(this.#type, index)
|
|
90
|
-
const channel = await this.#pend(pending)
|
|
95
|
+
const channel = await this.#pend(pending, index)
|
|
91
96
|
|
|
92
97
|
this.#add(channel)
|
|
93
98
|
this.#pipe(channel)
|
|
@@ -97,12 +102,31 @@ class Channel {
|
|
|
97
102
|
channel.diagnose('recover', () => this.#recover(channel))
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Tracks whether a shard is reachable, so that subscribing does not wait for
|
|
107
|
+
* one that is not. The pool itself is left alone: a channel is only benched
|
|
108
|
+
* when it actually fails to publish, otherwise a lost connection would
|
|
109
|
+
* interrupt the streams it is carrying.
|
|
110
|
+
*
|
|
111
|
+
* @param {comq.Connection} connection
|
|
112
|
+
* @param {number} index
|
|
113
|
+
*/
|
|
114
|
+
#watch (connection, index) {
|
|
115
|
+
this.#down[index] = new Promex()
|
|
116
|
+
|
|
117
|
+
if (connection.connected === false) this.#down[index].resolve()
|
|
118
|
+
|
|
119
|
+
connection.diagnose('close', () => this.#down[index].resolve())
|
|
120
|
+
connection.diagnose('open', () => (this.#down[index] = new Promex()))
|
|
121
|
+
}
|
|
122
|
+
|
|
100
123
|
/**
|
|
101
124
|
* @param {Promise<comq.Channel>} pending
|
|
125
|
+
* @param {number} index
|
|
102
126
|
* @return {Promise<comq.Channel>}
|
|
103
127
|
*/
|
|
104
|
-
async #pend (pending) {
|
|
105
|
-
this.#pending.
|
|
128
|
+
async #pend (pending, index) {
|
|
129
|
+
this.#pending.set(pending, index)
|
|
106
130
|
|
|
107
131
|
const channel = await pending
|
|
108
132
|
|
|
@@ -116,8 +140,43 @@ class Channel {
|
|
|
116
140
|
*/
|
|
117
141
|
#pipe (channel) {
|
|
118
142
|
for (const event of events.channel) {
|
|
143
|
+
if (event === RETURN) continue // returns are retried before being reported
|
|
144
|
+
|
|
119
145
|
channel.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args, channel.index))
|
|
120
146
|
}
|
|
147
|
+
|
|
148
|
+
channel.diagnose(RETURN, (message) => this.#returned(message, channel))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* An unroutable message is retried on the shards that have not seen it yet,
|
|
153
|
+
* since a queue may be declared on some of them only. The return is reported
|
|
154
|
+
* once every shard has rejected the message.
|
|
155
|
+
*
|
|
156
|
+
* @param {comq.amqp.Message} message
|
|
157
|
+
* @param {comq.Channel} channel
|
|
158
|
+
*/
|
|
159
|
+
#returned (message, channel) {
|
|
160
|
+
const report = () => this.#diagnostics.emit(RETURN, message, channel.index)
|
|
161
|
+
const attempt = (message.properties.headers?.[RETURN_HEADER] ?? 0) + 1
|
|
162
|
+
const rest = this.#pool.filter((one) => one !== channel)
|
|
163
|
+
|
|
164
|
+
// only replies are published to the default exchange, and only they are mandatory
|
|
165
|
+
const exhausted = message.fields.exchange !== DEFAULT ||
|
|
166
|
+
attempt >= this.#connections.length ||
|
|
167
|
+
rest.length === 0
|
|
168
|
+
|
|
169
|
+
if (exhausted) return report()
|
|
170
|
+
|
|
171
|
+
const properties = {
|
|
172
|
+
...message.properties,
|
|
173
|
+
mandatory: true,
|
|
174
|
+
headers: { ...message.properties.headers, [RETURN_HEADER]: attempt }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const next = rest[Math.floor(Math.random() * rest.length)]
|
|
178
|
+
|
|
179
|
+
next.fire(message.fields.routingKey, message.content, properties).catch(report)
|
|
121
180
|
}
|
|
122
181
|
|
|
123
182
|
/**
|
|
@@ -171,12 +230,49 @@ class Channel {
|
|
|
171
230
|
}
|
|
172
231
|
|
|
173
232
|
/**
|
|
233
|
+
* Resolves once every available shard has settled, requiring at least one to
|
|
234
|
+
* succeed. Benched shards are applied when they come back, which must not
|
|
235
|
+
* hold up the caller.
|
|
236
|
+
*
|
|
174
237
|
* @param {(channel: comq.Channel) => void} fn
|
|
238
|
+
* @return {Promise<any>}
|
|
175
239
|
*/
|
|
176
|
-
async #
|
|
177
|
-
const promises =
|
|
240
|
+
async #every (fn) {
|
|
241
|
+
const promises = []
|
|
242
|
+
|
|
243
|
+
for (const channel of this.#channels) {
|
|
244
|
+
promises.push(this.#unless(fn(channel), channel.index))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const [pending, index] of this.#pending) {
|
|
248
|
+
promises.push(this.#unless(pending.then(fn), index))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const recover of this.#bench.values()) recover.then(fn).catch(noop)
|
|
252
|
+
|
|
253
|
+
const results = await Promise.allSettled(promises)
|
|
254
|
+
const fulfilled = results.find((result) => result.status === 'fulfilled')
|
|
255
|
+
|
|
256
|
+
if (fulfilled === undefined) {
|
|
257
|
+
const reasons = results.map((result) => result.reason)
|
|
178
258
|
|
|
179
|
-
|
|
259
|
+
throw new AggregateError(reasons, 'No shard is available')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return fulfilled.value
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Gives up on a shard as soon as its connection is lost.
|
|
267
|
+
*
|
|
268
|
+
* @param {Promise<any>} promise
|
|
269
|
+
* @param {number} index
|
|
270
|
+
* @return {Promise<any>}
|
|
271
|
+
*/
|
|
272
|
+
async #unless (promise, index) {
|
|
273
|
+
const down = this.#down[index]
|
|
274
|
+
|
|
275
|
+
return down === undefined ? await promise : await Promise.race([promise, down])
|
|
180
276
|
}
|
|
181
277
|
|
|
182
278
|
/**
|
|
@@ -197,7 +293,7 @@ class Channel {
|
|
|
197
293
|
const promises = []
|
|
198
294
|
|
|
199
295
|
for (const channel of this.#channels) promises.push(fn(channel))
|
|
200
|
-
for (const pending of this.#pending) promises.push(pending.then(fn))
|
|
296
|
+
for (const pending of this.#pending.keys()) promises.push(pending.then(fn))
|
|
201
297
|
for (const recover of this.#bench.values()) promises.push(recover.then(fn))
|
|
202
298
|
|
|
203
299
|
return promises
|
|
@@ -234,4 +330,10 @@ async function create (connections, type) {
|
|
|
234
330
|
return channel
|
|
235
331
|
}
|
|
236
332
|
|
|
333
|
+
const DEFAULT = ''
|
|
334
|
+
const RETURN = 'return'
|
|
335
|
+
const RETURN_HEADER = 'x-return'
|
|
336
|
+
|
|
337
|
+
function noop () {}
|
|
338
|
+
|
|
237
339
|
exports.create = create
|
package/test/ReplyStream.test.js
CHANGED
|
@@ -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
|
+
})
|
package/test/connection.mock.js
CHANGED
|
@@ -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),
|
package/test/io.reply.test.js
CHANGED
|
@@ -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)
|
package/test/io.request.test.js
CHANGED
|
@@ -237,6 +237,30 @@ describe('reply', () => {
|
|
|
237
237
|
|
|
238
238
|
expect(output).toStrictEqual(value)
|
|
239
239
|
})
|
|
240
|
+
|
|
241
|
+
it('should re-send the Request if a reply stream is never confirmed', async () => {
|
|
242
|
+
global.COMQ_TESTING_MAX_BUFFER_SIZE = 3
|
|
243
|
+
|
|
244
|
+
const correlationId = requests.send.mock.calls[0][2].correlationId
|
|
245
|
+
const callback = replies.consume.mock.calls[0][1]
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
// out of order chunks overflow the buffer, so the stream dies before control.ok
|
|
249
|
+
for (let index = 1; index <= 5; index++) {
|
|
250
|
+
const properties = { correlationId, headers: { index } }
|
|
251
|
+
const message = /** @type {comq.amqp.Message} */ { content: randomBytes(8), properties }
|
|
252
|
+
|
|
253
|
+
await callback(message)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
await immediate()
|
|
257
|
+
await immediate()
|
|
258
|
+
|
|
259
|
+
expect(requests.send).toHaveBeenCalledTimes(2)
|
|
260
|
+
} finally {
|
|
261
|
+
delete global.COMQ_TESTING_MAX_BUFFER_SIZE
|
|
262
|
+
}
|
|
263
|
+
})
|
|
240
264
|
})
|
|
241
265
|
|
|
242
266
|
const reply = async (content = randomBytes(8), contentType = undefined) => {
|
|
@@ -87,28 +87,75 @@ describe.each(['consume', 'subscribe'])('%s', (method) => {
|
|
|
87
87
|
for (const chan of channels) expect(chan[method]).toHaveBeenCalledWith(...args)
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
-
it(`should
|
|
90
|
+
it(`should not resolve ${method} until pending channels are created`, async () => {
|
|
91
91
|
const promise = promex()
|
|
92
92
|
|
|
93
93
|
connections[0].createChannel.mockImplementationOnce(() => promise)
|
|
94
94
|
|
|
95
95
|
channel = await create(connections, type)
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
let resolved = false
|
|
98
|
+
|
|
99
|
+
const subscription = channel[method](...args).then(() => { resolved = true })
|
|
100
|
+
|
|
101
|
+
await immediate()
|
|
98
102
|
|
|
99
103
|
/** @type {jest.MockedObject<comq.Channel>} */
|
|
100
104
|
const chan1 = await connections[1].createChannel.mock.results[0].value
|
|
101
105
|
|
|
102
106
|
expect(chan1[method]).toHaveBeenCalled()
|
|
107
|
+
expect(resolved).toStrictEqual(false)
|
|
103
108
|
|
|
104
109
|
/** @type {jest.MockedObject<comq.Channel>} */
|
|
105
110
|
const chan0 = mock.channel()
|
|
106
111
|
|
|
107
112
|
promise.resolve(chan0)
|
|
108
113
|
|
|
109
|
-
await
|
|
114
|
+
await subscription
|
|
110
115
|
|
|
111
116
|
expect(chan0[method]).toHaveBeenCalled()
|
|
117
|
+
expect(resolved).toStrictEqual(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it(`should resolve ${method} without a disconnected shard`, async () => {
|
|
121
|
+
const promise = promex()
|
|
122
|
+
|
|
123
|
+
connections[0].createChannel.mockImplementationOnce(() => promise)
|
|
124
|
+
connections[0].connected = false
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
channel = await create(connections, type)
|
|
128
|
+
|
|
129
|
+
// must not wait for the channel that is never going to be created
|
|
130
|
+
await channel[method](...args)
|
|
131
|
+
|
|
132
|
+
/** @type {jest.MockedObject<comq.Channel>} */
|
|
133
|
+
const chan1 = await connections[1].createChannel.mock.results[0].value
|
|
134
|
+
|
|
135
|
+
expect(chan1[method]).toHaveBeenCalled()
|
|
136
|
+
} finally {
|
|
137
|
+
connections[0].connected = true
|
|
138
|
+
promise.resolve(mock.channel())
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it(`should resolve ${method} when a shard disconnects`, async () => {
|
|
143
|
+
const promise = promex()
|
|
144
|
+
|
|
145
|
+
connections[0].createChannel.mockImplementationOnce(() => promise)
|
|
146
|
+
|
|
147
|
+
channel = await create(connections, type)
|
|
148
|
+
|
|
149
|
+
const subscription = channel[method](...args)
|
|
150
|
+
const calls = connections[0].diagnose.mock.calls.filter((call) => call[0] === 'close')
|
|
151
|
+
|
|
152
|
+
expect(calls.length).toBeGreaterThan(0)
|
|
153
|
+
|
|
154
|
+
for (const call of calls) call[1]()
|
|
155
|
+
|
|
156
|
+
await subscription
|
|
157
|
+
|
|
158
|
+
promise.resolve(mock.channel())
|
|
112
159
|
})
|
|
113
160
|
|
|
114
161
|
it(`should ${method} using a channel removed from the pool once it is recovered`, async () => {
|
|
@@ -339,6 +386,43 @@ describe('diagnose', () => {
|
|
|
339
386
|
expect(listener).toHaveBeenCalledWith(expect.any(Number))
|
|
340
387
|
})
|
|
341
388
|
|
|
389
|
+
it('should re-fire a returned message on another shard', async () => {
|
|
390
|
+
const listener = /** @type {jest.MockedFunction} */ jest.fn()
|
|
391
|
+
const index = random(channels.length)
|
|
392
|
+
const chan = channels[index]
|
|
393
|
+
const message = returned()
|
|
394
|
+
|
|
395
|
+
channel.diagnose('return', listener)
|
|
396
|
+
|
|
397
|
+
emitReturn(chan, message)
|
|
398
|
+
|
|
399
|
+
const rest = channels.filter((one) => one !== chan)
|
|
400
|
+
|
|
401
|
+
const properties = expect.objectContaining(
|
|
402
|
+
{ mandatory: true, headers: { 'x-return': 1 } })
|
|
403
|
+
|
|
404
|
+
for (const one of rest) {
|
|
405
|
+
expect(one.fire).toHaveBeenCalledWith(message.fields.routingKey, message.content, properties)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
expect(listener).not.toHaveBeenCalled()
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('should emit `return` event once every shard has returned the message', async () => {
|
|
412
|
+
const listener = /** @type {jest.MockedFunction} */ jest.fn()
|
|
413
|
+
const index = random(channels.length)
|
|
414
|
+
const chan = channels[index]
|
|
415
|
+
const message = returned({ 'x-return': channels.length - 1 })
|
|
416
|
+
|
|
417
|
+
channel.diagnose('return', listener)
|
|
418
|
+
|
|
419
|
+
emitReturn(chan, message)
|
|
420
|
+
|
|
421
|
+
for (const one of channels) expect(one.fire).not.toHaveBeenCalled()
|
|
422
|
+
|
|
423
|
+
expect(listener).toHaveBeenCalledWith(message, index)
|
|
424
|
+
})
|
|
425
|
+
|
|
342
426
|
it('should emit `pause` and `unpause` events', async () => {
|
|
343
427
|
const queue = generate()
|
|
344
428
|
const buffer = randomBytes(8)
|
|
@@ -428,6 +512,30 @@ it('should remove channel with back pressure from the pool', async () => {
|
|
|
428
512
|
expect(chan.send).toHaveBeenCalled()
|
|
429
513
|
})
|
|
430
514
|
|
|
515
|
+
/**
|
|
516
|
+
* @param {Record<string, any>} [headers]
|
|
517
|
+
* @return {comq.amqp.Message}
|
|
518
|
+
*/
|
|
519
|
+
function returned (headers = {}) {
|
|
520
|
+
return /** @type {comq.amqp.Message} */ {
|
|
521
|
+
content: randomBytes(8),
|
|
522
|
+
fields: { exchange: '', routingKey: generate() },
|
|
523
|
+
properties: { correlationId: generate(), headers }
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* @param {jest.MockedObject<comq.Channel>} channel
|
|
529
|
+
* @param {comq.amqp.Message} message
|
|
530
|
+
*/
|
|
531
|
+
function emitReturn (channel, message) {
|
|
532
|
+
const calls = channel.diagnose.mock.calls.filter((call) => call[0] === 'return')
|
|
533
|
+
|
|
534
|
+
expect(calls.length).toBeGreaterThan(0)
|
|
535
|
+
|
|
536
|
+
for (const call of calls) call[1](message)
|
|
537
|
+
}
|
|
538
|
+
|
|
431
539
|
/**
|
|
432
540
|
* @return {Promise<jest.MockedObject<comq.Channel>[]>}
|
|
433
541
|
*/
|