comq 0.10.3 → 0.11.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.
@@ -1,4 +1,3 @@
1
- version: "3.9"
2
1
  services:
3
2
  rabbitmq-0:
4
3
  image: rabbitmq:3.10.0-management
@@ -23,4 +23,4 @@ Feature: Events
23
23
  Scenario: Streaming requests
24
24
  Given that events are exclusively consumed from the `numbers_added` exchange
25
25
  When a stream of 1k events is emitted to the `numbers_added` exchange
26
- Then 1k events is received
26
+ Then 1k events have been received
@@ -0,0 +1,22 @@
1
+ Feature: Poison events
2
+
3
+ Background:
4
+ Given an active connection to the broker
5
+
6
+ @manual
7
+ Scenario: Sending a poison event
8
+
9
+ This scenario will cause an exception, run it once.
10
+
11
+ Given that events from the `poison` exchange are causing exceptions
12
+ Then an event is emitted to the `poison` exchange
13
+ And after 100ms
14
+
15
+ @manual
16
+ Scenario: Consuming poison events
17
+
18
+ After the poison event is sent, run this scenario five times
19
+ and verify that the poison event was discarded.
20
+
21
+ Given that events from the `poison` exchange are causing exceptions
22
+ And after 100ms
@@ -31,15 +31,6 @@ Feature: Request-reply (RPC)
31
31
  null
32
32
  """
33
33
 
34
- Scenario: Request causing an exception is discarded
35
- Given function replying `throw_error` queue:
36
- """
37
- () => { throw new Error() }
38
- """
39
- When the consumer sends a request to the `throw_error` queue
40
- Then the message is discarded
41
- And the consumer does not receive the reply
42
-
43
34
  Scenario: Streaming requests
44
35
  Given a producer replying `ping` queue
45
36
  When the consumer sends 1k requests to the `ping` queue as a stream
@@ -14,9 +14,12 @@ declare namespace comq.features {
14
14
  published?: Buffer
15
15
  eventsPublishedCount: number
16
16
  eventsConsumedCount: number
17
- consumed?: Record<string, { payload: any, properties?: _amqp.Properties }>
17
+ consumed: Record<string, { payload: any, properties?: _amqp.Properties }>
18
18
  consumedCount: number
19
19
  events?: { [K in _diagnostics.Event]?: boolean }
20
+ enqueued?: any
21
+ processed: any
22
+ tasksProcessedCount: number
20
23
  exception?: Error
21
24
  consumptionPromise?: Promise<any>
22
25
  sharded: boolean
@@ -12,11 +12,14 @@ class Context extends World {
12
12
  connecting
13
13
  requestsSent = []
14
14
  reply
15
- consumed
15
+ consumed = {}
16
16
  published
17
17
  eventsPublishedCount = 0
18
18
  eventsConsumedCount = 0
19
19
  events = {}
20
+ processed
21
+ enqueued
22
+ tasksProcessedCount = 0
20
23
  exception
21
24
  expected
22
25
  sharded
@@ -38,6 +38,17 @@ Given('(that )events are exclusively consumed from the {token} exchange',
38
38
  this.consumptionPromise = consume.call(this, undefined, exchange)
39
39
  })
40
40
 
41
+ Given('(that )events from the {token} exchange are causing exceptions',
42
+ /**
43
+ * @param {string} exchange
44
+ * @this {comq.features.Context}
45
+ */
46
+ async function (exchange) {
47
+ await this.io.consume(exchange, 'exceptions', () => {
48
+ throw new Error('Expected exception')
49
+ })
50
+ })
51
+
41
52
  When('an event is emitted to the {token} exchange',
42
53
  /**
43
54
  * @param {string} exchange
@@ -112,7 +123,7 @@ Then('the event is received',
112
123
  await consumed.call(this)
113
124
  })
114
125
 
115
- Then('{quantity} events is received',
126
+ Then('{quantity} events ha(ve)(s) been received',
116
127
  /**
117
128
  * @this {comq.features.Context}
118
129
  */
@@ -167,8 +178,6 @@ Then('all events have been received',
167
178
  * @return {Promise<void>}
168
179
  */
169
180
  async function consume (group, exchange) {
170
- this.consumed ??= {}
171
-
172
181
  const consumer = async (payload, properties) => {
173
182
  this.consumed[group] = { payload, properties }
174
183
  this.eventsConsumedCount++
@@ -9,7 +9,7 @@ Then('the message is discarded',
9
9
  * @this {comq.features.Context}
10
10
  */
11
11
  async function () {
12
- if (this.events.discard !== true) await timeout(50)
12
+ if (this.events.discard !== true) await timeout(300)
13
13
 
14
14
  assert.equal(this.events.discard, true, 'The {message} was not discarded')
15
15
  })
@@ -0,0 +1,70 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const { Given, Then, When } = require('@cucumber/cucumber')
5
+ const { quantity, timeout } = require('@toa.io/generic')
6
+ const { randomBytes } = require('node:crypto')
7
+ const stream = require('node:stream')
8
+
9
+ Given('tasks from the {token} queue are being processed',
10
+ /**
11
+ * @param {string} queue
12
+ * @this {comq.features.Context}
13
+ */
14
+ async function (queue) {
15
+ const process = (message) => {
16
+ this.processed = message
17
+ this.tasksProcessedCount++
18
+ }
19
+
20
+ await this.io.process(queue, process)
21
+ })
22
+
23
+ Given('a task is sent to the {token} queue',
24
+ /**
25
+ * @param {string} queue
26
+ * @this {comq.features.Context}
27
+ */
28
+ async function (queue) {
29
+ this.enqueued = 'hello'
30
+
31
+ await this.io.enqueue(queue, this.enqueued)
32
+ })
33
+
34
+ When('a stream of {quantity} tasks is sent to the {token} queue',
35
+ /**
36
+ * @param {string} amountQ
37
+ * @param {string} exchange
38
+ * @this {comq.features.Context}
39
+ */
40
+ async function (amountQ, exchange) {
41
+ const amount = quantity(amountQ)
42
+
43
+ function * generate () {
44
+ for (let i = 0; i < amount; i++) yield randomBytes(8)
45
+ }
46
+
47
+ const tasks = stream.Readable.from(generate())
48
+
49
+ await this.io.enqueue(exchange, tasks)
50
+ })
51
+
52
+ Then('the task has been received',
53
+ /**
54
+ * @this {comq.features.Context}
55
+ */
56
+ function () {
57
+ assert.notEqual(this.enqueued, undefined, 'Task was not enqueued')
58
+ assert.equal(this.processed, this.enqueued, 'Task was not processed')
59
+ })
60
+
61
+ Then('{quantity} tasks ha(ve)(s) been processed',
62
+ /**
63
+ * @this {comq.features.Context}
64
+ */
65
+ async function (expectedQ) {
66
+ const expected = quantity(expectedQ)
67
+
68
+ await timeout(100) // let it process
69
+ assert.equal(this.tasksProcessedCount, expected, 'Not all tasks have been processed')
70
+ })
@@ -0,0 +1,14 @@
1
+ Feature: Tasks
2
+
3
+ Background:
4
+ Given an active connection to the broker
5
+
6
+ Scenario: Sending and receiving a task
7
+ Given tasks from the `hello` queue are being processed
8
+ When a task is sent to the `hello` queue
9
+ Then the task has been received
10
+
11
+ Scenario: Streaming tasks
12
+ Given tasks from the `stream` queue are being processed
13
+ When a stream of 1k tasks is sent to the `stream` queue
14
+ Then 1k tasks have been processed
@@ -0,0 +1,14 @@
1
+ Feature: Tasks over sharded connection
2
+
3
+ Background:
4
+ Given an active sharded connection
5
+
6
+ Scenario: Sending a task using sharded connection
7
+ Given tasks from the `hello` queue are being processed
8
+ When a task is sent to the `hello` queue
9
+ Then the task has been received
10
+
11
+ Scenario: Streaming tasks using sharded connection
12
+ Given tasks from the `sharded` queue are being processed
13
+ When a stream of 1k tasks is sent to the `sharded` queue
14
+ Then 1k tasks have been processed
package/lerna.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.10.3",
2
+ "version": "0.11.1",
3
3
  "conventionalCommits": true
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.10.3",
3
+ "version": "0.11.1",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -48,7 +48,7 @@
48
48
  "scripts": {
49
49
  "prepare": "echo run: husky install",
50
50
  "test": "standard && jest",
51
- "features": "cucumber-js --fail-fast",
51
+ "features": "cucumber-js --fail-fast -t 'not @manual'",
52
52
  "lint": "standard --fix --verbose | snazzy"
53
53
  },
54
54
  "standard": {
package/readme.md CHANGED
@@ -8,6 +8,7 @@ for distributed, eventually consistent systems running on Node.js.
8
8
  - [Dynamic topology](#topology)
9
9
  - [Request](#request)-[reply](#reply) (RPC)
10
10
  - Events ([pub](#emission)/[sub](#consumption))
11
+ - [Tasks](#tasks)
11
12
  - [Pipelines](#pipelines)
12
13
  - [Reply streams](#reply-streams)
13
14
  - [Content encoding](#encoding)
@@ -64,11 +65,13 @@ properties set.
64
65
  the `replyTo` property of the Request. The `correlationId` property of the Reply is set to the same
65
66
  value as in the Request.
66
67
 
67
- **Event** is an AMQP message that is published to an exchange.
68
+ **Event** is an AMQP message published to an exchange.
68
69
 
69
- **Producer** is an application role that receives Requests and produces Replies and Events.
70
+ **Task** is an AMQP message sent to a queue without a `replyTo` property set.
70
71
 
71
- **Consumer** is an application role that sends Requests and consumes Replies and Events.
72
+ **Producer** is an application role that receives Requests and Tasks, and produces Replies and Events.
73
+
74
+ **Consumer** is an application role that sends Requests and Tasks, and consumes Replies and Events.
72
75
 
73
76
  ## Reply
74
77
 
@@ -155,9 +158,25 @@ asserted.
155
158
  await io.emit('numbers_added', { a: 1, b: 2 })
156
159
  ```
157
160
 
161
+ ## Tasks
162
+
163
+ `async IO.enqueue(queue: string, payload: any, encoding?: string): void`
164
+
165
+ Publish encoded Task to the `queue`.
166
+
167
+ On the initial call, the `queue` is asserted on the Events channel using [Event topology](#topology).
168
+
169
+ `async IO.process(queue: string, processor): void`
170
+
171
+ `processor` function's signature is `async? (payload: any): void`
172
+
173
+ Process decoded Task from the `queue`.
174
+
175
+ The `queue` is asserted on the Events channel using Event topology.
176
+
158
177
  ## Pipelines
159
178
 
160
- Payloads for Requests and Events can be passed as a readable stream
179
+ Payloads for Requests, Events and Tasks can be passed as a readable stream
161
180
  in [object mode](https://nodejs.org/api/stream.html#object-mode), enabling the handling of large amounts of data with
162
181
  the benefits of RabbitMQ back pressure and flow control.
163
182
 
@@ -167,6 +186,8 @@ Returns a readable stream of replies.
167
186
 
168
187
  `async IO.emit(exchange: string, stream: Readable, encoding?: string): void`
169
188
 
189
+ `async IO.enqueue(queue: string, stream: Readable, encoding?: string): void`
190
+
170
191
  ```javascript
171
192
  function * generate () {
172
193
  yield { a: 1, b: 2 };
@@ -177,6 +198,10 @@ const events = Readable.from(generate())
177
198
 
178
199
  await io.emit('numbers_added', events)
179
200
 
201
+ const tasks = Readable.from(generate())
202
+
203
+ await io.enqueue('add_numbers', tasks)
204
+
180
205
  const requests = Readable.from(generate())
181
206
 
182
207
  for await (const reply of io.request('add_numbers', requests))
@@ -386,8 +411,9 @@ See [queue assertion options](https://amqp-node.github.io/amqplib/channel_api.ht
386
411
  manual [acknowledgment mode](https://www.rabbitmq.com/confirms.html#acknowledgment-modes),
387
412
  and Replies are consumed using automatic mode.
388
413
 
389
- If an incoming message causes an exception, then it is "negatively acknowledged" and requeued. If it
390
- causes an exception again, it will be discarded.
414
+ If an incoming message causes an exception, the corresponding channel is sealed, the message is republished, and the
415
+ exception is thrown.
416
+ If the message causes exceptions five times in a row, it is discarded.
391
417
 
392
418
  > It is highly recommended to set up a dead letter exchange policy to analyze messages that caused
393
419
  > exceptions. Note that in some cases, if the problematic message is a Request, a Consumer will
package/source/channel.js CHANGED
@@ -267,18 +267,29 @@ class Channel {
267
267
 
268
268
  this.#channel.ack(message)
269
269
  } catch (exception) {
270
- if (exception.message === 'Channel closed') return // the message will be requeued by the broker
270
+ if (exception.message === 'Channel closed') { return } // the message is requeued by the broker
271
271
 
272
- if (message.fields.redelivered) this.#discard(message, exception)
273
- else this.#requeue(message)
272
+ const redeliveries = message.properties.headers[REDELIVERY_HEADER] ?? 0
273
+
274
+ if (redeliveries >= MAX_REDELIVERIES) this.#discard(message, exception)
275
+ else {
276
+ await this.#requeue(message, redeliveries)
277
+ throw exception
278
+ }
274
279
  }
275
280
  }
276
281
 
277
282
  /**
278
283
  * @param {comq.amqp.Message} message
284
+ * @param {number} attempt
279
285
  */
280
- #requeue (message) {
281
- this.#channel.nack(message, false, true)
286
+ async #requeue (message, attempt) {
287
+ this.#channel.ack(message)
288
+
289
+ message.properties.headers[REDELIVERY_HEADER] = attempt + 1
290
+
291
+ await this.seal()
292
+ await this.#publish(message.fields.exchange, message.fields.routingKey, message.content, message.properties)
282
293
  }
283
294
 
284
295
  /**
@@ -358,6 +369,9 @@ const EXCLUSIVE = { exclusive: true }
358
369
 
359
370
  const INTERRUPTION = /** @type {Error} */ Symbol('internal interruption')
360
371
 
372
+ const MAX_REDELIVERIES = 5
373
+ const REDELIVERY_HEADER = 'x-attempt'
374
+
361
375
  function noop () {}
362
376
 
363
377
  exports.create = create
package/source/io.js CHANGED
@@ -114,13 +114,14 @@ class IO {
114
114
  * @param {string} exchange
115
115
  * @param {any} payload
116
116
  * @param {comq.Encoding | comq.amqp.options.Publish} [encoding]
117
+ * @param {'publish' | 'send'} method
117
118
  * @returns {Promise<void>}
118
119
  */
119
- async (exchange, payload, encoding) => {
120
+ async (exchange, payload, encoding, method = 'publish') => {
120
121
  if (payload instanceof stream.Readable) {
121
122
  return transform(
122
123
  payload,
123
- (payload) => this.emit(exchange, payload, encoding),
124
+ (payload) => this.emit(exchange, payload, encoding, method),
124
125
  this.#events
125
126
  )
126
127
  }
@@ -138,9 +139,20 @@ class IO {
138
139
 
139
140
  properties.contentType = contentType
140
141
 
141
- await this.#events.publish(exchange, buffer, properties)
142
+ await this.#events[method](exchange, buffer, properties)
142
143
  })
143
144
 
145
+ process = lazy(this, this.#createEventChannel,
146
+ async (queue, callback) => {
147
+ const consumer = this.#getEventConsumer(callback)
148
+
149
+ await this.#events.consume(queue, consumer)
150
+ })
151
+
152
+ enqueue (queue, payload, encoding) {
153
+ return this.emit(queue, payload, encoding, 'send')
154
+ }
155
+
144
156
  seal = memo(async () => {
145
157
  await this.#requests?.seal()
146
158
  await this.#events?.seal()
@@ -134,32 +134,6 @@ describe('acknowledgments', () => {
134
134
  else expect(options).toMatchObject({ noAck: true })
135
135
  })
136
136
 
137
- it.each(/** @type {[string, boolean][]} */ [
138
- ['nack', true],
139
- ['discard', false]
140
- ])('should %s the message caused an exception', async (_, requeue) => {
141
- topology.acknowledgments = true
142
-
143
- channel = await create(connection, topology)
144
- chan = await getCreatedChannel()
145
-
146
- const consumer = /** @type {Function} */ jest.fn(async () => { throw new Error() })
147
-
148
- await channel.consume(queue, consumer)
149
-
150
- const callback = chan.consume.mock.calls[0][1]
151
- const content = randomBytes(8)
152
- const properties = {}
153
- const fields = {}
154
- const message = /** @type {comq.amqp.Message} */ { content, properties, fields }
155
-
156
- if (!requeue) fields.redelivered = !requeue
157
-
158
- await callback(message)
159
-
160
- expect(chan.nack).toHaveBeenCalledWith(message, false, requeue)
161
- })
162
-
163
137
  it('should ignore Channel ended exception', async () => {
164
138
  topology.acknowledgments = true
165
139
 
@@ -844,10 +818,7 @@ describe('diagnostics', () => {
844
818
  expect(resumed).toStrictEqual(true)
845
819
  })
846
820
 
847
- it.each(/** @type {[string, boolean][]} */ [
848
- ['', true],
849
- [' not', false]
850
- ])('should%s emit `discard` event', async (_, redelivered) => {
821
+ it('should emit `discard` event', async () => {
851
822
  jest.clearAllMocks()
852
823
 
853
824
  topology.acknowledgments = true
@@ -866,14 +837,12 @@ describe('diagnostics', () => {
866
837
 
867
838
  const callback = /** @type {Function} */ chan.consume.mock.calls[0][1]
868
839
  const content = randomBytes(8)
869
- const properties = {}
870
- const fields = { redelivered }
871
- const message = /** @type {comq.amqp.Message} */ { content, properties, fields }
840
+ const properties = { headers: { 'x-attempt': 5 } }
841
+ const message = /** @type {comq.amqp.Message} */ { content, properties }
872
842
 
873
843
  await callback(message)
874
844
 
875
- if (redelivered) expect(listener).toHaveBeenCalledWith(message, exception)
876
- else expect(listener).not.toHaveBeenCalled()
845
+ expect(listener).toHaveBeenCalledWith(message, exception)
877
846
  })
878
847
  })
879
848
 
package/types/io.d.ts CHANGED
@@ -43,6 +43,16 @@ declare namespace comq {
43
43
 
44
44
  emit (exchange: string, stream: Readable, properties?: _amqp.Properties): Promise<void>
45
45
 
46
+ enqueue (exchange: string, payload: any, encoding?: _encoding.Encoding): Promise<void>
47
+
48
+ enqueue (exchange: string, payload: any, properties?: _amqp.Properties): Promise<void>
49
+
50
+ enqueue (exchange: string, stream: Readable, encoding?: _encoding.Encoding): Promise<void>
51
+
52
+ enqueue (exchange: string, stream: Readable, properties?: _amqp.Properties): Promise<void>
53
+
54
+ process<T = any> (queue: string, consumer: Consumer<T>): Promise<void>
55
+
46
56
  seal (): Promise<void>
47
57
 
48
58
  close (): Promise<void>