comq 0.19.0 → 0.20.1

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.
@@ -26,6 +26,32 @@ When('the network goes silent',
26
26
  silence.call(this)
27
27
  })
28
28
 
29
+ When('the network goes silent and refuses new connections',
30
+ /**
31
+ * @this {comq.features.Context}
32
+ */
33
+ function () {
34
+ for (const network of this.networks) network.refuse()
35
+
36
+ silence.call(this)
37
+ })
38
+
39
+ When('the network admits new connections',
40
+ /**
41
+ * @this {comq.features.Context}
42
+ */
43
+ function () {
44
+ for (const network of this.networks) network.admit()
45
+ })
46
+
47
+ When('the silent connection is let go',
48
+ /**
49
+ * @this {comq.features.Context}
50
+ */
51
+ function () {
52
+ for (const network of this.networks) network.release()
53
+ })
54
+
29
55
  // a request that is answered over a connection that is already silent proves
30
56
  // nothing, hence the window between the two is left as short as a step boundary
31
57
  When('the consumer sends a request to the {token} queue as the network goes silent',
@@ -23,6 +23,8 @@ class Network {
23
23
  /** @type {number} */
24
24
  #port
25
25
 
26
+ #refusing = false
27
+
26
28
  /**
27
29
  * @param {number} [n] broker index
28
30
  */
@@ -51,6 +53,26 @@ class Network {
51
53
  for (const tunnel of this.#tunnels) tunnel.silent = true
52
54
  }
53
55
 
56
+ /**
57
+ * A connection established from now on is closed as soon as it is accepted, so
58
+ * a connection that is lost stays lost until the network admits it.
59
+ */
60
+ refuse () {
61
+ this.#refusing = true
62
+ }
63
+
64
+ admit () {
65
+ this.#refusing = false
66
+ }
67
+
68
+ /**
69
+ * Closes the tunnels that went silent, at both ends, so the broker learns that the
70
+ * connection it was still holding is gone — the moment its heartbeat would otherwise tell it.
71
+ */
72
+ release () {
73
+ for (const tunnel of this.#tunnels) if (tunnel.silent) this.#collapse(tunnel)
74
+ }
75
+
54
76
  async close () {
55
77
  for (const tunnel of this.#tunnels) this.#collapse(tunnel)
56
78
 
@@ -61,6 +83,8 @@ class Network {
61
83
  * @param {net.Socket} client
62
84
  */
63
85
  #tunnel = (client) => {
86
+ if (this.#refusing) return client.destroy()
87
+
64
88
  const upstream = net.connect(this.#port, this.#host)
65
89
 
66
90
  /** @type {comq.features.Tunnel} */
@@ -71,10 +95,22 @@ class Network {
71
95
  client.on('data', (chunk) => { if (!tunnel.silent) upstream.write(chunk) })
72
96
  upstream.on('data', (chunk) => { if (!tunnel.silent) client.write(chunk) })
73
97
 
74
- for (const socket of [client, upstream]) {
75
- socket.on('error', () => this.#collapse(tunnel))
76
- socket.on('close', () => this.#collapse(tunnel))
77
- }
98
+ // a silent network tells neither end that the other has gone: the client giving up on a
99
+ // silent connection leaves the broker holding its end, until the network is let go
100
+ client.on('error', () => this.#hangUp(tunnel))
101
+ client.on('close', () => this.#hangUp(tunnel))
102
+
103
+ upstream.on('error', () => this.#collapse(tunnel))
104
+ upstream.on('close', () => this.#collapse(tunnel))
105
+ }
106
+
107
+ /**
108
+ * @param {comq.features.Tunnel} tunnel
109
+ */
110
+ #hangUp (tunnel) {
111
+ if (!tunnel.silent) return this.#collapse(tunnel)
112
+
113
+ tunnel.client.destroy()
78
114
  }
79
115
 
80
116
  /**
@@ -0,0 +1,102 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const amqplib = require('amqplib')
5
+ const { Given, When, Then } = require('@cucumber/cucumber')
6
+
7
+ const { getAddress, USER, PASSWORD } = require('./brokers')
8
+ const { until, timeout } = require('../../test/helpers')
9
+
10
+ Given('a producer failing every request to the {token} queue',
11
+ /**
12
+ * @param {string} queue
13
+ * @this {comq.features.Context}
14
+ */
15
+ async function (queue) {
16
+ await this.io.reply(queue, () => {
17
+ throw new Error('Expected failure')
18
+ })
19
+ })
20
+
21
+ When('a request is sent to the {token} queue',
22
+ /**
23
+ * The caller is left waiting on purpose: the promise is what the last step asserts on.
24
+ *
25
+ * @param {string} queue
26
+ * @this {comq.features.Context}
27
+ */
28
+ async function (queue) {
29
+ this.awaited = this.io.request(queue, { question: 'the answer?' }, 'application/json')
30
+
31
+ // nothing settles it unless the parked request is answered, and an unhandled
32
+ // rejection would take the run down before the assertion is reached
33
+ this.awaited.catch(() => undefined)
34
+ })
35
+
36
+ Then('the request is parked',
37
+ /**
38
+ * @this {comq.features.Context}
39
+ */
40
+ async function () {
41
+ await until(() => this.events.discard === true)
42
+
43
+ assert.equal(this.events.discard, true, 'The request was not parked')
44
+ })
45
+
46
+ When('the parked request from the {token} queue is answered by hand',
47
+ /**
48
+ * What an operator draining the parking queue would do: take the message, read who
49
+ * was asking, and publish the reply they are still waiting for.
50
+ *
51
+ * @param {string} queue
52
+ * @this {comq.features.Context}
53
+ */
54
+ async function (queue) {
55
+ const connection = await amqplib.connect(`amqp://${USER}:${PASSWORD}@${getAddress(0)}`)
56
+ const channel = await connection.createChannel()
57
+
58
+ try {
59
+ let parked
60
+
61
+ await channel.consume('comq.parked.' + queue, (message) => {
62
+ parked = message
63
+ channel.ack(message)
64
+ })
65
+
66
+ await until(() => parked !== undefined)
67
+
68
+ assert.notEqual(parked, undefined, `Nothing was parked from ${queue}`)
69
+
70
+ const { replyTo, correlationId, contentType } = parked.properties
71
+
72
+ assert.notEqual(replyTo, undefined, 'The parked request lost its replyTo')
73
+ assert.notEqual(correlationId, undefined, 'The parked request lost its correlationId')
74
+
75
+ this.answer = { answered: true, was: JSON.parse(parked.content.toString()) }
76
+
77
+ channel.sendToQueue(replyTo, Buffer.from(JSON.stringify(this.answer)),
78
+ { correlationId, contentType })
79
+
80
+ // let the frame reach the broker before the connection goes
81
+ await timeout(100)
82
+ } finally {
83
+ await channel.close()
84
+ await connection.close()
85
+ }
86
+ })
87
+
88
+ Then('the caller receives the reply',
89
+ /**
90
+ * @this {comq.features.Context}
91
+ */
92
+ async function () {
93
+ const reply = await Promise.race([
94
+ this.awaited,
95
+ timeout(5000).then(() => TIMED_OUT)
96
+ ])
97
+
98
+ assert.notEqual(reply, TIMED_OUT, 'The caller is still waiting')
99
+ assert.deepEqual(reply, this.answer, 'The caller received something else')
100
+ })
101
+
102
+ const TIMED_OUT = Symbol('the caller was still waiting')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
package/readme.md CHANGED
@@ -6,7 +6,8 @@ for distributed, eventually consistent systems running on Node.js.
6
6
  ## Features
7
7
 
8
8
  - [Dynamic topology](#topology)
9
- - [Request](#request)-[reply](#reply) (RPC)
9
+ - [Request](#request)-[reply](#reply) (RPC), with a [timeout](#timeout)
10
+ - [Addressed requests](#addressed-requests) to the one connection holding a Key
10
11
  - Events ([pub](#emission)/[sub](#consumption)), fanned out or [routed](#routing)
11
12
  - [Tasks](#tasks)
12
13
  - [Pipelines](#pipelines)
@@ -106,19 +107,50 @@ await io.reply('add_numbers', ({ a, b }) => (a + b))
106
107
 
107
108
  ## Request
108
109
 
109
- `async IO.request(queue: string, payload: any, encoding?: string): any`
110
+ `async IO.request(queue: string, payload: any, options?: string | RequestOptions): any`
110
111
 
111
112
  Send encoded Request message with `replyTo` and `correlationId` properties set and
112
- return decoded Reply content. The promise stays pending until the Reply arrives.
113
+ return decoded Reply content. The promise stays pending until the Reply arrives, or until the
114
+ [timeout](#timeout) passes.
113
115
 
114
116
  On the initial call, queues for Requests and Replies are asserted.
115
117
 
118
+ `options` is the encoding, or an object:
119
+
120
+ | Option | Type | Default |
121
+ |------------|---------------|--------------------|
122
+ | `encoding` | `string` | `application/json` |
123
+ | `timeout` | `number`, ms | none |
124
+ | `signal` | `AbortSignal` | none |
125
+
116
126
  ### Example
117
127
 
118
128
  ```javascript
119
129
  const sum = await io.request('add_numbers', { a: 1, b: 2 })
120
130
  ```
121
131
 
132
+ ### Timeout
133
+
134
+ A Request with a `timeout` rejects once it passes, with the `TimeoutError` of
135
+ [`AbortSignal.timeout`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static).
136
+ It is published with that much
137
+ [expiration](https://www.rabbitmq.com/docs/ttl#per-message-ttl-in-publishers), so a Request no
138
+ Producer has taken by then is dropped by the broker and never processed. A Request a Producer has
139
+ already taken is processed to the end, and its Reply is discarded. A Request re-sent after a lost
140
+ connection carries the time it has left.
141
+
142
+ A `signal` rejects the Request with its reason once aborted, within the `timeout` where both are
143
+ given. It ends the wait and leaves the Request where it is: in its queue until the `timeout`
144
+ passes, or, without a `timeout`, until a Producer takes it. A Request abandoned by its `signal` may
145
+ therefore still be processed.
146
+
147
+ A Request that failed and waits for its [next attempt](#retries) loses its expiration on the way
148
+ back to its queue, so it may be processed after its caller has stopped waiting.
149
+
150
+ ```javascript
151
+ const sum = await io.request('add_numbers', { a: 1, b: 2 }, { timeout: 5000 })
152
+ ```
153
+
122
154
  ## Consumption
123
155
 
124
156
  `async IO.consume(exchange: string, group?: string, consumer): void`
@@ -194,6 +226,65 @@ await io.route('records', 'store.orders', { id: 1, status: 'paid' })
194
226
  await io.route('records', 'store.customers', { id: 2 }) // not delivered to the above
195
227
  ```
196
228
 
229
+ ## Addressed Requests
230
+
231
+ `async IO.back(exchange: string, key: string, producer): void`
232
+
233
+ `async IO.call(exchange: string, key: string, payload: any, options?: string | RequestOptions): any`
234
+
235
+ A Request to the one connection holding a Key, where a [Request](#request) goes to whichever
236
+ Producer takes it first.
237
+
238
+ `back` asserts a [direct exchange](#routing) and a queue named `<exchange>.<key>`, *exclusive* to
239
+ the connection, binds it under the `key` and starts consuming Requests, as [`reply`](#reply) does.
240
+ **On each broker, one connection holds a Key at a time.** While another connection holds it,
241
+ `back` claims it again and again, with the backoff a lost connection is
242
+ [restored](#connection-tolerance) with, and emits [`taken`](#diagnostics) on every refusal. The Key
243
+ is let go when its connection closes, however it closes — once the broker has noticed, for a
244
+ connection that went without a word. `back` returns once a broker holds the Key. The rest of the
245
+ connection works throughout, and a connection that is restored claims its Keys again the same way.
246
+
247
+ `call` publishes the encoded Request to the exchange under the `key` and returns the decoded Reply,
248
+ taking the same options as [`request`](#request).
249
+
250
+ A call ends in one of three ways:
251
+
252
+ - **A Reply.**
253
+ - **Refused at once**, rejecting with `Unroutable`, when no connection holds the Key: it never did,
254
+ its holder has closed, or its holder is [sealed](#sealing). A refused call reached no one.
255
+ - **At its [timeout](#timeout)**, when its holder has gone without being sealed — a crashed process,
256
+ a lost connection — or takes longer. A call nobody has taken by then is dropped.
257
+
258
+ A call waits for as long as it takes unless it has a timeout, and a holder that is gone never
259
+ answers it. **Give every call a timeout.**
260
+
261
+ Sealing withdraws every Key before it stops consuming: a call published from then on is refused,
262
+ and one published before it is delivered and answered. Calls queued beyond the
263
+ [prefetch](#channels) at that moment go with the connection and end at their timeout.
264
+
265
+ A Key is as alive as its connection: while the holder reconnects, calls to it are refused, and
266
+ calls queued for it are lost.
267
+
268
+ Over a [sharded connection](#sharded-connection), `back` claims the Key on every shard and returns
269
+ once one of them holds it; a shard where it is taken goes on claiming it. Two connections given the
270
+ same Key can therefore hold it on different shards and both answer calls, and `taken` is what says
271
+ so. A call returned by one shard is published on the next, and refused once every shard has
272
+ returned it.
273
+
274
+ ### Example
275
+
276
+ ```javascript
277
+ const { Unroutable } = require('comq')
278
+
279
+ await io.back('sessions', 'a1', (message) => sessions.get(message.id))
280
+
281
+ try {
282
+ const session = await io.call('sessions', 'a1', { id: 7 }, { timeout: 5000 })
283
+ } catch (error) {
284
+ if (error instanceof Unroutable) console.log('Nobody holds', error.key)
285
+ }
286
+ ```
287
+
197
288
  ## Tasks
198
289
 
199
290
  `async IO.enqueue(queue: string, payload: any, encoding?: string): void`
@@ -425,24 +516,6 @@ dynamic, such as those that depend on runtime data like incoming messages, makin
425
516
  impossible or hard to maintain. The tradeoff of potentially encountering runtime topology
426
517
  declaration exceptions, which are more likely to happen during development, is deemed acceptable.
427
518
 
428
- ### Settings
429
-
430
- Each channel type has a preset, and the trailing argument of `connect` overrides it:
431
-
432
- ```javascript
433
- const io = await comq.connect(url, {
434
- event: { delay: [5000, 60000] }, // two retries
435
- request: { delay: 1000 } // one
436
- })
437
- ```
438
-
439
- `delay` governs [retries](#retries); the rest of [the settings](./types/topology.d.ts) are not
440
- meant to be changed.
441
-
442
- > Changing `delay` declares new retry queues rather than redeclaring the existing ones, so a
443
- > rolling deploy that changes it has no window in which either version fails. The queues left
444
- > behind are empty and can be removed once nothing is publishing to them.
445
-
446
519
  ### Channels
447
520
 
448
521
  `IO` lazy creates individual channels for Requests, Replies, and Events.
@@ -467,6 +540,8 @@ requests and are expecting replies.
467
540
  and [Consumption](#consumption), and as _direct_ for [Routing](#routing). One name is one or
468
541
  the other: asserting it as both is what the broker refuses.
469
542
  - Queues for Replies are _exclusive_ and _auto deleted_.
543
+ - A queue [`back`](#addressed-requests) holds is _exclusive_, bound under its Key to a _direct_
544
+ exchange.
470
545
 
471
546
  comq declares two kinds of queue of its own, for [failed messages](#retries):
472
547
 
@@ -508,8 +583,8 @@ other consumers nor that consumer's next message; only the message that failed i
508
583
  | Event | 1s, 10s, 30s, 90s | 5 | 131s |
509
584
  | Request | 1s, 3s, 5s, 10s | 5 | 19s |
510
585
 
511
- Requests are shorter because a caller is blocked on one with no timeout, and a Reply arriving
512
- long after it gave up has nowhere useful to land. Nobody waits on an Event.
586
+ Requests are shorter because a caller is blocked on one, and a Reply arriving long after it
587
+ stopped waiting has nowhere useful to land. Nobody waits on an Event.
513
588
 
514
589
  One retry queue and one exchange are declared per distinct wait and shared by every queue that
515
590
  uses them, so their number grows with the length of the ladder rather than with the number of
@@ -566,11 +641,12 @@ Parked queues grow until somebody drains them, which is deliberate — the alter
566
641
  evidence. Alert on [`discard`](#diagnostics), and do not delete `comq.retry.*` or `comq.parked.*`
567
642
  queues on a running system.
568
643
 
569
- > **A parked Request is never answered.** A Consumer awaiting its Reply waits indefinitely, and
570
- > with a limited prefetch that can deadlock it. Parking keeps the Request rather than deleting
644
+ > **A parked Request is never answered.** A Consumer awaiting its Reply waits until its
645
+ > [timeout](#timeout), or indefinitely without one, and with a limited prefetch that can deadlock
646
+ > it. Parking keeps the Request rather than deleting
571
647
  > it — and it keeps `replyTo` and `correlationId`, so a Reply can still be produced from it by
572
648
  > hand while the caller is alive — but comq itself sends no Reply and reports no error to the
573
- > caller. Give a Request a timeout on the calling side if you cannot tolerate that.
649
+ > caller.
574
650
 
575
651
  #### What is guaranteed
576
652
 
@@ -585,11 +661,21 @@ A retried message re-enters its queue behind the messages published while it wai
585
661
  Retries and parked messages are published *persistent* whatever the channel is, so they survive a
586
662
  restart of the broker even on the Request channel. Ordinary publishing is untouched: Requests and
587
663
  Replies stay [delivery mode 1](#messages), and only a message that has already failed is written
588
- to disk. Two weaker points remain on that channel: it
589
- does not use publisher confirms, so comq has no positive acknowledgement that the broker took the
590
- copy; and the return hop of a retry is performed by the broker's dead-lettering, which on classic
591
- queues is at-most-once and can lose the message if the source queue is unavailable when the delay
592
- expires.
664
+ to disk.
665
+
666
+ On the Request channel the copy is published without [publisher confirms](#channels). Those are
667
+ an Events property here, because a confirm is a round trip and Requests are where that is felt —
668
+ the same reason they are not persistent. Confirm mode belongs to the channel rather than to a
669
+ publish, so the failure path cannot ask for it on its own.
670
+
671
+ What that costs is narrow. Commands on a channel are handled in order, so the broker takes the
672
+ copy before it releases the original, and `mandatory` brings back a copy it could not route. What
673
+ is left uncovered is a broker that accepted the frame and then failed to keep it.
674
+
675
+ The return hop of a retry — the broker moving a message out of the retry queue when its wait
676
+ expires — is dead-lettering, and on classic queues that is at-most-once: a retry can be lost if
677
+ its source queue is unavailable at the moment the delay expires. This applies to every channel,
678
+ not only Requests.
593
679
 
594
680
  See:
595
681
 
@@ -605,6 +691,25 @@ See:
605
691
  | Reply | unlimited | no | exclusive | automatic | no | — |
606
692
  | Event | limited | yes | durable | manual | yes | 1s, 10s, 30s, 90s |
607
693
 
694
+ ### Settings
695
+
696
+ Each channel type has a [preset](./source/topology), and the trailing argument of `connect`
697
+ overrides any of its fields:
698
+
699
+ ```javascript
700
+ const io = await comq.connect(url, {
701
+ event: { delay: [5000, 60000] }, // two retries
702
+ request: { delay: 1000 } // one
703
+ })
704
+ ```
705
+
706
+ `delay` is the one meant to be set. Changing the rest will change what a Request, a Reply and an
707
+ Event *are*.
708
+
709
+ > Changing `delay` declares new retry queues rather than redeclaring the existing ones, so a
710
+ > rolling deploy that changes it has no window in which either version fails. The queues left
711
+ > behind are empty and can be removed once nothing is publishing to them.
712
+
608
713
  ## Graceful shutdown
609
714
 
610
715
  ### Sealing
@@ -615,6 +720,9 @@ See:
615
720
  Requests.
616
721
  Sending Requests, receiving Replies, and emitting Events will still be available.
617
722
 
723
+ Keys held by [`back`](#addressed-requests) are withdrawn first, so a call published from then on is
724
+ refused.
725
+
618
726
  ### Disconnection
619
727
 
620
728
  `async IO.close(): void`
@@ -685,6 +793,8 @@ Subscribe to one of the diagnostic events:
685
793
  [amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_publish) are
686
794
  passed as arguments. In the case of a [sharded connection](#sharded-connection), the message is
687
795
  reported only once every shard has rejected it.
796
+ - `taken`: a Key [`back`](#addressed-requests) claims is held by another connection on this broker, and
797
+ is claimed again. Channel type and the queue name are passed.
688
798
  - `pause`: channel is paused. Channel type is passed.
689
799
  In the case of a [sharded connection](#sharded-connection), it means that there is no shard left
690
800
  to publish to, be it because every one of them has rejected a publish or lost its connection.
@@ -15,7 +15,8 @@ const recorder = (context, method) => async function (...args) {
15
15
 
16
16
  const result = await method.apply(context, args)
17
17
 
18
- method[CALLS].push(args)
18
+ // forgotten while it ran, by a seal: a sealed context replays nothing
19
+ method[CALLS]?.push(args)
19
20
 
20
21
  return result
21
22
  }
package/source/channel.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { Promex } = require('promex')
4
+ const { retry } = require('reretry')
4
5
  const { failsafe, lazy, recall } = require('./attributes')
5
6
  const { verdictOf, PARK } = require('./verdicts')
6
7
  const emitter = require('./emitter')
@@ -34,6 +35,13 @@ class Channel {
34
35
  */
35
36
  #queues = new Map()
36
37
 
38
+ /**
39
+ * The exchange and key each held queue is bound under, so that sealing withdraws them.
40
+ *
41
+ * @type {Map<string, [string, string]>}
42
+ */
43
+ #held = new Map()
44
+
37
45
  /** @type {Promex | null} */
38
46
  #paused = null
39
47
 
@@ -80,8 +88,9 @@ class Channel {
80
88
  // the consumers of the previous channel went down with it, their tags mean nothing here
81
89
  this.#tags = []
82
90
 
83
- // a fresh channel has declared nothing
91
+ // a fresh channel has declared nothing, and bound nothing
84
92
  this.#queues.clear()
93
+ this.#held.clear()
85
94
 
86
95
  await this.#channel.prefetch(this.#topology.prefetch)
87
96
 
@@ -131,6 +140,33 @@ class Channel {
131
140
  if (!this.#sealed) await this.#consume(queue, callback)
132
141
  })))
133
142
 
143
+ /**
144
+ * Consumes a queue exclusive to this connection, bound to a direct exchange under a key:
145
+ * whatever is published under that key reaches this connection and no other. While another
146
+ * connection holds the queue, it is claimed again and again until it is let go, and every
147
+ * refusal is reported as `taken`.
148
+ */
149
+ held = recall(this,
150
+ failsafe(this, this.#recover,
151
+ lazy(this, this.#assertRouted,
152
+ /**
153
+ * @param {string} exchange
154
+ * @param {string} queue
155
+ * @param {string} key
156
+ * @param {comq.channels.Consumer} callback
157
+ * @returns {Promise<void>}
158
+ */
159
+ async (exchange, queue, key, callback) => {
160
+ if (this.#sealed) return
161
+
162
+ const holding = this.#hold(exchange, queue, key, callback)
163
+
164
+ // a recovery restores the rest of the connection while a key another connection holds
165
+ // is claimed, and a claim the connection is lost under is replayed by the next recovery
166
+ if (this.#recovering) holding.catch(noop)
167
+ else await holding
168
+ })))
169
+
134
170
  send = failsafe(this, this.#recover,
135
171
  lazy(this, this.#assertQueue,
136
172
  /**
@@ -201,6 +237,15 @@ class Channel {
201
237
  async seal () {
202
238
  this.#sealed = true
203
239
 
240
+ // a key is withdrawn before its consumer goes: a Request published from here on is
241
+ // returned to its caller, and one published before it is delivered and answered
242
+ const unbindings = Array.from(this.#held,
243
+ ([queue, [exchange, key]]) => this.#channel.unbindQueue(queue, exchange, key))
244
+
245
+ await Promise.allSettled(unbindings)
246
+
247
+ this.#held.clear()
248
+
204
249
  const cancellations = this.#tags.map((tag) => this.#channel.cancel(tag))
205
250
 
206
251
  await Promise.all(cancellations).catch(noop) // won't recover anyway
@@ -249,6 +294,84 @@ class Channel {
249
294
  this.#diagnostics.emit('recover')
250
295
  }
251
296
 
297
+ /**
298
+ * @param {string} exchange
299
+ * @param {string} queue
300
+ * @param {string} key
301
+ * @param {comq.channels.Consumer} callback
302
+ * @returns {Promise<void>}
303
+ */
304
+ async #hold (exchange, queue, key, callback) {
305
+ const claimed = await this.#claim(queue)
306
+
307
+ if (!claimed) return
308
+
309
+ await this.#assertQueue(queue, EXCLUSIVE)
310
+ await this.#channel.bindQueue(queue, exchange, key)
311
+
312
+ this.#held.set(queue, [exchange, key])
313
+
314
+ if (!this.#sealed) await this.#consume(queue, callback)
315
+ }
316
+
317
+ /**
318
+ * Claims a queue exclusive to this connection, again and again while another connection
319
+ * holds it. The broker announces nothing when a queue is let go, so it is asked again, with
320
+ * the backoff a lost connection is restored with.
321
+ *
322
+ * @param {string} queue
323
+ * @returns {Promise<boolean>} whether the queue is claimed, as opposed to sealed while claiming
324
+ */
325
+ async #claim (queue) {
326
+ const connection = this.#connection
327
+
328
+ return await retry(async (again) => {
329
+ if (this.#sealed) return false
330
+
331
+ // a connection that is gone is recovered from, like any other interruption
332
+ if (connection !== this.#connection) throw INTERRUPTION
333
+
334
+ if (await this.#declare(connection, queue)) return true
335
+
336
+ this.#diagnostics.emit('taken', queue)
337
+
338
+ return again
339
+ }, global.COMQ_TESTING_CLAIM_BACKOFF)
340
+ }
341
+
342
+ /**
343
+ * Declares a queue exclusive to this connection, and says whether another connection holds it.
344
+ *
345
+ * The broker refuses such a queue by closing the channel that asked for it, and a channel error
346
+ * nobody listens for closes the whole connection. So the declaration is made on a channel of its
347
+ * own, which is all that closes. The queue belongs to the connection, and outlives that channel.
348
+ *
349
+ * @param {comq.amqp.Connection} connection
350
+ * @param {string} queue
351
+ * @returns {Promise<boolean>}
352
+ */
353
+ async #declare (connection, queue) {
354
+ const probe = await connection.createChannel().catch(() => { throw INTERRUPTION })
355
+
356
+ /** @type {Error & { code?: number } | undefined} */
357
+ let refusal
358
+
359
+ probe.on('error', (error) => { refusal = error })
360
+
361
+ try {
362
+ await probe.assertQueue(queue, EXCLUSIVE)
363
+ } catch (exception) {
364
+ if (refusal?.code === RESOURCE_LOCKED) return false
365
+
366
+ // what the broker refused with says more than the channel it closed
367
+ throw refusal ?? exception
368
+ }
369
+
370
+ await probe.close().catch(noop)
371
+
372
+ return true
373
+ }
374
+
252
375
  // region initializers
253
376
 
254
377
  /**
@@ -648,6 +771,9 @@ const EXCLUSIVE = { exclusive: true }
648
771
 
649
772
  const INTERRUPTION = /** @type {Error} */ Symbol('internal interruption')
650
773
 
774
+ // the AMQP reply code a broker closes a channel with when another connection holds the queue
775
+ const RESOURCE_LOCKED = 405
776
+
651
777
  const RETRY_PREFIX = 'comq.retry.'
652
778
  const PARKED_PREFIX = 'comq.parked.'
653
779
 
@@ -672,3 +798,4 @@ const PARKED_AT_HEADER = 'x-comq-at'
672
798
  function noop () {}
673
799
 
674
800
  exports.create = create
801
+ exports.RETRY_PREFIX = RETRY_PREFIX
@@ -371,6 +371,8 @@ const SHUTDOWN_MS = 5_000
371
371
 
372
372
  const SOCKET_OPTIONS = {
373
373
  timeout: CONNECT_MS,
374
+ // a message is written once and is small, which is what Nagle's algorithm holds back
375
+ noDelay: true,
374
376
  // a peer that went away without a word is noticed by the kernel as well
375
377
  keepAlive: true,
376
378
  keepAliveDelay: KEEPALIVE_MS
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', 'retry', 'pause', 'resume', 'return', 'lost', 'remove']
7
+ exports.channel = ['flow', 'drain', 'recover', 'discard', 'retry', 'pause', 'resume', 'return', 'lost', 'remove', 'taken']