comq 0.4.0-dev.5 → 0.4.0-dev.7

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/docs/notes.md CHANGED
@@ -35,3 +35,13 @@ next Request is consumed from the Request queue*.
35
35
  A lightweight Producer (one that produces responses quickly) can result in being "overloaded"
36
36
  from the Consumer's perspective (that is, does not consume messages with an expected rate), even
37
37
  though the Producer being idle while waiting for response publication confirmations.
38
+
39
+ ## `replyTo` property prefix `direct:///`
40
+
41
+ `IO.request` accepts `replyTo` property modifier function `(replyTo): string` as fourth argument. It
42
+ is the temporary solution to provide an option for compatibility with
43
+ official [RabbitMQ client for .NET](https://github.com/rabbitmq/rabbitmq-dotnet-client/blob/feceedccd6b81d4b461795390432dc3330fdf596/projects/Unit/TestPublicationAddress.cs).
44
+
45
+ It is yet to be clarified and implemented correctly. [#6](https://github.com/toa-io/comq/issues/6)
46
+
47
+ See [Direct Reply-to](https://www.rabbitmq.com/direct-reply-to.html).
@@ -5,12 +5,17 @@ Feature: Events (pub/sub)
5
5
 
6
6
  Scenario: Sending and receiving an event
7
7
  Given that `checker` is consuming events from the `numbers_added` exchange
8
- When I emit an event to the `numbers_added` exchange
8
+ When an event is emitted to the `numbers_added` exchange
9
9
  Then `checker` receives the event
10
10
 
11
11
  Scenario: Receiving an event by two consumer groups
12
12
  Given that `first` is consuming events from the `numbers_added` exchange
13
13
  And `second` is consuming events from the `numbers_added` exchange
14
- When I emit an event to the `numbers_added` exchange
14
+ When an event is emitted to the `numbers_added` exchange
15
15
  Then `first` receives the event
16
16
  And `second` receives the event
17
+
18
+ Scenario: Receiving an event exclusively
19
+ Given that events are exclusively consumed from the `numbers_added` exchange
20
+ When an event is emitted to the `numbers_added` exchange
21
+ Then the event is received
@@ -57,8 +57,14 @@ describe('Given {token} consuming events from the {token} exchange is expected',
57
57
  it('should be', async () => undefined)
58
58
  })
59
59
 
60
- describe('When I emit an event to the {token} exchange', () => {
61
- const step = tomato.steps.Wh('I emit an event to the {token} exchange')
60
+ describe('Given that events are exclusively consumed from the {token} exchange', () => {
61
+ tomato.steps.Gi('that events are exclusively consumed from the {token} exchange')
62
+
63
+ it('should be', async () => undefined)
64
+ })
65
+
66
+ describe('When an event is emitted to the {token} exchange', () => {
67
+ const step = tomato.steps.Wh('an event is emitted to the {token} exchange')
62
68
 
63
69
  it('should be', async () => undefined)
64
70
 
@@ -121,3 +127,28 @@ describe('Then {token} receives the event', () => {
121
127
  await expect(step.call(context, group)).resolves.not.toThrow()
122
128
  })
123
129
  })
130
+
131
+ describe('Then the event is received', () => {
132
+ const step = tomato.steps.Th('the event is received')
133
+
134
+ const payload = generate()
135
+ const group = undefined
136
+
137
+ beforeEach(() => {
138
+ context.consumed = { [group]: payload }
139
+ })
140
+
141
+ it('should be', async () => undefined)
142
+
143
+ it('should throw if not consumed', async () => {
144
+ context.published = generate()
145
+
146
+ await expect(step.call(context, group)).rejects.toThrow(AssertionError)
147
+ })
148
+
149
+ it('should pass if consumed', async () => {
150
+ context.published = payload
151
+
152
+ await expect(step.call(context, group)).resolves.not.toThrow()
153
+ })
154
+ })
@@ -12,7 +12,7 @@ Given('(that ){token} is consuming events from the {token} exchange',
12
12
  * @this {comq.features.Context}
13
13
  */
14
14
  async function (group, exchange) {
15
- await consume(this, group, exchange)
15
+ await consume.call(this, group, exchange)
16
16
  })
17
17
 
18
18
  Given('{token} consuming events from the {token} exchange is expected',
@@ -24,10 +24,19 @@ Given('{token} consuming events from the {token} exchange is expected',
24
24
  async function (group, exchange) {
25
25
  await timeout(500) // let it crash
26
26
 
27
- this.expected = consume(this, group, exchange)
27
+ this.expected = consume.call(this, group, exchange)
28
28
  })
29
29
 
30
- When('I emit an event to the {token} exchange',
30
+ Given('that events are exclusively consumed from the {token} exchange',
31
+ /**
32
+ * @param {string} exchange
33
+ * @this {comq.features.Context}
34
+ */
35
+ async function (exchange) {
36
+ this.expected = consume.call(this, undefined, exchange)
37
+ })
38
+
39
+ When('an event is emitted to the {token} exchange',
31
40
  /**
32
41
  * @param {string} exchange
33
42
  * @this {comq.features.Context}
@@ -48,22 +57,38 @@ Then('{token} receives the event',
48
57
  * @this {comq.features.Context}
49
58
  */
50
59
  async function (group) {
51
- await timeout(100)
60
+ await consumed.call(this, group)
61
+ })
52
62
 
53
- assert.notEqual(this.published, undefined, 'No event has been published')
54
- assert.equal(this.published, this.consumed[group], `'${group}' haven't consumed event`)
63
+ Then('the event is received',
64
+ /**
65
+ * @this {comq.features.Context}
66
+ */
67
+ async function () {
68
+ await consumed.call(this)
55
69
  })
56
70
 
57
71
  /**
58
- * @param {comq.features.Context} context
72
+ * @this {comq.features.Context} context
59
73
  * @param {string} group
60
74
  * @param {string} exchange
61
75
  * @return {Promise<void>}
62
76
  */
63
- const consume = async (context, group, exchange) => {
64
- context.consumed ??= {}
77
+ async function consume (group, exchange) {
78
+ this.consumed ??= {}
65
79
 
66
- return context.io.consume(exchange, group, async (payload) => {
67
- context.consumed[group] = payload
80
+ return this.io.consume(exchange, group, async (payload) => {
81
+ this.consumed[group] = payload
68
82
  })
69
83
  }
84
+
85
+ /**
86
+ * @param {string} group
87
+ * @return {Promise<void>}
88
+ */
89
+ async function consumed (group) {
90
+ await timeout(100) // let it consume
91
+
92
+ assert.notEqual(this.published, undefined, 'No event has been published')
93
+ assert.equal(this.published, this.consumed[group], 'Event hasn\'t been exclusively consumed')
94
+ }
package/lerna.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "useWorkspaces": true,
3
- "version": "0.4.0-dev.0",
3
+ "version": "0.4.0-dev.7",
4
4
  "conventionalCommits": true
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.4.0-dev.5",
3
+ "version": "0.4.0-dev.7",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -37,7 +37,7 @@
37
37
  "standard": "17.0.0"
38
38
  },
39
39
  "dependencies": {
40
- "@toa.io/generic": "0.3.0",
40
+ "@toa.io/generic": "0.4.0-dev.3",
41
41
  "amqplib": "0.10.3",
42
42
  "msgpackr": "1.8.5"
43
43
  },
package/readme.md CHANGED
@@ -94,7 +94,7 @@ await io.reply('add_numbers', ({ a, b }) => (a + b))
94
94
 
95
95
  ## Request
96
96
 
97
- `async IO.request(queue: string, payload: any, [encoding: string, replyTo: string])`
97
+ `async IO.request(queue: string, payload: any, [encoding: string])`
98
98
 
99
99
  Send encoded Request message with `replyTo` and `correlationId` properties set and
100
100
  return decoded Reply content.
@@ -109,19 +109,22 @@ const sum = await io.request('add_numbers', { a: 1, b: 2 })
109
109
 
110
110
  ## Consumption
111
111
 
112
- `async IO.consume(exchange: string, group: string, consumer)`
112
+ `async IO.consume(exchange: string, [group: string], consumer)`
113
113
 
114
114
  `consumer` function's signature is `async? (payload: any): void`
115
115
 
116
116
  Start consuming decoded Events.
117
117
 
118
- Asserts fanout `exchange` (once per unique `exchange`) and queue for the Consumer `group` (once per
119
- unique `exchange` and `group` pair), and then binds the queue to the exchange. That is, one Event
120
- message is delivered to a single Consumer within *each group*.
118
+ Asserts fanout `exchange` (once per unique `exchange`) and the queue for the Consumer `group` (once
119
+ per unique `exchange` and `group` pair), and then binds the queue to the exchange. That is, one
120
+ Event message is delivered to a single Consumer within *each group*.
121
121
 
122
122
  > Typically, the value of `group` refers to the name of a microservice running in multiple
123
123
  > instances.
124
124
 
125
+ If the `group` is `undefined` or omitted, a unique value is generated and the queue is declared as
126
+ exclusive.
127
+
125
128
  ### Example
126
129
 
127
130
  ```javascript
package/source/channel.js CHANGED
@@ -126,12 +126,15 @@ class Channel {
126
126
 
127
127
  /**
128
128
  * @param {string} name
129
- * @returns {Promise<void>}
129
+ * @returns {Promise<string[]>}
130
130
  */
131
131
  async #assertQueue (name) {
132
- const options = this.#topology.durable ? DURABLE : EXCLUSIVE
132
+ const passed = arguments.length === 2 ? arguments[1] : undefined
133
+ const options = passed ?? (this.#topology.durable ? DURABLE : EXCLUSIVE)
134
+
135
+ const { queue } = await this.#channel.assertQueue(name, options)
133
136
 
134
- await this.#channel.assertQueue(name, options)
137
+ return [queue]
135
138
  }
136
139
 
137
140
  /**
@@ -149,11 +152,18 @@ class Channel {
149
152
  *
150
153
  * @param {string} exchange
151
154
  * @param {string} queue
152
- * @returns {Promise<void>}
155
+ * @returns {Promise<string[]>}
153
156
  */
154
157
  async #assertBoundQueue (exchange, queue) {
155
- await this.#assertQueue(queue)
158
+ /** @type {comq.amqp.options.Consume} */
159
+ let options
160
+
161
+ if (queue === undefined) options = { exclusive: true }
162
+
163
+ queue = (await this.#assertQueue(queue, options))[0]
156
164
  await this.#channel.bindQueue(queue, exchange, '')
165
+
166
+ return [exchange, queue]
157
167
  }
158
168
 
159
169
  // endregion
package/source/io.js CHANGED
@@ -61,16 +61,14 @@ class IO {
61
61
  * @param {string} queue
62
62
  * @param {any} payload
63
63
  * @param {comq.encoding} [encoding]
64
- * @param {string} replyTo
64
+ * @param {comq.ReplyToPropertyFormatter} replyToFormatter
65
65
  * @returns {Promise<void>}
66
66
  */
67
- async (queue, payload, encoding, replyTo) => {
67
+ async (queue, payload, encoding, replyToFormatter) => {
68
68
  const [buffer, contentType] = this.#encode(payload, encoding)
69
69
  const correlationId = randomBytes(8).toString('hex')
70
70
  const emitter = this.#emitters[queue]
71
-
72
- replyTo ??= emitter.queue
73
-
71
+ const replyTo = replyToFormatter?.(emitter.queue) ?? emitter.queue
74
72
  const properties = { contentType, correlationId, replyTo }
75
73
  const reply = this.#createReply()
76
74
 
@@ -83,7 +81,8 @@ class IO {
83
81
 
84
82
  consume = lazy(this, this.#createEventChannel,
85
83
  async (exchange, group, callback) => {
86
- const queue = io.concat(exchange, group)
84
+ const exclusive = group === undefined || callback === undefined // 2 arguments passed
85
+ const queue = exclusive ? undefined : io.concat(exchange, group)
87
86
  const consumer = this.#getEventConsumer(callback)
88
87
 
89
88
  await this.#events.subscribe(exchange, queue, consumer)
@@ -11,7 +11,7 @@ class Channel extends EventEmitter {
11
11
  cancel = jest.fn(async () => undefined)
12
12
  ack = jest.fn(() => undefined)
13
13
  nack = jest.fn(() => undefined)
14
- assertQueue = jest.fn(async () => undefined)
14
+ assertQueue = jest.fn(async (name) => ({ queue: name ?? generate() }))
15
15
  assertExchange = jest.fn(async () => undefined)
16
16
  bindQueue = jest.fn(async () => undefined)
17
17
  publish = jest.fn((_0, _1, _2, _3, resolve) => resolve?.(null))
@@ -293,9 +293,10 @@ describe('throw', () => {
293
293
  })
294
294
  })
295
295
 
296
- describe('subscribe', () => {
296
+ describe.each(['group', 'exclusive'])('%s subscribe', (option) => {
297
+ let queue = option === 'group' ? generate() : undefined
298
+
297
299
  const exchange = generate()
298
- const queue = generate()
299
300
  const consumer = /** @type {comq.channel.consumer} */ jest.fn(() => undefined)
300
301
 
301
302
  beforeEach(async () => {
@@ -319,13 +320,33 @@ describe('subscribe', () => {
319
320
  else expect(options).toMatchObject({ durable: false })
320
321
  })
321
322
 
322
- it('should assert queue', async () => {
323
- const options = topology.durable ? { durable: true } : { exclusive: true }
323
+ if (option === 'group') {
324
+ it('should assert queue', async () => {
325
+ const options = topology.durable ? { durable: true } : { exclusive: true }
324
326
 
325
- expect(chan.assertQueue).toHaveBeenCalledWith(queue, expect.objectContaining(options))
326
- })
327
+ expect(chan.assertQueue).toHaveBeenCalledWith(queue, expect.objectContaining(options))
328
+ })
329
+ } else {
330
+ it.each([true, false])('should assert exclusive queue (topology.durable: %s)', async (durable) => {
331
+ jest.clearAllMocks()
332
+
333
+ topology.durable = durable
334
+
335
+ channel = await create(connection, topology)
336
+ chan = await getCreatedChannel()
337
+
338
+ await channel.subscribe(exchange, undefined, consumer)
339
+
340
+ expect(chan.assertQueue).toHaveBeenCalledWith(
341
+ undefined,
342
+ expect.objectContaining({ exclusive: true })
343
+ )
344
+ })
345
+ }
327
346
 
328
347
  it('should bind queue to exchange', async () => {
348
+ const { queue } = await chan.assertQueue.mock.results[0].value
349
+
329
350
  expect(chan.bindQueue).toHaveBeenCalledTimes(1)
330
351
  expect(chan.bindQueue).toHaveBeenCalledWith(queue, exchange, '')
331
352
  })
@@ -345,6 +366,8 @@ describe('subscribe', () => {
345
366
 
346
367
  const options = ack ? {} : { noAck: true }
347
368
 
369
+ queue = (await chan.assertQueue.mock.results[0].value).queue
370
+
348
371
  expect(chan.consume).toHaveBeenCalledTimes(1)
349
372
  expect(chan.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.objectContaining(options))
350
373
 
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ // region setup
4
+
3
5
  const { generate } = require('randomstring')
4
6
  const { encode } = require('../source/encode')
5
7
 
@@ -21,6 +23,8 @@ beforeEach(async () => {
21
23
  io = new IO(connection)
22
24
  })
23
25
 
26
+ // endregion
27
+
24
28
  it('should be', async () => {
25
29
  expect(io.consume).toBeDefined()
26
30
  })
@@ -32,36 +36,54 @@ const consumer = jest.fn(() => undefined)
32
36
  /** @type {jest.MockedObject<comq.Channel>} */
33
37
  let events
34
38
 
35
- beforeEach(async () => {
39
+ beforeEach(() => {
36
40
  jest.clearAllMocks()
41
+ })
37
42
 
38
- await io.consume(exchange, group, consumer)
43
+ describe('group consumption', () => {
44
+ beforeEach(async () => {
45
+ await io.consume(exchange, group, consumer)
39
46
 
40
- events = await findChannel('event')
41
- })
47
+ events = await findChannel('event')
48
+ })
42
49
 
43
- it('should create events channel', async () => {
44
- expect(connection.createChannel).toHaveBeenCalledWith('event')
45
- })
50
+ it('should create events channel', async () => {
51
+ expect(connection.createChannel).toHaveBeenCalledWith('event')
52
+ })
53
+
54
+ it('should subscribe', async () => {
55
+ expect(events).toBeDefined()
56
+
57
+ const queue = exchange + '..' + group
58
+
59
+ expect(events.subscribe).toHaveBeenCalledWith(exchange, queue, expect.any(Function))
60
+ })
46
61
 
47
- it('should subscribe', async () => {
48
- expect(events).toBeDefined()
62
+ it.each(encodings)('should pass decoded event (%s)', async (contentType) => {
63
+ const payload = generate()
64
+ const content = encode(payload, contentType)
65
+ const properties = { contentType }
66
+ const message = /** @type {import('amqplib').ConsumeMessage} */ { content, properties }
67
+ const callback = events.subscribe.mock.calls[0][2]
49
68
 
50
- const queue = exchange + '..' + group
69
+ await callback(message)
51
70
 
52
- expect(events.subscribe).toHaveBeenCalledWith(exchange, queue, expect.any(Function))
71
+ expect(consumer).toHaveBeenCalledWith(payload)
72
+ })
53
73
  })
54
74
 
55
- it.each(encodings)('should pass decoded event (%s)', async (contentType) => {
56
- const payload = generate()
57
- const content = encode(payload, contentType)
58
- const properties = { contentType }
59
- const message = /** @type {import('amqplib').ConsumeMessage} */ { content, properties }
60
- const callback = events.subscribe.mock.calls[0][2]
75
+ describe.each(['omitted', 'undefined'])('exclusive consumption (group is %s)', (option) => {
76
+ beforeEach(async () => {
77
+ if (option === 'omitted') await io.consume(exchange, consumer)
78
+ else await io.consume(exchange, undefined, consumer)
61
79
 
62
- await callback(message)
80
+ events = await findChannel('event')
81
+ })
63
82
 
64
- expect(consumer).toHaveBeenCalledWith(payload)
83
+ it('should subscribe', async () => {
84
+ expect(events).toBeDefined()
85
+ expect(events.subscribe).toHaveBeenCalledWith(exchange, undefined, expect.any(Function))
86
+ })
65
87
  })
66
88
 
67
89
  /**
@@ -83,21 +83,22 @@ describe('send', () => {
83
83
  expect(typeof properties.correlationId).toStrictEqual('string')
84
84
  })
85
85
 
86
- it('should set default replyTo', async () => {
86
+ it('should set replyTo', async () => {
87
87
  const properties = call[2]
88
88
  const rx = new RegExp(`^${queue}..[a-z0-9]+`)
89
89
 
90
90
  expect(properties.replyTo).toMatch(rx)
91
91
  })
92
92
 
93
- it('should set specified replyTo', async () => {
93
+ it('should set replyTo using specified formatter', async () => {
94
94
  jest.clearAllMocks()
95
95
 
96
96
  /** @type {comq.encoding} */
97
+ const prefix = generate()
97
98
  const encoding = 'application/json'
98
- const replyTo = generate()
99
+ const formatter = (replyTo) => prefix + replyTo
99
100
 
100
- promise = io.request(queue, payload, encoding, replyTo)
101
+ promise = io.request(queue, payload, encoding, formatter)
101
102
 
102
103
  await immediate()
103
104
 
@@ -105,7 +106,7 @@ describe('send', () => {
105
106
 
106
107
  const properties = requests.send.mock.calls[0][2]
107
108
 
108
- expect(properties.replyTo).toStrictEqual(replyTo)
109
+ expect(properties.replyTo).toMatch(new RegExp(`${prefix}${queue}..[a-z0-9]+`))
109
110
  })
110
111
 
111
112
  it('should consume replyTo', async () => {
package/types/io.d.ts CHANGED
@@ -16,13 +16,22 @@ declare namespace comq {
16
16
  clear(): void
17
17
  }
18
18
 
19
+ type ReplyToPropertyFormatter = (queue: string) => string
20
+
19
21
  interface IO {
20
22
  reply(queue: string, produce: producer): Promise<void>
21
23
 
22
- request(queue: string, payload: any, encoding?: _encoding.encoding, replyTo?: string): Promise<any>
24
+ request(
25
+ queue: string,
26
+ payload: any,
27
+ encoding?: _encoding.encoding,
28
+ replyToFormatter?: ReplyToPropertyFormatter)
29
+ : Promise<any>
23
30
 
24
31
  consume(exchange: string, group: string, consumer: consumer): Promise<void>
25
32
 
33
+ consume(exchange: string, consumer: consumer): Promise<void>
34
+
26
35
  emit(exchange: string, payload: any, encoding?: _encoding.encoding): Promise<void>
27
36
 
28
37
  seal(): Promise<void>