comq 0.25.1 → 0.26.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.
Files changed (44) hide show
  1. package/docs/headers.md +16 -1
  2. package/features/properties.feature +39 -0
  3. package/features/shards.virtual.feature +89 -0
  4. package/features/steps/context.js +29 -2
  5. package/features/steps/hooks.js +1 -0
  6. package/features/steps/names.js +245 -0
  7. package/features/steps/networks.js +23 -5
  8. package/features/steps/rpc.js +14 -2
  9. package/package.json +1 -1
  10. package/readme.md +102 -4
  11. package/source/channel.js +147 -2
  12. package/source/connect.js +18 -15
  13. package/source/connection.js +8 -5
  14. package/source/events.js +7 -0
  15. package/source/io.js +31 -9
  16. package/source/shards/channel.js +289 -19
  17. package/source/shards/connection.js +404 -18
  18. package/source/shards/expand.js +57 -0
  19. package/source/shards/index.js +2 -0
  20. package/source/shards/resolve.js +134 -0
  21. package/source/shards/watch.js +257 -0
  22. package/source/singleton.js +3 -2
  23. package/test/amqplib.mock.js +1 -0
  24. package/test/assert.test.js +7 -2
  25. package/test/channel.quiet.test.js +301 -0
  26. package/test/connect.test.js +49 -8
  27. package/test/connection.mock.js +3 -0
  28. package/test/connection.test.js +15 -0
  29. package/test/io.call.test.js +1 -1
  30. package/test/io.diagnostics.test.js +1 -1
  31. package/test/io.reply.test.js +12 -1
  32. package/test/io.request.test.js +18 -0
  33. package/test/io.shards.test.js +85 -0
  34. package/test/shards/channel.test.js +411 -0
  35. package/test/shards/connection.test.js +372 -17
  36. package/test/shards/expand.test.js +58 -0
  37. package/test/shards/resolve.test.js +73 -0
  38. package/test/shards/watch.test.js +264 -0
  39. package/test/singleton.test.js +1 -1
  40. package/types/channel.d.ts +23 -0
  41. package/types/connection.d.ts +13 -2
  42. package/types/diagnostic.d.ts +2 -1
  43. package/types/io.d.ts +17 -1
  44. package/types/shards.d.ts +58 -0
package/readme.md CHANGED
@@ -18,6 +18,7 @@ for distributed, eventually consistent systems running on Node.js.
18
18
  - [Poison message handling](#messages)
19
19
  - [Connection tolerance](#connection-tolerance) and broker restart resilience
20
20
  - [Sharded connection](#sharded-connection) :rocket:
21
+ - [Virtual shards](#virtual-shards) that follow DNS
21
22
  - [Singleton connection](#singleton-connection)
22
23
  - [Graceful shutdown](#graceful-shutdown)
23
24
 
@@ -81,10 +82,10 @@ Event published to a fanout exchange carries none.
81
82
 
82
83
  `async IO.reply(queue: string, producer): void`
83
84
 
84
- `producer` function's signature is `async? (message: any): any`
85
+ `producer` function's signature is `async? (message: any, properties?): any`
85
86
 
86
87
  Assert a `queue` and start consuming Requests. Received messages are decoded and the resulting
87
- content is passed to the `producer`. The result returned by the `producer` is then encoded and sent
88
+ content is passed to the `producer`, followed by the [properties](docs/headers.md) of the Request. The result returned by the `producer` is then encoded and sent
88
89
  back to the queue specified in the `replyTo` property of the Request, along with a `correlationId`
89
90
  that has the same value as in the Request.
90
91
 
@@ -109,8 +110,11 @@ await io.reply('add_numbers', ({ a, b }) => (a + b))
109
110
 
110
111
  `async IO.request(queue: string, payload: any, encoding?: string): any`
111
112
 
113
+ `async IO.request(queue: string, payload: any, properties?): any`
114
+
112
115
  Send encoded Request message with `replyTo` and `correlationId` properties set and
113
- return decoded Reply content. The promise stays pending until the Reply arrives, however long
116
+ return decoded Reply content. A Request may carry [properties](docs/headers.md) of its own, such
117
+ as `headers`. The promise stays pending until the Reply arrives, however long
114
118
  that takes: a Request has no timeout and cannot be withdrawn. One no Producer can answer is
115
119
  [parked](#parked-messages), and can still be answered while its caller waits.
116
120
 
@@ -508,6 +512,89 @@ const io = await connect(shard0, shard1)
508
512
  await io.close()
509
513
  ```
510
514
 
515
+ ### Virtual shards
516
+
517
+ The URLs of a sharded connection are *names*, and several of them may stand for one broker. A
518
+ connection is made to each broker rather than to each name: thirty-two names resolving to two
519
+ addresses are two connections.
520
+
521
+ ```javascript
522
+ const io = await connect('amqp://rmq0.example.com', 'amqp://rmq1.example.com', /* ... */)
523
+ ```
524
+
525
+ A range in a host is as many URLs. Its end is exclusive, so a range that starts at zero names as
526
+ many hosts as its end says, and a start written with leading zeros pads every number to its
527
+ width:
528
+
529
+ ```javascript
530
+ await connect('amqp://developer:secret@rmq[0..32].example.com') // rmq0 … rmq31
531
+ await connect('amqp://developer:secret@rmq[00..32].example.com') // rmq00 … rmq31
532
+ ```
533
+
534
+ This is what lets the number of brokers change without anyone being reconfigured: the names stay,
535
+ and what they resolve to moves.
536
+
537
+ A broker is told by its address, port, virtual host and credentials. A name with several
538
+ addresses is still one broker, connected through one of them: two connections to one broker
539
+ would compete for the queues that are exclusive to a connection. Different addresses are taken
540
+ for different brokers.
541
+
542
+ #### Following the names
543
+
544
+ The names are resolved again every `interval`. What they resolve to is seldom seen changing
545
+ whole: records are updated one after another, and until their TTL has run out caches answer
546
+ with the old address and the new one in turn. So a change is acted upon only once the answer has
547
+ stayed the same for the `settle` time, and anything else observed meanwhile starts that time
548
+ anew. A name that fails to resolve keeps its address, which is reported as `error`.
549
+
550
+ Then the brokers are brought in line with the names, making before breaking:
551
+
552
+ 1. **Join.** A connection is made to each broker that is newly named. Whatever is consumed is
553
+ consumed from it as well, and then it joins the pool. Until every named broker has joined,
554
+ nothing below happens: a name pointing at a broker that is not there yet takes nothing away.
555
+ 2. **Retire.** A broker no name stands for is taken out of the pool: nothing new is published to
556
+ it. It goes on being consumed from, and a Reply still goes back through it.
557
+ 3. **Leave.** A retired broker is disconnected once, for the whole `linger` time, it has had
558
+ nothing to do: the queues consumed on it are empty, no message is with its consumer or
559
+ waiting to be [retried](#retries), no publication awaits its confirmation, and no Request
560
+ sent through it awaits its Reply.
561
+
562
+ Everyone connected follows the names on their own, and not at the same moment. The `linger` time
563
+ is what covers the difference: what is published to a retired broker by those who have not seen
564
+ the names move yet is consumed by those who have. There is no deadline, since leaving a broker
565
+ that is still published to would strand the messages. A retired broker that is named again is
566
+ back in the pool, and one that stays out of reach for the `linger` time is given up on.
567
+
568
+ A broker that is out of reach is not waited for: what its names resolve to next is taken at
569
+ once, as it is when a single connection is restored by its name.
570
+
571
+ The [guarantees](#what-is-guaranteed) are the ones a sharded connection has. A publication is
572
+ confirmed by whichever broker took it, a consumer does not leave a queue that holds messages,
573
+ and a Request is either answered through the broker it was sent to or re-sent as it is when a
574
+ shard is lost.
575
+
576
+ #### Timing
577
+
578
+ The trailing argument of `connect` takes it next to the [topology settings](#settings), in
579
+ milliseconds:
580
+
581
+ ```javascript
582
+ await connect(...urls, { resolution: { interval: 10_000, settle: 60_000, linger: 60_000 } })
583
+ ```
584
+
585
+ - `interval`: how often the names are resolved, 10 seconds by default.
586
+ - `settle`: how long an answer is to hold before it is acted upon.
587
+ - `linger`: how long a retired broker is to stay idle before it is left.
588
+
589
+ Unless given, `settle` and `linger` are the longest TTL the names have been answered with, and
590
+ no less than 30 seconds: a TTL bounds how long a cache may answer with what is no longer true,
591
+ and says nothing of records being updated one by one. A name that is not found in DNS itself (one
592
+ from the hosts file, or one completed by the resolver's search list) has no TTL, and makes them
593
+ 60 seconds.
594
+
595
+ A single URL is one connection made by its name, as it always was, and nothing here applies to
596
+ it.
597
+
511
598
  ## Singleton connection
512
599
 
513
600
  `async assert(url: string): IO`
@@ -821,6 +908,12 @@ Subscribe to one of the diagnostic events:
821
908
  reported only once every shard has rejected it.
822
909
  - `taken`: a Key [`back`](#addressed-requests) claims is held by another connection on this broker, and
823
910
  is claimed again. Channel type and the queue name are passed.
911
+ - `join`: a broker the names of the [virtual shards](#virtual-shards) stand for has joined the
912
+ pool, or a retired one is back in it. The shard number and the address are passed.
913
+ - `retire`: no name stands for a broker any longer, and nothing new is published to it. The
914
+ shard number and the address are passed.
915
+ - `leave`: a retired broker has had nothing more to give, and is disconnected. The shard number
916
+ and the address are passed.
824
917
  - `pause`: channel is paused. Channel type is passed.
825
918
  In the case of a [sharded connection](#sharded-connection), it means that there is no shard left
826
919
  to publish to, be it because every one of them has rejected a publish or lost its connection.
@@ -830,7 +923,9 @@ In the case of a [sharded connection](#sharded-connection), an additional argume
830
923
  shard number will be passed to listeners.
831
924
  This is applicable except for the `pause` and `resume` events,
832
925
  which are emitted when the associated channels are paused or resumed across all shards.
833
- The shard number corresponds to the position of the argument used in the `connect` function call.
926
+ A shard is a broker, and is numbered in the order the brokers are first named: with a URL per
927
+ broker that is the position of the argument used in the `connect` function call. A broker that
928
+ [joins](#virtual-shards) later takes the next number, and a number is not given twice.
834
929
 
835
930
  [^3]: As the [`connect`](#connect) function returns an instance of `IO` *after* the connection has been
836
931
  established, there is no way to capture the initial `open` event.
@@ -847,6 +942,9 @@ io.diagnose('close', (error, shard) => console.log('AMQP connection closed', { m
847
942
  io.diagnose('error', (error, shard) => console.log('AMQP connection failed', { message: error.message, shard }))
848
943
  io.diagnose('lost', (type, shard) => console.log('AMQP shard lost', { type, shard }))
849
944
  io.diagnose('recover', (type, shard) => console.log('AMQP channel recovered', { type, shard }))
945
+ io.diagnose('join', (shard, address) => console.log('AMQP broker joined', { shard, address }))
946
+ io.diagnose('retire', (shard, address) => console.log('AMQP broker retired', { shard, address }))
947
+ io.diagnose('leave', (shard, address) => console.log('AMQP broker left', { shard, address }))
850
948
  ```
851
949
 
852
950
  # Gratitude
package/source/channel.js CHANGED
@@ -52,6 +52,21 @@ class Channel {
52
52
  /** @type {Set<Promise<void>>} failed messages on their way to a retry or the parking queue */
53
53
  #settling = new Set()
54
54
 
55
+ /** @type {Set<string>} the queues being consumed */
56
+ #queues = new Set()
57
+
58
+ /** @type {number} how many deliveries are with their consumers */
59
+ #handling = 0
60
+
61
+ /** @type {Promex | null} resolved once no delivery is with its consumer */
62
+ #rest = null
63
+
64
+ /** @type {number} when a message was last published, or a delivery was last done with */
65
+ #active = 0
66
+
67
+ /** @type {number} when the last of the messages waiting to be retried is delivered again */
68
+ #awaited = 0
69
+
55
70
  #diagnostics = emitter.create()
56
71
 
57
72
  #closed = false
@@ -87,6 +102,7 @@ class Channel {
87
102
 
88
103
  // a fresh channel has bound nothing
89
104
  this.#held.clear()
105
+ this.#queues.clear()
90
106
 
91
107
  await this.#channel.prefetch(this.#topology.prefetch)
92
108
 
@@ -267,6 +283,44 @@ class Channel {
267
283
  return this.#closed
268
284
  }
269
285
 
286
+ /**
287
+ * Resolves once the deliveries that are with their consumers are done with, and so is what
288
+ * was published. It is waited for between sealing and closing by whoever means to lose none
289
+ * of them: a channel that is closed takes back what it has delivered.
290
+ *
291
+ * @return {Promise<void>}
292
+ */
293
+ async settled () {
294
+ while (this.#handling > 0) await (this.#rest ??= new Promex())
295
+
296
+ await Promise.allSettled([...this.#confirmations, ...this.#settling])
297
+ }
298
+
299
+ /**
300
+ * Since when the channel has had nothing to do, which is now while it has: a delivery with
301
+ * its consumer, a publication awaiting its confirmation, a failed message that is yet to come
302
+ * back, or a message in a queue it consumes. It is what a broker that is being left is
303
+ * asked, since whatever is left behind on it is lost to this connection.
304
+ *
305
+ * @return {Promise<number>}
306
+ */
307
+ async quiet () {
308
+ const now = Date.now()
309
+
310
+ const busy = this.#handling > 0 || this.#confirmations.size > 0 ||
311
+ this.#settling.size > 0 || this.#paused !== null || this.#awaited > now
312
+
313
+ if (busy) return now
314
+
315
+ try {
316
+ if (await this.#depth() > 0) return now
317
+ } catch {
318
+ return now // what cannot be told is not taken for nothing
319
+ }
320
+
321
+ return Math.max(this.#active, this.#awaited)
322
+ }
323
+
270
324
  async recover (connection) {
271
325
  this.#connection = connection
272
326
 
@@ -372,6 +426,45 @@ class Channel {
372
426
  return true
373
427
  }
374
428
 
429
+ /**
430
+ * @return {Promise<number>} how many messages wait in the queues being consumed
431
+ */
432
+ async #depth () {
433
+ let depth = 0
434
+
435
+ for (const queue of this.#queues) depth += await this.#count(queue)
436
+
437
+ return depth
438
+ }
439
+
440
+ /**
441
+ * Asked on a channel of its own, for the reason `#declare` has one: a queue that is gone is
442
+ * answered by closing the channel that asked.
443
+ *
444
+ * @param {string} queue
445
+ * @return {Promise<number>}
446
+ */
447
+ async #count (queue) {
448
+ const probe = await this.#connection.createChannel()
449
+
450
+ /** @type {Error & { code?: number } | undefined} */
451
+ let refusal
452
+
453
+ probe.on('error', (error) => { refusal = error })
454
+
455
+ try {
456
+ const { messageCount } = await probe.checkQueue(queue)
457
+
458
+ await probe.close().catch(noop)
459
+
460
+ return messageCount
461
+ } catch (exception) {
462
+ if (refusal?.code === NOT_FOUND) return 0
463
+
464
+ throw refusal ?? exception
465
+ }
466
+ }
467
+
375
468
  // region initializers
376
469
 
377
470
  /**
@@ -532,6 +625,8 @@ class Channel {
532
625
 
533
626
  options = Object.assign({ persistent: this.#topology.persistent }, options)
534
627
 
628
+ this.#active = Date.now()
629
+
535
630
  const confirmation = this.#topology.confirms ? this.#confirmation() : undefined
536
631
  const resume = this.#channel.publish(exchange, queue, buffer, options, confirmation?.callback)
537
632
 
@@ -570,16 +665,58 @@ class Channel {
570
665
  /** @type {comq.amqp.options.Consume} */
571
666
  const options = {}
572
667
 
573
- if (this.#topology.acknowledgments) consumer = this.#getAcknowledgingConsumer(queue, consumer)
574
- else options.noAck = true
668
+ if (this.#topology.acknowledgments) {
669
+ consumer = this.#getAcknowledgingConsumer(queue, consumer)
670
+ } else {
671
+ consumer = this.#attended(consumer)
672
+ options.noAck = true
673
+ }
575
674
 
576
675
  const response = await this.#channel.consume(queue, consumer, options)
577
676
 
578
677
  this.#tags.push(response.consumerTag)
678
+ this.#queues.add(queue)
579
679
 
580
680
  return response.consumerTag
581
681
  })
582
682
 
683
+ /**
684
+ * Counts the deliveries that are with a consumer nothing is acknowledged for, without a
685
+ * promise of its own for the ones that are done with as they arrive.
686
+ *
687
+ * @param {comq.channels.Consumer} consumer
688
+ * @returns {comq.channels.Consumer}
689
+ */
690
+ #attended = (consumer) =>
691
+ (message) => {
692
+ this.#handling++
693
+
694
+ let result
695
+
696
+ try {
697
+ result = consumer(message)
698
+ } catch (exception) {
699
+ this.#attendedTo()
700
+
701
+ throw exception
702
+ }
703
+
704
+ if (typeof result?.then === 'function') result.then(this.#attendedTo, this.#attendedTo)
705
+ else this.#attendedTo()
706
+
707
+ return result
708
+ }
709
+
710
+ #attendedTo = () => {
711
+ this.#handling--
712
+ this.#active = Date.now()
713
+
714
+ if (this.#handling === 0 && this.#rest !== null) {
715
+ this.#rest.resolve()
716
+ this.#rest = null
717
+ }
718
+ }
719
+
583
720
  /**
584
721
  * @param {string} queue the queue being consumed
585
722
  * @param {comq.channels.Consumer} consumer
@@ -587,6 +724,8 @@ class Channel {
587
724
  */
588
725
  #getAcknowledgingConsumer = (queue, consumer) =>
589
726
  async (message) => {
727
+ this.#handling++
728
+
590
729
  try {
591
730
  await consumer(message)
592
731
 
@@ -601,6 +740,8 @@ class Channel {
601
740
  await settling
602
741
 
603
742
  this.#settling.delete(settling)
743
+ } finally {
744
+ this.#attendedTo()
604
745
  }
605
746
  }
606
747
 
@@ -647,6 +788,7 @@ class Channel {
647
788
  await this.#publish(this.#retryQueueOf(attempt), queue, message.content, properties)
648
789
 
649
790
  this.#channel.ack(message)
791
+ this.#awaited = Math.max(this.#awaited, Date.now() + this.#delays[attempt - 1])
650
792
 
651
793
  // the attempt that just failed, rather than the one it is about to get
652
794
  this.#diagnostics.emit('retry', message, exception, attempt)
@@ -781,6 +923,9 @@ const INTERRUPTION = /** @type {Error} */ Symbol('internal interruption')
781
923
  // the AMQP reply code a broker closes a channel with when another connection holds the queue
782
924
  const RESOURCE_LOCKED = 405
783
925
 
926
+ // and the one it closes a channel with when asked about a queue it does not have
927
+ const NOT_FOUND = 404
928
+
784
929
  const RETRY_PREFIX = 'comq.retry.'
785
930
 
786
931
  /** Where every message that has run out of attempts is kept, whatever it was consumed from. */
package/source/connect.js CHANGED
@@ -16,29 +16,29 @@ const assert = async (...args) => {
16
16
  }
17
17
 
18
18
  /**
19
- * The urls are variadic, so the overrides are the trailing argument when there is one
20
- * that is not a url.
19
+ * The urls are variadic, so the options are the trailing argument when there is one
20
+ * that is not a url: the topology overrides, and how the names are followed.
21
21
  *
22
- * @param {(string | comq.topology.Overrides)[]} args
23
- * @return {[string[], comq.topology.Overrides]}
22
+ * @param {(string | comq.Options)[]} args
23
+ * @return {[string[], comq.topology.Overrides, comq.shards.Timing | undefined]}
24
24
  */
25
25
  const split = (args) => {
26
26
  const last = args[args.length - 1]
27
27
 
28
28
  const urls = /** @type {string[]} */ (typeof last === 'object' ? args.slice(0, -1) : args)
29
- const overrides = /** @type {comq.topology.Overrides} */ (typeof last === 'object' ? last : {})
29
+ const { resolution, ...overrides } = /** @type {comq.Options} */ (typeof last === 'object' ? last : {})
30
30
 
31
- return [urls, overrides]
31
+ return [urls.flatMap(shards.expand), overrides, resolution]
32
32
  }
33
33
 
34
34
  /**
35
- * @param {(string | comq.topology.Overrides)[]} args
36
- * @param {new (url: string, overrides: comq.topology.Overrides) => comq.Connection} ConnectionClass
35
+ * @param {(string | comq.Options)[]} args
36
+ * @param {new (url: string, overrides: comq.topology.Overrides, servername?: string) => comq.Connection} ConnectionClass
37
37
  * @return {Promise<IO>}
38
38
  */
39
39
  const create = async (args, ConnectionClass) => {
40
- const [urls, overrides] = split(args)
41
- const connection = connectionOf(urls, overrides, ConnectionClass)
40
+ const [urls, overrides, resolution] = split(args)
41
+ const connection = connectionOf(urls, overrides, resolution, ConnectionClass)
42
42
 
43
43
  await connection.open()
44
44
 
@@ -46,17 +46,20 @@ const create = async (args, ConnectionClass) => {
46
46
  }
47
47
 
48
48
  /**
49
+ * Several URLs are the names of the shards, which may be fewer than the names: a connection
50
+ * is made to each broker they stand for. A single URL is connected by its name, as it is.
51
+ *
49
52
  * @param {string[]} urls
50
53
  * @param {comq.topology.Overrides} overrides
54
+ * @param {comq.shards.Timing | undefined} resolution
51
55
  * @param {Function} ConnectionClass
52
56
  * @return {comq.Connection}
53
57
  */
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]
58
+ const connectionOf = (urls, overrides, resolution, ConnectionClass) => {
59
+ if (urls.length === 1) return new ConnectionClass(urls[0], overrides)
58
60
 
59
- return new shards.Connection(connections)
61
+ return new shards.Connection(urls,
62
+ (url, servername) => new ConnectionClass(url, overrides, servername), resolution)
60
63
  }
61
64
 
62
65
  exports.connect = connect
@@ -38,18 +38,21 @@ class Connection {
38
38
  /** @type {comq.topology.Overrides} */
39
39
  #overrides
40
40
 
41
+ /** @type {object} */
42
+ #options
43
+
41
44
  #diagnostics = emitter.create()
42
45
 
43
- /**
44
- * @param {string} url
45
- */
46
46
  /**
47
47
  * @param {string} url
48
48
  * @param {comq.topology.Overrides} [overrides] per channel type, merged over the presets
49
+ * @param {string} [servername] the name TLS verifies the broker by, when the URL has its
50
+ * address in place of the name
49
51
  */
50
- constructor (url, overrides = {}) {
52
+ constructor (url, overrides = {}, servername = undefined) {
51
53
  this.#url = heartbeaten(url)
52
54
  this.#overrides = overrides
55
+ this.#options = servername === undefined ? SOCKET_OPTIONS : { ...SOCKET_OPTIONS, servername }
53
56
  }
54
57
 
55
58
  get connected () {
@@ -255,7 +258,7 @@ class Connection {
255
258
  * @return {Promise<comq.amqp.Connection>}
256
259
  */
257
260
  async #connect () {
258
- const connecting = amqp.connect(this.#url, SOCKET_OPTIONS)
261
+ const connecting = amqp.connect(this.#url, this.#options)
259
262
 
260
263
  let timer
261
264
 
package/source/events.js CHANGED
@@ -3,5 +3,12 @@
3
3
  /** @type {comq.diagnostics.Event[]} */
4
4
  exports.connection = ['open', 'close', 'error', 'reconnect', 'exhausted']
5
5
 
6
+ /**
7
+ * What a sharded connection tells of the brokers its names stand for.
8
+ *
9
+ * @type {comq.diagnostics.Event[]}
10
+ */
11
+ exports.shards = ['join', 'retire', 'leave']
12
+
6
13
  /** @type {comq.diagnostics.Event[]} */
7
14
  exports.channel = ['flow', 'drain', 'recover', 'discard', 'retry', 'pause', 'resume', 'return', 'lost', 'remove', 'taken']
package/source/io.js CHANGED
@@ -63,7 +63,7 @@ class IO {
63
63
  constructor (connection) {
64
64
  this.#connection = connection
65
65
 
66
- for (const event of events.connection) {
66
+ for (const event of [...events.connection, ...events.shards]) {
67
67
  const forwarder = (...args) => this.#diagnostics.emit(event, ...args)
68
68
 
69
69
  this.#connection.diagnose(event, forwarder)
@@ -89,16 +89,22 @@ class IO {
89
89
  *
90
90
  * @param {string} queue
91
91
  * @param {any | Readable} payload
92
- * @param {comq.Encoding} [encoding]
92
+ * @param {comq.Encoding | comq.amqp.Properties} [encoding]
93
93
  * @returns {Promise<any | Readable>}
94
94
  */
95
95
  async request (queue, payload, encoding) {
96
+ if (typeof encoding !== 'object' || encoding === null) {
97
+ return await this.#request(queue, payload, { encoding })
98
+ }
99
+
96
100
  // a caller of the version that took a timeout here would otherwise wait on in silence
97
- if (typeof encoding === 'object' && encoding !== null) {
98
- throw new TypeError('A Request takes an encoding and waits for its Reply')
101
+ if ('timeout' in encoding || 'signal' in encoding) {
102
+ throw new TypeError('A Request takes an encoding or properties and waits for its Reply')
99
103
  }
100
104
 
101
- return await this.#request(queue, payload, { encoding })
105
+ const { contentType, ...properties } = encoding
106
+
107
+ return await this.#request(queue, payload, { encoding: contentType, properties })
102
108
  }
103
109
 
104
110
  /**
@@ -294,6 +300,9 @@ class IO {
294
300
 
295
301
  this.#setupRetransmission()
296
302
 
303
+ // a shard that is being retired is not left while it owes a Reply
304
+ this.#requests.occupy?.((index) => this.#awaiting(index))
305
+
297
306
  // on a sharded connection, only once every shard has returned it
298
307
  this.#requests.diagnose('return', this.#returned)
299
308
  }
@@ -393,7 +402,7 @@ class IO {
393
402
  */
394
403
  async (request) => {
395
404
  const payload = decode(request)
396
- const reply = await produce(producer, payload)
405
+ const reply = await produce(producer, payload, request.properties)
397
406
 
398
407
  if (request.properties.replyTo === undefined) return
399
408
 
@@ -455,7 +464,9 @@ class IO {
455
464
 
456
465
  const [buffer, contentType] = this.#encode(payload, terms.encoding)
457
466
  const request = this.#createRequest(contentType, signal)
458
- const properties = { ...request.properties }
467
+
468
+ // what makes it a Request is comq's to set, whatever it was sent with
469
+ const properties = { ...terms.properties, ...request.properties }
459
470
 
460
471
  if (expires !== undefined) {
461
472
  const left = Math.ceil(expires - Date.now())
@@ -745,6 +756,16 @@ class IO {
745
756
  }
746
757
  }
747
758
 
759
+ /**
760
+ * @param {number} index
761
+ * @return {boolean} whether a Request sent through a shard is yet to be answered
762
+ */
763
+ #awaiting (index) {
764
+ for (const request of this.#pendingReplies.values()) if (request.shard === index) return true
765
+
766
+ return false
767
+ }
768
+
748
769
  /**
749
770
  * @param {comq.Request} request
750
771
  * @param {Promex} reply
@@ -856,10 +877,11 @@ async function abortable (promise, signal) {
856
877
  *
857
878
  * @param {comq.Producer} producer
858
879
  * @param {any} payload
880
+ * @param {comq.amqp.Properties} properties
859
881
  */
860
- async function produce (producer, payload) {
882
+ async function produce (producer, payload, properties) {
861
883
  try {
862
- return await producer(payload)
884
+ return await producer(payload, properties)
863
885
  } catch (exception) {
864
886
  if (verdictOf(exception) !== PARK) throw exception
865
887