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 +16 -0
- package/features/shards.feature +16 -0
- package/features/shards.subscriptions.feature +25 -0
- package/features/steps/brokers.js +8 -0
- package/features/steps/context.js +6 -1
- package/features/steps/hooks.js +3 -0
- package/features/steps/parameters.js +1 -1
- package/features/steps/rpc.js +93 -1
- package/package.json +1 -1
- package/source/.io/ReplyStream.js +13 -0
- package/source/connection.js +5 -1
- package/source/events.js +1 -1
- package/source/io.js +30 -12
- package/source/shards/channel.js +144 -12
- 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 +141 -0
- package/test/shards/channel.test.js +163 -3
- package/types/connection.d.ts +2 -0
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
|
|
package/features/shards.feature
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/features/steps/hooks.js
CHANGED
|
@@ -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
|
)
|
package/features/steps/rpc.js
CHANGED
|
@@ -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
|
@@ -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
|
@@ -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
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 {
|
|
39
|
-
#pendingReplies = new
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
453
|
-
|
|
469
|
+
// trigger failsafe attribute
|
|
470
|
+
reply.reject(RETRANSMISSION)
|
|
471
|
+
}
|
|
454
472
|
}
|
|
455
473
|
|
|
456
474
|
/**
|
package/source/shards/channel.js
CHANGED
|
@@ -19,8 +19,14 @@ 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 = []
|
|
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.#
|
|
57
|
+
return await this.#every((channel) => channel.consume(queue, consumer))
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
async subscribe (queue, group, consumer) {
|
|
55
|
-
await this.#
|
|
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.
|
|
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 #
|
|
177
|
-
const promises =
|
|
254
|
+
async #every (fn) {
|
|
255
|
+
const promises = []
|
|
178
256
|
|
|
179
|
-
|
|
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
|
-
|
|
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
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
*/
|