comq 0.18.0 → 0.20.0
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/discussions/poison-message-handling.md +14 -10
- package/docs/headers.md +1 -0
- package/features/events.poison.feature +11 -0
- package/features/rpc.call.feature +71 -0
- package/features/rpc.call.recovery.feature +75 -0
- package/features/rpc.call.shards.feature +24 -0
- package/features/rpc.parked.feature +20 -0
- package/features/rpc.timeout.feature +19 -0
- package/features/steps/call.js +462 -0
- package/features/steps/context.js +4 -0
- package/features/steps/events.js +15 -0
- package/features/steps/network.js +8 -0
- package/features/steps/networks.js +24 -4
- package/features/steps/parked.js +102 -0
- package/features/steps/poison.js +3 -2
- package/package.json +1 -1
- package/readme.md +176 -31
- package/source/attributes/recall.js +2 -1
- package/source/channel.js +138 -3
- package/source/events.js +1 -1
- package/source/index.js +6 -0
- package/source/io.js +239 -14
- package/source/shards/channel.js +20 -3
- package/source/unroutable.js +27 -0
- package/source/verdicts.js +54 -0
- package/test/amqplib.mock.js +1 -0
- package/test/channel.held.test.js +237 -0
- package/test/channel.test.js +104 -0
- package/test/connection.mock.js +1 -0
- package/test/io.call.test.js +161 -0
- package/test/io.reply.test.js +66 -0
- package/test/io.request.terms.test.js +247 -0
- package/test/shards/channel.test.js +68 -0
- package/test/verdicts.test.js +57 -0
- package/types/channel.d.ts +3 -0
- package/types/diagnostic.d.ts +1 -1
- package/types/index.d.ts +26 -0
- package/types/io.d.ts +42 -2
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert')
|
|
4
|
+
const amqplib = require('amqplib')
|
|
5
|
+
const { Given, When, Then, After } = require('@cucumber/cucumber')
|
|
6
|
+
|
|
7
|
+
const { connect, Unroutable } = require('../../')
|
|
8
|
+
const { parse } = require('./yaml')
|
|
9
|
+
const { getAddress, USER, PASSWORD } = require('./brokers')
|
|
10
|
+
const { timeout, until } = require('../../test/helpers')
|
|
11
|
+
|
|
12
|
+
Given('a holder answering {token} under the {token} key:',
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} exchange
|
|
15
|
+
* @param {string} key
|
|
16
|
+
* @param {string} javascript
|
|
17
|
+
* @this {comq.features.Context}
|
|
18
|
+
*/
|
|
19
|
+
async function (exchange, key, javascript) {
|
|
20
|
+
// eslint-disable-next-line no-new-func
|
|
21
|
+
const producer = new Function('return ' + javascript)()
|
|
22
|
+
|
|
23
|
+
this.io.diagnose('taken', () => (this.taken = true))
|
|
24
|
+
|
|
25
|
+
await this.io.back(exchange, key, producer)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
Given('a holder on another connection answering {token} under the {token} key',
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} exchange
|
|
31
|
+
* @param {string} key
|
|
32
|
+
* @this {comq.features.Context}
|
|
33
|
+
*/
|
|
34
|
+
async function (exchange, key) {
|
|
35
|
+
this.holder = await another(this)
|
|
36
|
+
|
|
37
|
+
await this.holder.back(exchange, key, echo)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
Given('a holder on another connection answering {token} under the {token} key in {number}ms',
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} exchange
|
|
43
|
+
* @param {string} key
|
|
44
|
+
* @param {number} delay
|
|
45
|
+
* @this {comq.features.Context}
|
|
46
|
+
*/
|
|
47
|
+
async function (exchange, key, delay) {
|
|
48
|
+
this.holder = await another(this)
|
|
49
|
+
|
|
50
|
+
await this.holder.back(exchange, key, async (payload) => {
|
|
51
|
+
await timeout(delay)
|
|
52
|
+
|
|
53
|
+
return echo(payload)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
Given('a holder connected to broker {number} answering {token} under the {token} key',
|
|
58
|
+
/**
|
|
59
|
+
* @param {number} broker
|
|
60
|
+
* @param {string} exchange
|
|
61
|
+
* @param {string} key
|
|
62
|
+
* @this {comq.features.Context}
|
|
63
|
+
*/
|
|
64
|
+
async function (broker, exchange, key) {
|
|
65
|
+
this.holder = await another(this, broker)
|
|
66
|
+
|
|
67
|
+
await this.holder.back(exchange, key, echo)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
Given('a holder never answering {token} under the {token} key',
|
|
71
|
+
/**
|
|
72
|
+
* Holds the key as comq does, and takes a Request without ever answering it, so that losing
|
|
73
|
+
* its connection loses the Request with it.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} exchange
|
|
76
|
+
* @param {string} key
|
|
77
|
+
* @this {comq.features.Context}
|
|
78
|
+
*/
|
|
79
|
+
async function (exchange, key) {
|
|
80
|
+
const connection = await amqplib.connect(url(0))
|
|
81
|
+
const channel = await connection.createChannel()
|
|
82
|
+
const queue = exchange + '.' + key
|
|
83
|
+
|
|
84
|
+
await channel.assertExchange(exchange, 'direct', { durable: true })
|
|
85
|
+
await channel.assertQueue(queue, { exclusive: true })
|
|
86
|
+
await channel.bindQueue(queue, exchange, key)
|
|
87
|
+
await channel.consume(queue, () => undefined)
|
|
88
|
+
|
|
89
|
+
this.silent = connection
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
When('the silent holder crashes',
|
|
93
|
+
/**
|
|
94
|
+
* Its socket is destroyed without a word, as a process that dies leaves it.
|
|
95
|
+
*
|
|
96
|
+
* @this {comq.features.Context}
|
|
97
|
+
*/
|
|
98
|
+
function () {
|
|
99
|
+
const connection = this.silent
|
|
100
|
+
|
|
101
|
+
this.silent = undefined
|
|
102
|
+
|
|
103
|
+
connection.on('error', () => undefined)
|
|
104
|
+
connection.connection.stream.destroy()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
When('the consumer calls {token} under the {token} key',
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} exchange
|
|
110
|
+
* @param {string} key
|
|
111
|
+
* @this {comq.features.Context}
|
|
112
|
+
*/
|
|
113
|
+
async function (exchange, key) {
|
|
114
|
+
call(this, this.io.call(exchange, key, { key }))
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
When('the consumer calls {token} under the {token} key with:',
|
|
118
|
+
/**
|
|
119
|
+
* @param {string} exchange
|
|
120
|
+
* @param {string} key
|
|
121
|
+
* @param {string} yaml
|
|
122
|
+
* @this {comq.features.Context}
|
|
123
|
+
*/
|
|
124
|
+
async function (exchange, key, yaml) {
|
|
125
|
+
call(this, this.io.call(exchange, key, parse(yaml)))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
When('the consumer calls {token} under the {token} key with a {number}ms timeout',
|
|
129
|
+
/**
|
|
130
|
+
* @param {string} exchange
|
|
131
|
+
* @param {string} key
|
|
132
|
+
* @param {number} ms
|
|
133
|
+
* @this {comq.features.Context}
|
|
134
|
+
*/
|
|
135
|
+
async function (exchange, key, ms) {
|
|
136
|
+
call(this, this.io.call(exchange, key, { key }, { timeout: ms }))
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
When('the consumer calls {token} under the {token} key {number} times',
|
|
140
|
+
/**
|
|
141
|
+
* @param {string} exchange
|
|
142
|
+
* @param {string} key
|
|
143
|
+
* @param {number} times
|
|
144
|
+
* @this {comq.features.Context}
|
|
145
|
+
*/
|
|
146
|
+
async function (exchange, key, times) {
|
|
147
|
+
const calls = Array.from({ length: times }, (_, n) => this.io.call(exchange, key, n))
|
|
148
|
+
|
|
149
|
+
this.replies = await Promise.all(calls)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
When('the consumer sends a request to the {token} queue with a {number}ms timeout',
|
|
153
|
+
/**
|
|
154
|
+
* @param {string} queue
|
|
155
|
+
* @param {number} ms
|
|
156
|
+
* @this {comq.features.Context}
|
|
157
|
+
*/
|
|
158
|
+
async function (queue, ms) {
|
|
159
|
+
call(this, this.io.request(queue, { queue }, { timeout: ms }))
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
When('the holder is sealed',
|
|
163
|
+
/**
|
|
164
|
+
* @this {comq.features.Context}
|
|
165
|
+
*/
|
|
166
|
+
async function () {
|
|
167
|
+
await this.holder.seal()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
When('a second holder on another connection holds {token} under the {token} key',
|
|
171
|
+
/**
|
|
172
|
+
* @param {string} exchange
|
|
173
|
+
* @param {string} key
|
|
174
|
+
* @this {comq.features.Context}
|
|
175
|
+
*/
|
|
176
|
+
async function (exchange, key) {
|
|
177
|
+
const second = await another(this)
|
|
178
|
+
|
|
179
|
+
second.diagnose('taken', () => (this.taken = true))
|
|
180
|
+
|
|
181
|
+
this.holding = second.back(exchange, key, echo)
|
|
182
|
+
|
|
183
|
+
// asserted on by a later step, and an unhandled rejection would end the run before it
|
|
184
|
+
this.holding.catch(() => undefined)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
When('the first holder disconnects',
|
|
188
|
+
/**
|
|
189
|
+
* @this {comq.features.Context}
|
|
190
|
+
*/
|
|
191
|
+
async function () {
|
|
192
|
+
await this.holder.close()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
When('a producer counting requests to the {token} queue',
|
|
196
|
+
/**
|
|
197
|
+
* @param {string} queue
|
|
198
|
+
* @this {comq.features.Context}
|
|
199
|
+
*/
|
|
200
|
+
async function (queue) {
|
|
201
|
+
this.produced = 0
|
|
202
|
+
|
|
203
|
+
await this.io.reply(queue, () => {
|
|
204
|
+
this.produced++
|
|
205
|
+
|
|
206
|
+
return null
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
Then('the call is refused as unroutable',
|
|
211
|
+
/**
|
|
212
|
+
* @this {comq.features.Context}
|
|
213
|
+
*/
|
|
214
|
+
async function () {
|
|
215
|
+
await assert.rejects(this.reply, (error) => error instanceof Unroutable)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
Then('the consumer stops waiting',
|
|
219
|
+
/**
|
|
220
|
+
* @this {comq.features.Context}
|
|
221
|
+
*/
|
|
222
|
+
async function () {
|
|
223
|
+
await assert.rejects(this.reply, (error) => error.name === 'TimeoutError')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
Given('a holder connected to broker {number} answering {token} under the {token} key as {token}',
|
|
227
|
+
/**
|
|
228
|
+
* @param {number} broker
|
|
229
|
+
* @param {string} exchange
|
|
230
|
+
* @param {string} key
|
|
231
|
+
* @param {string} name
|
|
232
|
+
* @this {comq.features.Context}
|
|
233
|
+
*/
|
|
234
|
+
async function (broker, exchange, key, name) {
|
|
235
|
+
this.holder = await another(this, broker)
|
|
236
|
+
|
|
237
|
+
await this.holder.back(exchange, key, () => name)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
When('a second holder on another connection holds {token} under the {token} key as {token}',
|
|
241
|
+
/**
|
|
242
|
+
* @param {string} exchange
|
|
243
|
+
* @param {string} key
|
|
244
|
+
* @param {string} name
|
|
245
|
+
* @this {comq.features.Context}
|
|
246
|
+
*/
|
|
247
|
+
async function (exchange, key, name) {
|
|
248
|
+
const second = await another(this)
|
|
249
|
+
|
|
250
|
+
second.diagnose('taken', () => (this.taken = true))
|
|
251
|
+
|
|
252
|
+
await second.back(exchange, key, () => name)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
Then('the second holder finds the key taken',
|
|
256
|
+
/**
|
|
257
|
+
* @this {comq.features.Context}
|
|
258
|
+
*/
|
|
259
|
+
async function () {
|
|
260
|
+
assert.equal(await until(() => this.taken === true), true, 'The key was not found taken')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
Then('every call to {token} under the {token} key is answered by {token} within {number} seconds', { timeout: 120_000 },
|
|
264
|
+
/**
|
|
265
|
+
* @param {string} exchange
|
|
266
|
+
* @param {string} key
|
|
267
|
+
* @param {string} name
|
|
268
|
+
* @param {number} seconds
|
|
269
|
+
* @this {comq.features.Context}
|
|
270
|
+
*/
|
|
271
|
+
async function (exchange, key, name, seconds) {
|
|
272
|
+
const answered = await eventually(async () => {
|
|
273
|
+
const calls = Array.from({ length: 20 }, () => this.io.call(exchange, key, null, { timeout: 1000 }))
|
|
274
|
+
const replies = await Promise.all(calls)
|
|
275
|
+
|
|
276
|
+
if (!replies.every((reply) => reply === name)) throw new Error('Answered by another')
|
|
277
|
+
}, seconds)
|
|
278
|
+
|
|
279
|
+
assert.equal(answered, true, `Calls were not all answered by ${name}`)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
Then('the second holder holds the key',
|
|
283
|
+
/**
|
|
284
|
+
* @this {comq.features.Context}
|
|
285
|
+
*/
|
|
286
|
+
async function () {
|
|
287
|
+
await this.holding
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
Then('the holder finds its key taken while the broker holds the silent connection', { timeout: 60_000 },
|
|
291
|
+
/**
|
|
292
|
+
* @this {comq.features.Context}
|
|
293
|
+
*/
|
|
294
|
+
async function () {
|
|
295
|
+
assert.equal(await until(() => this.taken === true, 50_000), true, 'The key was not found taken')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
Then('a call to {token} under the {token} key is answered within {number} seconds', { timeout: 120_000 },
|
|
299
|
+
/**
|
|
300
|
+
* @param {string} exchange
|
|
301
|
+
* @param {string} key
|
|
302
|
+
* @param {number} seconds
|
|
303
|
+
* @this {comq.features.Context}
|
|
304
|
+
*/
|
|
305
|
+
async function (exchange, key, seconds) {
|
|
306
|
+
const answered = await eventually(() => this.io.call(exchange, key, { key }, { timeout: 1000 }), seconds)
|
|
307
|
+
|
|
308
|
+
assert.equal(answered, true, 'The call was not answered')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
Then('a call to {token} under the {token} key from another connection is answered within {number} seconds', { timeout: 120_000 },
|
|
312
|
+
/**
|
|
313
|
+
* @param {string} exchange
|
|
314
|
+
* @param {string} key
|
|
315
|
+
* @param {number} seconds
|
|
316
|
+
* @this {comq.features.Context}
|
|
317
|
+
*/
|
|
318
|
+
async function (exchange, key, seconds) {
|
|
319
|
+
const caller = await another(this)
|
|
320
|
+
const answered = await eventually(() => caller.call(exchange, key, { key }, { timeout: 1000 }), seconds)
|
|
321
|
+
|
|
322
|
+
assert.equal(answered, true, 'The call was not answered')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
Then('a call to {token} under the {token} key is refused within {number} seconds', { timeout: 60_000 },
|
|
326
|
+
/**
|
|
327
|
+
* @param {string} exchange
|
|
328
|
+
* @param {string} key
|
|
329
|
+
* @param {number} seconds
|
|
330
|
+
* @this {comq.features.Context}
|
|
331
|
+
*/
|
|
332
|
+
async function (exchange, key, seconds) {
|
|
333
|
+
const refused = await eventually(async () => {
|
|
334
|
+
try {
|
|
335
|
+
await this.io.call(exchange, key, { key }, { timeout: 1000 })
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (error instanceof Unroutable) return
|
|
338
|
+
|
|
339
|
+
throw error
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
throw new Error('Answered')
|
|
343
|
+
}, seconds)
|
|
344
|
+
|
|
345
|
+
assert.equal(refused, true, 'The call was not refused')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
Then('the call ends', { timeout: 90_000 },
|
|
349
|
+
/**
|
|
350
|
+
* @this {comq.features.Context}
|
|
351
|
+
*/
|
|
352
|
+
async function () {
|
|
353
|
+
await this.reply.catch(() => undefined)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
Then('the consumer stops waiting within {number} second(s)', { timeout: 90_000 },
|
|
357
|
+
/**
|
|
358
|
+
* @param {number} seconds
|
|
359
|
+
* @this {comq.features.Context}
|
|
360
|
+
*/
|
|
361
|
+
async function (seconds) {
|
|
362
|
+
const stopped = this.reply.then(() => 'answered', (error) => error.name === 'TimeoutError' ? 'stopped' : error.message)
|
|
363
|
+
const late = timeout(seconds * 1000).then(() => 'still waiting')
|
|
364
|
+
|
|
365
|
+
assert.equal(await Promise.race([stopped, late]), 'stopped')
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
Then('every call is answered',
|
|
369
|
+
/**
|
|
370
|
+
* @this {comq.features.Context}
|
|
371
|
+
*/
|
|
372
|
+
async function () {
|
|
373
|
+
assert.deepEqual(this.replies, this.replies.map((_, n) => n))
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
Then('the producer receives nothing',
|
|
377
|
+
/**
|
|
378
|
+
* @this {comq.features.Context}
|
|
379
|
+
*/
|
|
380
|
+
async function () {
|
|
381
|
+
await timeout(500)
|
|
382
|
+
|
|
383
|
+
assert.equal(this.produced, 0, 'An expired request was processed')
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
After(
|
|
387
|
+
/**
|
|
388
|
+
* @this {comq.features.Context}
|
|
389
|
+
*/
|
|
390
|
+
async function () {
|
|
391
|
+
await Promise.all((this.holders ?? []).map((io) => io.close()))
|
|
392
|
+
await this.silent?.close().catch(() => undefined)
|
|
393
|
+
|
|
394
|
+
this.holders = []
|
|
395
|
+
this.silent = undefined
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Another connection, to the brokers the consumer is connected to, or to one of them.
|
|
400
|
+
*
|
|
401
|
+
* @param {comq.features.Context} context
|
|
402
|
+
* @param {number} [broker]
|
|
403
|
+
* @returns {Promise<comq.IO>}
|
|
404
|
+
*/
|
|
405
|
+
async function another (context, broker) {
|
|
406
|
+
const urls = broker !== undefined
|
|
407
|
+
? [url(broker)]
|
|
408
|
+
: context.sharded ? [url(0), url(1)] : [url(0)]
|
|
409
|
+
|
|
410
|
+
const io = await connect(...urls)
|
|
411
|
+
|
|
412
|
+
context.holders ??= []
|
|
413
|
+
context.holders.push(io)
|
|
414
|
+
|
|
415
|
+
return io
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* @param {comq.features.Context} context
|
|
420
|
+
* @param {Promise<any>} reply
|
|
421
|
+
*/
|
|
422
|
+
function call (context, reply) {
|
|
423
|
+
// asserted on by a later step, and an unhandled rejection would end the run before it
|
|
424
|
+
reply.catch(() => undefined)
|
|
425
|
+
|
|
426
|
+
context.reply = reply
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* @param {number} broker
|
|
431
|
+
* @returns {string}
|
|
432
|
+
*/
|
|
433
|
+
function url (broker) {
|
|
434
|
+
return `amqp://${USER}:${PASSWORD}@${getAddress(broker)}`
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Makes an attempt until it succeeds, or until the time is up.
|
|
439
|
+
*
|
|
440
|
+
* @param {() => Promise<unknown>} attempt
|
|
441
|
+
* @param {number} seconds
|
|
442
|
+
* @returns {Promise<boolean>} whether it succeeded
|
|
443
|
+
*/
|
|
444
|
+
async function eventually (attempt, seconds) {
|
|
445
|
+
const deadline = Date.now() + seconds * 1000
|
|
446
|
+
|
|
447
|
+
while (Date.now() < deadline) {
|
|
448
|
+
try {
|
|
449
|
+
await attempt()
|
|
450
|
+
|
|
451
|
+
return true
|
|
452
|
+
} catch {
|
|
453
|
+
await timeout(500)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return false
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function echo (payload) {
|
|
461
|
+
return payload
|
|
462
|
+
}
|
|
@@ -21,6 +21,8 @@ class Context extends World {
|
|
|
21
21
|
attempts = []
|
|
22
22
|
counts = {}
|
|
23
23
|
parked = {}
|
|
24
|
+
awaited
|
|
25
|
+
answer
|
|
24
26
|
processed
|
|
25
27
|
enqueued
|
|
26
28
|
tasksProcessedCount = 0
|
|
@@ -69,6 +71,8 @@ class Context extends World {
|
|
|
69
71
|
this.attempts = []
|
|
70
72
|
this.counts = {}
|
|
71
73
|
this.parked = {}
|
|
74
|
+
this.awaited = undefined
|
|
75
|
+
this.answer = undefined
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
/**
|
package/features/steps/events.js
CHANGED
|
@@ -38,6 +38,21 @@ Given('(that )events are exclusively consumed from the {token} exchange',
|
|
|
38
38
|
this.consumptionPromise = consume.call(this, undefined, exchange)
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
+
Given('(that )events from the {token} exchange are refused as poison',
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} exchange
|
|
44
|
+
* @this {comq.features.Context}
|
|
45
|
+
*/
|
|
46
|
+
async function (exchange) {
|
|
47
|
+
const { Park } = require('../../')
|
|
48
|
+
|
|
49
|
+
await this.io.consume(exchange, 'exceptions', (payload, properties) => {
|
|
50
|
+
this.attempts.push(properties.headers?.['x-comq-attempt'] ?? 1)
|
|
51
|
+
|
|
52
|
+
throw new Park('Expected refusal')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
41
56
|
Given('(that )events from the {token} exchange are causing exceptions',
|
|
42
57
|
/**
|
|
43
58
|
* @param {string} exchange
|
|
@@ -26,6 +26,14 @@ When('the network goes silent',
|
|
|
26
26
|
silence.call(this)
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
+
When('the silent connection is let go',
|
|
30
|
+
/**
|
|
31
|
+
* @this {comq.features.Context}
|
|
32
|
+
*/
|
|
33
|
+
function () {
|
|
34
|
+
for (const network of this.networks) network.release()
|
|
35
|
+
})
|
|
36
|
+
|
|
29
37
|
// a request that is answered over a connection that is already silent proves
|
|
30
38
|
// nothing, hence the window between the two is left as short as a step boundary
|
|
31
39
|
When('the consumer sends a request to the {token} queue as the network goes silent',
|
|
@@ -51,6 +51,14 @@ class Network {
|
|
|
51
51
|
for (const tunnel of this.#tunnels) tunnel.silent = true
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Closes the tunnels that went silent, at both ends, so the broker learns that the
|
|
56
|
+
* connection it was still holding is gone — the moment its heartbeat would otherwise tell it.
|
|
57
|
+
*/
|
|
58
|
+
release () {
|
|
59
|
+
for (const tunnel of this.#tunnels) if (tunnel.silent) this.#collapse(tunnel)
|
|
60
|
+
}
|
|
61
|
+
|
|
54
62
|
async close () {
|
|
55
63
|
for (const tunnel of this.#tunnels) this.#collapse(tunnel)
|
|
56
64
|
|
|
@@ -71,10 +79,22 @@ class Network {
|
|
|
71
79
|
client.on('data', (chunk) => { if (!tunnel.silent) upstream.write(chunk) })
|
|
72
80
|
upstream.on('data', (chunk) => { if (!tunnel.silent) client.write(chunk) })
|
|
73
81
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
// a silent network tells neither end that the other has gone: the client giving up on a
|
|
83
|
+
// silent connection leaves the broker holding its end, until the network is let go
|
|
84
|
+
client.on('error', () => this.#hangUp(tunnel))
|
|
85
|
+
client.on('close', () => this.#hangUp(tunnel))
|
|
86
|
+
|
|
87
|
+
upstream.on('error', () => this.#collapse(tunnel))
|
|
88
|
+
upstream.on('close', () => this.#collapse(tunnel))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {comq.features.Tunnel} tunnel
|
|
93
|
+
*/
|
|
94
|
+
#hangUp (tunnel) {
|
|
95
|
+
if (!tunnel.silent) return this.#collapse(tunnel)
|
|
96
|
+
|
|
97
|
+
tunnel.client.destroy()
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
/**
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert')
|
|
4
|
+
const amqplib = require('amqplib')
|
|
5
|
+
const { Given, When, Then } = require('@cucumber/cucumber')
|
|
6
|
+
|
|
7
|
+
const { getAddress, USER, PASSWORD } = require('./brokers')
|
|
8
|
+
const { until, timeout } = require('../../test/helpers')
|
|
9
|
+
|
|
10
|
+
Given('a producer failing every request to the {token} queue',
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} queue
|
|
13
|
+
* @this {comq.features.Context}
|
|
14
|
+
*/
|
|
15
|
+
async function (queue) {
|
|
16
|
+
await this.io.reply(queue, () => {
|
|
17
|
+
throw new Error('Expected failure')
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
When('a request is sent to the {token} queue',
|
|
22
|
+
/**
|
|
23
|
+
* The caller is left waiting on purpose: the promise is what the last step asserts on.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} queue
|
|
26
|
+
* @this {comq.features.Context}
|
|
27
|
+
*/
|
|
28
|
+
async function (queue) {
|
|
29
|
+
this.awaited = this.io.request(queue, { question: 'the answer?' }, 'application/json')
|
|
30
|
+
|
|
31
|
+
// nothing settles it unless the parked request is answered, and an unhandled
|
|
32
|
+
// rejection would take the run down before the assertion is reached
|
|
33
|
+
this.awaited.catch(() => undefined)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
Then('the request is parked',
|
|
37
|
+
/**
|
|
38
|
+
* @this {comq.features.Context}
|
|
39
|
+
*/
|
|
40
|
+
async function () {
|
|
41
|
+
await until(() => this.events.discard === true)
|
|
42
|
+
|
|
43
|
+
assert.equal(this.events.discard, true, 'The request was not parked')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
When('the parked request from the {token} queue is answered by hand',
|
|
47
|
+
/**
|
|
48
|
+
* What an operator draining the parking queue would do: take the message, read who
|
|
49
|
+
* was asking, and publish the reply they are still waiting for.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} queue
|
|
52
|
+
* @this {comq.features.Context}
|
|
53
|
+
*/
|
|
54
|
+
async function (queue) {
|
|
55
|
+
const connection = await amqplib.connect(`amqp://${USER}:${PASSWORD}@${getAddress(0)}`)
|
|
56
|
+
const channel = await connection.createChannel()
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
let parked
|
|
60
|
+
|
|
61
|
+
await channel.consume('comq.parked.' + queue, (message) => {
|
|
62
|
+
parked = message
|
|
63
|
+
channel.ack(message)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
await until(() => parked !== undefined)
|
|
67
|
+
|
|
68
|
+
assert.notEqual(parked, undefined, `Nothing was parked from ${queue}`)
|
|
69
|
+
|
|
70
|
+
const { replyTo, correlationId, contentType } = parked.properties
|
|
71
|
+
|
|
72
|
+
assert.notEqual(replyTo, undefined, 'The parked request lost its replyTo')
|
|
73
|
+
assert.notEqual(correlationId, undefined, 'The parked request lost its correlationId')
|
|
74
|
+
|
|
75
|
+
this.answer = { answered: true, was: JSON.parse(parked.content.toString()) }
|
|
76
|
+
|
|
77
|
+
channel.sendToQueue(replyTo, Buffer.from(JSON.stringify(this.answer)),
|
|
78
|
+
{ correlationId, contentType })
|
|
79
|
+
|
|
80
|
+
// let the frame reach the broker before the connection goes
|
|
81
|
+
await timeout(100)
|
|
82
|
+
} finally {
|
|
83
|
+
await channel.close()
|
|
84
|
+
await connection.close()
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
Then('the caller receives the reply',
|
|
89
|
+
/**
|
|
90
|
+
* @this {comq.features.Context}
|
|
91
|
+
*/
|
|
92
|
+
async function () {
|
|
93
|
+
const reply = await Promise.race([
|
|
94
|
+
this.awaited,
|
|
95
|
+
timeout(5000).then(() => TIMED_OUT)
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
assert.notEqual(reply, TIMED_OUT, 'The caller is still waiting')
|
|
99
|
+
assert.deepEqual(reply, this.answer, 'The caller received something else')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const TIMED_OUT = Symbol('the caller was still waiting')
|
package/features/steps/poison.js
CHANGED
|
@@ -57,9 +57,10 @@ Then('the parked message is kept, and says it came from the {token} exchange',
|
|
|
57
57
|
assert.equal(headers['x-comq-exchange'], exchange,
|
|
58
58
|
`The parked message says it came from '${headers['x-comq-exchange']}'`)
|
|
59
59
|
|
|
60
|
+
// absent on a message parked from its first delivery, exactly as a consumer reads it;
|
|
60
61
|
// derived rather than stated, so it cannot drift from the configured ladder
|
|
61
|
-
assert.equal(headers['x-comq-attempt'], this.attempts.length,
|
|
62
|
-
'The parked message
|
|
62
|
+
assert.equal(headers['x-comq-attempt'] ?? 1, this.attempts.length,
|
|
63
|
+
'The parked message did not have as many attempts as the consumer saw')
|
|
63
64
|
})
|
|
64
65
|
|
|
65
66
|
Then('{token} has received {int} event(s)',
|