comq 0.17.1 → 0.19.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 +1124 -0
- package/docs/headers.md +37 -0
- package/features/events.poison.feature +43 -12
- package/features/steps/context.js +18 -2
- package/features/steps/events.js +20 -1
- package/features/steps/poison.js +66 -4
- package/package.json +1 -1
- package/readme.md +138 -14
- package/source/channel.js +218 -30
- package/source/connect.js +31 -21
- package/source/connection.js +11 -2
- package/source/events.js +1 -1
- package/source/index.js +4 -0
- package/source/io.js +27 -1
- package/source/singleton.js +47 -4
- package/source/topology/event.json +7 -1
- package/source/topology/request.json +7 -1
- package/source/verdicts.js +54 -0
- 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 +697 -5
- package/test/connect.test.js +31 -2
- package/test/helpers.js +15 -0
- package/test/io.diagnostics.test.js +1 -1
- package/test/io.reply.test.js +66 -0
- package/test/presets.test.js +4 -2
- package/test/singleton.test.js +1 -1
- package/test/verdicts.test.js +57 -0
- package/types/connection.d.ts +6 -1
- package/types/diagnostic.d.ts +1 -1
- package/types/index.d.ts +17 -0
- package/types/io.d.ts +3 -1
- package/types/topology.d.ts +16 -0
package/source/channel.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { Promex } = require('promex')
|
|
4
4
|
const { failsafe, lazy, recall } = require('./attributes')
|
|
5
|
+
const { verdictOf, PARK } = require('./verdicts')
|
|
5
6
|
const emitter = require('./emitter')
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -25,6 +26,14 @@ class Channel {
|
|
|
25
26
|
/** @type {string[]} */
|
|
26
27
|
#tags = []
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* How each queue was declared, so that the queue holding what it could not
|
|
31
|
+
* process is declared to live exactly as long as it does.
|
|
32
|
+
*
|
|
33
|
+
* @type {Map<string, comq.amqp.options.Queue>}
|
|
34
|
+
*/
|
|
35
|
+
#queues = new Map()
|
|
36
|
+
|
|
28
37
|
/** @type {Promex | null} */
|
|
29
38
|
#paused = null
|
|
30
39
|
|
|
@@ -71,6 +80,9 @@ class Channel {
|
|
|
71
80
|
// the consumers of the previous channel went down with it, their tags mean nothing here
|
|
72
81
|
this.#tags = []
|
|
73
82
|
|
|
83
|
+
// a fresh channel has declared nothing
|
|
84
|
+
this.#queues.clear()
|
|
85
|
+
|
|
74
86
|
await this.#channel.prefetch(this.#topology.prefetch)
|
|
75
87
|
|
|
76
88
|
this.#channel.on('drain', this.#unpause)
|
|
@@ -249,6 +261,8 @@ class Channel {
|
|
|
249
261
|
|
|
250
262
|
const { queue } = await this.#channel.assertQueue(name, options)
|
|
251
263
|
|
|
264
|
+
this.#queues.set(queue, options)
|
|
265
|
+
|
|
252
266
|
return [queue]
|
|
253
267
|
}
|
|
254
268
|
|
|
@@ -308,6 +322,80 @@ class Channel {
|
|
|
308
322
|
return [exchange, queue]
|
|
309
323
|
}
|
|
310
324
|
|
|
325
|
+
/**
|
|
326
|
+
* The queue a failed message waits in before it is delivered again. It has no consumer:
|
|
327
|
+
* the broker holds the message for the delay and then dead letters it, and because no
|
|
328
|
+
* routing key is configured the message keeps its own, which names the queue it came
|
|
329
|
+
* from. One queue serves every source queue that shares the delay.
|
|
330
|
+
*
|
|
331
|
+
* It is published to through a fanout exchange rather than directly. Published directly,
|
|
332
|
+
* the message's routing key would be this queue's own name, and on expiry the broker
|
|
333
|
+
* would route it back here — a cycle it resolves by dropping the message at the first
|
|
334
|
+
* expiry, silently.
|
|
335
|
+
*
|
|
336
|
+
* @returns {Promise<void>}
|
|
337
|
+
*/
|
|
338
|
+
async #assertRetryQueue () {
|
|
339
|
+
if (!this.#topology.acknowledgments) return
|
|
340
|
+
|
|
341
|
+
// one queue per distinct wait, declared here rather than when a message first fails:
|
|
342
|
+
// asserting a queue from inside the failure handler is the least likely moment for it
|
|
343
|
+
// to succeed, and a failure there would spin the message without ever delaying it
|
|
344
|
+
for (const delay of new Set(this.#delays)) {
|
|
345
|
+
const name = RETRY_PREFIX + delay
|
|
346
|
+
|
|
347
|
+
await this.#channel.assertExchange(name, 'fanout', DURABLE)
|
|
348
|
+
|
|
349
|
+
await this.#assertQueue(name, {
|
|
350
|
+
...DURABLE,
|
|
351
|
+
arguments: {
|
|
352
|
+
'x-message-ttl': delay,
|
|
353
|
+
'x-dead-letter-exchange': DEFAULT
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
await this.#channel.bindQueue(name, name, DEFAULT)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The queue a message is kept in once it has run out of attempts. It has no consumer
|
|
363
|
+
* either: what is in it is waiting for a person.
|
|
364
|
+
*
|
|
365
|
+
* It is declared to live exactly as long as the queue it serves, which for a groupless
|
|
366
|
+
* subscriber means an exclusive queue whose contents go when the connection does. That
|
|
367
|
+
* is deliberate: such a subscriber is ephemeral by construction, and a durable queue per
|
|
368
|
+
* generated name would leak one per restart, forever. An ephemeral subscriber's failures
|
|
369
|
+
* are ephemeral too.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} queue the queue the message was consumed from
|
|
372
|
+
* @returns {Promise<void>}
|
|
373
|
+
*/
|
|
374
|
+
async #assertParkedQueue (queue) {
|
|
375
|
+
if (!this.#topology.acknowledgments) return
|
|
376
|
+
|
|
377
|
+
const options = this.#queues.get(queue) ?? (this.#topology.durable ? DURABLE : EXCLUSIVE)
|
|
378
|
+
|
|
379
|
+
await this.#assertQueue(parkedQueueOf(queue), options)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The waits between attempts, as a ladder even when it is one rung. */
|
|
383
|
+
get #delays () {
|
|
384
|
+
const delay = this.#topology.delay
|
|
385
|
+
|
|
386
|
+
return Array.isArray(delay) ? delay : [delay]
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The queue a message waits in after its nth attempt, and the exchange it is published
|
|
391
|
+
* through: they share a name.
|
|
392
|
+
*
|
|
393
|
+
* @param {number} attempt the attempt that just failed, counting from one
|
|
394
|
+
*/
|
|
395
|
+
#retryQueueOf (attempt) {
|
|
396
|
+
return RETRY_PREFIX + this.#delays[attempt - 1]
|
|
397
|
+
}
|
|
398
|
+
|
|
311
399
|
// endregion
|
|
312
400
|
|
|
313
401
|
/**
|
|
@@ -345,69 +433,151 @@ class Channel {
|
|
|
345
433
|
}
|
|
346
434
|
|
|
347
435
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* @returns {Promise<string>}
|
|
436
|
+
* The retry topology is asserted once per channel and a parked queue once per queue:
|
|
437
|
+
* `lazy` keys an initializer by the arguments it takes, and both again after a recovery.
|
|
351
438
|
*/
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
|
|
439
|
+
#consume = lazy(this, [this.#assertRetryQueue, this.#assertParkedQueue],
|
|
440
|
+
/**
|
|
441
|
+
* @param {string} queue
|
|
442
|
+
* @param {comq.channels.Consumer} consumer
|
|
443
|
+
* @returns {Promise<string>}
|
|
444
|
+
*/
|
|
445
|
+
async (queue, consumer) => {
|
|
446
|
+
/** @type {comq.amqp.options.Consume} */
|
|
447
|
+
const options = {}
|
|
355
448
|
|
|
356
|
-
|
|
357
|
-
|
|
449
|
+
if (this.#topology.acknowledgments) consumer = this.#getAcknowledgingConsumer(queue, consumer)
|
|
450
|
+
else options.noAck = true
|
|
358
451
|
|
|
359
|
-
|
|
452
|
+
const response = await this.#channel.consume(queue, consumer, options)
|
|
360
453
|
|
|
361
|
-
|
|
454
|
+
this.#tags.push(response.consumerTag)
|
|
362
455
|
|
|
363
|
-
|
|
364
|
-
|
|
456
|
+
return response.consumerTag
|
|
457
|
+
})
|
|
365
458
|
|
|
366
459
|
/**
|
|
460
|
+
* @param {string} queue the queue being consumed
|
|
367
461
|
* @param {comq.channels.Consumer} consumer
|
|
368
462
|
* @returns {comq.channels.Consumer}
|
|
369
463
|
*/
|
|
370
|
-
#getAcknowledgingConsumer = (consumer) =>
|
|
464
|
+
#getAcknowledgingConsumer = (queue, consumer) =>
|
|
371
465
|
async (message) => {
|
|
372
466
|
try {
|
|
373
467
|
await consumer(message)
|
|
374
468
|
|
|
375
469
|
this.#channel.ack(message)
|
|
376
470
|
} catch (exception) {
|
|
377
|
-
if (exception
|
|
471
|
+
if (exception?.message === 'Channel closed') { return } // the message is requeued by the broker
|
|
378
472
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (redeliveries >= MAX_REDELIVERIES) this.#discard(message, exception)
|
|
382
|
-
else {
|
|
383
|
-
await this.#requeue(message, redeliveries)
|
|
384
|
-
throw exception
|
|
385
|
-
}
|
|
473
|
+
await this.#failed(queue, message, exception)
|
|
386
474
|
}
|
|
387
475
|
}
|
|
388
476
|
|
|
389
477
|
/**
|
|
478
|
+
* A message its consumer could not handle is delayed and given another attempt, and
|
|
479
|
+
* kept once it has had enough of them.
|
|
480
|
+
*
|
|
481
|
+
* Nothing here may reject. amqplib dispatches a delivery through an event emitter and
|
|
482
|
+
* drops the promise it gets back, so a rejection has nobody to catch it and ends the
|
|
483
|
+
* process. What cannot be carried out is handed back to the broker instead.
|
|
484
|
+
*
|
|
485
|
+
* @param {string} queue
|
|
486
|
+
* @param {comq.amqp.Message} message
|
|
487
|
+
* @param {Error} exception
|
|
488
|
+
*/
|
|
489
|
+
async #failed (queue, message, exception) {
|
|
490
|
+
try {
|
|
491
|
+
// the header is which attempt this delivery is, and the first does not carry one
|
|
492
|
+
const attempt = message.properties.headers?.[ATTEMPT_HEADER] ?? 1
|
|
493
|
+
|
|
494
|
+
// a consumer that says the message will never work is taken at its word; otherwise
|
|
495
|
+
// one rung per retry, and a message that has climbed the ladder has nowhere left to wait
|
|
496
|
+
const parking = verdictOf(exception) === PARK || attempt > this.#delays.length
|
|
497
|
+
|
|
498
|
+
if (parking) await this.#park(queue, message, exception)
|
|
499
|
+
else await this.#retry(queue, message, attempt, exception)
|
|
500
|
+
} catch {
|
|
501
|
+
// the message could not be moved: give the delivery back rather than lose it
|
|
502
|
+
this.#return(message)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* @param {string} queue
|
|
390
508
|
* @param {comq.amqp.Message} message
|
|
391
509
|
* @param {number} attempt
|
|
510
|
+
* @param {Error} exception
|
|
392
511
|
*/
|
|
393
|
-
async #
|
|
394
|
-
this.#
|
|
512
|
+
async #retry (queue, message, attempt, exception) {
|
|
513
|
+
const properties = this.#carry(message, { [ATTEMPT_HEADER]: attempt + 1 })
|
|
395
514
|
|
|
396
|
-
message
|
|
515
|
+
// the copy is placed before the original is released: a message the broker holds
|
|
516
|
+
// twice can be recovered, one it no longer holds at all cannot
|
|
517
|
+
await this.#publish(this.#retryQueueOf(attempt), queue, message.content, properties)
|
|
397
518
|
|
|
398
|
-
|
|
399
|
-
|
|
519
|
+
this.#channel.ack(message)
|
|
520
|
+
|
|
521
|
+
// the attempt that just failed, rather than the one it is about to get
|
|
522
|
+
this.#diagnostics.emit('retry', message, exception, attempt)
|
|
400
523
|
}
|
|
401
524
|
|
|
402
525
|
/**
|
|
526
|
+
* @param {string} queue
|
|
403
527
|
* @param {comq.amqp.Message} message
|
|
404
528
|
* @param {Error} [exception]
|
|
405
529
|
*/
|
|
406
|
-
#
|
|
407
|
-
this.#
|
|
530
|
+
async #park (queue, message, exception) {
|
|
531
|
+
const properties = this.#carry(message, {
|
|
532
|
+
[PARKED_QUEUE_HEADER]: queue,
|
|
533
|
+
[PARKED_REASON_HEADER]: exception?.message,
|
|
534
|
+
// a verdict is usually thrown with the exception that prompted it, and that one
|
|
535
|
+
// says what actually went wrong
|
|
536
|
+
[PARKED_CAUSE_HEADER]: exception?.cause?.message,
|
|
537
|
+
[PARKED_AT_HEADER]: Date.now()
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
await this.#publish(DEFAULT, parkedQueueOf(queue), message.content, properties)
|
|
541
|
+
|
|
542
|
+
this.#channel.ack(message)
|
|
543
|
+
|
|
408
544
|
this.#diagnostics.emit('discard', message, exception)
|
|
409
545
|
}
|
|
410
546
|
|
|
547
|
+
/**
|
|
548
|
+
* The properties a copy of a failed message is published with.
|
|
549
|
+
*
|
|
550
|
+
* The exchange and routing key are recorded on the first failure and carried from
|
|
551
|
+
* then on: a message that has been through the retry queue comes back through the
|
|
552
|
+
* default exchange, so by the time it is parked its own fields describe that hop
|
|
553
|
+
* rather than where it was published.
|
|
554
|
+
*
|
|
555
|
+
* It is published persistent whatever the channel is: Requests are transient for the
|
|
556
|
+
* sake of latency, and the path a failed message takes is not the one latency is on.
|
|
557
|
+
*
|
|
558
|
+
* @param {comq.amqp.Message} message
|
|
559
|
+
* @param {object} added
|
|
560
|
+
* @returns {comq.amqp.options.Publish}
|
|
561
|
+
*/
|
|
562
|
+
#carry (message, added) {
|
|
563
|
+
const headers = {
|
|
564
|
+
[ORIGIN_EXCHANGE_HEADER]: message.fields?.exchange,
|
|
565
|
+
[ORIGIN_KEY_HEADER]: message.fields?.routingKey,
|
|
566
|
+
...message.properties.headers,
|
|
567
|
+
...added
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return { ...message.properties, headers, persistent: true, mandatory: true }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* @param {comq.amqp.Message} message
|
|
575
|
+
*/
|
|
576
|
+
#return (message) {
|
|
577
|
+
// a channel that is already gone requeues what it held anyway
|
|
578
|
+
try { this.#channel.nack(message, false, true) } catch { /* nothing left to do with it */ }
|
|
579
|
+
}
|
|
580
|
+
|
|
411
581
|
#pause () {
|
|
412
582
|
if (this.#paused !== null) return
|
|
413
583
|
|
|
@@ -478,8 +648,26 @@ const EXCLUSIVE = { exclusive: true }
|
|
|
478
648
|
|
|
479
649
|
const INTERRUPTION = /** @type {Error} */ Symbol('internal interruption')
|
|
480
650
|
|
|
481
|
-
const
|
|
482
|
-
const
|
|
651
|
+
const RETRY_PREFIX = 'comq.retry.'
|
|
652
|
+
const PARKED_PREFIX = 'comq.parked.'
|
|
653
|
+
|
|
654
|
+
const parkedQueueOf = (queue) => PARKED_PREFIX + queue
|
|
655
|
+
|
|
656
|
+
// Everything comq writes onto a message, under its own prefix: AMQP defines no retry
|
|
657
|
+
// counter, so this one is comq's invention rather than a convention, and an unprefixed
|
|
658
|
+
// name would be free to collide with the application's own headers or another library's.
|
|
659
|
+
//
|
|
660
|
+
// What a person looking at a parked message needs, and nothing else. The broker's own
|
|
661
|
+
// `x-death` is no substitute: it names the retry queue rather than where the message
|
|
662
|
+
// came from, and its `count` is a second counter that agrees with this one until it
|
|
663
|
+
// does not.
|
|
664
|
+
const ATTEMPT_HEADER = 'x-comq-attempt'
|
|
665
|
+
const ORIGIN_EXCHANGE_HEADER = 'x-comq-exchange'
|
|
666
|
+
const ORIGIN_KEY_HEADER = 'x-comq-key'
|
|
667
|
+
const PARKED_QUEUE_HEADER = 'x-comq-queue'
|
|
668
|
+
const PARKED_REASON_HEADER = 'x-comq-reason'
|
|
669
|
+
const PARKED_CAUSE_HEADER = 'x-comq-cause'
|
|
670
|
+
const PARKED_AT_HEADER = 'x-comq-at'
|
|
483
671
|
|
|
484
672
|
function noop () {}
|
|
485
673
|
|
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/index.js
CHANGED
package/source/io.js
CHANGED
|
@@ -4,6 +4,7 @@ const stream = require('node:stream')
|
|
|
4
4
|
const { setTimeout } = require('node:timers/promises')
|
|
5
5
|
const { Promex } = require('promex')
|
|
6
6
|
const { memo, failsafe, lazy, track } = require('./attributes')
|
|
7
|
+
const { verdictOf, PARK } = require('./verdicts')
|
|
7
8
|
|
|
8
9
|
const { decode } = require('./decode')
|
|
9
10
|
const { encode } = require('./encode')
|
|
@@ -302,7 +303,7 @@ class IO {
|
|
|
302
303
|
*/
|
|
303
304
|
async (request) => {
|
|
304
305
|
const payload = decode(request)
|
|
305
|
-
const reply = await producer
|
|
306
|
+
const reply = await produce(producer, payload)
|
|
306
307
|
|
|
307
308
|
if (request.properties.replyTo === undefined) return
|
|
308
309
|
|
|
@@ -549,4 +550,29 @@ const DEFAULT = 'application/json'
|
|
|
549
550
|
|
|
550
551
|
const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
|
|
551
552
|
|
|
553
|
+
/**
|
|
554
|
+
* A verdict answers what should happen to a message now that it has failed and nobody
|
|
555
|
+
* is waiting for it. A Producer has a caller waiting, and what it is owed is a reply,
|
|
556
|
+
* so `Park` is a category error there rather than a policy choice — comq's own type
|
|
557
|
+
* vocabulary already draws the line between a Consumer and a Producer.
|
|
558
|
+
*
|
|
559
|
+
* It cannot be caught at wiring time, since nothing there knows what a producer will
|
|
560
|
+
* throw. Caught here instead and re-thrown as an ordinary rejection, which retries and
|
|
561
|
+
* eventually parks, carrying an explanation the parked message keeps as its reason.
|
|
562
|
+
*
|
|
563
|
+
* @param {comq.Producer} producer
|
|
564
|
+
* @param {any} payload
|
|
565
|
+
*/
|
|
566
|
+
async function produce (producer, payload) {
|
|
567
|
+
try {
|
|
568
|
+
return await producer(payload)
|
|
569
|
+
} catch (exception) {
|
|
570
|
+
if (verdictOf(exception) !== PARK) throw exception
|
|
571
|
+
|
|
572
|
+
throw new Error('Park is not applicable to a reply producer: a Request has a caller ' +
|
|
573
|
+
'awaiting a reply, so it is retried and parked on the count like any other failure',
|
|
574
|
+
{ cause: exception })
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
552
578
|
exports.IO = IO
|
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
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a consumer says what should happen to a message it could not handle.
|
|
5
|
+
*
|
|
6
|
+
* The brand is a registered symbol rather than the class identity: comq is a library,
|
|
7
|
+
* and a dependency that pins it while the application resolves its own tree makes two
|
|
8
|
+
* copies of it the ordinary outcome. `instanceof` is silently `false` across those,
|
|
9
|
+
* which would read a `Park` as an unclassified rejection and retry it to exhaustion
|
|
10
|
+
* before parking it anyway. The global symbol registry is shared; a class is not.
|
|
11
|
+
*/
|
|
12
|
+
const VERDICT = Symbol.for('comq.verdict')
|
|
13
|
+
|
|
14
|
+
const RETRY = 'retry'
|
|
15
|
+
const PARK = 'park'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whatever the consumer needed was briefly not there. This is what an unclassified
|
|
19
|
+
* rejection already means, so throwing it changes nothing but the reader's certainty.
|
|
20
|
+
*/
|
|
21
|
+
class Retry extends Error {
|
|
22
|
+
[VERDICT] = RETRY
|
|
23
|
+
|
|
24
|
+
name = 'Retry'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This consumer will never process this message, however many times it is handed over.
|
|
29
|
+
* The message is kept rather than retried.
|
|
30
|
+
*/
|
|
31
|
+
class Park extends Error {
|
|
32
|
+
[VERDICT] = PARK
|
|
33
|
+
|
|
34
|
+
name = 'Park'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A rejection nobody classified is a rejection nobody chose: it means Retry.
|
|
39
|
+
*
|
|
40
|
+
* @param {any} exception
|
|
41
|
+
* @returns {'retry' | 'park'}
|
|
42
|
+
*/
|
|
43
|
+
const verdictOf = (exception) => {
|
|
44
|
+
const verdict = exception?.[VERDICT]
|
|
45
|
+
|
|
46
|
+
return verdict === PARK || verdict === RETRY ? verdict : RETRY
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
exports.Retry = Retry
|
|
50
|
+
exports.Park = Park
|
|
51
|
+
exports.verdictOf = verdictOf
|
|
52
|
+
exports.VERDICT = VERDICT
|
|
53
|
+
exports.RETRY = RETRY
|
|
54
|
+
exports.PARK = PARK
|
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])
|