comq 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/discussions/poison-message-handling.md +14 -10
- package/docs/headers.md +1 -0
- package/features/events.poison.feature +11 -0
- package/features/rpc.call.feature +71 -0
- package/features/rpc.call.recovery.feature +75 -0
- package/features/rpc.call.shards.feature +24 -0
- package/features/rpc.parked.feature +20 -0
- package/features/rpc.timeout.feature +19 -0
- package/features/steps/call.js +462 -0
- package/features/steps/context.js +4 -0
- package/features/steps/events.js +15 -0
- package/features/steps/network.js +8 -0
- package/features/steps/networks.js +24 -4
- package/features/steps/parked.js +102 -0
- package/features/steps/poison.js +3 -2
- package/package.json +1 -1
- package/readme.md +176 -31
- package/source/attributes/recall.js +2 -1
- package/source/channel.js +138 -3
- package/source/events.js +1 -1
- package/source/index.js +6 -0
- package/source/io.js +239 -14
- package/source/shards/channel.js +20 -3
- package/source/unroutable.js +27 -0
- package/source/verdicts.js +54 -0
- package/test/amqplib.mock.js +1 -0
- package/test/channel.held.test.js +237 -0
- package/test/channel.test.js +104 -0
- package/test/connection.mock.js +1 -0
- package/test/io.call.test.js +161 -0
- package/test/io.reply.test.js +66 -0
- package/test/io.request.terms.test.js +247 -0
- package/test/shards/channel.test.js +68 -0
- package/test/verdicts.test.js +57 -0
- package/types/channel.d.ts +3 -0
- package/types/diagnostic.d.ts +1 -1
- package/types/index.d.ts +26 -0
- package/types/io.d.ts +42 -2
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,
|
|
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,13 +583,48 @@ 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
|
|
512
|
-
|
|
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
|
|
516
591
|
queues.
|
|
517
592
|
|
|
593
|
+
#### Saying how it failed
|
|
594
|
+
|
|
595
|
+
A consumer can classify its failure, which decides whether the message is worth another
|
|
596
|
+
attempt at all:
|
|
597
|
+
|
|
598
|
+
```javascript
|
|
599
|
+
const { Retry, Park } = require('comq')
|
|
600
|
+
|
|
601
|
+
await io.consume('orders', 'billing', async (order) => {
|
|
602
|
+
if (typeof order.total !== 'number') throw new Park('no total on order')
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
await billing.charge(order)
|
|
606
|
+
} catch (e) {
|
|
607
|
+
throw new Retry('billing unavailable', { cause: e })
|
|
608
|
+
}
|
|
609
|
+
})
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
- `Park` says this consumer will never process this message, however many times it is handed
|
|
613
|
+
over. It is [parked](#parked-messages) at once, without climbing the ladder.
|
|
614
|
+
- `Retry` says whatever was missing may be back shortly. This is what an unclassified rejection
|
|
615
|
+
already means, so throwing it changes nothing but the reader's certainty.
|
|
616
|
+
|
|
617
|
+
**A bare rejection means `Retry`** — a failure nobody classified is a failure nobody chose — so
|
|
618
|
+
consumers written before this behave exactly as they did.
|
|
619
|
+
|
|
620
|
+
Both are `Error`s and both take a `cause`, which is recorded on the parked message alongside the
|
|
621
|
+
verdict's own message.
|
|
622
|
+
|
|
623
|
+
> **`Park` is not applicable to a `Producer` given to [`IO.reply`](#reply).** A Request
|
|
624
|
+
> has a caller awaiting a reply, so what happens to a failed one is not a policy choice. A `Park`
|
|
625
|
+
> thrown there is treated as an ordinary failure — retried, then parked on the count — and the
|
|
626
|
+
> parked message says so as its reason.
|
|
627
|
+
|
|
518
628
|
#### Parked messages
|
|
519
629
|
|
|
520
630
|
A message that has run out of attempts is published to `comq.parked.<queue>` and acknowledged
|
|
@@ -531,11 +641,12 @@ Parked queues grow until somebody drains them, which is deliberate — the alter
|
|
|
531
641
|
evidence. Alert on [`discard`](#diagnostics), and do not delete `comq.retry.*` or `comq.parked.*`
|
|
532
642
|
queues on a running system.
|
|
533
643
|
|
|
534
|
-
> **A parked Request is never answered.** A Consumer awaiting its Reply waits
|
|
535
|
-
> with a limited prefetch that can deadlock
|
|
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
|
|
536
647
|
> it — and it keeps `replyTo` and `correlationId`, so a Reply can still be produced from it by
|
|
537
648
|
> hand while the caller is alive — but comq itself sends no Reply and reports no error to the
|
|
538
|
-
> caller.
|
|
649
|
+
> caller.
|
|
539
650
|
|
|
540
651
|
#### What is guaranteed
|
|
541
652
|
|
|
@@ -550,11 +661,21 @@ A retried message re-enters its queue behind the messages published while it wai
|
|
|
550
661
|
Retries and parked messages are published *persistent* whatever the channel is, so they survive a
|
|
551
662
|
restart of the broker even on the Request channel. Ordinary publishing is untouched: Requests and
|
|
552
663
|
Replies stay [delivery mode 1](#messages), and only a message that has already failed is written
|
|
553
|
-
to disk.
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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.
|
|
558
679
|
|
|
559
680
|
See:
|
|
560
681
|
|
|
@@ -570,6 +691,25 @@ See:
|
|
|
570
691
|
| Reply | unlimited | no | exclusive | automatic | no | — |
|
|
571
692
|
| Event | limited | yes | durable | manual | yes | 1s, 10s, 30s, 90s |
|
|
572
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
|
+
|
|
573
713
|
## Graceful shutdown
|
|
574
714
|
|
|
575
715
|
### Sealing
|
|
@@ -580,6 +720,9 @@ See:
|
|
|
580
720
|
Requests.
|
|
581
721
|
Sending Requests, receiving Replies, and emitting Events will still be available.
|
|
582
722
|
|
|
723
|
+
Keys held by [`back`](#addressed-requests) are withdrawn first, so a call published from then on is
|
|
724
|
+
refused.
|
|
725
|
+
|
|
583
726
|
### Disconnection
|
|
584
727
|
|
|
585
728
|
`async IO.close(): void`
|
|
@@ -650,6 +793,8 @@ Subscribe to one of the diagnostic events:
|
|
|
650
793
|
[amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_publish) are
|
|
651
794
|
passed as arguments. In the case of a [sharded connection](#sharded-connection), the message is
|
|
652
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.
|
|
653
798
|
- `pause`: channel is paused. Channel type is passed.
|
|
654
799
|
In the case of a [sharded connection](#sharded-connection), it means that there is no shard left
|
|
655
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
|
-
|
|
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,7 +1,9 @@
|
|
|
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')
|
|
6
|
+
const { verdictOf, PARK } = require('./verdicts')
|
|
5
7
|
const emitter = require('./emitter')
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -33,6 +35,13 @@ class Channel {
|
|
|
33
35
|
*/
|
|
34
36
|
#queues = new Map()
|
|
35
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
|
+
|
|
36
45
|
/** @type {Promex | null} */
|
|
37
46
|
#paused = null
|
|
38
47
|
|
|
@@ -79,8 +88,9 @@ class Channel {
|
|
|
79
88
|
// the consumers of the previous channel went down with it, their tags mean nothing here
|
|
80
89
|
this.#tags = []
|
|
81
90
|
|
|
82
|
-
// a fresh channel has declared nothing
|
|
91
|
+
// a fresh channel has declared nothing, and bound nothing
|
|
83
92
|
this.#queues.clear()
|
|
93
|
+
this.#held.clear()
|
|
84
94
|
|
|
85
95
|
await this.#channel.prefetch(this.#topology.prefetch)
|
|
86
96
|
|
|
@@ -130,6 +140,33 @@ class Channel {
|
|
|
130
140
|
if (!this.#sealed) await this.#consume(queue, callback)
|
|
131
141
|
})))
|
|
132
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
|
+
|
|
133
170
|
send = failsafe(this, this.#recover,
|
|
134
171
|
lazy(this, this.#assertQueue,
|
|
135
172
|
/**
|
|
@@ -200,6 +237,15 @@ class Channel {
|
|
|
200
237
|
async seal () {
|
|
201
238
|
this.#sealed = true
|
|
202
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
|
+
|
|
203
249
|
const cancellations = this.#tags.map((tag) => this.#channel.cancel(tag))
|
|
204
250
|
|
|
205
251
|
await Promise.all(cancellations).catch(noop) // won't recover anyway
|
|
@@ -248,6 +294,84 @@ class Channel {
|
|
|
248
294
|
this.#diagnostics.emit('recover')
|
|
249
295
|
}
|
|
250
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
|
+
|
|
251
375
|
// region initializers
|
|
252
376
|
|
|
253
377
|
/**
|
|
@@ -490,8 +614,11 @@ class Channel {
|
|
|
490
614
|
// the header is which attempt this delivery is, and the first does not carry one
|
|
491
615
|
const attempt = message.properties.headers?.[ATTEMPT_HEADER] ?? 1
|
|
492
616
|
|
|
493
|
-
//
|
|
494
|
-
|
|
617
|
+
// a consumer that says the message will never work is taken at its word; otherwise
|
|
618
|
+
// one rung per retry, and a message that has climbed the ladder has nowhere left to wait
|
|
619
|
+
const parking = verdictOf(exception) === PARK || attempt > this.#delays.length
|
|
620
|
+
|
|
621
|
+
if (parking) await this.#park(queue, message, exception)
|
|
495
622
|
else await this.#retry(queue, message, attempt, exception)
|
|
496
623
|
} catch {
|
|
497
624
|
// the message could not be moved: give the delivery back rather than lose it
|
|
@@ -527,6 +654,9 @@ class Channel {
|
|
|
527
654
|
const properties = this.#carry(message, {
|
|
528
655
|
[PARKED_QUEUE_HEADER]: queue,
|
|
529
656
|
[PARKED_REASON_HEADER]: exception?.message,
|
|
657
|
+
// a verdict is usually thrown with the exception that prompted it, and that one
|
|
658
|
+
// says what actually went wrong
|
|
659
|
+
[PARKED_CAUSE_HEADER]: exception?.cause?.message,
|
|
530
660
|
[PARKED_AT_HEADER]: Date.now()
|
|
531
661
|
})
|
|
532
662
|
|
|
@@ -641,6 +771,9 @@ const EXCLUSIVE = { exclusive: true }
|
|
|
641
771
|
|
|
642
772
|
const INTERRUPTION = /** @type {Error} */ Symbol('internal interruption')
|
|
643
773
|
|
|
774
|
+
// the AMQP reply code a broker closes a channel with when another connection holds the queue
|
|
775
|
+
const RESOURCE_LOCKED = 405
|
|
776
|
+
|
|
644
777
|
const RETRY_PREFIX = 'comq.retry.'
|
|
645
778
|
const PARKED_PREFIX = 'comq.parked.'
|
|
646
779
|
|
|
@@ -659,8 +792,10 @@ const ORIGIN_EXCHANGE_HEADER = 'x-comq-exchange'
|
|
|
659
792
|
const ORIGIN_KEY_HEADER = 'x-comq-key'
|
|
660
793
|
const PARKED_QUEUE_HEADER = 'x-comq-queue'
|
|
661
794
|
const PARKED_REASON_HEADER = 'x-comq-reason'
|
|
795
|
+
const PARKED_CAUSE_HEADER = 'x-comq-cause'
|
|
662
796
|
const PARKED_AT_HEADER = 'x-comq-at'
|
|
663
797
|
|
|
664
798
|
function noop () {}
|
|
665
799
|
|
|
666
800
|
exports.create = create
|
|
801
|
+
exports.RETRY_PREFIX = RETRY_PREFIX
|
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']
|
package/source/index.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { connect, assert } = require('./connect')
|
|
4
|
+
const { Retry, Park } = require('./verdicts')
|
|
5
|
+
const { Unroutable } = require('./unroutable')
|
|
4
6
|
|
|
5
7
|
exports.connect = connect
|
|
6
8
|
exports.assert = assert
|
|
9
|
+
|
|
10
|
+
exports.Retry = Retry
|
|
11
|
+
exports.Park = Park
|
|
12
|
+
exports.Unroutable = Unroutable
|