comq 0.17.1 → 0.18.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/.github/workflows/{dev.yaml → features.yaml} +14 -3
- package/discussions/poison-message-handling.md +1120 -0
- package/docs/headers.md +36 -0
- package/features/events.poison.feature +32 -12
- package/features/steps/context.js +18 -2
- package/features/steps/events.js +5 -1
- package/features/steps/poison.js +65 -4
- package/package.json +1 -1
- package/readme.md +103 -14
- package/source/channel.js +210 -30
- package/source/connect.js +31 -21
- package/source/connection.js +11 -2
- package/source/events.js +1 -1
- package/source/singleton.js +47 -4
- package/source/topology/event.json +7 -1
- package/source/topology/request.json +7 -1
- package/test/assert.test.js +2 -2
- package/test/channel.fixtures.js +2 -1
- package/test/channel.routed.test.js +7 -3
- package/test/channel.test.js +593 -5
- package/test/connect.test.js +31 -2
- package/test/helpers.js +15 -0
- package/test/io.diagnostics.test.js +1 -1
- package/test/presets.test.js +4 -2
- package/test/singleton.test.js +1 -1
- package/types/connection.d.ts +6 -1
- package/types/diagnostic.d.ts +1 -1
- package/types/io.d.ts +3 -1
- package/types/topology.d.ts +16 -0
package/source/channel.js
CHANGED
|
@@ -25,6 +25,14 @@ class Channel {
|
|
|
25
25
|
/** @type {string[]} */
|
|
26
26
|
#tags = []
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* How each queue was declared, so that the queue holding what it could not
|
|
30
|
+
* process is declared to live exactly as long as it does.
|
|
31
|
+
*
|
|
32
|
+
* @type {Map<string, comq.amqp.options.Queue>}
|
|
33
|
+
*/
|
|
34
|
+
#queues = new Map()
|
|
35
|
+
|
|
28
36
|
/** @type {Promex | null} */
|
|
29
37
|
#paused = null
|
|
30
38
|
|
|
@@ -71,6 +79,9 @@ class Channel {
|
|
|
71
79
|
// the consumers of the previous channel went down with it, their tags mean nothing here
|
|
72
80
|
this.#tags = []
|
|
73
81
|
|
|
82
|
+
// a fresh channel has declared nothing
|
|
83
|
+
this.#queues.clear()
|
|
84
|
+
|
|
74
85
|
await this.#channel.prefetch(this.#topology.prefetch)
|
|
75
86
|
|
|
76
87
|
this.#channel.on('drain', this.#unpause)
|
|
@@ -249,6 +260,8 @@ class Channel {
|
|
|
249
260
|
|
|
250
261
|
const { queue } = await this.#channel.assertQueue(name, options)
|
|
251
262
|
|
|
263
|
+
this.#queues.set(queue, options)
|
|
264
|
+
|
|
252
265
|
return [queue]
|
|
253
266
|
}
|
|
254
267
|
|
|
@@ -308,6 +321,80 @@ class Channel {
|
|
|
308
321
|
return [exchange, queue]
|
|
309
322
|
}
|
|
310
323
|
|
|
324
|
+
/**
|
|
325
|
+
* The queue a failed message waits in before it is delivered again. It has no consumer:
|
|
326
|
+
* the broker holds the message for the delay and then dead letters it, and because no
|
|
327
|
+
* routing key is configured the message keeps its own, which names the queue it came
|
|
328
|
+
* from. One queue serves every source queue that shares the delay.
|
|
329
|
+
*
|
|
330
|
+
* It is published to through a fanout exchange rather than directly. Published directly,
|
|
331
|
+
* the message's routing key would be this queue's own name, and on expiry the broker
|
|
332
|
+
* would route it back here — a cycle it resolves by dropping the message at the first
|
|
333
|
+
* expiry, silently.
|
|
334
|
+
*
|
|
335
|
+
* @returns {Promise<void>}
|
|
336
|
+
*/
|
|
337
|
+
async #assertRetryQueue () {
|
|
338
|
+
if (!this.#topology.acknowledgments) return
|
|
339
|
+
|
|
340
|
+
// one queue per distinct wait, declared here rather than when a message first fails:
|
|
341
|
+
// asserting a queue from inside the failure handler is the least likely moment for it
|
|
342
|
+
// to succeed, and a failure there would spin the message without ever delaying it
|
|
343
|
+
for (const delay of new Set(this.#delays)) {
|
|
344
|
+
const name = RETRY_PREFIX + delay
|
|
345
|
+
|
|
346
|
+
await this.#channel.assertExchange(name, 'fanout', DURABLE)
|
|
347
|
+
|
|
348
|
+
await this.#assertQueue(name, {
|
|
349
|
+
...DURABLE,
|
|
350
|
+
arguments: {
|
|
351
|
+
'x-message-ttl': delay,
|
|
352
|
+
'x-dead-letter-exchange': DEFAULT
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
await this.#channel.bindQueue(name, name, DEFAULT)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The queue a message is kept in once it has run out of attempts. It has no consumer
|
|
362
|
+
* either: what is in it is waiting for a person.
|
|
363
|
+
*
|
|
364
|
+
* It is declared to live exactly as long as the queue it serves, which for a groupless
|
|
365
|
+
* subscriber means an exclusive queue whose contents go when the connection does. That
|
|
366
|
+
* is deliberate: such a subscriber is ephemeral by construction, and a durable queue per
|
|
367
|
+
* generated name would leak one per restart, forever. An ephemeral subscriber's failures
|
|
368
|
+
* are ephemeral too.
|
|
369
|
+
*
|
|
370
|
+
* @param {string} queue the queue the message was consumed from
|
|
371
|
+
* @returns {Promise<void>}
|
|
372
|
+
*/
|
|
373
|
+
async #assertParkedQueue (queue) {
|
|
374
|
+
if (!this.#topology.acknowledgments) return
|
|
375
|
+
|
|
376
|
+
const options = this.#queues.get(queue) ?? (this.#topology.durable ? DURABLE : EXCLUSIVE)
|
|
377
|
+
|
|
378
|
+
await this.#assertQueue(parkedQueueOf(queue), options)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The waits between attempts, as a ladder even when it is one rung. */
|
|
382
|
+
get #delays () {
|
|
383
|
+
const delay = this.#topology.delay
|
|
384
|
+
|
|
385
|
+
return Array.isArray(delay) ? delay : [delay]
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The queue a message waits in after its nth attempt, and the exchange it is published
|
|
390
|
+
* through: they share a name.
|
|
391
|
+
*
|
|
392
|
+
* @param {number} attempt the attempt that just failed, counting from one
|
|
393
|
+
*/
|
|
394
|
+
#retryQueueOf (attempt) {
|
|
395
|
+
return RETRY_PREFIX + this.#delays[attempt - 1]
|
|
396
|
+
}
|
|
397
|
+
|
|
311
398
|
// endregion
|
|
312
399
|
|
|
313
400
|
/**
|
|
@@ -345,69 +432,145 @@ class Channel {
|
|
|
345
432
|
}
|
|
346
433
|
|
|
347
434
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* @returns {Promise<string>}
|
|
435
|
+
* The retry topology is asserted once per channel and a parked queue once per queue:
|
|
436
|
+
* `lazy` keys an initializer by the arguments it takes, and both again after a recovery.
|
|
351
437
|
*/
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
|
|
438
|
+
#consume = lazy(this, [this.#assertRetryQueue, this.#assertParkedQueue],
|
|
439
|
+
/**
|
|
440
|
+
* @param {string} queue
|
|
441
|
+
* @param {comq.channels.Consumer} consumer
|
|
442
|
+
* @returns {Promise<string>}
|
|
443
|
+
*/
|
|
444
|
+
async (queue, consumer) => {
|
|
445
|
+
/** @type {comq.amqp.options.Consume} */
|
|
446
|
+
const options = {}
|
|
355
447
|
|
|
356
|
-
|
|
357
|
-
|
|
448
|
+
if (this.#topology.acknowledgments) consumer = this.#getAcknowledgingConsumer(queue, consumer)
|
|
449
|
+
else options.noAck = true
|
|
358
450
|
|
|
359
|
-
|
|
451
|
+
const response = await this.#channel.consume(queue, consumer, options)
|
|
360
452
|
|
|
361
|
-
|
|
453
|
+
this.#tags.push(response.consumerTag)
|
|
362
454
|
|
|
363
|
-
|
|
364
|
-
|
|
455
|
+
return response.consumerTag
|
|
456
|
+
})
|
|
365
457
|
|
|
366
458
|
/**
|
|
459
|
+
* @param {string} queue the queue being consumed
|
|
367
460
|
* @param {comq.channels.Consumer} consumer
|
|
368
461
|
* @returns {comq.channels.Consumer}
|
|
369
462
|
*/
|
|
370
|
-
#getAcknowledgingConsumer = (consumer) =>
|
|
463
|
+
#getAcknowledgingConsumer = (queue, consumer) =>
|
|
371
464
|
async (message) => {
|
|
372
465
|
try {
|
|
373
466
|
await consumer(message)
|
|
374
467
|
|
|
375
468
|
this.#channel.ack(message)
|
|
376
469
|
} catch (exception) {
|
|
377
|
-
if (exception
|
|
470
|
+
if (exception?.message === 'Channel closed') { return } // the message is requeued by the broker
|
|
378
471
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (redeliveries >= MAX_REDELIVERIES) this.#discard(message, exception)
|
|
382
|
-
else {
|
|
383
|
-
await this.#requeue(message, redeliveries)
|
|
384
|
-
throw exception
|
|
385
|
-
}
|
|
472
|
+
await this.#failed(queue, message, exception)
|
|
386
473
|
}
|
|
387
474
|
}
|
|
388
475
|
|
|
389
476
|
/**
|
|
477
|
+
* A message its consumer could not handle is delayed and given another attempt, and
|
|
478
|
+
* kept once it has had enough of them.
|
|
479
|
+
*
|
|
480
|
+
* Nothing here may reject. amqplib dispatches a delivery through an event emitter and
|
|
481
|
+
* drops the promise it gets back, so a rejection has nobody to catch it and ends the
|
|
482
|
+
* process. What cannot be carried out is handed back to the broker instead.
|
|
483
|
+
*
|
|
484
|
+
* @param {string} queue
|
|
485
|
+
* @param {comq.amqp.Message} message
|
|
486
|
+
* @param {Error} exception
|
|
487
|
+
*/
|
|
488
|
+
async #failed (queue, message, exception) {
|
|
489
|
+
try {
|
|
490
|
+
// the header is which attempt this delivery is, and the first does not carry one
|
|
491
|
+
const attempt = message.properties.headers?.[ATTEMPT_HEADER] ?? 1
|
|
492
|
+
|
|
493
|
+
// one rung per retry: a message that has climbed the ladder has nowhere left to wait
|
|
494
|
+
if (attempt > this.#delays.length) await this.#park(queue, message, exception)
|
|
495
|
+
else await this.#retry(queue, message, attempt, exception)
|
|
496
|
+
} catch {
|
|
497
|
+
// the message could not be moved: give the delivery back rather than lose it
|
|
498
|
+
this.#return(message)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* @param {string} queue
|
|
390
504
|
* @param {comq.amqp.Message} message
|
|
391
505
|
* @param {number} attempt
|
|
506
|
+
* @param {Error} exception
|
|
392
507
|
*/
|
|
393
|
-
async #
|
|
394
|
-
this.#
|
|
508
|
+
async #retry (queue, message, attempt, exception) {
|
|
509
|
+
const properties = this.#carry(message, { [ATTEMPT_HEADER]: attempt + 1 })
|
|
395
510
|
|
|
396
|
-
message
|
|
511
|
+
// the copy is placed before the original is released: a message the broker holds
|
|
512
|
+
// twice can be recovered, one it no longer holds at all cannot
|
|
513
|
+
await this.#publish(this.#retryQueueOf(attempt), queue, message.content, properties)
|
|
397
514
|
|
|
398
|
-
|
|
399
|
-
|
|
515
|
+
this.#channel.ack(message)
|
|
516
|
+
|
|
517
|
+
// the attempt that just failed, rather than the one it is about to get
|
|
518
|
+
this.#diagnostics.emit('retry', message, exception, attempt)
|
|
400
519
|
}
|
|
401
520
|
|
|
402
521
|
/**
|
|
522
|
+
* @param {string} queue
|
|
403
523
|
* @param {comq.amqp.Message} message
|
|
404
524
|
* @param {Error} [exception]
|
|
405
525
|
*/
|
|
406
|
-
#
|
|
407
|
-
this.#
|
|
526
|
+
async #park (queue, message, exception) {
|
|
527
|
+
const properties = this.#carry(message, {
|
|
528
|
+
[PARKED_QUEUE_HEADER]: queue,
|
|
529
|
+
[PARKED_REASON_HEADER]: exception?.message,
|
|
530
|
+
[PARKED_AT_HEADER]: Date.now()
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
await this.#publish(DEFAULT, parkedQueueOf(queue), message.content, properties)
|
|
534
|
+
|
|
535
|
+
this.#channel.ack(message)
|
|
536
|
+
|
|
408
537
|
this.#diagnostics.emit('discard', message, exception)
|
|
409
538
|
}
|
|
410
539
|
|
|
540
|
+
/**
|
|
541
|
+
* The properties a copy of a failed message is published with.
|
|
542
|
+
*
|
|
543
|
+
* The exchange and routing key are recorded on the first failure and carried from
|
|
544
|
+
* then on: a message that has been through the retry queue comes back through the
|
|
545
|
+
* default exchange, so by the time it is parked its own fields describe that hop
|
|
546
|
+
* rather than where it was published.
|
|
547
|
+
*
|
|
548
|
+
* It is published persistent whatever the channel is: Requests are transient for the
|
|
549
|
+
* sake of latency, and the path a failed message takes is not the one latency is on.
|
|
550
|
+
*
|
|
551
|
+
* @param {comq.amqp.Message} message
|
|
552
|
+
* @param {object} added
|
|
553
|
+
* @returns {comq.amqp.options.Publish}
|
|
554
|
+
*/
|
|
555
|
+
#carry (message, added) {
|
|
556
|
+
const headers = {
|
|
557
|
+
[ORIGIN_EXCHANGE_HEADER]: message.fields?.exchange,
|
|
558
|
+
[ORIGIN_KEY_HEADER]: message.fields?.routingKey,
|
|
559
|
+
...message.properties.headers,
|
|
560
|
+
...added
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return { ...message.properties, headers, persistent: true, mandatory: true }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* @param {comq.amqp.Message} message
|
|
568
|
+
*/
|
|
569
|
+
#return (message) {
|
|
570
|
+
// a channel that is already gone requeues what it held anyway
|
|
571
|
+
try { this.#channel.nack(message, false, true) } catch { /* nothing left to do with it */ }
|
|
572
|
+
}
|
|
573
|
+
|
|
411
574
|
#pause () {
|
|
412
575
|
if (this.#paused !== null) return
|
|
413
576
|
|
|
@@ -478,8 +641,25 @@ const EXCLUSIVE = { exclusive: true }
|
|
|
478
641
|
|
|
479
642
|
const INTERRUPTION = /** @type {Error} */ Symbol('internal interruption')
|
|
480
643
|
|
|
481
|
-
const
|
|
482
|
-
const
|
|
644
|
+
const RETRY_PREFIX = 'comq.retry.'
|
|
645
|
+
const PARKED_PREFIX = 'comq.parked.'
|
|
646
|
+
|
|
647
|
+
const parkedQueueOf = (queue) => PARKED_PREFIX + queue
|
|
648
|
+
|
|
649
|
+
// Everything comq writes onto a message, under its own prefix: AMQP defines no retry
|
|
650
|
+
// counter, so this one is comq's invention rather than a convention, and an unprefixed
|
|
651
|
+
// name would be free to collide with the application's own headers or another library's.
|
|
652
|
+
//
|
|
653
|
+
// What a person looking at a parked message needs, and nothing else. The broker's own
|
|
654
|
+
// `x-death` is no substitute: it names the retry queue rather than where the message
|
|
655
|
+
// came from, and its `count` is a second counter that agrees with this one until it
|
|
656
|
+
// does not.
|
|
657
|
+
const ATTEMPT_HEADER = 'x-comq-attempt'
|
|
658
|
+
const ORIGIN_EXCHANGE_HEADER = 'x-comq-exchange'
|
|
659
|
+
const ORIGIN_KEY_HEADER = 'x-comq-key'
|
|
660
|
+
const PARKED_QUEUE_HEADER = 'x-comq-queue'
|
|
661
|
+
const PARKED_REASON_HEADER = 'x-comq-reason'
|
|
662
|
+
const PARKED_AT_HEADER = 'x-comq-at'
|
|
483
663
|
|
|
484
664
|
function noop () {}
|
|
485
665
|
|
package/source/connect.js
CHANGED
|
@@ -6,45 +6,55 @@ const { SingletonConnection } = require('./singleton')
|
|
|
6
6
|
const shards = require('./shards')
|
|
7
7
|
|
|
8
8
|
/** @type {comq.Connect} */
|
|
9
|
-
const connect = async (...
|
|
10
|
-
return create(
|
|
9
|
+
const connect = async (...args) => {
|
|
10
|
+
return create(args, Connection)
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/** @type {comq.Connect} */
|
|
14
|
-
const assert = async (...
|
|
15
|
-
return create(
|
|
14
|
+
const assert = async (...args) => {
|
|
15
|
+
return create(args, SingletonConnection)
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* The urls are variadic, so the overrides are the trailing argument when there is one
|
|
20
|
+
* that is not a url.
|
|
21
|
+
*
|
|
22
|
+
* @param {(string | comq.topology.Overrides)[]} args
|
|
23
|
+
* @return {[string[], comq.topology.Overrides]}
|
|
24
|
+
*/
|
|
25
|
+
const split = (args) => {
|
|
26
|
+
const last = args[args.length - 1]
|
|
27
|
+
|
|
28
|
+
const urls = /** @type {string[]} */ (typeof last === 'object' ? args.slice(0, -1) : args)
|
|
29
|
+
const overrides = /** @type {comq.topology.Overrides} */ (typeof last === 'object' ? last : {})
|
|
30
|
+
|
|
31
|
+
return [urls, overrides]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {(string | comq.topology.Overrides)[]} args
|
|
36
|
+
* @param {new (url: string, overrides: comq.topology.Overrides) => comq.Connection} ConnectionClass
|
|
21
37
|
* @return {Promise<IO>}
|
|
22
38
|
*/
|
|
23
|
-
const create = async (
|
|
24
|
-
const
|
|
39
|
+
const create = async (args, ConnectionClass) => {
|
|
40
|
+
const [urls, overrides] = split(args)
|
|
41
|
+
const connection = connectionOf(urls, overrides, ConnectionClass)
|
|
25
42
|
|
|
26
43
|
await connection.open()
|
|
27
44
|
|
|
28
45
|
return new IO(connection)
|
|
29
46
|
}
|
|
30
47
|
|
|
31
|
-
const connectionConstructor = (ConnectionClass) =>
|
|
32
|
-
/**
|
|
33
|
-
* @param {string[]} urls
|
|
34
|
-
* @return {comq.Connection}
|
|
35
|
-
*/
|
|
36
|
-
(urls) => {
|
|
37
|
-
if (urls.length === 1) return new ConnectionClass(urls[0])
|
|
38
|
-
else return shardedConnection(urls, ConnectionClass)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
48
|
/**
|
|
42
49
|
* @param {string[]} urls
|
|
43
|
-
* @param {
|
|
50
|
+
* @param {comq.topology.Overrides} overrides
|
|
51
|
+
* @param {Function} ConnectionClass
|
|
44
52
|
* @return {comq.Connection}
|
|
45
53
|
*/
|
|
46
|
-
const
|
|
47
|
-
const connections = urls.map((url) => new ConnectionClass(url))
|
|
54
|
+
const connectionOf = (urls, overrides, ConnectionClass) => {
|
|
55
|
+
const connections = urls.map((url) => new ConnectionClass(url, overrides))
|
|
56
|
+
|
|
57
|
+
if (connections.length === 1) return connections[0]
|
|
48
58
|
|
|
49
59
|
return new shards.Connection(connections)
|
|
50
60
|
}
|
package/source/connection.js
CHANGED
|
@@ -38,6 +38,9 @@ class Connection {
|
|
|
38
38
|
/** @type {NodeJS.Timeout | null} */
|
|
39
39
|
#heartbeatTimer = null
|
|
40
40
|
|
|
41
|
+
/** @type {comq.topology.Overrides} */
|
|
42
|
+
#overrides
|
|
43
|
+
|
|
41
44
|
/** @type {import('node:net').Socket | null} */
|
|
42
45
|
#heartbeatSocket = null
|
|
43
46
|
|
|
@@ -49,8 +52,13 @@ class Connection {
|
|
|
49
52
|
/**
|
|
50
53
|
* @param {string} url
|
|
51
54
|
*/
|
|
52
|
-
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} url
|
|
57
|
+
* @param {comq.topology.Overrides} [overrides] per channel type, merged over the presets
|
|
58
|
+
*/
|
|
59
|
+
constructor (url, overrides = {}) {
|
|
53
60
|
this.#url = heartbeaten(url)
|
|
61
|
+
this.#overrides = overrides
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
get connected () {
|
|
@@ -89,7 +97,8 @@ class Connection {
|
|
|
89
97
|
async (type, index) => {
|
|
90
98
|
if (this.#connection === undefined) await this.#recovery
|
|
91
99
|
|
|
92
|
-
|
|
100
|
+
// a copy: the presets are shared by every connection in the process
|
|
101
|
+
const topology = { ...presets[type], ...this.#overrides[type] }
|
|
93
102
|
|
|
94
103
|
// a closed channel held here would hold its IO, and a shared connection is
|
|
95
104
|
// in no hurry to make another channel that would have swept it out
|
package/source/events.js
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
exports.connection = ['open', 'close', 'error', 'reconnect', 'exhausted']
|
|
5
5
|
|
|
6
6
|
/** @type {comq.diagnostics.Event[]} */
|
|
7
|
-
exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost', 'remove']
|
|
7
|
+
exports.channel = ['flow', 'drain', 'recover', 'discard', 'retry', 'pause', 'resume', 'return', 'lost', 'remove']
|
package/source/singleton.js
CHANGED
|
@@ -9,12 +9,32 @@ class SingletonConnection extends Connection {
|
|
|
9
9
|
/** @type {Promise<void>} */
|
|
10
10
|
#opened = null
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
/** @type {comq.topology.Overrides} */
|
|
13
|
+
#overrides
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An instance is shared, so its topology is whatever the first caller asked for.
|
|
17
|
+
* A later caller asking for something else would get a connection retrying on a
|
|
18
|
+
* ladder it did not choose, which is expensive to debug and cheap to refuse.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} url
|
|
21
|
+
* @param {comq.topology.Overrides} [overrides]
|
|
22
|
+
*/
|
|
23
|
+
constructor (url, overrides = {}) {
|
|
24
|
+
const instance = instances.get(url)
|
|
25
|
+
|
|
26
|
+
if (instance !== undefined) {
|
|
27
|
+
if (!same(instance.#overrides, overrides)) {
|
|
28
|
+
throw new Error(`Connection to ${url} has already been asserted with a different topology`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return instance
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
super(url, overrides)
|
|
16
35
|
|
|
17
36
|
this.#url = url
|
|
37
|
+
this.#overrides = overrides
|
|
18
38
|
|
|
19
39
|
instances.set(url, this)
|
|
20
40
|
}
|
|
@@ -65,4 +85,27 @@ const instances = new Map()
|
|
|
65
85
|
/** @type {Map<string, number>} */
|
|
66
86
|
const counters = new Map()
|
|
67
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Topology overrides are a couple of small plain objects, so this is enough and
|
|
90
|
+
* does not pull in a dependency for it.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} one
|
|
93
|
+
* @param {object} another
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
function same (one, another) {
|
|
97
|
+
return JSON.stringify(sorted(one)) === JSON.stringify(sorted(another))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {any} value
|
|
102
|
+
* @returns {any} the value with every object's keys in a stable order
|
|
103
|
+
*/
|
|
104
|
+
function sorted (value) {
|
|
105
|
+
if (Array.isArray(value)) return value.map(sorted)
|
|
106
|
+
if (value === null || typeof value !== 'object') return value
|
|
107
|
+
|
|
108
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]))
|
|
109
|
+
}
|
|
110
|
+
|
|
68
111
|
exports.SingletonConnection = SingletonConnection
|
package/test/assert.test.js
CHANGED
|
@@ -39,7 +39,7 @@ it('should return instance of IO', async () => {
|
|
|
39
39
|
|
|
40
40
|
it('should create singleton connection', async () => {
|
|
41
41
|
expect(SingletonConnection).toHaveBeenCalled()
|
|
42
|
-
expect(SingletonConnection).toHaveBeenCalledWith(url)
|
|
42
|
+
expect(SingletonConnection).toHaveBeenCalledWith(url, {})
|
|
43
43
|
expect(IO).toHaveBeenCalledWith(SingletonConnection.mock.instances[0])
|
|
44
44
|
})
|
|
45
45
|
|
|
@@ -50,7 +50,7 @@ it('should create sharded connection', async () => {
|
|
|
50
50
|
|
|
51
51
|
io = await assert(...urls)
|
|
52
52
|
|
|
53
|
-
for (const url of urls) expect(SingletonConnection).toHaveBeenCalledWith(url)
|
|
53
|
+
for (const url of urls) expect(SingletonConnection).toHaveBeenCalledWith(url, {})
|
|
54
54
|
|
|
55
55
|
expect(shards.Connection).toHaveBeenCalledWith(SingletonConnection.mock.instances)
|
|
56
56
|
expect(IO).toHaveBeenCalledWith(shards.Connection.mock.instances[0])
|
package/test/channel.fixtures.js
CHANGED
|
@@ -93,7 +93,7 @@ describe('bound', () => {
|
|
|
93
93
|
|
|
94
94
|
const { queue: asserted } = await chan.assertQueue.mock.results[0].value
|
|
95
95
|
|
|
96
|
-
expect(
|
|
96
|
+
expect(bindings()).toHaveLength(1)
|
|
97
97
|
expect(chan.bindQueue).toHaveBeenCalledWith(asserted, exchange, key)
|
|
98
98
|
})
|
|
99
99
|
|
|
@@ -107,13 +107,17 @@ describe('bound', () => {
|
|
|
107
107
|
await channel.bound(exchange, queue, key, consumer)
|
|
108
108
|
await channel.bound(exchange, queue, key, consumer)
|
|
109
109
|
|
|
110
|
-
expect(
|
|
110
|
+
expect(bindings()).toHaveLength(1)
|
|
111
111
|
})
|
|
112
112
|
|
|
113
113
|
it('should bind again for another key', async () => {
|
|
114
114
|
await channel.bound(exchange, queue, key, consumer)
|
|
115
115
|
await channel.bound(exchange, generate(), generate(), consumer)
|
|
116
116
|
|
|
117
|
-
expect(
|
|
117
|
+
expect(bindings()).toHaveLength(2)
|
|
118
118
|
})
|
|
119
|
+
|
|
120
|
+
/** The bindings of the caller, less the one comq makes for its own retry queue. */
|
|
121
|
+
const bindings = () =>
|
|
122
|
+
chan.bindQueue.mock.calls.filter(([, exchange]) => !exchange.startsWith('comq.'))
|
|
119
123
|
})
|