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.
@@ -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
  /**
@@ -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
- for (const socket of [client, upstream]) {
75
- socket.on('error', () => this.#collapse(tunnel))
76
- socket.on('close', () => this.#collapse(tunnel))
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')
@@ -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 was not retried to exhaustion')
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)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",