comq 0.21.0 → 0.22.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/features/closing.feature +32 -0
- package/features/steps/closing.js +109 -0
- package/package.json +1 -1
- package/readme.md +25 -0
- package/source/abandoned.js +15 -0
- package/source/batch.js +3 -3
- package/source/index.js +2 -0
- package/source/io.js +31 -0
- package/types/index.d.ts +6 -0
- package/types/io.d.ts +6 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Feature: Abandoning the Replies a close is waiting for
|
|
2
|
+
|
|
3
|
+
A consumer callback may call out while it runs, and a close waits for every callback to return.
|
|
4
|
+
Where the answer that callback waits for is the one thing going away with the close, the two
|
|
5
|
+
wait for each other. Abandoning ends that wait.
|
|
6
|
+
|
|
7
|
+
Background:
|
|
8
|
+
Given an active connection to the broker
|
|
9
|
+
|
|
10
|
+
Scenario: Abandoning lets a close finish
|
|
11
|
+
Given a producer replying `upstream` queue by requesting the `downstream` queue
|
|
12
|
+
When the consumer sends a request to the `upstream` queue
|
|
13
|
+
And the producer is handling the request
|
|
14
|
+
And the connection abandons its replies
|
|
15
|
+
Then the connection closes within 1000ms
|
|
16
|
+
And the producer was told its reply is abandoned
|
|
17
|
+
And the consumer was told its own reply is abandoned
|
|
18
|
+
|
|
19
|
+
Scenario: A close already under way is told to stop waiting
|
|
20
|
+
Given a producer replying `upstream` queue by requesting the `downstream` queue
|
|
21
|
+
When the consumer sends a request to the `upstream` queue
|
|
22
|
+
And the producer is handling the request
|
|
23
|
+
And the connection has started closing
|
|
24
|
+
And the connection abandons its replies
|
|
25
|
+
Then the connection closes within 1000ms
|
|
26
|
+
|
|
27
|
+
Scenario: A Request made once the Replies are abandoned is refused
|
|
28
|
+
Given a producer replying `upstream` queue by requesting the `downstream` queue
|
|
29
|
+
When the consumer sends a request to the `upstream` queue
|
|
30
|
+
And the producer is handling the request
|
|
31
|
+
And the connection abandons its replies
|
|
32
|
+
Then a request to the `downstream` queue is refused as abandoned
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert')
|
|
4
|
+
const { Given, When, Then } = require('@cucumber/cucumber')
|
|
5
|
+
const { Abandoned } = require('../../')
|
|
6
|
+
const { timeout } = require('../../test/helpers')
|
|
7
|
+
|
|
8
|
+
Given('a producer replying {token} queue by requesting the {token} queue',
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} queue
|
|
11
|
+
* @param {string} downstream
|
|
12
|
+
* @this {comq.features.Context}
|
|
13
|
+
*/
|
|
14
|
+
async function (queue, downstream) {
|
|
15
|
+
this.handling = new Promise((resolve) => { this.handled = resolve })
|
|
16
|
+
|
|
17
|
+
const producer = async (request) => {
|
|
18
|
+
const reply = this.io.request(downstream, request)
|
|
19
|
+
|
|
20
|
+
this.handled()
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
return await reply
|
|
24
|
+
} catch (error) {
|
|
25
|
+
// recorded rather than rethrown: what this asserts is what the caller was told,
|
|
26
|
+
// and a rejected producer would have the delivery retried on top of it
|
|
27
|
+
this.abandoned = error
|
|
28
|
+
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await this.io.reply(queue, producer)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
When('the producer is handling the request',
|
|
37
|
+
/**
|
|
38
|
+
* @this {comq.features.Context}
|
|
39
|
+
*/
|
|
40
|
+
async function () {
|
|
41
|
+
await this.handling
|
|
42
|
+
|
|
43
|
+
// the consumer's own Request is outstanding as well, and abandoning abandons that one too;
|
|
44
|
+
// its outcome is taken here so that it is asserted rather than left to surface as an
|
|
45
|
+
// unhandled rejection
|
|
46
|
+
this.reply.catch((error) => { this.refused = error })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
When('the connection has started closing',
|
|
50
|
+
/**
|
|
51
|
+
* @this {comq.features.Context}
|
|
52
|
+
*/
|
|
53
|
+
async function () {
|
|
54
|
+
this.closing = this.io.close()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
When('the connection abandons its replies',
|
|
58
|
+
/**
|
|
59
|
+
* @this {comq.features.Context}
|
|
60
|
+
*/
|
|
61
|
+
async function () {
|
|
62
|
+
await this.io.abandon()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
Then('the connection closes within {number}ms',
|
|
66
|
+
/**
|
|
67
|
+
* @param {number} ms
|
|
68
|
+
* @this {comq.features.Context}
|
|
69
|
+
*/
|
|
70
|
+
async function (ms) {
|
|
71
|
+
const io = this.io
|
|
72
|
+
|
|
73
|
+
// taken off the world so that the hook on the way out does not await a close
|
|
74
|
+
// this may have just proven does not end
|
|
75
|
+
this.io = undefined
|
|
76
|
+
this.connected = false
|
|
77
|
+
|
|
78
|
+
const closing = io.close().then(() => true, () => true)
|
|
79
|
+
const closed = await Promise.race([closing, timeout(ms).then(() => false)])
|
|
80
|
+
|
|
81
|
+
assert.ok(closed, `The connection did not close within ${ms}ms`)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
Then('the producer was told its reply is abandoned',
|
|
85
|
+
/**
|
|
86
|
+
* @this {comq.features.Context}
|
|
87
|
+
*/
|
|
88
|
+
async function () {
|
|
89
|
+
assert.ok(this.abandoned instanceof Abandoned,
|
|
90
|
+
`The producer was told ${this.abandoned ?? 'nothing'}`)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
Then('the consumer was told its own reply is abandoned',
|
|
94
|
+
/**
|
|
95
|
+
* @this {comq.features.Context}
|
|
96
|
+
*/
|
|
97
|
+
async function () {
|
|
98
|
+
assert.ok(this.refused instanceof Abandoned,
|
|
99
|
+
`The consumer was told ${this.refused ?? 'nothing'}`)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
Then('a request to the {token} queue is refused as abandoned',
|
|
103
|
+
/**
|
|
104
|
+
* @param {string} queue
|
|
105
|
+
* @this {comq.features.Context}
|
|
106
|
+
*/
|
|
107
|
+
async function (queue) {
|
|
108
|
+
await assert.rejects(() => this.io.request(queue, null), Abandoned)
|
|
109
|
+
})
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -734,6 +734,28 @@ Sending Requests, receiving Replies, and emitting Events will still be available
|
|
|
734
734
|
Keys held by [`back`](#addressed-requests) are withdrawn first, so a call published from then on is
|
|
735
735
|
refused.
|
|
736
736
|
|
|
737
|
+
### Abandoning
|
|
738
|
+
|
|
739
|
+
`async IO.abandon(): void`
|
|
740
|
+
|
|
741
|
+
Stop waiting for Replies.
|
|
742
|
+
|
|
743
|
+
A [close](#disconnection) waits for every `producer` and `consumer` call to return, and such a
|
|
744
|
+
call may itself be waiting on a Request of its own. Where the answer to that Request is the very
|
|
745
|
+
thing going away — the process it would come from is shutting down alongside this one — the close
|
|
746
|
+
waits for as long as the answer takes.
|
|
747
|
+
|
|
748
|
+
`IO.abandon()` ends that wait:
|
|
749
|
+
|
|
750
|
+
- every outstanding Reply is rejected with `Abandoned`, so a caller inside a `producer` or
|
|
751
|
+
`consumer` is told at once rather than held;
|
|
752
|
+
- a Request made from then on is refused with `Abandoned`, so a callback cannot start another
|
|
753
|
+
wait on the way out.
|
|
754
|
+
|
|
755
|
+
The Requests those Replies belonged to were sent and may well be processed; what is given up is
|
|
756
|
+
this side's interest in the answers. It may be called before a close or while one is already under
|
|
757
|
+
way, which is how a close that has begun is told to stop waiting.
|
|
758
|
+
|
|
737
759
|
### Disconnection
|
|
738
760
|
|
|
739
761
|
`async IO.close(): void`
|
|
@@ -742,6 +764,9 @@ refused.
|
|
|
742
764
|
2. Wait for any outstanding messages to be processed[^2] and acknowledged.
|
|
743
765
|
3. Close the connection.
|
|
744
766
|
|
|
767
|
+
Step 2 still runs where the Replies have been [abandoned](#abandoning): each message is processed
|
|
768
|
+
to a verdict and acknowledged, and what was given up is the waiting rather than the drain.
|
|
769
|
+
|
|
745
770
|
[^2]: Therefore, if the underlying connection is lost, `.close()` will only be completed once the
|
|
746
771
|
connection is [recovered](#connection-tolerance).
|
|
747
772
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Reply nobody is waiting for any more, or a Request made once the waiting has stopped. The
|
|
5
|
+
* Request itself was sent and may well be processed; what was given up is the answer.
|
|
6
|
+
*/
|
|
7
|
+
class Abandoned extends Error {
|
|
8
|
+
constructor () {
|
|
9
|
+
super('The connection no longer waits for a Reply')
|
|
10
|
+
|
|
11
|
+
this.name = 'Abandoned'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
exports.Abandoned = Abandoned
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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/index.js
CHANGED
|
@@ -4,6 +4,7 @@ const { connect, assert } = require('./connect')
|
|
|
4
4
|
const { Retry, Park } = require('./verdicts')
|
|
5
5
|
const { Unroutable } = require('./unroutable')
|
|
6
6
|
const { Interrupted } = require('./interrupted')
|
|
7
|
+
const { Abandoned } = require('./abandoned')
|
|
7
8
|
|
|
8
9
|
exports.connect = connect
|
|
9
10
|
exports.assert = assert
|
|
@@ -12,3 +13,4 @@ exports.Retry = Retry
|
|
|
12
13
|
exports.Park = Park
|
|
13
14
|
exports.Unroutable = Unroutable
|
|
14
15
|
exports.Interrupted = Interrupted
|
|
16
|
+
exports.Abandoned = Abandoned
|
package/source/io.js
CHANGED
|
@@ -6,6 +6,7 @@ const { Promex } = require('promex')
|
|
|
6
6
|
const { memo, failsafe, lazy, track } = require('./attributes')
|
|
7
7
|
const { verdictOf, PARK } = require('./verdicts')
|
|
8
8
|
const { Unroutable } = require('./unroutable')
|
|
9
|
+
const { Abandoned } = require('./abandoned')
|
|
9
10
|
|
|
10
11
|
const { decode } = require('./decode')
|
|
11
12
|
const { encode } = require('./encode')
|
|
@@ -39,6 +40,9 @@ class IO {
|
|
|
39
40
|
/** @type {Map<Promex, comq.Request>} */
|
|
40
41
|
#pendingReplies = new Map()
|
|
41
42
|
|
|
43
|
+
/** Whether this has stopped waiting for Replies, see `abandon`. */
|
|
44
|
+
#abandoning = false
|
|
45
|
+
|
|
42
46
|
/** @type {[comq.diagnostics.Event, Function][]} */
|
|
43
47
|
#forwarders = []
|
|
44
48
|
|
|
@@ -250,6 +254,30 @@ class IO {
|
|
|
250
254
|
await this.#destroyStreams(this.#replyStreams)
|
|
251
255
|
})
|
|
252
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Stops waiting for Replies: every one still outstanding is rejected with `Abandoned`, and a
|
|
259
|
+
* Request made from now on is refused with it.
|
|
260
|
+
*
|
|
261
|
+
* A close waits for every consumer callback to return, and such a callback may be waiting on
|
|
262
|
+
* a Request of its own. Where the answer to that Request is the very thing going away — the
|
|
263
|
+
* process it would come from is shutting down alongside this one — the close waits for as
|
|
264
|
+
* long as the answer takes. This is how that wait is ended. What the Requests themselves do
|
|
265
|
+
* is not affected: they were sent and may well be processed; what is given up is this side's
|
|
266
|
+
* interest in the answers.
|
|
267
|
+
*
|
|
268
|
+
* Callable at any time, including while a close is already under way.
|
|
269
|
+
*/
|
|
270
|
+
abandon = memo(async () => {
|
|
271
|
+
this.#abandoning = true
|
|
272
|
+
|
|
273
|
+
// the same three steps `#abandon` takes for one Reply whose caller stopped waiting
|
|
274
|
+
for (const [reply, request] of this.#pendingReplies) {
|
|
275
|
+
request.emitter.off(request.properties.correlationId)
|
|
276
|
+
this.#pendingReplies.delete(reply)
|
|
277
|
+
reply.reject(new Abandoned())
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
253
281
|
close = memo(async () => {
|
|
254
282
|
await this.seal()
|
|
255
283
|
await this.#destroyStreams(this.#replyPipes)
|
|
@@ -425,6 +453,9 @@ class IO {
|
|
|
425
453
|
|
|
426
454
|
signal?.throwIfAborted()
|
|
427
455
|
|
|
456
|
+
// one made now would be waited for by nobody: the Replies are abandoned already
|
|
457
|
+
if (this.#abandoning) throw new Abandoned()
|
|
458
|
+
|
|
428
459
|
const [buffer, contentType] = this.#encode(payload, terms.encoding)
|
|
429
460
|
const request = this.#createRequest(target, contentType, signal)
|
|
430
461
|
const properties = { ...request.properties }
|
package/types/index.d.ts
CHANGED
|
@@ -38,3 +38,9 @@ export class Unroutable extends Error {
|
|
|
38
38
|
export class Interrupted extends Error {
|
|
39
39
|
readonly reason: string
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A Reply nobody is waiting for any more, or a Request made once the waiting has stopped.
|
|
44
|
+
* See `IO.abandon`.
|
|
45
|
+
*/
|
|
46
|
+
export class Abandoned extends Error {}
|
package/types/io.d.ts
CHANGED
|
@@ -119,6 +119,12 @@ declare namespace comq {
|
|
|
119
119
|
|
|
120
120
|
seal(): Promise<void>
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Stops waiting for Replies: every one still outstanding is rejected with `Abandoned`, and a
|
|
124
|
+
* Request made from now on is refused with it. Callable while a close is already under way.
|
|
125
|
+
*/
|
|
126
|
+
abandon(): Promise<void>
|
|
127
|
+
|
|
122
128
|
close(): Promise<void>
|
|
123
129
|
|
|
124
130
|
diagnose(event: 'open', listener: (index?: number) => void): void
|