comq 0.16.1 → 0.17.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.
@@ -14,3 +14,8 @@ updates:
14
14
  target-branch: "dev"
15
15
  labels:
16
16
  - "Bump"
17
+ ignore:
18
+ # a newer preset needs conventional-changelog-writer@9, which the
19
+ # semantic-release plugins do not use yet; the release fails to render its notes
20
+ - dependency-name: "conventional-changelog-conventionalcommits"
21
+ update-types: ["version-update:semver-major"]
@@ -0,0 +1,19 @@
1
+ Feature: Routed events
2
+
3
+ An exchange that routes by key, where an event exchange fans out: a message reaches the
4
+ queues bound under the key it was published with, and none of the others.
5
+
6
+ Background:
7
+ Given an active connection to the broker
8
+
9
+ Scenario: Receiving an event by its routing key
10
+ Given that `orders` is bound to the `records` exchange under the `placed` key
11
+ When a message is routed to the `records` exchange under the `placed` key
12
+ Then `orders` receives the event
13
+
14
+ Scenario: Not receiving an event bound under another key
15
+ Given that `orders` is bound to the `records` exchange under the `placed` key
16
+ And `customers` is bound to the `records` exchange under the `shipped` key
17
+ When a message is routed to the `records` exchange under the `placed` key
18
+ Then `orders` receives the event
19
+ And `customers` receives nothing
@@ -0,0 +1,55 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const { randomBytes } = require('node:crypto')
5
+ const { timeout } = require('../../test/helpers')
6
+ const { Given, When, Then } = require('@cucumber/cucumber')
7
+
8
+ Given('(that ){token} is bound to the {token} exchange under the {token} key',
9
+ /**
10
+ * @param {string} queue
11
+ * @param {string} exchange
12
+ * @param {string} key
13
+ * @this {comq.features.Context}
14
+ */
15
+ async function (queue, exchange, key) {
16
+ await subscribe.call(this, queue, exchange, key)
17
+ })
18
+
19
+ When('a message is routed to the {token} exchange under the {token} key',
20
+ /**
21
+ * @param {string} exchange
22
+ * @param {string} key
23
+ * @this {comq.features.Context}
24
+ */
25
+ async function (exchange, key) {
26
+ const message = randomBytes(8)
27
+
28
+ await this.io.route(exchange, key, message)
29
+
30
+ this.published = message
31
+ })
32
+
33
+ Then('{token} receives nothing',
34
+ /**
35
+ * @param {string} queue
36
+ * @this {comq.features.Context}
37
+ */
38
+ async function (queue) {
39
+ await timeout(100) // let it not consume
40
+
41
+ assert.equal(this.consumed[queue], undefined, `'${queue}' has received an event`)
42
+ })
43
+
44
+ /**
45
+ * @param {string} queue
46
+ * @param {string} exchange
47
+ * @param {string} key
48
+ * @this {comq.features.Context}
49
+ */
50
+ async function subscribe (queue, exchange, key) {
51
+ await this.io.subscribe(exchange, queue, key, async (payload, properties) => {
52
+ this.consumed[queue] = { payload, properties }
53
+ this.eventsConsumedCount++
54
+ })
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
package/readme.md CHANGED
@@ -7,7 +7,7 @@ for distributed, eventually consistent systems running on Node.js.
7
7
 
8
8
  - [Dynamic topology](#topology)
9
9
  - [Request](#request)-[reply](#reply) (RPC)
10
- - Events ([pub](#emission)/[sub](#consumption))
10
+ - Events ([pub](#emission)/[sub](#consumption)), fanned out or [routed](#routing)
11
11
  - [Tasks](#tasks)
12
12
  - [Pipelines](#pipelines)
13
13
  - [Reply streams](#reply-streams)
@@ -67,6 +67,9 @@ value as in the Request.
67
67
 
68
68
  **Event** is an AMQP message published to an exchange.
69
69
 
70
+ **Key** is the routing key an Event is published with, and the one a queue is bound under. An
71
+ Event published to a fanout exchange carries none.
72
+
70
73
  **Task** is an AMQP message sent to a queue without a `replyTo` property set.
71
74
 
72
75
  **Producer** is an application role that receives Requests and Tasks, and produces Replies and Events.
@@ -158,6 +161,36 @@ asserted.
158
161
  await io.emit('numbers_added', { a: 1, b: 2 })
159
162
  ```
160
163
 
164
+ ## Routing
165
+
166
+ `async IO.route(exchange: string, key: string, payload: any, encoding?: string): void`
167
+
168
+ `async IO.subscribe(exchange: string, queue: string, key: string, consumer): void`
169
+
170
+ Publish and consume Events addressed by a Key, where [Emission](#emission)
171
+ and [Consumption](#consumption) fan out.
172
+
173
+ `route` asserts a
174
+ [direct exchange](https://www.rabbitmq.com/tutorials/amqp-concepts.html#exchange-direct) (once per
175
+ unique `exchange`) and publishes the encoded Event to it under the `key`. `subscribe` asserts the
176
+ same exchange and a durable `queue`, binds it under the `key` (once per unique `exchange`, `queue`
177
+ and `key`), and starts consuming. **An Event reaches the queues bound under the Key it carries,
178
+ and no others.**
179
+
180
+ The queue is named rather than derived from a Consumer group, because what identifies it here is
181
+ the Key it is bound under rather than the exchange it belongs to. It is also durable, so what is
182
+ published while nothing is consuming is held rather than dropped.
183
+
184
+ ### Example
185
+
186
+ ```javascript
187
+ await io.subscribe('records', 'records.orders', 'store.orders',
188
+ (record) => console.log(record))
189
+
190
+ await io.route('records', 'store.orders', { id: 1, status: 'paid' })
191
+ await io.route('records', 'store.customers', { id: 2 }) // not delivered to the above
192
+ ```
193
+
161
194
  ## Tasks
162
195
 
163
196
  `async IO.enqueue(queue: string, payload: any, encoding?: string): void`
@@ -255,6 +288,11 @@ These intervals are not configurable.
255
288
 
256
289
  An "end stream" message is sent to the `replyTo` queue when the Reply stream is finished.
257
290
 
291
+ When the Consumer falls behind, a "pause" message is sent to the Producer's control queue,
292
+ and the Producer stops pulling values from the iterator until a "resume" message follows.
293
+ The Producer announces its support of these messages in the confirmation message,
294
+ so they are never sent to a Producer that would not understand them.
295
+
258
296
  See also [Reply stream shutdown](#reply-stream-shutdown).
259
297
 
260
298
  ### Loss of tail
@@ -278,7 +316,8 @@ At the same time, there is no guarantee that the stream will be transmitted to t
278
316
 
279
317
  > When using the [Sharded connection](#sharded-connection), the order of yielded values is maintained through buffering.
280
318
  > However, there is a scenario in which some of the yielded values may be lost if a broker crashes.
281
- > In this case, the Reply stream will be destroyed once the buffer's maximum size is exceeded.
319
+ > In this case, the Reply stream will be destroyed once the buffer's maximum size is exceeded
320
+ > (1000 values or 16 MiB of encoded messages).
282
321
  > Also, buffered control messages can result in [stream idling](#stream-control).
283
322
 
284
323
  ## Encoding
@@ -403,6 +442,9 @@ requests and are expecting replies.
403
442
 
404
443
  - Exchanges and queues for Events, and queues for Requests
405
444
  are _durable_.
445
+ - An exchange is asserted as _fanout_ for [Emission](#emission)
446
+ and [Consumption](#consumption), and as _direct_ for [Routing](#routing). One name is one or
447
+ the other: asserting it as both is what the broker refuses.
406
448
  - Queues for Replies are _exclusive_ and _auto deleted_.
407
449
 
408
450
  See [queue assertion options](https://amqp-node.github.io/amqplib/channel_api.html#channel_assertQueue).
@@ -1,18 +1,23 @@
1
1
  'use strict'
2
2
 
3
3
  const { EventEmitter } = require('node:events')
4
- const { control, HEARTBEAT_INTERVAL } = require('./const')
4
+ const { Promex } = require('promex')
5
+ const { control, FLOW_HEADER, HEARTBEAT_INTERVAL } = require('./const')
5
6
 
6
7
  /** @typedef {(message: any, properties?: comq.amqp.options.Publish) => Promise<void>} Reply */
7
8
 
8
9
  class ReplyPipe extends EventEmitter {
9
10
  #index = -1
10
11
  #interrupted = false
12
+ #closed = false
11
13
  #heartbeatInterval = global['COMQ_TESTING_HEARTBEAT_INTERVAL'] || HEARTBEAT_INTERVAL
12
14
 
13
15
  /** @type {ReturnType<setInterval> | null} */
14
16
  #interval = null
15
17
 
18
+ /** Closed while the consumer has asked for a pause. */
19
+ #gate = null
20
+
16
21
  /** @type {string} */
17
22
  #replyTo
18
23
 
@@ -51,7 +56,8 @@ class ReplyPipe extends EventEmitter {
51
56
 
52
57
  this.#properties = {
53
58
  chunk: { correlationId, ...CHUNK },
54
- control: { correlationId, replyTo: feedback.queue, ...CONTROL }
59
+ control: { correlationId, replyTo: feedback.queue, ...CONTROL },
60
+ ok: { correlationId, replyTo: feedback.queue, ...CONTROL, headers: FLOW }
55
61
  }
56
62
 
57
63
  channel.diagnose('return', this.#onReturn)
@@ -59,11 +65,13 @@ class ReplyPipe extends EventEmitter {
59
65
  }
60
66
 
61
67
  async pipe () {
62
- await this.#transmit(control.ok, this.#properties.control)
68
+ await this.#transmit(control.ok, this.#properties.ok)
69
+
70
+ if (this.#closed) return
63
71
 
64
- this.#stream.on('data', this.#onData)
65
- this.#stream.on('close', this.#close)
66
72
  this.#heartbeat()
73
+
74
+ void this.#pump()
67
75
  }
68
76
 
69
77
  destroy () {
@@ -71,11 +79,35 @@ class ReplyPipe extends EventEmitter {
71
79
  this.#stream.destroy()
72
80
  }
73
81
 
82
+ /**
83
+ * The source is pulled rather than listened to: a value is asked for once the
84
+ * previous one has been handed to the channel, so a paused channel or a paused
85
+ * consumer holds the source back instead of piling its output up here.
86
+ */
87
+ async #pump () {
88
+ try {
89
+ for await (const chunk of this.#stream) {
90
+ if (this.#gate !== null) await this.#gate
91
+ if (this.#closed) break
92
+
93
+ await this.#transmit(chunk, this.#properties.chunk)
94
+
95
+ if (this.#closed) break
96
+
97
+ this.#heartbeat()
98
+ }
99
+ } catch {
100
+ // the source has been destroyed, by this pipe or by whoever made it
101
+ }
102
+
103
+ this.#close()
104
+ }
105
+
74
106
  async #transmit (data, properties) {
75
107
  this.#index++
76
108
 
77
- const ok = await this.#reply(data,
78
- { ...properties, headers: { index: this.#index } })
109
+ const headers = { ...properties.headers, index: this.#index }
110
+ const ok = await this.#reply(data, { ...properties, headers })
79
111
 
80
112
  if (!ok) this.#interrupt()
81
113
  }
@@ -97,12 +129,21 @@ class ReplyPipe extends EventEmitter {
97
129
  #clear () {
98
130
  clearInterval(this.#interval)
99
131
 
100
- this.#stream.removeAllListeners()
101
132
  this.#channel.forget('return', this.#onReturn)
102
133
  this.#feedback.off(this.#properties.control.correlationId, this.#control)
134
+ this.#resume()
135
+ }
136
+
137
+ #resume () {
138
+ this.#gate?.resolve()
139
+ this.#gate = null
103
140
  }
104
141
 
105
142
  #close = () => {
143
+ if (this.#closed) return
144
+
145
+ this.#closed = true
146
+
106
147
  this.emit('close')
107
148
  this.#clear()
108
149
 
@@ -111,12 +152,6 @@ class ReplyPipe extends EventEmitter {
111
152
  }
112
153
  }
113
154
 
114
- #onData = async (chunk) => {
115
- await this.#transmit(chunk, this.#properties.chunk)
116
-
117
- this.#heartbeat()
118
- }
119
-
120
155
  #onReturn = (message) => {
121
156
  if (message.fields.routingKey === this.#replyTo)
122
157
  this.#interrupt()
@@ -127,6 +162,12 @@ class ReplyPipe extends EventEmitter {
127
162
  case control.end:
128
163
  this.#interrupt()
129
164
  break
165
+ case control.pause:
166
+ this.#gate ??= new Promex()
167
+ break
168
+ case control.resume:
169
+ this.#resume()
170
+ break
130
171
  default:
131
172
  throw new Error(`Unknown control message: ${message}`)
132
173
  }
@@ -155,4 +196,6 @@ const CHUNK = { mandatory: true }
155
196
  /** @type {comq.amqp.options.Publish} */
156
197
  const CONTROL = { type: 'control', mandatory: true }
157
198
 
199
+ const FLOW = { [FLOW_HEADER]: true }
200
+
158
201
  exports.ReplyPipe = ReplyPipe
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { Readable } = require('node:stream')
4
4
  const { Promex } = require('promex')
5
- const { IDLE_INTERVAL, control } = require('./const')
5
+ const { IDLE_INTERVAL, FLOW_HEADER, control } = require('./const')
6
6
 
7
7
  class ReplyStream extends Readable {
8
8
  confirmation = new Promex()
@@ -16,6 +16,7 @@ class ReplyStream extends Readable {
16
16
  /** @type {string} */
17
17
  #correlationId
18
18
 
19
+ /** The confirmation message, addressing the control queue of the producer. */
19
20
  #control
20
21
 
21
22
  #reply
@@ -26,9 +27,19 @@ class ReplyStream extends Readable {
26
27
 
27
28
  #buffered = 0
28
29
 
30
+ #bufferedBytes = 0
31
+
29
32
  #maxBufferSize
30
33
 
31
- /** @type {Map<number, unknown>} */
34
+ #maxBufferBytes
35
+
36
+ /** Whether the producer honours `pause` and `resume`. */
37
+ #flow = false
38
+
39
+ /** Whether the producer has been asked to pause. */
40
+ #throttled = false
41
+
42
+ /** @type {Map<number, { payload: unknown, properties: comq.amqp.Properties, size: number }>} */
32
43
  #queue = new Map()
33
44
 
34
45
  /**
@@ -42,6 +53,7 @@ class ReplyStream extends Readable {
42
53
  this.#correlationId = request.properties.correlationId
43
54
  this.#idleInterval = global['COMQ_TESTING_IDLE_INTERVAL'] || IDLE_INTERVAL
44
55
  this.#maxBufferSize = global['COMQ_TESTING_MAX_BUFFER_SIZE'] || MAX_BUFFER_SIZE
56
+ this.#maxBufferBytes = global['COMQ_TESTING_MAX_BUFFER_BYTES'] || MAX_BUFFER_BYTES
45
57
  this.#reply = reply
46
58
 
47
59
  this.confirmation.catch(noop) // it is not awaited until the stream is handed over
@@ -65,15 +77,25 @@ class ReplyStream extends Readable {
65
77
  super._destroy(error, callback)
66
78
  }
67
79
 
68
- _read (_) {}
80
+ /**
81
+ * Called once the consumer has room again.
82
+ */
83
+ _read (_) {
84
+ if (!this.#throttled) return
85
+
86
+ this.#throttled = false
87
+
88
+ void this.#reply(this.#control, control.resume)
89
+ }
69
90
 
70
91
  /**
71
92
  * @param {unknown} payload
72
93
  * @param {comq.amqp.Properties} properties
94
+ * @param {number} [size] of the encoded payload
73
95
  */
74
- arrange (payload, properties) {
96
+ arrange (payload, properties, size = 0) {
75
97
  if (properties.headers.index !== this.#index) {
76
- this._buffer(payload, properties)
98
+ this._buffer(payload, properties, size)
77
99
 
78
100
  return
79
101
  }
@@ -85,6 +107,7 @@ class ReplyStream extends Readable {
85
107
 
86
108
  while ((message = this.#queue.get(this.#index))) {
87
109
  this.#queue.delete(this.#index)
110
+ this.#bufferedBytes -= message.size
88
111
  this._add(message.payload, message.properties)
89
112
  }
90
113
 
@@ -104,18 +127,45 @@ class ReplyStream extends Readable {
104
127
  if (properties.type === 'control')
105
128
  this._control(payload, properties)
106
129
  else if (!this.push(payload))
107
- this._clear()
130
+ this._throttle()
108
131
  }
109
132
 
110
- _buffer (payload, properties) {
111
- if (this.#buffered > this.#maxBufferSize) {
133
+ /**
134
+ * Values arriving out of order are held until the gap is filled. The hold is
135
+ * bounded by their number and, roughly, by their size: the size is that of
136
+ * the encoded message, which is what is known of a decoded value.
137
+ *
138
+ * @param {unknown} payload
139
+ * @param {comq.amqp.Properties} properties
140
+ * @param {number} size
141
+ * @private
142
+ */
143
+ _buffer (payload, properties, size) {
144
+ if (this.#buffered > this.#maxBufferSize || this.#bufferedBytes + size > this.#maxBufferBytes) {
112
145
  this.destroy()
113
146
 
114
147
  return
115
148
  }
116
149
 
117
150
  this.#buffered++
118
- this.#queue.set(properties.headers.index, { payload, properties })
151
+ this.#bufferedBytes += size
152
+ this.#queue.set(properties.headers.index, { payload, properties, size })
153
+ }
154
+
155
+ /**
156
+ * The consumer is behind. Values in flight keep coming, and a producer that
157
+ * knows nothing of `pause` keeps going, in which case they pile up here rather
158
+ * than get lost: dropping the listener would leave the stream waiting for a
159
+ * value that has already passed.
160
+ *
161
+ * @private
162
+ */
163
+ _throttle () {
164
+ if (this.#throttled || !this.#flow) return
165
+
166
+ this.#throttled = true
167
+
168
+ void this.#reply(this.#control, control.pause)
119
169
  }
120
170
 
121
171
  /**
@@ -127,6 +177,7 @@ class ReplyStream extends Readable {
127
177
  switch (message) {
128
178
  case control.ok:
129
179
  this.#control = { properties }
180
+ this.#flow = properties.headers?.[FLOW_HEADER] === true
130
181
  this.confirmation.resolve()
131
182
  break
132
183
  case control.heartbeat:
@@ -147,12 +198,15 @@ class ReplyStream extends Readable {
147
198
 
148
199
  _clear () {
149
200
  clearTimeout(this.#timeout)
150
- this.#emitter.removeAllListeners(this.#correlationId)
201
+ this.#emitter.off(this.#correlationId)
151
202
  }
152
203
  }
153
204
 
154
205
  const MAX_BUFFER_SIZE = 1000
155
206
 
207
+ /** @type {number} */
208
+ const MAX_BUFFER_BYTES = 16 * 1024 * 1024
209
+
156
210
  const UNCONFIRMED = 'Reply stream has been destroyed before confirmation'
157
211
 
158
212
  function noop () {}
@@ -3,8 +3,16 @@
3
3
  exports.control = {
4
4
  ok: 'ok',
5
5
  heartbeat: 'heartbeat',
6
- end: 'end'
6
+ end: 'end',
7
+ pause: 'pause',
8
+ resume: 'resume'
7
9
  }
8
10
 
11
+ /**
12
+ * Set on the confirmation message by a producer that honours `pause` and
13
+ * `resume`, so that a consumer never sends them to one that does not.
14
+ */
15
+ exports.FLOW_HEADER = 'x-flow'
16
+
9
17
  exports.HEARTBEAT_INTERVAL = 5_000
10
18
  exports.IDLE_INTERVAL = 12_000
@@ -1,22 +1,97 @@
1
1
  'use strict'
2
2
 
3
3
  const { randomBytes } = require('node:crypto')
4
- const { EventEmitter } = require('node:events')
5
4
  const { concat } = require('./concat')
6
5
 
6
+ /**
7
+ * Routes the replies arriving on a queue to whoever is waiting for them.
8
+ *
9
+ * A Map keyed by correlation identifier rather than an EventEmitter: with
10
+ * thousands of requests in flight the emitter's event table degrades into a
11
+ * dictionary, and every `once` costs a wrapper.
12
+ *
13
+ * @implements {comq.ReplyEmitter}
14
+ */
15
+ class ReplyEmitter {
16
+ /** @type {string} */
17
+ queue
18
+
19
+ /** @type {string} */
20
+ #prefix
21
+
22
+ /** @type {number} */
23
+ #sequence = 0
24
+
25
+ /** @type {Map<string, comq.ReplyHandler>} */
26
+ #handlers = new Map()
27
+
28
+ /**
29
+ * @param {string} label
30
+ */
31
+ constructor (label) {
32
+ const id = randomBytes(8).toString('hex')
33
+
34
+ this.queue = concat(label, id)
35
+ this.#prefix = id
36
+ }
37
+
38
+ /**
39
+ * A correlation identifier that is unique across processes as well: the
40
+ * control queue of a producer sees the requests of every consumer, and tells
41
+ * them apart by nothing else. Random bytes per request would cost more than
42
+ * everything else on the way to the broker put together.
43
+ *
44
+ * @return {string}
45
+ */
46
+ next () {
47
+ return this.#prefix + (++this.#sequence).toString(36)
48
+ }
49
+
50
+ /**
51
+ * @param {string} id
52
+ * @param {comq.ReplyHandler} handler
53
+ */
54
+ on (id, handler) {
55
+ this.#handlers.set(id, handler)
56
+ }
57
+
58
+ /**
59
+ * @param {string} id
60
+ * @param {comq.ReplyHandler} [handler] the one to remove, or whichever is there
61
+ */
62
+ off (id, handler) {
63
+ if (handler === undefined || this.#handlers.get(id) === handler) this.#handlers.delete(id)
64
+ }
65
+
66
+ /**
67
+ * @param {string} id
68
+ * @param {any} payload
69
+ * @param {comq.amqp.Properties} properties
70
+ * @param {number} [size] of the encoded payload
71
+ * @return {boolean} whether anyone was waiting
72
+ */
73
+ emit (id, payload, properties, size) {
74
+ const handler = this.#handlers.get(id)
75
+
76
+ if (handler === undefined) return false
77
+
78
+ handler(payload, properties, size)
79
+
80
+ return true
81
+ }
82
+
83
+ /** How many replies are being waited for. */
84
+ get pending () {
85
+ return this.#handlers.size
86
+ }
87
+ }
88
+
7
89
  /**
8
90
  * @param {string} label
9
91
  * @returns {comq.ReplyEmitter}
10
92
  */
11
93
  function createReplyEmitter (label) {
12
- const id = randomBytes(8).toString('hex')
13
- const queue = concat(label, id)
14
- const emitter = new EventEmitter()
15
-
16
- emitter.setMaxListeners(0)
17
- emitter.queue = queue
18
-
19
- return /** @type {comq.ReplyEmitter} */ emitter
94
+ return new ReplyEmitter(label)
20
95
  }
21
96
 
22
97
  exports.createReplyEmitter = createReplyEmitter