comq 0.21.0 → 0.23.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,63 @@
1
+ # One parking queue
2
+
3
+ > Supersedes the parking queue's naming and lifetime in
4
+ > [poison message handling](./poison-message-handling.md), Part 1. Everything else about
5
+ > parking — when a message is parked, what it carries, what is published before what — is
6
+ > unchanged.
7
+
8
+ Everything a consumer could not process is kept in one queue, `comq.parked`, whatever it was
9
+ consumed from. It is durable, it is declared once per channel beside the retry queues, and comq
10
+ never removes it or what is in it.
11
+
12
+ A parked message already says where it came from. `x-comq-queue` is the queue it was consumed
13
+ from, `x-comq-exchange` and `x-comq-key` where it was originally published, and all three are
14
+ recorded on the first failure rather than read at parking time. Reading one source's parked
15
+ messages is a match on `x-comq-queue`.
16
+
17
+ ## What this changes
18
+
19
+ | | before | after |
20
+ |---|---|---|
21
+ | parking queues | one per consumed queue | one |
22
+ | its lifetime | the lifetime of the queue it served | durable, always |
23
+ | what a groupless subscriber parks | went with its connection | kept |
24
+ | scales with | how many queues the connection consumes | nothing |
25
+
26
+ ## Why the queue per source goes
27
+
28
+ **It was measured.** The cost of this design was worked through when it was made, at 200 source
29
+ queues and 200 parking queues, and accepted. In a deployment of about forty processes the
30
+ parking queues came to 1318 of 2731 queues on the broker — 48 % — and every one of them was
31
+ empty, with no consumer and no message it had ever held. At about 70 KB of broker memory per
32
+ queue, that is the largest single item on a broker that had been blocking publishers on a memory
33
+ alarm.
34
+
35
+ The count is what makes it grow: one per consumed queue means the broker pays for how widely an
36
+ application is decomposed, forever, whether or not anything ever fails.
37
+
38
+ **Depth per source survives.** The reason recorded for the queue per source was that depth per
39
+ consumer is how anyone notices a problem. What notices a problem is the `discard` diagnostic,
40
+ which fires per parked message and names the queue it came from. Depth in `comq.parked` still
41
+ says how much is waiting, and `x-comq-queue` still says what for.
42
+
43
+ **The lifetime rule goes with it.** The parking queue mirrored its source's durability so that a
44
+ durable parking queue would not be left behind an exclusive `amq.gen-*` source — one leaked per
45
+ restart, forever. With a fixed name there is nothing to leak, and the rule that prevented the
46
+ leak was throwing away the evidence it was meant to keep: a groupless subscriber's failures
47
+ vanished with its connection, at the moment there was nobody left to tell.
48
+
49
+ ## Upgrading
50
+
51
+ `comq.parked.<queue>` queues from an earlier version keep whatever they already hold. comq stops
52
+ declaring them and stops publishing to them, so they are drained and deleted once, by hand.
53
+
54
+ Nothing is redeclared with different arguments, so no assertion answers `PRECONDITION_FAILED`
55
+ and no broker has to be emptied. During a rollout the processes still on the old version go on
56
+ parking per source and the new ones park in `comq.parked`; both are readable, and both say
57
+ which queue a message came from.
58
+
59
+ ## What comq says about it
60
+
61
+ That the queue is there, that a message in it names its source, and that comq removes neither.
62
+ What to do with a queue on a broker — a `max-length`, a TTL, an alert on depth — is read off
63
+ those facts by whoever can see the broker.
@@ -0,0 +1,44 @@
1
+ # One reply queue
2
+
3
+ An `IO` receives every reply on one queue, `comq.reply..<id>`, whatever it sent the request to.
4
+
5
+ A reply is found by its `correlationId`. `ReplyEmitter.next` builds one from eight random bytes
6
+ and a counter, and it is unique across processes — it has to be, because a producer's control
7
+ queue sees the requests of every consumer and tells them apart by nothing else. Routing a reply
8
+ reads that identifier and nothing else: the queue it arrived on was never consulted.
9
+
10
+ ## What this changes
11
+
12
+ | | before | after |
13
+ |---|---|---|
14
+ | reply queues | one per distinct queue or exchange requested | one |
15
+ | its name | `<target>..<id>` | `comq.reply..<id>` |
16
+ | scales with | how widely the caller fans out | nothing |
17
+
18
+ Both are exclusive, so both go with the connection. A sharded connection holds one per shard, as
19
+ it held one per target per shard before.
20
+
21
+ ## Why the queue per target goes
22
+
23
+ The count was the caller's to set and the broker's to pay: a connection that requests fifty
24
+ queues held fifty queues and fifty consumers for as long as it lived, whether or not it ever
25
+ requested them again. Nothing bounded it but how widely the application was decomposed.
26
+
27
+ The name was the argument for it — a queue named after what is being called says, to somebody
28
+ reading the broker, who is waiting on what. It says it of a queue that carries no messages
29
+ between requests, and the same thing is on every message that does pass through: `correlationId`
30
+ identifies the request, and the reply-side handler knows which one it belongs to.
31
+
32
+ ## What is given up
33
+
34
+ Reading the broker no longer tells you which operations a connection calls. A reply queue names
35
+ its connection instead, and what it is waiting for is in flight rather than on the broker.
36
+
37
+ ## Upgrading
38
+
39
+ Reply queues are exclusive and are asserted on the first request, so a process on the old
40
+ version and one on the new hold different queues and neither declares the other's. Nothing is
41
+ redeclared, and a rollout needs no order.
42
+
43
+ A parked request keeps the `replyTo` it was sent with, so one parked by an old process still
44
+ names the queue that process was listening on — which, as before, is gone once that process is.
@@ -7,7 +7,8 @@
7
7
  > where it survives a retry.
8
8
  >
9
9
  > **All three parts are implemented.** Text below is written as a proposal because that is what
10
- > it was; it describes what the code now does.
10
+ > it was; it describes what the code now does — except for the parking queue's name and
11
+ > lifetime, which [one parking queue](./one-parking-queue.md) supersedes.
11
12
 
12
13
  When a consumer callback rejects, comq is supposed to retry the message a few times and then
13
14
  give up on it. What it actually does is kill the process, and take every other consumer in that
package/docs/headers.md CHANGED
@@ -26,7 +26,7 @@ the message properties.
26
26
  | `x-comq-attempt` | a retried message | Which attempt this delivery is, counting from one. Absent on the first, so read it as `headers?.['x-comq-attempt'] ?? 1`. The last attempt is one more than the number of rungs in the [backoff ladder](../readme.md#retries). |
27
27
  | `x-comq-exchange` | a retried or parked message | The exchange it was originally published to. |
28
28
  | `x-comq-key` | a retried or parked message | The routing key it was originally published with. |
29
- | `x-comq-queue` | a parked message | The queue it was consumed from. |
29
+ | `x-comq-queue` | a parked message | The queue it was consumed from. One `comq.parked` queue holds what every source parked, so this is what tells them apart. |
30
30
  | `x-comq-reason` | a parked message | The `message` of the exception that ended its attempts. |
31
31
  | `x-comq-cause` | a parked message | The `message` of that exception's `cause`, when it has one — a verdict is usually thrown with the failure that prompted it. |
32
32
  | `x-comq-at` | a parked message | When it was parked, as `Date.now()`. |
@@ -0,0 +1,32 @@
1
+ Feature: The parking queue
2
+
3
+ What a consumer could not process is kept in one queue, `comq.parked`, whatever it was
4
+ consumed from. The message says where it came from, so the queue does not have to.
5
+
6
+ Background:
7
+ Given an active connection to the broker
8
+
9
+ Scenario: Messages parked from two queues are kept in one
10
+
11
+ A queue per source would repeat what every parked message already carries, and
12
+ would stay on the broker for as long as the broker does.
13
+
14
+ Given that events from the `parking_left` exchange are causing exceptions
15
+ And that events from the `parking_right` exchange are causing exceptions
16
+ When an event is emitted to the `parking_left` exchange
17
+ And an event is emitted to the `parking_right` exchange
18
+ Then a message parked from the `parking_left` exchange is in the parking queue
19
+ And a message parked from the `parking_right` exchange is in the parking queue
20
+
21
+ Scenario: A message parked from a groupless subscription outlives its connection
22
+
23
+ A groupless subscriber consumes a queue of its own that the broker removes with the
24
+ connection. What it could not process is not ephemeral: it is waiting for a person,
25
+ and there is nobody left to tell.
26
+
27
+ Given that groupless events from the `parking_ephemeral` exchange are causing exceptions
28
+ When an event is emitted to the `parking_ephemeral` exchange
29
+ And the message is parked
30
+ And the connection is closed
31
+ And an active connection to the broker
32
+ Then a message parked from the `parking_ephemeral` exchange is in the parking queue
@@ -0,0 +1,26 @@
1
+ Feature: Reply queues
2
+
3
+ A connection consumes its replies from one queue, whatever it sends requests to. The
4
+ reply is found by its correlation identifier, which is unique across processes, so the
5
+ queue does not have to name what was called.
6
+
7
+ Scenario: One reply queue however many queues are requested
8
+
9
+ Given an active connection to the broker
10
+ And a producer replying `replies_alpha` queue
11
+ And a producer replying `replies_beta` queue
12
+ When the consumer sends a request to the `replies_alpha` queue
13
+ Then the consumer receives the reply
14
+ When the consumer sends a request to the `replies_beta` queue
15
+ Then the consumer receives the reply
16
+ And the broker holds 1 reply queue
17
+
18
+ Scenario: One reply queue per shard
19
+
20
+ A shard is a broker of its own, and a request published to one is answered on it.
21
+
22
+ Given an active sharded connection
23
+ And a producer replying `replies_sharded` queue
24
+ When the consumer sends 10 requests to the `replies_sharded` queue
25
+ Then all replies have been received
26
+ And each broker holds 1 reply queue
@@ -12,8 +12,13 @@ const IMAGE = 'rabbitmq:4.3-management'
12
12
  const USER = 'developer'
13
13
  const PASSWORD = 'secret'
14
14
  const AMQP_PORT = 5672
15
+ const MANAGEMENT_PORT = 15672
15
16
  const BROKERS_AMOUNT = 2
16
17
  const HOST_PORTS = [5673, 5674]
18
+
19
+ // what a scenario reads the topology from: a queue nothing holds open is
20
+ // invisible over AMQP, and its absence is what some of them are about
21
+ const MANAGEMENT_HOST_PORTS = [15673, 15674]
17
22
  const HEALTHCHECK_INTERVAL = global.COMQ_TESTING_HEALTHCHECK_INTERVAL ?? 1000
18
23
 
19
24
  /** @type {import('@testcontainers/rabbitmq').StartedRabbitMQContainer[]} */
@@ -33,7 +38,9 @@ async function startBrokers () {
33
38
  })
34
39
  .withAutoRemove(false)
35
40
  .withName(`comq-rmq-${n}`)
36
- .withExposedPorts({ container: AMQP_PORT, host: hostPort })
41
+ .withExposedPorts(
42
+ { container: AMQP_PORT, host: hostPort },
43
+ { container: MANAGEMENT_PORT, host: MANAGEMENT_HOST_PORTS[n] })
37
44
  .withHealthCheck({
38
45
  test: ['CMD', 'rabbitmq-diagnostics', '-q', 'ping'],
39
46
  interval: 5000,
@@ -88,6 +95,22 @@ function getAddress (n = 0) {
88
95
  return `localhost:${HOST_PORTS[n]}`
89
96
  }
90
97
 
98
+ /**
99
+ * The queues on a broker, by name.
100
+ *
101
+ * @param {number} [n]
102
+ * @returns {Promise<string[]>}
103
+ */
104
+ async function queues (n = 0) {
105
+ const authorization = 'Basic ' + Buffer.from(`${USER}:${PASSWORD}`).toString('base64')
106
+ const url = `http://localhost:${MANAGEMENT_HOST_PORTS[n]}/api/queues/%2F?columns=name`
107
+ const response = await fetch(url, { headers: { authorization } })
108
+
109
+ if (!response.ok) throw new Error(`Cannot read the queues of broker ${n}: ${response.status}`)
110
+
111
+ return (await response.json()).map((queue) => queue.name)
112
+ }
113
+
91
114
  /**
92
115
  * @param {number} [n]
93
116
  */
@@ -129,5 +152,6 @@ module.exports = {
129
152
  startBrokers,
130
153
  stopBrokers,
131
154
  getAddress,
155
+ queues,
132
156
  actions
133
157
  }
@@ -1,11 +1,10 @@
1
1
  'use strict'
2
2
 
3
3
  const assert = require('node:assert')
4
- const amqplib = require('amqplib')
5
4
  const { Given, When, Then } = require('@cucumber/cucumber')
6
5
 
7
- const { getAddress, USER, PASSWORD } = require('./brokers')
8
6
  const { until, timeout } = require('../../test/helpers')
7
+ const { reading, pick } = require('./parking')
9
8
 
10
9
  Given('a producer failing every request to the {token} queue',
11
10
  /**
@@ -52,18 +51,9 @@ When('the parked request from the {token} queue is answered by hand',
52
51
  * @this {comq.features.Context}
53
52
  */
54
53
  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)
54
+ await reading(async (channel) => {
55
+ const parked = await pick(channel,
56
+ (one) => one.properties.headers?.['x-comq-queue'] === queue)
67
57
 
68
58
  assert.notEqual(parked, undefined, `Nothing was parked from ${queue}`)
69
59
 
@@ -74,15 +64,14 @@ When('the parked request from the {token} queue is answered by hand',
74
64
 
75
65
  this.answer = { answered: true, was: JSON.parse(parked.content.toString()) }
76
66
 
67
+ channel.ack(parked)
68
+
77
69
  channel.sendToQueue(replyTo, Buffer.from(JSON.stringify(this.answer)),
78
70
  { correlationId, contentType })
79
71
 
80
72
  // let the frame reach the broker before the connection goes
81
73
  await timeout(100)
82
- } finally {
83
- await channel.close()
84
- await connection.close()
85
- }
74
+ })
86
75
  })
87
76
 
88
77
  Then('the caller receives the reply',
@@ -0,0 +1,113 @@
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 { timeout } = require('../../test/helpers')
9
+
10
+ Given('(that )groupless events from the {token} exchange are causing exceptions',
11
+ /**
12
+ * @param {string} exchange
13
+ * @this {comq.features.Context}
14
+ */
15
+ async function (exchange) {
16
+ await this.io.consume(exchange, (payload, properties) => {
17
+ this.attempts.push(properties.headers?.['x-comq-attempt'] ?? 1)
18
+
19
+ throw new Error('Expected exception')
20
+ })
21
+ })
22
+
23
+ When('the connection is closed',
24
+ /**
25
+ * @this {comq.features.Context}
26
+ */
27
+ async function () {
28
+ await this.disconnect()
29
+ })
30
+
31
+ Then('a message parked from the {token} exchange is in the parking queue',
32
+ /**
33
+ * @param {string} exchange
34
+ * @this {comq.features.Context}
35
+ */
36
+ async function (exchange) {
37
+ await reading(async (channel) => {
38
+ const message = await pick(channel,
39
+ (one) => one.properties.headers?.['x-comq-exchange'] === exchange)
40
+
41
+ assert.notEqual(message, undefined,
42
+ `Nothing in '${PARKED}' came from the '${exchange}' exchange`)
43
+
44
+ const headers = message.properties.headers
45
+
46
+ // a groupless subscriber consumes a name the broker chose, so what is asserted
47
+ // here is that the message says which queue, not which name that is
48
+ assert.notEqual(headers['x-comq-queue'], undefined,
49
+ 'The parked message does not name the queue it came from')
50
+
51
+ assert.notEqual(headers['x-comq-reason'], undefined, 'The parked message has no reason')
52
+
53
+ channel.nack(message, false, true)
54
+ })
55
+ })
56
+
57
+ /**
58
+ * Runs `read` against a connection of its own, so that what it does to the parking
59
+ * queue is not what comq is doing to it.
60
+ *
61
+ * @param {(channel: import('amqplib').Channel) => Promise<void>} read
62
+ */
63
+ async function reading (read) {
64
+ const connection = await amqplib.connect(`amqp://${USER}:${PASSWORD}@${getAddress(0)}`)
65
+ const channel = await connection.createChannel()
66
+
67
+ try {
68
+ await read(channel)
69
+ } finally {
70
+ await channel.close().catch(noop)
71
+ await connection.close().catch(noop)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The first message in the parking queue that `matches`, waited for. One queue holds what
77
+ * every scenario before this one left in it, so everything that does not match is given
78
+ * back; the match is left unacknowledged, for the caller to take or to return.
79
+ *
80
+ * @param {import('amqplib').Channel} channel
81
+ * @param {(message: import('amqplib').GetMessage) => boolean} matches
82
+ * @returns {Promise<import('amqplib').GetMessage | undefined>}
83
+ */
84
+ async function pick (channel, matches) {
85
+ const deadline = Date.now() + DEADLINE
86
+
87
+ do {
88
+ const taken = []
89
+
90
+ let delivery
91
+
92
+ while ((delivery = await channel.get(PARKED, { noAck: false })) !== false) taken.push(delivery)
93
+
94
+ const match = taken.find(matches)
95
+
96
+ for (const one of taken) if (one !== match) channel.nack(one, false, true)
97
+
98
+ if (match !== undefined) return match
99
+
100
+ await timeout(50)
101
+ } while (Date.now() < deadline)
102
+ }
103
+
104
+ const PARKED = 'comq.parked'
105
+
106
+ /** The ladder the suite runs on is half a second; this is room for it and the broker. */
107
+ const DEADLINE = 5000
108
+
109
+ function noop () {}
110
+
111
+ exports.PARKED = PARKED
112
+ exports.reading = reading
113
+ exports.pick = pick
@@ -3,6 +3,7 @@
3
3
  const assert = require('node:assert')
4
4
  const { until } = require('../../test/helpers')
5
5
  const { Then } = require('@cucumber/cucumber')
6
+ const { PARKED, reading, pick } = require('./parking')
6
7
 
7
8
  Then('the event is attempted {int} time(s)',
8
9
  /**
@@ -35,32 +36,29 @@ Then('the parked message is kept, and says it came from the {token} exchange',
35
36
  */
36
37
  async function (exchange) {
37
38
  const queue = exchange + '..exceptions' // the group the throwing consumer uses
38
- const parked = 'comq.parked.' + queue
39
39
 
40
- await this.io.process(parked, (payload, properties) => {
41
- this.parked[queue] = { payload, properties }
42
- })
43
-
44
- await until(() => this.parked[queue] !== undefined)
40
+ await reading(async (channel) => {
41
+ const message = await pick(channel,
42
+ (one) => one.properties.headers?.['x-comq-queue'] === queue)
45
43
 
46
- const message = this.parked[queue]
44
+ assert.notEqual(message, undefined, `Nothing in '${PARKED}' came from ${queue}`)
47
45
 
48
- assert.notEqual(message, undefined, `Nothing was parked in ${parked}`)
46
+ const headers = message.properties.headers
49
47
 
50
- const headers = message.properties.headers
48
+ assert.notEqual(headers['x-comq-reason'], undefined, 'The parked message has no reason')
51
49
 
52
- assert.equal(headers['x-comq-queue'], queue, 'The parked message does not name its queue')
53
- assert.notEqual(headers['x-comq-reason'], undefined, 'The parked message has no reason')
50
+ // the message came back through the default exchange on every retry, so this
51
+ // is only right if the origin was recorded on the first failure
52
+ assert.equal(headers['x-comq-exchange'], exchange,
53
+ `The parked message says it came from '${headers['x-comq-exchange']}'`)
54
54
 
55
- // the message came back through the default exchange on every retry, so this
56
- // is only right if the origin was recorded on the first failure
57
- assert.equal(headers['x-comq-exchange'], exchange,
58
- `The parked message says it came from '${headers['x-comq-exchange']}'`)
55
+ // absent on a message parked from its first delivery, exactly as a consumer reads it;
56
+ // derived rather than stated, so it cannot drift from the configured ladder
57
+ assert.equal(headers['x-comq-attempt'] ?? 1, this.attempts.length,
58
+ 'The parked message did not have as many attempts as the consumer saw')
59
59
 
60
- // absent on a message parked from its first delivery, exactly as a consumer reads it;
61
- // derived rather than stated, so it cannot drift from the configured ladder
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')
60
+ channel.ack(message)
61
+ })
64
62
  })
65
63
 
66
64
  Then('{token} has received {int} event(s)',
@@ -0,0 +1,35 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const { Then } = require('@cucumber/cucumber')
5
+
6
+ const { queues, BROKERS_AMOUNT } = require('./brokers')
7
+
8
+ Then('the broker holds {int} reply queue(s)',
9
+ /**
10
+ * @param {number} count
11
+ */
12
+ async function (count) {
13
+ await held(0, count)
14
+ })
15
+
16
+ Then('each broker holds {int} reply queue(s)',
17
+ /**
18
+ * @param {number} count
19
+ */
20
+ async function (count) {
21
+ for (let n = 0; n < BROKERS_AMOUNT; n++) await held(n, count)
22
+ })
23
+
24
+ /**
25
+ * @param {number} broker
26
+ * @param {number} count
27
+ */
28
+ async function held (broker, count) {
29
+ const replies = (await queues(broker)).filter((name) => name.startsWith(PREFIX))
30
+
31
+ assert.equal(replies.length, count,
32
+ `Broker ${broker} holds ${replies.length} reply queue(s): ${replies.join(', ')}`)
33
+ }
34
+
35
+ const PREFIX = 'comq.reply'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
package/readme.md CHANGED
@@ -113,7 +113,8 @@ Send encoded Request message with `replyTo` and `correlationId` properties set a
113
113
  return decoded Reply content. The promise stays pending until the Reply arrives, or until the
114
114
  [timeout](#timeout) passes.
115
115
 
116
- On the initial call, queues for Requests and Replies are asserted.
116
+ On the initial call, the queue for Requests is asserted, and so is the one this `IO` receives
117
+ every Reply on.
117
118
 
118
119
  `options` is the encoding, or an object:
119
120
 
@@ -550,7 +551,9 @@ requests and are expecting replies.
550
551
  - An exchange is asserted as _fanout_ for [Emission](#emission)
551
552
  and [Consumption](#consumption), and as _direct_ for [Routing](#routing). One name is one or
552
553
  the other: asserting it as both is what the broker refuses.
553
- - Queues for Replies are _exclusive_ and _auto deleted_.
554
+ - One queue takes every Reply an `IO` receives, `comq.reply..<id>`, _exclusive_ and gone with the
555
+ connection. A Reply is matched by its `correlationId`, which is unique across processes, so the
556
+ queue names neither what was called nor what is waiting.
554
557
  - A queue [`back`](#addressed-requests) holds is _exclusive_, bound under its Key to a _direct_
555
558
  exchange.
556
559
 
@@ -558,7 +561,8 @@ comq declares two kinds of queue of its own, for [failed messages](#retries):
558
561
 
559
562
  - `comq.retry.<delay>`, with a fanout exchange of the same name, one pair per distinct rung of
560
563
  the [backoff ladder](#retries), shared by every queue that uses it.
561
- - `comq.parked.<queue>`, one per consumed queue, declared to live as long as it does.
564
+ - `comq.parked`, one queue for everything parked, whatever it was consumed from, always
565
+ _durable_.
562
566
 
563
567
  See [queue assertion options](https://amqp-node.github.io/amqplib/channel_api.html#channel_assertQueue).
564
568
 
@@ -638,8 +642,10 @@ verdict's own message.
638
642
 
639
643
  #### Parked messages
640
644
 
641
- A message that has run out of attempts is published to `comq.parked.<queue>` and acknowledged
642
- only once the broker confirms it. It is not deleted, and it does not depend on a broker-side
645
+ A message that has run out of attempts is published to `comq.parked` and acknowledged
646
+ only once the broker confirms it. There is one such queue, and it holds what every queue this
647
+ connection consumes could not process — including a groupless subscription's, which the broker
648
+ removes with the connection while what it could not process stays. It is not deleted, and it does not depend on a broker-side
643
649
  policy. The [`discard`](#diagnostics) diagnostic event is emitted when it happens, and
644
650
  [`retry`](#diagnostics) on every attempt before it.
645
651
 
@@ -648,9 +654,12 @@ name where it was originally published, `x-comq-queue` the queue it was consumed
648
654
  `x-comq-reason` the exception's message, and `x-comq-at` when it was parked. Its original
649
655
  properties are kept as they were.
650
656
 
651
- Parked queues grow until somebody drains them, which is deliberate — the alternative is deleting
652
- evidence. Alert on [`discard`](#diagnostics), and do not delete `comq.retry.*` or `comq.parked.*`
653
- queues on a running system.
657
+ A message is found by the queue it names rather than by the queue it is in: `comq.parked` holds
658
+ messages from every source, so reading one source's means matching on `x-comq-queue`.
659
+
660
+ The queue grows until somebody drains it, which is deliberate — the alternative is deleting
661
+ evidence. Nothing comq does removes it or what it holds. Alert on [`discard`](#diagnostics), and
662
+ do not delete `comq.retry.*` or `comq.parked` on a running system.
654
663
 
655
664
  > **A parked Request is never answered.** A Consumer awaiting its Reply waits until its
656
665
  > [timeout](#timeout), or indefinitely without one, and with a limited prefetch that can deadlock
@@ -60,12 +60,25 @@ class ReplyPipe extends EventEmitter {
60
60
  ok: { correlationId, replyTo: feedback.queue, ...CONTROL, headers: FLOW }
61
61
  }
62
62
 
63
+ // The source is not read until the confirmation is published, and a blocked broker holds the
64
+ // publish for as long as it stays blocked. A source failing in the meantime — a reply stream
65
+ // being relayed times out — would raise an `error` nobody listens to. How it failed is not
66
+ // reported to the consumer anyway: `#pump` ends the stream once it finds the source destroyed.
67
+ stream.on('error', noop)
68
+
63
69
  channel.diagnose('return', this.#onReturn)
64
70
  feedback.on(correlationId, this.#control)
65
71
  }
66
72
 
67
73
  async pipe () {
68
- await this.#transmit(control.ok, this.#properties.ok)
74
+ try {
75
+ await this.#transmit(control.ok, this.#properties.ok)
76
+ } catch (exception) {
77
+ // nobody is going to read the source now
78
+ this.#interrupt()
79
+
80
+ throw exception
81
+ }
69
82
 
70
83
  if (this.#closed) return
71
84
 
@@ -198,4 +211,6 @@ const CONTROL = { type: 'control', mandatory: true }
198
211
 
199
212
  const FLOW = { [FLOW_HEADER]: true }
200
213
 
214
+ function noop () {}
215
+
201
216
  exports.ReplyPipe = ReplyPipe
package/source/batch.js CHANGED
@@ -10,9 +10,9 @@
10
10
  * end of the same turn: nothing waits for a timer, and nothing waits for a write that may never
11
11
  * come.
12
12
  *
13
- * This belongs in amqplib, whose loop does the writing, and is proposed there:
14
- * https://github.com/temich/amqplib/tree/perf/one-write-per-turn. It is done here until that
15
- * lands, and goes when it does.
13
+ * This belongs in amqplib, whose `Mux` writes the frames one at a time, and where corking that
14
+ * loop is six lines and needs no wrapping of a socket the library does not own. It is done here
15
+ * until amqplib carries it, and goes when it does.
16
16
  *
17
17
  * @param {import('node:net').Socket} socket
18
18
  */
package/source/channel.js CHANGED
@@ -27,14 +27,6 @@ class Channel {
27
27
  /** @type {string[]} */
28
28
  #tags = []
29
29
 
30
- /**
31
- * How each queue was declared, so that the queue holding what it could not
32
- * process is declared to live exactly as long as it does.
33
- *
34
- * @type {Map<string, comq.amqp.options.Queue>}
35
- */
36
- #queues = new Map()
37
-
38
30
  /**
39
31
  * The exchange and key each held queue is bound under, so that sealing withdraws them.
40
32
  *
@@ -88,8 +80,7 @@ class Channel {
88
80
  // the consumers of the previous channel went down with it, their tags mean nothing here
89
81
  this.#tags = []
90
82
 
91
- // a fresh channel has declared nothing, and bound nothing
92
- this.#queues.clear()
83
+ // a fresh channel has bound nothing
93
84
  this.#held.clear()
94
85
 
95
86
  await this.#channel.prefetch(this.#topology.prefetch)
@@ -384,8 +375,6 @@ class Channel {
384
375
 
385
376
  const { queue } = await this.#channel.assertQueue(name, options)
386
377
 
387
- this.#queues.set(queue, options)
388
-
389
378
  return [queue]
390
379
  }
391
380
 
@@ -485,21 +474,23 @@ class Channel {
485
474
  * The queue a message is kept in once it has run out of attempts. It has no consumer
486
475
  * either: what is in it is waiting for a person.
487
476
  *
488
- * It is declared to live exactly as long as the queue it serves, which for a groupless
489
- * subscriber means an exclusive queue whose contents go when the connection does. That
490
- * is deliberate: such a subscriber is ephemeral by construction, and a durable queue per
491
- * generated name would leak one per restart, forever. An ephemeral subscriber's failures
492
- * are ephemeral too.
477
+ * One queue serves every queue this channel consumes, as one retry queue serves every
478
+ * source that shares its delay. A parked message carries the queue it came from, so a
479
+ * queue per source would name what the message already says, and stay on the broker
480
+ * long after the queue it was named for has gone.
481
+ *
482
+ * It is durable whatever the channel is. A groupless subscriber's own queue goes with
483
+ * its connection, but what it could not process is waiting for a person, and there is
484
+ * nobody left to tell.
493
485
  *
494
- * @param {string} queue the queue the message was consumed from
495
486
  * @returns {Promise<void>}
496
487
  */
497
- async #assertParkedQueue (queue) {
488
+ async #assertParkedQueue () {
498
489
  if (!this.#topology.acknowledgments) return
499
490
 
500
- const options = this.#queues.get(queue) ?? (this.#topology.durable ? DURABLE : EXCLUSIVE)
501
-
502
- await this.#assertQueue(parkedQueueOf(queue), options)
491
+ // declared here rather than when a message is first parked, for the reason the retry
492
+ // queues are: the failure handler is the least likely moment for a declare to succeed
493
+ await this.#assertQueue(PARKED, DURABLE)
503
494
  }
504
495
 
505
496
  /** The waits between attempts, as a ladder even when it is one rung. */
@@ -556,8 +547,9 @@ class Channel {
556
547
  }
557
548
 
558
549
  /**
559
- * The retry topology is asserted once per channel and a parked queue once per queue:
560
- * `lazy` keys an initializer by the arguments it takes, and both again after a recovery.
550
+ * The retry and parking topology is asserted once per channel: `lazy` keys an
551
+ * initializer by the arguments it takes, and neither takes any. Both again after
552
+ * a recovery.
561
553
  */
562
554
  #consume = lazy(this, [this.#assertRetryQueue, this.#assertParkedQueue],
563
555
  /**
@@ -660,7 +652,7 @@ class Channel {
660
652
  [PARKED_AT_HEADER]: Date.now()
661
653
  })
662
654
 
663
- await this.#publish(DEFAULT, parkedQueueOf(queue), message.content, properties)
655
+ await this.#publish(DEFAULT, PARKED, message.content, properties)
664
656
 
665
657
  this.#channel.ack(message)
666
658
 
@@ -775,9 +767,9 @@ const INTERRUPTION = /** @type {Error} */ Symbol('internal interruption')
775
767
  const RESOURCE_LOCKED = 405
776
768
 
777
769
  const RETRY_PREFIX = 'comq.retry.'
778
- const PARKED_PREFIX = 'comq.parked.'
779
770
 
780
- const parkedQueueOf = (queue) => PARKED_PREFIX + queue
771
+ /** Where every message that has run out of attempts is kept, whatever it was consumed from. */
772
+ const PARKED = 'comq.parked'
781
773
 
782
774
  // Everything comq writes onto a message, under its own prefix: AMQP defines no retry
783
775
  // counter, so this one is comq's invention rather than a convention, and an unprefixed
package/source/io.js CHANGED
@@ -30,8 +30,15 @@ class IO {
30
30
  /** @type {comq.Channel} */
31
31
  #events
32
32
 
33
- /** @type {Map<string, comq.ReplyEmitter>} */
34
- #emitters = new Map()
33
+ /**
34
+ * Where this IO's replies arrive. One queue answers every request it makes: a reply is
35
+ * found by its correlation identifier, which is unique across processes, so a queue per
36
+ * target would name what the identifier already tells apart — and the broker would hold
37
+ * one for every distinct queue the caller has ever requested.
38
+ *
39
+ * @type {comq.ReplyEmitter}
40
+ */
41
+ #emitter
35
42
 
36
43
  /** @type {comq.ReplyEmitter | null} */
37
44
  #control = null
@@ -312,13 +319,20 @@ class IO {
312
319
  return [buffer, properties]
313
320
  }
314
321
 
315
- async #consumeReplies (queue) {
316
- const emitter = io.createReplyEmitter(queue)
317
- const consumer = this.#getReplyConsumer(queue, emitter)
322
+ async #consumeReplies () {
323
+ this.#emitter = await this.#consumeRepliesOf(REPLY)
324
+ }
325
+
326
+ /**
327
+ * @param {string} label
328
+ * @returns {Promise<comq.ReplyEmitter>}
329
+ */
330
+ async #consumeRepliesOf (label) {
331
+ const emitter = io.createReplyEmitter(label)
318
332
 
319
- this.#emitters.set(queue, emitter)
333
+ await this.#replies.consume(emitter.queue, this.#getReplyConsumer(emitter))
320
334
 
321
- await this.#replies.consume(emitter.queue, consumer)
335
+ return emitter
322
336
  }
323
337
 
324
338
  // endregion
@@ -389,11 +403,10 @@ class IO {
389
403
  })
390
404
 
391
405
  /**
392
- * @param {string} queue
393
406
  * @param {comq.ReplyEmitter} emitter
394
407
  * @returns {comq.channels.Consumer}
395
408
  */
396
- #getReplyConsumer = (queue, emitter) =>
409
+ #getReplyConsumer = (emitter) =>
397
410
  (message) => {
398
411
  const payload = decode(message)
399
412
 
@@ -426,7 +439,7 @@ class IO {
426
439
  signal?.throwIfAborted()
427
440
 
428
441
  const [buffer, contentType] = this.#encode(payload, terms.encoding)
429
- const request = this.#createRequest(target, contentType, signal)
442
+ const request = this.#createRequest(contentType, signal)
430
443
  const properties = { ...request.properties }
431
444
 
432
445
  if (expires !== undefined) {
@@ -493,13 +506,12 @@ class IO {
493
506
  * The request holds no copy of what was sent: a retransmission encodes the
494
507
  * payload anew, and an unanswered request would otherwise keep two of it.
495
508
  *
496
- * @param {string} queue
497
509
  * @param {comq.Encoding} contentType
498
510
  * @param {AbortSignal} [signal]
499
511
  * @return {comq.Request}
500
512
  */
501
- #createRequest (queue, contentType, signal) {
502
- const emitter = this.#emitters.get(queue)
513
+ #createRequest (contentType, signal) {
514
+ const emitter = this.#emitter
503
515
  const correlationId = emitter.next()
504
516
 
505
517
  /** @type {comq.amqp.Properties} */
@@ -580,11 +592,7 @@ class IO {
580
592
  * @return {Promise<comq.ReplyEmitter>}
581
593
  */
582
594
  async #createControl () {
583
- const queue = 'control'
584
-
585
- await this.#consumeReplies(queue)
586
-
587
- return this.#emitters.get(queue)
595
+ return await this.#consumeRepliesOf(CONTROL)
588
596
  }
589
597
 
590
598
  /**
@@ -693,6 +701,12 @@ const DEFAULT = 'application/json'
693
701
 
694
702
  const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
695
703
 
704
+ /** What this IO's replies arrive on, before the random suffix that makes the queue its own. */
705
+ const REPLY = 'comq.reply'
706
+
707
+ /** What a producer steers its reply streams from, likewise. */
708
+ const CONTROL = 'control'
709
+
696
710
  /**
697
711
  * What a caller passed, settled once: a timeout becomes the moment the Request expires, so that
698
712
  * every attempt of it shares one deadline.
@@ -168,3 +168,43 @@ it('should heartbeat while idle', async () => {
168
168
 
169
169
  pipe.destroy()
170
170
  })
171
+
172
+ // the confirmation is published before the source is read, and a blocked broker holds the publish
173
+ // for as long as it is blocked: the source may fail in the meantime, and nobody listens to it yet
174
+ it('should survive a source that fails before the confirmation is sent', async () => {
175
+ const gate = new Promex()
176
+
177
+ reply.mockImplementation(async (message, properties) => {
178
+ sent.push([message, properties])
179
+ await gate
180
+
181
+ return true
182
+ })
183
+
184
+ const source = new Readable({ objectMode: true, read () {} })
185
+ const creating = ReplyPipe.create(request, source, channel, feedback, reply)
186
+
187
+ source.destroy(new Error('nothing arrived'))
188
+
189
+ await timeout(10)
190
+
191
+ gate.resolve()
192
+
193
+ const pipe = await creating
194
+
195
+ await closing(pipe)
196
+
197
+ expect(source.destroyed).toStrictEqual(true)
198
+ })
199
+
200
+ it('should destroy the source if the confirmation cannot be sent', async () => {
201
+ reply.mockImplementation(async () => {
202
+ throw new Error('channel closed')
203
+ })
204
+
205
+ const source = new Readable({ objectMode: true, read () {} })
206
+
207
+ await expect(ReplyPipe.create(request, source, channel, feedback, reply)).rejects.toThrow('channel closed')
208
+
209
+ expect(source.destroyed).toStrictEqual(true)
210
+ })
@@ -1040,6 +1040,7 @@ describe('release', () => {
1040
1040
  describe('failed messages', () => {
1041
1041
  const DELAY = 1000
1042
1042
  const RETRY = 'comq.retry.' + DELAY
1043
+ const PARKED = 'comq.parked'
1043
1044
 
1044
1045
  let queue
1045
1046
  let exception
@@ -1108,10 +1109,18 @@ describe('failed messages', () => {
1108
1109
  it('should assert the parked queue', async () => {
1109
1110
  await channel.consume(queue, consumer)
1110
1111
 
1111
- expect(chan.assertQueue).toHaveBeenCalledWith('comq.parked.' + queue,
1112
+ expect(chan.assertQueue).toHaveBeenCalledWith(PARKED,
1112
1113
  expect.objectContaining({ durable: true }))
1113
1114
  })
1114
1115
 
1116
+ it('should not name the parked queue after the queue it serves', async () => {
1117
+ await channel.consume(queue, consumer)
1118
+
1119
+ const named = chan.assertQueue.mock.calls.filter(([name]) => name.includes(queue))
1120
+
1121
+ expect(named).toHaveLength(1) // the source queue itself
1122
+ })
1123
+
1115
1124
  it('should assert the source queue first', async () => {
1116
1125
  await channel.consume(queue, consumer)
1117
1126
 
@@ -1130,15 +1139,14 @@ describe('failed messages', () => {
1130
1139
  expect(queues).toHaveLength(1)
1131
1140
  })
1132
1141
 
1133
- it('should assert a parked queue per consumed queue', async () => {
1134
- const one = generate()
1135
- const another = generate()
1142
+ it('should assert one parked queue however many queues are consumed', async () => {
1143
+ await channel.consume(generate(), consumer)
1144
+ await channel.consume(generate(), consumer)
1145
+ await channel.consume(generate(), consumer)
1136
1146
 
1137
- await channel.consume(one, consumer)
1138
- await channel.consume(another, consumer)
1147
+ const parked = chan.assertQueue.mock.calls.filter(([name]) => name === PARKED)
1139
1148
 
1140
- expect(chan.assertQueue).toHaveBeenCalledWith('comq.parked.' + one, expect.anything())
1141
- expect(chan.assertQueue).toHaveBeenCalledWith('comq.parked.' + another, expect.anything())
1149
+ expect(parked).toHaveLength(1)
1142
1150
  })
1143
1151
 
1144
1152
  it('should not assert either without acknowledgments', async () => {
@@ -1156,7 +1164,9 @@ describe('failed messages', () => {
1156
1164
  expect(chan.assertExchange).not.toHaveBeenCalled()
1157
1165
  })
1158
1166
 
1159
- it('should declare the parked queue exclusive for an exclusive queue', async () => {
1167
+ it('should declare the parked queue durable whatever the channel is', async () => {
1168
+ // what a groupless subscriber could not process is waiting for a person, and
1169
+ // there is nobody left to tell once its own queue has gone with its connection
1160
1170
  jest.clearAllMocks()
1161
1171
 
1162
1172
  topology.durable = false
@@ -1166,9 +1176,10 @@ describe('failed messages', () => {
1166
1176
  await channel.consume(queue, consumer)
1167
1177
 
1168
1178
  const [, options] = chan.assertQueue.mock.calls
1169
- .find(([name]) => name === 'comq.parked.' + queue)
1179
+ .find(([name]) => name === PARKED)
1170
1180
 
1171
- expect(options).toMatchObject({ exclusive: true })
1181
+ expect(options).toMatchObject({ durable: true })
1182
+ expect(options.exclusive).toBeUndefined()
1172
1183
  })
1173
1184
 
1174
1185
  it('should re-assert after recovery', async () => {
@@ -1181,7 +1192,7 @@ describe('failed messages', () => {
1181
1192
  const repl = await getCreatedChannel(replacement)
1182
1193
 
1183
1194
  expect(repl.assertExchange).toHaveBeenCalledWith(RETRY, 'fanout', expect.anything())
1184
- expect(repl.assertQueue).toHaveBeenCalledWith('comq.parked.' + queue, expect.anything())
1195
+ expect(repl.assertQueue).toHaveBeenCalledWith(PARKED, expect.anything())
1185
1196
  })
1186
1197
  })
1187
1198
 
@@ -1471,7 +1482,7 @@ describe('failed messages', () => {
1471
1482
 
1472
1483
  expect(asserted()).toStrictEqual(['comq.retry.1000'])
1473
1484
  expect(publications().map(([exchange, key]) => exchange || key))
1474
- .toStrictEqual(['comq.retry.1000', 'comq.parked.' + queue])
1485
+ .toStrictEqual(['comq.retry.1000', PARKED])
1475
1486
  })
1476
1487
  })
1477
1488
 
@@ -1486,7 +1497,7 @@ describe('failed messages', () => {
1486
1497
  const [exchange, key] = publications()[0]
1487
1498
 
1488
1499
  expect(exchange).toStrictEqual('')
1489
- expect(key).toStrictEqual('comq.parked.' + queue)
1500
+ expect(key).toStrictEqual(PARKED)
1490
1501
  })
1491
1502
 
1492
1503
  it('should not retry once the attempts are spent', async () => {
@@ -1563,7 +1574,7 @@ describe('failed messages', () => {
1563
1574
 
1564
1575
  const targets = publications().map(([, key]) => key)
1565
1576
 
1566
- expect(targets).toStrictEqual([queue, 'comq.parked.' + queue])
1577
+ expect(targets).toStrictEqual([queue, PARKED])
1567
1578
  })
1568
1579
 
1569
1580
  it('should give a message one delivery more than the ladder has rungs', async () => {
@@ -1581,7 +1592,7 @@ describe('failed messages', () => {
1581
1592
 
1582
1593
  const targets = publications().map(([, key]) => key)
1583
1594
 
1584
- expect(targets).toStrictEqual([queue, queue, 'comq.parked.' + queue])
1595
+ expect(targets).toStrictEqual([queue, queue, PARKED])
1585
1596
  })
1586
1597
  })
1587
1598
 
@@ -1596,7 +1607,7 @@ describe('failed messages', () => {
1596
1607
  const [exchange, key] = publications()[0]
1597
1608
 
1598
1609
  expect(exchange).toStrictEqual('')
1599
- expect(key).toStrictEqual('comq.parked.' + queue)
1610
+ expect(key).toStrictEqual(PARKED)
1600
1611
  expect(publications()).toHaveLength(1)
1601
1612
  })
1602
1613
 
@@ -1684,7 +1695,7 @@ describe('failed messages', () => {
1684
1695
 
1685
1696
  await deliver(delivery({}))
1686
1697
 
1687
- expect(publications()[0][1]).toStrictEqual('comq.parked.' + queue)
1698
+ expect(publications()[0][1]).toStrictEqual(PARKED)
1688
1699
  })
1689
1700
  })
1690
1701
 
@@ -41,7 +41,7 @@ let replies
41
41
 
42
42
  let promise
43
43
 
44
- const REPLY = new RegExp(`^${queue}..[0-9a-f]+$`)
44
+ const REPLY = /^comq\.reply\.\.[0-9a-f]+$/
45
45
 
46
46
  beforeEach(async () => {
47
47
  promise = io.request(queue, payload)
@@ -62,6 +62,17 @@ it('should consume replies queue', async () => {
62
62
  expect(replies.consume).toHaveBeenCalledWith(expect.stringMatching(REPLY), expect.any(Function))
63
63
  })
64
64
 
65
+ it('should consume one replies queue however many queues are requested', async () => {
66
+ // a reply is found by its correlation identifier, so a queue per target would
67
+ // name what the identifier already tells apart
68
+ io.request(generate(), payload)
69
+ io.request(generate(), payload)
70
+
71
+ await immediate()
72
+
73
+ expect(replies.consume).toHaveBeenCalledTimes(1)
74
+ })
75
+
65
76
  describe('send', () => {
66
77
  let call
67
78
 
@@ -84,9 +95,8 @@ describe('send', () => {
84
95
 
85
96
  it('should set replyTo', async () => {
86
97
  const properties = call[2]
87
- const rx = new RegExp(`^${queue}..[a-z0-9]+`)
88
98
 
89
- expect(properties.replyTo).toMatch(rx)
99
+ expect(properties.replyTo).toMatch(REPLY)
90
100
  })
91
101
 
92
102
  it('should consume replyTo', async () => {