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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
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 loop does the writing, and is proposed there:
14
- * https://github.com/temich/amqplib/tree/perf/one-write-per-turn. It is done here until that
15
- * lands, and goes when it does.
13
+ * This belongs in amqplib, whose `Mux` writes the frames one at a time, and where corking that
14
+ * loop is six lines and needs no wrapping of a socket the library does not own. It is done here
15
+ * until amqplib carries it, and goes when it does.
16
16
  *
17
17
  * @param {import('node:net').Socket} socket
18
18
  */
package/source/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