comq 0.15.2 → 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 +8 -0
- package/features/shards.feature +16 -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 +61 -1
- package/package.json +1 -1
- package/source/connection.js +1 -1
- package/source/events.js +1 -1
- package/source/io.js +21 -11
- package/source/shards/channel.js +34 -4
- package/test/io.request.test.js +117 -0
- package/test/shards/channel.test.js +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
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
|
+
|
|
1
9
|
## [0.15.2](https://github.com/toa-io/comq/compare/v0.15.1...v0.15.2) (2026-08-04)
|
|
2
10
|
|
|
3
11
|
|
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
|
|
@@ -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
|
|
@@ -425,6 +474,17 @@ Then('all replies have been received',
|
|
|
425
474
|
await Promise.all(this.requestsSent)
|
|
426
475
|
})
|
|
427
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
|
+
|
|
428
488
|
async function send (queue, payload) {
|
|
429
489
|
if (this.expected) await this.expected
|
|
430
490
|
|
package/package.json
CHANGED
package/source/connection.js
CHANGED
|
@@ -151,7 +151,7 @@ class Connection {
|
|
|
151
151
|
* @param {comq.amqp.Connection} connection
|
|
152
152
|
* @param {number} [timeoutMs]
|
|
153
153
|
*/
|
|
154
|
-
#armWatchdog (connection, timeoutMs = WATCHDOG_MS) {
|
|
154
|
+
#armWatchdog (connection, timeoutMs = global.COMQ_TESTING_WATCHDOG_INTERVAL ?? WATCHDOG_MS) {
|
|
155
155
|
const socket = connection.connection?.stream
|
|
156
156
|
|
|
157
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', 'return']
|
|
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 {
|
|
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)
|
|
@@ -455,10 +461,14 @@ class IO {
|
|
|
455
461
|
}
|
|
456
462
|
|
|
457
463
|
#retransmit = () => {
|
|
458
|
-
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)
|
|
459
468
|
|
|
460
|
-
|
|
461
|
-
|
|
469
|
+
// trigger failsafe attribute
|
|
470
|
+
reply.reject(RETRANSMISSION)
|
|
471
|
+
}
|
|
462
472
|
}
|
|
463
473
|
|
|
464
474
|
/**
|
package/source/shards/channel.js
CHANGED
|
@@ -25,6 +25,9 @@ class Channel {
|
|
|
25
25
|
/** @type {Promex[]} */
|
|
26
26
|
#down = []
|
|
27
27
|
|
|
28
|
+
/** @type {boolean[]} */
|
|
29
|
+
#alive = []
|
|
30
|
+
|
|
28
31
|
/** @type {Map<comq.Channel, Promex>} */
|
|
29
32
|
#bench = new Map()
|
|
30
33
|
|
|
@@ -106,18 +109,28 @@ class Channel {
|
|
|
106
109
|
* Tracks whether a shard is reachable, so that subscribing does not wait for
|
|
107
110
|
* one that is not. The pool itself is left alone: a channel is only benched
|
|
108
111
|
* when it actually fails to publish, otherwise a lost connection would
|
|
109
|
-
* interrupt the streams it is carrying.
|
|
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.
|
|
110
114
|
*
|
|
111
115
|
* @param {comq.Connection} connection
|
|
112
116
|
* @param {number} index
|
|
113
117
|
*/
|
|
114
118
|
#watch (connection, index) {
|
|
115
119
|
this.#down[index] = new Promex()
|
|
120
|
+
this.#alive[index] = connection.connected !== false
|
|
116
121
|
|
|
117
122
|
if (connection.connected === false) this.#down[index].resolve()
|
|
118
123
|
|
|
119
|
-
connection.diagnose('close', () =>
|
|
120
|
-
|
|
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
|
+
})
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
/**
|
|
@@ -141,6 +154,7 @@ class Channel {
|
|
|
141
154
|
#pipe (channel) {
|
|
142
155
|
for (const event of events.channel) {
|
|
143
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
|
|
144
158
|
|
|
145
159
|
channel.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args, channel.index))
|
|
146
160
|
}
|
|
@@ -299,13 +313,28 @@ class Channel {
|
|
|
299
313
|
return promises
|
|
300
314
|
}
|
|
301
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
|
+
|
|
302
328
|
/**
|
|
303
329
|
* @param {(channel: comq.Channel) => void} fn
|
|
304
330
|
*/
|
|
305
331
|
async #one (fn) {
|
|
306
332
|
if (this.#pool.length === 0) await this.#recovery
|
|
307
333
|
|
|
308
|
-
|
|
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)]
|
|
309
338
|
|
|
310
339
|
try {
|
|
311
340
|
return await fn(channel)
|
|
@@ -333,6 +362,7 @@ async function create (connections, type) {
|
|
|
333
362
|
const DEFAULT = ''
|
|
334
363
|
const RETURN = 'return'
|
|
335
364
|
const RETURN_HEADER = 'x-return'
|
|
365
|
+
const LOST = 'lost'
|
|
336
366
|
|
|
337
367
|
function noop () {}
|
|
338
368
|
|
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) => {
|
|
@@ -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 = []
|