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.
- 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/silence.feature +3 -2
- package/features/steps/call.js +462 -0
- package/features/steps/context.js +4 -0
- package/features/steps/network.js +26 -0
- package/features/steps/networks.js +40 -4
- package/features/steps/parked.js +102 -0
- package/package.json +1 -1
- package/readme.md +141 -31
- package/source/attributes/recall.js +2 -1
- package/source/channel.js +128 -1
- package/source/connection.js +2 -0
- package/source/events.js +1 -1
- package/source/index.js +2 -0
- package/source/io.js +212 -13
- package/source/shards/channel.js +20 -3
- package/source/unroutable.js +27 -0
- package/test/amqplib.mock.js +1 -0
- package/test/channel.held.test.js +237 -0
- package/test/connection.mock.js +1 -0
- package/test/connection.test.js +7 -0
- package/test/io.call.test.js +161 -0
- package/test/io.request.terms.test.js +247 -0
- package/test/shards/channel.test.js +68 -0
- package/types/channel.d.ts +3 -0
- package/types/diagnostic.d.ts +1 -1
- package/types/index.d.ts +9 -0
- package/types/io.d.ts +42 -2
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
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,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
|
|
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
|
|
@@ -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
|
|
570
|
-
> 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
|
|
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.
|
|
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.
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
package/source/connection.js
CHANGED
|
@@ -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']
|