comq 0.16.1 → 0.16.2

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"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
package/readme.md CHANGED
@@ -255,6 +255,11 @@ These intervals are not configurable.
255
255
 
256
256
  An "end stream" message is sent to the `replyTo` queue when the Reply stream is finished.
257
257
 
258
+ When the Consumer falls behind, a "pause" message is sent to the Producer's control queue,
259
+ and the Producer stops pulling values from the iterator until a "resume" message follows.
260
+ The Producer announces its support of these messages in the confirmation message,
261
+ so they are never sent to a Producer that would not understand them.
262
+
258
263
  See also [Reply stream shutdown](#reply-stream-shutdown).
259
264
 
260
265
  ### Loss of tail
@@ -278,7 +283,8 @@ At the same time, there is no guarantee that the stream will be transmitted to t
278
283
 
279
284
  > When using the [Sharded connection](#sharded-connection), the order of yielded values is maintained through buffering.
280
285
  > 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.
286
+ > In this case, the Reply stream will be destroyed once the buffer's maximum size is exceeded
287
+ > (1000 values or 16 MiB of encoded messages).
282
288
  > Also, buffered control messages can result in [stream idling](#stream-control).
283
289
 
284
290
  ## Encoding
@@ -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
@@ -1,8 +1,18 @@
1
1
  'use strict'
2
2
 
3
+ /**
4
+ * Runs the initializers once per distinct set of arguments before the method.
5
+ *
6
+ * The record of what has been initialized is kept per context, so that a context
7
+ * that is dropped takes its record with it, and it is looked up by argument rather
8
+ * than scanned — a channel publishing to a thousand queues asks for the thousandth
9
+ * as fast as for the first.
10
+ *
11
+ * @param {object} context
12
+ * @param {Function | Function[]} initializers
13
+ * @param {(...args: unknown[]) => Promise<unknown>} method
14
+ */
3
15
  function lazy (context, initializers, method) {
4
- if (context[LOCK] === undefined) context[LOCK] = Symbol('methods locking key')
5
-
6
16
  if (!Array.isArray(initializers)) initializers = [initializers]
7
17
 
8
18
  return async function (...args) {
@@ -24,7 +34,7 @@ async function call (context, initializers, args) {
24
34
  let override
25
35
 
26
36
  for (const init of initializers) {
27
- const result = await resolve(context, init, args)
37
+ const result = await lock(context, init, args)
28
38
 
29
39
  if (result !== undefined) override = result
30
40
  }
@@ -33,46 +43,76 @@ async function call (context, initializers, args) {
33
43
  }
34
44
 
35
45
  /**
46
+ * An initializer expecting N arguments is run once per distinct N leading
47
+ * arguments, which are the keys of a trie of Maps N levels deep.
48
+ *
36
49
  * @param {object} context
37
- * @param {Function} init
38
- * @param {any[]} args
39
- * @returns {Promise}
50
+ * @param {(...args: unknown[]) => Promise<unknown>} init
51
+ * @param {unknown[]} args
52
+ * @returns {Promise<unknown>}
40
53
  */
41
- function resolve (context, init, args) {
42
- const key = context[LOCK]
43
- const expected = args.slice(0, init.length)
54
+ function lock (context, init, args) {
55
+ const arity = init.length
56
+
57
+ let node = table(context, init)
58
+
59
+ for (let i = 0; i < arity - 1; i++) node = branch(node, args[i])
60
+
61
+ const key = arity === 0 ? NONE : args[arity - 1]
62
+ const found = node.get(key)
63
+
64
+ if (found !== undefined) return found
65
+
66
+ const promise = init.apply(context, args.slice(0, arity))
67
+
68
+ node.set(key, promise)
44
69
 
45
- return lock(context, init, expected, key)
70
+ return promise
46
71
  }
47
72
 
48
73
  /**
49
74
  * @param {object} context
50
- * @param {(...args: unknown[]) => Promise<unknown>} init
51
- * @param {unknown[]} args
52
- * @param {Symbol} key
53
- * @returns {Promise<unknown>}
75
+ * @param {Function} init
76
+ * @return {Map<unknown, unknown>}
54
77
  */
55
- function lock (context, init, args, key) {
56
- if (init[key] === undefined) init[key] = []
78
+ function table (context, init) {
79
+ let tables = TABLES.get(context)
57
80
 
58
- const locks = init[key]
59
- const found = locks.find((lock) => lock.args.reduce((match, argument, i) => match && argument === args[i], true))
81
+ if (tables === undefined) {
82
+ tables = new Map()
83
+ TABLES.set(context, tables)
84
+ }
60
85
 
61
- if (found !== undefined) return found.promise
86
+ return branch(tables, init)
87
+ }
62
88
 
63
- const promise = init.apply(context, args)
64
- const lock = { args, promise }
89
+ /**
90
+ * @param {Map<unknown, unknown>} node
91
+ * @param {unknown} key
92
+ * @return {Map<unknown, unknown>}
93
+ */
94
+ function branch (node, key) {
95
+ let next = node.get(key)
65
96
 
66
- locks.push(lock)
97
+ if (next === undefined) {
98
+ next = new Map()
99
+ node.set(key, next)
100
+ }
67
101
 
68
- return promise
102
+ return /** @type {Map<unknown, unknown>} */ next
69
103
  }
70
104
 
105
+ /**
106
+ * @param {object} context
107
+ */
71
108
  function reset (context) {
72
- context[LOCK] = context[LOCK] = Symbol('methods locking key')
109
+ TABLES.delete(context)
73
110
  }
74
111
 
75
- const LOCK = Symbol('context locking key')
112
+ /** @type {WeakMap<object, Map<Function, Map<unknown, unknown>>>} */
113
+ const TABLES = new WeakMap()
114
+
115
+ const NONE = Symbol('no arguments')
76
116
 
77
117
  lazy.reset = reset
78
118
 
@@ -297,3 +297,42 @@ describe('reset', () => {
297
297
  expect(initialize2).toHaveBeenCalledTimes(2)
298
298
  })
299
299
  })
300
+
301
+ class Exposed {
302
+ do = lazy(this, this.initialize, async () => undefined)
303
+
304
+ async initialize (_) {}
305
+ }
306
+
307
+ // an initializer is a method shared by every instance of its class, so a record
308
+ // kept on it would outlive the instances it was made for
309
+ it('should keep no record on the initializer or the context', async () => {
310
+ const instance = new Exposed()
311
+
312
+ await instance.do(generate())
313
+ lazy.reset(instance)
314
+ await instance.do(generate())
315
+
316
+ expect(Object.getOwnPropertySymbols(Exposed.prototype.initialize)).toHaveLength(0)
317
+ expect(Object.getOwnPropertySymbols(instance)).toHaveLength(0)
318
+ })
319
+
320
+ it('should tell arguments apart by identity', async () => {
321
+ let calls = 0
322
+
323
+ class Counted {
324
+ do = lazy(this, this.initialize, async () => undefined)
325
+
326
+ async initialize (_) {
327
+ calls++
328
+ }
329
+ }
330
+
331
+ const instance = new Counted()
332
+
333
+ await instance.do(undefined)
334
+ await instance.do('undefined')
335
+ await instance.do(undefined)
336
+
337
+ expect(calls).toStrictEqual(2)
338
+ })
@@ -36,7 +36,24 @@ async function replay (context) {
36
36
  await Promise.all(promises)
37
37
  }
38
38
 
39
+ /**
40
+ * Forgets the recorded calls, and the arguments they hold on to.
41
+ *
42
+ * @param {object} context
43
+ */
44
+ function reset (context) {
45
+ const methods = context[METHODS]
46
+
47
+ if (methods === undefined) return
48
+
49
+ for (const method of methods) method[CALLS] = undefined
50
+
51
+ context[METHODS] = undefined
52
+ }
53
+
39
54
  const METHODS = Symbol('context methods')
40
55
  const CALLS = Symbol('method calls')
41
56
 
57
+ recall.reset = reset
58
+
42
59
  exports.recall = recall