comq 0.22.0 → 0.24.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.
Files changed (42) hide show
  1. package/discussions/amqp-1.0.md +127 -0
  2. package/discussions/one-parking-queue.md +63 -0
  3. package/discussions/one-reply-queue.md +44 -0
  4. package/discussions/poison-message-handling.md +2 -1
  5. package/docs/headers.md +1 -1
  6. package/features/parking.feature +32 -0
  7. package/features/rpc.call.feature +23 -1
  8. package/features/rpc.call.recovery.feature +0 -21
  9. package/features/rpc.parked.feature +6 -6
  10. package/features/rpc.replies.feature +26 -0
  11. package/features/steps/brokers.js +25 -1
  12. package/features/steps/call.js +90 -30
  13. package/features/steps/events.js +1 -1
  14. package/features/steps/parked.js +7 -18
  15. package/features/steps/parking.js +113 -0
  16. package/features/steps/poison.js +17 -19
  17. package/features/steps/replies.js +35 -0
  18. package/package.json +6 -4
  19. package/readme.md +54 -75
  20. package/source/.io/ReplyPipe.js +16 -1
  21. package/source/channel.js +19 -27
  22. package/source/connection.js +1 -4
  23. package/source/index.js +0 -2
  24. package/source/io.js +48 -59
  25. package/test/ReplyPipe.test.js +40 -0
  26. package/test/amqplib.mock.js +1 -1
  27. package/test/channel.test.js +29 -18
  28. package/test/connection.test.js +1 -1
  29. package/test/{io.request.terms.test.js → io.call.terms.test.js} +31 -30
  30. package/test/io.call.test.js +0 -10
  31. package/test/io.request.test.js +26 -3
  32. package/test/memory.test.js +1 -1
  33. package/types/amqp.d.ts +1 -1
  34. package/types/channel.d.ts +1 -1
  35. package/types/index.d.ts +0 -6
  36. package/types/io.d.ts +9 -14
  37. package/features/closing.feature +0 -32
  38. package/features/rpc.timeout.feature +0 -19
  39. package/features/steps/closing.js +0 -109
  40. package/source/abandoned.js +0 -15
  41. package/source/batch.js +0 -62
  42. package/test/batch.test.js +0 -94
@@ -41,7 +41,7 @@ let replies
41
41
 
42
42
  let promise
43
43
 
44
- const REPLY = new RegExp(`^${queue}..[0-9a-f]+$`)
44
+ const REPLY = /^comq\.reply\.\.[0-9a-f]+$/
45
45
 
46
46
  beforeEach(async () => {
47
47
  promise = io.request(queue, payload)
@@ -62,6 +62,17 @@ it('should consume replies queue', async () => {
62
62
  expect(replies.consume).toHaveBeenCalledWith(expect.stringMatching(REPLY), expect.any(Function))
63
63
  })
64
64
 
65
+ it('should consume one replies queue however many queues are requested', async () => {
66
+ // a reply is found by its correlation identifier, so a queue per target would
67
+ // name what the identifier already tells apart
68
+ io.request(generate(), payload)
69
+ io.request(generate(), payload)
70
+
71
+ await immediate()
72
+
73
+ expect(replies.consume).toHaveBeenCalledTimes(1)
74
+ })
75
+
65
76
  describe('send', () => {
66
77
  let call
67
78
 
@@ -84,9 +95,8 @@ describe('send', () => {
84
95
 
85
96
  it('should set replyTo', async () => {
86
97
  const properties = call[2]
87
- const rx = new RegExp(`^${queue}..[a-z0-9]+`)
88
98
 
89
- expect(properties.replyTo).toMatch(rx)
99
+ expect(properties.replyTo).toMatch(REPLY)
90
100
  })
91
101
 
92
102
  it('should consume replyTo', async () => {
@@ -105,6 +115,19 @@ describe('send', () => {
105
115
  expect(call[2]).toMatchObject({ contentType })
106
116
  })
107
117
 
118
+ it('should not expire', async () => {
119
+ expect(call[2].expiration).toBeUndefined()
120
+ })
121
+
122
+ it('should refuse options other than the encoding', async () => {
123
+ requests.send.mockClear()
124
+
125
+ await expect(io.request(queue, payload, { timeout: 1000 })).rejects.toBeInstanceOf(TypeError)
126
+ await expect(io.request(queue, payload, { signal: new AbortController().signal })).rejects.toBeInstanceOf(TypeError)
127
+
128
+ expect(requests.send).not.toHaveBeenCalled()
129
+ })
130
+
108
131
  it('should throw if encoding is not supported', async () => {
109
132
  const encoding = /** @type {comq.Encoding} */ 'wtf/' + generate()
110
133
 
@@ -12,7 +12,7 @@ const { timeout } = require('./helpers')
12
12
  const mock = require('./amqplib.mock')
13
13
  const { amqplib, connect } = mock
14
14
 
15
- jest.mock('amqplib', () => mock.amqplib)
15
+ jest.mock('@toa.io/amqplib', () => mock.amqplib)
16
16
 
17
17
  const { connect: open, assert } = require('../source')
18
18
  const { SingletonConnection } = require('../source/singleton')
package/types/amqp.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import * as amqplib from 'amqplib'
1
+ import * as amqplib from '@toa.io/amqplib'
2
2
 
3
3
  declare namespace comq.amqp {
4
4
 
@@ -1,4 +1,4 @@
1
- import { Message, Options, Connection } from 'amqplib'
1
+ import { Message, Options, Connection } from '@toa.io/amqplib'
2
2
  import * as _diagnostics from './diagnostic'
3
3
 
4
4
  declare namespace comq {
package/types/index.d.ts CHANGED
@@ -38,9 +38,3 @@ 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
@@ -37,19 +37,19 @@ declare namespace comq {
37
37
  signal?: AbortSignal
38
38
  }
39
39
 
40
- /** How long a caller waits for a Reply. */
41
- interface RequestOptions {
40
+ /** How long a caller waits for the Reply to a call. */
41
+ interface CallOptions {
42
42
  encoding?: _encoding.Encoding
43
43
 
44
44
  /**
45
- * Milliseconds to wait for the Reply. A Request nobody has taken by then is dropped by the
45
+ * Milliseconds to wait for the Reply. A call nobody has taken by then is dropped by the
46
46
  * broker; one already taken may still be processed, and its Reply is discarded.
47
47
  */
48
48
  timeout?: number
49
49
 
50
50
  /**
51
- * Ends the wait when aborted, within the timeout. The Request stays in its queue until the
52
- * timeout passes, or, without one, until a Producer takes it.
51
+ * Ends the wait when aborted, within the timeout. The call stays in its queue until the
52
+ * timeout passes, or, without one, until its holder takes it.
53
53
  */
54
54
  signal?: AbortSignal
55
55
  }
@@ -64,15 +64,16 @@ declare namespace comq {
64
64
  interface IO extends _diagnostics.Diagnosable {
65
65
  reply(queue: string, produce: Producer): Promise<void>
66
66
 
67
- request<Reply = any, Request = any>(queue: string, payload: Request, options?: _encoding.Encoding | RequestOptions): Promise<Reply> | Promise<Readable>
67
+ /** Waits for the Reply for as long as it takes. */
68
+ request<Reply = any, Request = any>(queue: string, payload: Request, encoding?: _encoding.Encoding): Promise<Reply> | Promise<Readable>
68
69
 
69
- request(queue: string, stream: Readable, options?: _encoding.Encoding | RequestOptions): Promise<Readable>
70
+ request(queue: string, stream: Readable, encoding?: _encoding.Encoding): Promise<Readable>
70
71
 
71
72
  /**
72
73
  * Sends a Request to whoever holds `key` on the routed `exchange`, through `back`. One that
73
74
  * nobody holds is refused with `Unroutable`.
74
75
  */
75
- call<Reply = any, Request = any>(exchange: string, key: string, payload: Request, options?: _encoding.Encoding | RequestOptions): Promise<Reply> | Promise<Readable>
76
+ call<Reply = any, Request = any>(exchange: string, key: string, payload: Request, options?: _encoding.Encoding | CallOptions): Promise<Reply> | Promise<Readable>
76
77
 
77
78
  /**
78
79
  * Answers the Requests `call` sends under `key`, from a queue this connection alone holds.
@@ -119,12 +120,6 @@ declare namespace comq {
119
120
 
120
121
  seal(): Promise<void>
121
122
 
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
-
128
123
  close(): Promise<void>
129
124
 
130
125
  diagnose(event: 'open', listener: (index?: number) => void): void
@@ -1,32 +0,0 @@
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
@@ -1,19 +0,0 @@
1
- Feature: Request timeout
2
-
3
- A caller may say how long it waits for a reply. A request nobody took by then is dropped by the
4
- broker; one already taken is still processed, and its reply is discarded.
5
-
6
- Background:
7
- Given an active connection to the broker
8
-
9
- Scenario: A caller stops waiting at its timeout
10
- Given a producer replying `slow` queue in 1000ms
11
- When the consumer sends a request to the `slow` queue with a 200ms timeout
12
- Then the consumer stops waiting
13
-
14
- Scenario: A request nobody took in time is never processed
15
- When the consumer sends a request to the `later` queue with a 200ms timeout
16
- Then the consumer stops waiting
17
- When after 300ms
18
- And a producer counting requests to the `later` queue
19
- Then the producer receives nothing
@@ -1,109 +0,0 @@
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
- })
@@ -1,15 +0,0 @@
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 DELETED
@@ -1,62 +0,0 @@
1
- 'use strict'
2
-
3
- /**
4
- * Writes as one what a connection sends in one turn of the event loop.
5
- *
6
- * amqplib hands its frames to the socket one write at a time, and a request served is two of
7
- * them: the reply, and the acknowledgement of the request it answers. With Nagle's algorithm off,
8
- * which is what a socket sending without delay runs under, each is a syscall and a packet of its
9
- * own. Corked, they are held in the socket's buffer and leave together, in one `writev`, at the
10
- * end of the same turn: nothing waits for a timer, and nothing waits for a write that may never
11
- * come.
12
- *
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
- *
17
- * @param {import('node:net').Socket} socket
18
- */
19
- function batch (socket) {
20
- // whatever a connection is carried over is left as it is unless it is a stream that corks
21
- if (!corkable(socket) || socket[BATCHED] === true) return
22
-
23
- socket[BATCHED] = true
24
-
25
- const write = socket.write.bind(socket)
26
-
27
- const flush = () => {
28
- socket[CORKED] = false
29
- socket.uncork()
30
- }
31
-
32
- const cork = () => {
33
- if (socket[CORKED] === true) return
34
-
35
- socket[CORKED] = true
36
- socket.cork()
37
-
38
- process.nextTick(flush)
39
- }
40
-
41
- socket.write = (...args) => {
42
- cork()
43
-
44
- return write(...args)
45
- }
46
- }
47
-
48
- /**
49
- * @param {any} socket
50
- * @returns {boolean}
51
- */
52
- function corkable (socket) {
53
- return socket !== null && socket !== undefined &&
54
- typeof socket.write === 'function' &&
55
- typeof socket.cork === 'function' &&
56
- typeof socket.uncork === 'function'
57
- }
58
-
59
- const BATCHED = Symbol('comq.batched')
60
- const CORKED = Symbol('comq.corked')
61
-
62
- exports.batch = batch
@@ -1,94 +0,0 @@
1
- 'use strict'
2
-
3
- const { Writable } = require('node:stream')
4
-
5
- const { batch } = require('../source/batch')
6
-
7
- /** A socket, as far as writing to it goes: what it was given, and how many times. */
8
- function socket () {
9
- const writes = []
10
-
11
- return Object.assign(new Writable({
12
- write (chunk, _encoding, callback) {
13
- writes.push([chunk.toString()])
14
- callback()
15
- },
16
- writev (chunks, callback) {
17
- writes.push(chunks.map(({ chunk }) => chunk.toString()))
18
- callback()
19
- }
20
- }), { writes })
21
- }
22
-
23
- const tick = () => new Promise((resolve) => process.nextTick(resolve))
24
-
25
- it('should be', async () => {
26
- expect(batch).toBeDefined()
27
- })
28
-
29
- it('should write what a turn wrote as one', async () => {
30
- const stream = socket()
31
-
32
- batch(stream)
33
-
34
- stream.write('a')
35
- stream.write('b')
36
-
37
- await tick()
38
-
39
- expect(stream.writes).toStrictEqual([['a', 'b']])
40
- })
41
-
42
- it('should write what a later turn wrote on its own', async () => {
43
- const stream = socket()
44
-
45
- batch(stream)
46
-
47
- stream.write('a')
48
-
49
- await tick()
50
-
51
- stream.write('b')
52
-
53
- await tick()
54
-
55
- expect(stream.writes).toStrictEqual([['a'], ['b']])
56
- })
57
-
58
- it('should not hold a write past its turn', async () => {
59
- const stream = socket()
60
-
61
- batch(stream)
62
-
63
- stream.write('a')
64
-
65
- await tick()
66
-
67
- expect(stream.writes).toStrictEqual([['a']])
68
- })
69
-
70
- it('should take a socket once', async () => {
71
- const stream = socket()
72
-
73
- batch(stream)
74
-
75
- const wrapped = stream.write
76
-
77
- batch(stream)
78
-
79
- expect(stream.write).toStrictEqual(wrapped)
80
- })
81
-
82
- it('should leave alone what does not cork', async () => {
83
- for (const value of [undefined, null, {}, { write: () => undefined }]) {
84
- expect(() => batch(value)).not.toThrow()
85
- }
86
-
87
- const written = []
88
- const plain = { write: (chunk) => written.push(chunk) }
89
-
90
- batch(plain)
91
- plain.write('a')
92
-
93
- expect(written).toStrictEqual(['a'])
94
- })