comq 0.24.1 → 0.25.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.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env sh
2
+
3
+ [ -n "$CI" ] && exit 0
4
+
5
+ npx --no -- commitlint --edit "$1"
package/benchmarks/run.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // What one request costs the process that answers it. See readme.md.
2
2
  //
3
3
  // node benchmarks/run.js [--rates 1000,5000] [--concurrency 1,8] [--messages 20000]
4
- // [--seconds 6] [--rounds 2]
4
+ // [--seconds 6] [--rounds 2] [--streams 8000]
5
5
 
6
6
  import { fork } from 'node:child_process'
7
7
  import { parseArgs } from 'node:util'
@@ -17,7 +17,8 @@ const { values } = parseArgs({
17
17
  concurrency: { type: 'string', default: '1,8,64,256' },
18
18
  messages: { type: 'string', default: '20000' },
19
19
  seconds: { type: 'string', default: '6' },
20
- rounds: { type: 'string', default: '2' }
20
+ rounds: { type: 'string', default: '2' },
21
+ streams: { type: 'string', default: '' }
21
22
  }
22
23
  })
23
24
 
@@ -27,6 +28,7 @@ const concurrencies = numbers(values.concurrency)
27
28
  const messages = Number(values.messages)
28
29
  const seconds = Number(values.seconds)
29
30
  const rounds = Number(values.rounds)
31
+ const streams = values.streams === '' ? [] : numbers(values.streams)
30
32
 
31
33
  const durations = []
32
34
 
@@ -45,14 +47,24 @@ const io = await connect(url)
45
47
  try {
46
48
  const open = []
47
49
  const closed = []
50
+ const streamed = []
48
51
 
49
52
  for (let round = 1; round <= rounds; round++) {
53
+ if (streams.length > 0) {
54
+ for (const length of streams) streamed.push(await stream(length, round))
55
+
56
+ continue
57
+ }
58
+
50
59
  for (const rate of rates) open.push(await atRate(rate, round))
51
60
  for (const concurrency of concurrencies) closed.push(await inFlight(concurrency, round))
52
61
  }
53
62
 
54
- report('At a fixed rate', ['rate'], open)
55
- report('With requests in flight', ['in flight'], closed)
63
+ if (streams.length > 0) reportStreams(streamed)
64
+ else {
65
+ report('At a fixed rate', ['rate'], open)
66
+ report('With requests in flight', ['in flight'], closed)
67
+ }
56
68
  } finally {
57
69
  await io.close()
58
70
 
@@ -105,6 +117,56 @@ async function load (work) {
105
117
  }
106
118
  }
107
119
 
120
+ /**
121
+ * A reply stream of `length` values, read to its end: what it cost the peer that sent it and this
122
+ * process that read it, per value, and how long it took.
123
+ */
124
+ async function stream (length, round) {
125
+ await readStream(Math.min(length, 1000))
126
+
127
+ const before = await io.request('comq.benchmark.meter', null)
128
+ const cpu = process.cpuUsage()
129
+ const started = process.hrtime.bigint()
130
+
131
+ await readStream(length)
132
+
133
+ const elapsed = Number(process.hrtime.bigint() - started) / 1e6
134
+ const consumer = process.cpuUsage(cpu)
135
+ const after = await io.request('comq.benchmark.meter', null)
136
+ const producer = after.cpu.user + after.cpu.system - before.cpu.user - before.cpu.system
137
+
138
+ return {
139
+ round,
140
+ of: length,
141
+ ms: round2(elapsed),
142
+ producer: round2(producer / length),
143
+ consumer: round2((consumer.user + consumer.system) / length),
144
+ writes: round3((after.write + after.writev - before.write - before.writev) / length)
145
+ }
146
+ }
147
+
148
+ async function readStream (length) {
149
+ const reply = await io.request('comq.benchmark.stream', { length })
150
+
151
+ let read = 0
152
+
153
+ // eslint-disable-next-line no-unused-vars
154
+ for await (const _ of reply) read++
155
+
156
+ if (read !== length) throw new Error(`The stream held ${read} values of ${length}`)
157
+ }
158
+
159
+ function reportStreams (rows) {
160
+ console.log('\n## A reply stream\n')
161
+ console.log('| values | round | ms | producer CPU µs | consumer CPU µs | writes |')
162
+ console.log('| ---: | ---: | ---: | ---: | ---: | ---: |')
163
+
164
+ for (const row of rows) {
165
+ console.log(`| ${row.of} | ${row.round} | ${row.ms} | ${row.producer} | ${row.consumer} | ` +
166
+ `${row.writes} |`)
167
+ }
168
+ }
169
+
108
170
  async function send (rate, duration, record) {
109
171
  const started = process.hrtime.bigint()
110
172
  const pending = []
@@ -32,6 +32,15 @@ const url = process.env.COMQ_BENCHMARK_URL ?? 'amqp://developer:secret@localhost
32
32
 
33
33
  let answered = 0
34
34
 
35
+ /** What a value of a reply stream carries: a record, as a set read whole is made of. */
36
+ const VALUE = {
37
+ id: '4c4759e6f9c74da989d64511df42d6f4',
38
+ title: 'First pot',
39
+ rank: 7,
40
+ public: true,
41
+ tags: ['one', 'two', 'three']
42
+ }
43
+
35
44
  const io = await connect(url)
36
45
 
37
46
  await io.reply('comq.benchmark.echo', (payload) => {
@@ -40,6 +49,11 @@ await io.reply('comq.benchmark.echo', (payload) => {
40
49
  return payload
41
50
  })
42
51
 
52
+ // a reply stream of the values a caller asks for, each the size of an echo's payload
53
+ await io.reply('comq.benchmark.stream', function * ({ length }) {
54
+ for (let i = 0; i < length; i++) yield { ...VALUE, i }
55
+ })
56
+
43
57
  // read before and after a window, so what is reported is what that window cost
44
58
  await io.reply('comq.benchmark.meter', () => ({ answered, cpu: process.cpuUsage(), ...counters }))
45
59
 
@@ -0,0 +1,6 @@
1
+ 'use strict'
2
+
3
+ // the same convention semantic-release reads the history with (see .releaserc)
4
+ module.exports = {
5
+ extends: ['@commitlint/config-conventional']
6
+ }
@@ -21,6 +21,34 @@ Feature: Reply streams
21
21
  [0, 1, 2, 3, 4]
22
22
  """
23
23
 
24
+ Scenario: Requesting a stream of many values at once
25
+ Given function replying `get_numbers` queue:
26
+ """
27
+ function * ({ limit }) {
28
+ for (let i = 0; i < limit; i++) yield { i }
29
+ }
30
+ """
31
+ When the consumer requests a stream with the following request to the `get_numbers` queue:
32
+ """yaml
33
+ limit: 300
34
+ """
35
+ Then the consumer receives a stream of 300 values in order
36
+
37
+ Scenario: Requesting a stream of buffers
38
+ Given function replying `get_bytes` queue:
39
+ """
40
+ function * () {
41
+ yield Buffer.from('first')
42
+ yield Buffer.alloc(0)
43
+ yield Buffer.from('third')
44
+ }
45
+ """
46
+ When the consumer requests a stream with request to the `get_bytes` queue
47
+ Then the consumer receives the stream of buffers:
48
+ """yaml
49
+ [first, '', third]
50
+ """
51
+
24
52
  Scenario: Requesting streams concurrently
25
53
  Given a number generator with 10ms increasing delay replying `get_numbers` queue
26
54
  When the consumer0 requests a stream with request to the `get_numbers` queue
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const assert = require('node:assert')
4
- const { Promex } = require('promex')
4
+ const { Promex } = require('../../source/promex')
5
5
  const { Given, When, Then, After } = require('@cucumber/cucumber')
6
6
 
7
7
  const { connect } = require('../../')
@@ -291,7 +291,37 @@ Then('the consumer receives the stream:',
291
291
 
292
292
  for await (const reply of this.stream) replies.push(reply)
293
293
 
294
- assert.equal(values.length, replies.length, `Stream length mismatch: expected ${values.length}, received ${replies.length}`)
294
+ assert.deepStrictEqual(replies, values)
295
+ })
296
+
297
+ Then('the consumer receives a stream of {number} values in order',
298
+ /**
299
+ * @param {number} count
300
+ * @this {comq.features.Context}
301
+ */
302
+ async function (count) {
303
+ const replies = []
304
+
305
+ for await (const reply of this.stream) replies.push(reply)
306
+
307
+ assert.deepStrictEqual(replies, Array.from({ length: count }, (_, i) => ({ i })))
308
+ })
309
+
310
+ Then('the consumer receives the stream of buffers:',
311
+ /**
312
+ * @param {string} yaml what each buffer holds, as text
313
+ * @this {comq.features.Context}
314
+ */
315
+ async function (yaml) {
316
+ const values = parse(yaml)
317
+ const replies = []
318
+
319
+ for await (const reply of this.stream) {
320
+ assert.ok(Buffer.isBuffer(reply), `Not a buffer: ${JSON.stringify(reply)}`)
321
+ replies.push(reply.toString())
322
+ }
323
+
324
+ assert.deepStrictEqual(replies, values)
295
325
  })
296
326
 
297
327
  Then('the consumer receives the stream',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -27,19 +27,22 @@
27
27
  "feat": "scripts/feat.js"
28
28
  },
29
29
  "devDependencies": {
30
+ "@commitlint/cli": "^21.2.3",
31
+ "@commitlint/config-conventional": "^21.2.3",
30
32
  "@cucumber/cucumber": "^13.2.1",
33
+ "@jest/fake-timers": "^30.5.2",
31
34
  "@semantic-release/commit-analyzer": "^13.0.1",
32
35
  "@semantic-release/git": "^11.0.1",
33
36
  "@semantic-release/github": "^12.0.2",
34
37
  "@semantic-release/npm": "^13.1.2",
35
38
  "@semantic-release/release-notes-generator": "^14.1.0",
36
39
  "@testcontainers/rabbitmq": "^12.1.0",
37
- "@types/jest": "^30.0.0",
38
40
  "conventional-changelog-conventionalcommits": "^8.0.0",
41
+ "expect": "^30.5.2",
39
42
  "fast-glob": "^3.3.3",
40
43
  "fuzzysort": "4.0.2",
41
44
  "husky": "^9.1.7",
42
- "jest": "^30.4.2",
45
+ "jest-mock": "^30.5.2",
43
46
  "js-yaml": "^5.4.1",
44
47
  "randomstring": "^1.3.1",
45
48
  "semantic-release": "^25.0.9",
@@ -48,28 +51,33 @@
48
51
  "testcontainers": "^12.0.4"
49
52
  },
50
53
  "dependencies": {
51
- "@toa.io/amqplib": "^0.1.0",
52
- "promex": "^0.1.1",
53
- "reretry": "^0.1.0"
54
+ "@toa.io/amqplib": "^0.1.0"
54
55
  },
55
56
  "engines": {
56
57
  "node": ">=24.0.0"
57
58
  },
58
59
  "scripts": {
59
60
  "prepare": "husky",
60
- "test": "standard && NODE_OPTIONS=--experimental-vm-modules jest",
61
+ "test": "standard && node --test --experimental-test-module-mocks --import ./test/globals.js 'test/**/*.test.js' 'source/**/*.test.js'",
61
62
  "features": "cucumber-js --fail-fast -t 'not @manual'",
62
63
  "features:fast": "cucumber-js --fail-fast -t 'not @manual and not @heavy'",
63
64
  "benchmark": "node benchmarks/run.js",
64
65
  "lint": "standard --fix --verbose | snazzy"
65
66
  },
66
67
  "standard": {
67
- "env": [
68
- "jest"
68
+ "globals": [
69
+ "afterAll",
70
+ "afterEach",
71
+ "beforeAll",
72
+ "beforeEach",
73
+ "describe",
74
+ "expect",
75
+ "it",
76
+ "jest",
77
+ "test"
69
78
  ]
70
79
  },
71
80
  "overrides": {
72
- "test-exclude": "^8.0.0",
73
81
  "rimraf": "^6.1.3",
74
82
  "archiver-utils": {
75
83
  "glob": "^13.0.6"
package/readme.md CHANGED
@@ -354,6 +354,12 @@ for await (const number of stream)
354
354
  console.log(number)
355
355
  ```
356
356
 
357
+ Values a producer yields together travel together: what the iterator yields before the event loop
358
+ turns is sent as one message, up to 128 values, and a value that has to be waited for is sent as
359
+ it comes. Buffers travel in messages of their own. The stream returned by `IO.request` yields each
360
+ value on its own either way. Values in an encoding other than `application/json` travel one to a
361
+ message.
362
+
357
363
  ### Stream topology
358
364
 
359
365
  <picture>
@@ -1,8 +1,8 @@
1
1
  'use strict'
2
2
 
3
3
  const { EventEmitter } = require('node:events')
4
- const { Promex } = require('promex')
5
- const { control, FLOW_HEADER, HEARTBEAT_INTERVAL } = require('./const')
4
+ const { Promex } = require('../promex')
5
+ const { control, batch, FLOW_HEADER, HEARTBEAT_INTERVAL } = require('./const')
6
6
 
7
7
  /** @typedef {(message: any, properties?: comq.amqp.options.Publish) => Promise<void>} Reply */
8
8
 
@@ -36,6 +36,9 @@ class ReplyPipe extends EventEmitter {
36
36
  /** @type {Reply} */
37
37
  #reply
38
38
 
39
+ /** Whether the encoding can carry a list of values. */
40
+ #lists
41
+
39
42
  /**
40
43
  * @param {comq.amqp.Message} request
41
44
  * @param {stream.Readable} stream
@@ -54,8 +57,12 @@ class ReplyPipe extends EventEmitter {
54
57
  this.#reply = reply
55
58
  this.#replyTo = replyTo
56
59
 
60
+ this.#lists = request.properties.contentType === LISTS
61
+
57
62
  this.#properties = {
58
63
  chunk: { correlationId, ...CHUNK },
64
+ batch: { correlationId, ...CHUNK, type: batch.values },
65
+ buffers: { correlationId, ...CHUNK, type: batch.buffers },
59
66
  control: { correlationId, replyTo: feedback.queue, ...CONTROL },
60
67
  ok: { correlationId, replyTo: feedback.queue, ...CONTROL, headers: FLOW }
61
68
  }
@@ -99,21 +106,90 @@ class ReplyPipe extends EventEmitter {
99
106
  */
100
107
  async #pump () {
101
108
  try {
102
- for await (const chunk of this.#stream) {
103
- if (this.#gate !== null) await this.#gate
104
- if (this.#closed) break
109
+ await this.#pumpBatches()
110
+ } catch {
111
+ // the source has been destroyed, by this pipe or by whoever made it
112
+ }
113
+
114
+ this.#close()
115
+ }
116
+
117
+ /**
118
+ * Values go out together while they arrive together: what the source yields before the event
119
+ * loop turns joins the message, and the first value that has to be waited for leaves with the
120
+ * ones before it rather than waiting for company. A cursor yielding the documents of a batch it
121
+ * holds sends them as one message; a source yielding one value at a time sends each as it
122
+ * comes. Values and buffers travel in messages of their own, and values in an encoding that
123
+ * holds no list travel one to a message.
124
+ */
125
+ async #pumpBatches () {
126
+ const iterator = this.#stream[Symbol.asyncIterator]()
127
+
128
+ let pending = next(iterator)
129
+
130
+ while (true) {
131
+ const first = await pending
105
132
 
106
- await this.#transmit(chunk, this.#properties.chunk)
133
+ if (first.done === true) return
107
134
 
108
- if (this.#closed) break
135
+ const chunks = [first.value]
136
+ const buffers = Buffer.isBuffer(first.value)
137
+ const turn = immediate()
109
138
 
110
- this.#heartbeat()
139
+ let done = false
140
+
141
+ pending = next(iterator)
142
+
143
+ while (chunks.length < (buffers || this.#lists ? MAX_BATCH : 1)) {
144
+ const arrived = await Promise.race([pending, turn])
145
+
146
+ if (arrived === TURN) break
147
+
148
+ if (arrived.done === true) {
149
+ done = true
150
+
151
+ break
152
+ }
153
+
154
+ // of the other kind, it starts the next message
155
+ if (Buffer.isBuffer(arrived.value) !== buffers) {
156
+ pending = Promise.resolve(arrived)
157
+
158
+ break
159
+ }
160
+
161
+ chunks.push(arrived.value)
162
+ pending = next(iterator)
111
163
  }
112
- } catch {
113
- // the source has been destroyed, by this pipe or by whoever made it
164
+
165
+ if (!(await this.#send(...this.#message(chunks, buffers)))) {
166
+ await iterator.return?.()
167
+
168
+ return
169
+ }
170
+
171
+ if (done) return
114
172
  }
173
+ }
115
174
 
116
- this.#close()
175
+ #message (chunks, buffers) {
176
+ if (chunks.length === 1) return [chunks[0], this.#properties.chunk]
177
+ else if (buffers) return [prefixed(chunks), this.#properties.buffers]
178
+ else return [chunks, this.#properties.batch]
179
+ }
180
+
181
+ /** Answers whether the pipe goes on. */
182
+ async #send (data, properties) {
183
+ if (this.#gate !== null) await this.#gate
184
+ if (this.#closed) return false
185
+
186
+ await this.#transmit(data, properties)
187
+
188
+ if (this.#closed) return false
189
+
190
+ this.#heartbeat()
191
+
192
+ return true
117
193
  }
118
194
 
119
195
  async #transmit (data, properties) {
@@ -206,6 +282,49 @@ class ReplyPipe extends EventEmitter {
206
282
  /** @type {comq.amqp.options.Publish} */
207
283
  const CHUNK = { mandatory: true }
208
284
 
285
+ /** the most values a message carries */
286
+ const MAX_BATCH = 128
287
+
288
+ /** the encoding a batch of values is a list in */
289
+ const LISTS = 'application/json'
290
+
291
+ const TURN = Symbol('turn')
292
+
293
+ /**
294
+ * The next value, asked for ahead of when it is awaited. A source that fails while a message is
295
+ * being sent is found failed by the loop that awaits it next, or by nobody where the pipe has
296
+ * stopped — and a rejection nobody awaits is an unhandled one.
297
+ */
298
+ function next (iterator) {
299
+ const pending = iterator.next()
300
+
301
+ pending.catch(noop)
302
+
303
+ return pending
304
+ }
305
+
306
+ /** Settles once the event loop has turned, with what says so. */
307
+ function immediate () {
308
+ return new Promise((resolve) => setImmediate(resolve, TURN))
309
+ }
310
+
311
+ /**
312
+ * @param {Buffer[]} buffers
313
+ * @returns {Buffer}
314
+ */
315
+ function prefixed (buffers) {
316
+ const parts = []
317
+
318
+ for (const buffer of buffers) {
319
+ const length = Buffer.allocUnsafe(4)
320
+
321
+ length.writeUInt32BE(buffer.length)
322
+ parts.push(length, buffer)
323
+ }
324
+
325
+ return Buffer.concat(parts)
326
+ }
327
+
209
328
  /** @type {comq.amqp.options.Publish} */
210
329
  const CONTROL = { type: 'control', mandatory: true }
211
330
 
@@ -1,8 +1,8 @@
1
1
  'use strict'
2
2
 
3
3
  const { Readable } = require('node:stream')
4
- const { Promex } = require('promex')
5
- const { IDLE_INTERVAL, FLOW_HEADER, control } = require('./const')
4
+ const { Promex } = require('../promex')
5
+ const { IDLE_INTERVAL, FLOW_HEADER, batch, control } = require('./const')
6
6
  const { Interrupted } = require('../interrupted')
7
7
 
8
8
  class ReplyStream extends Readable {
@@ -133,10 +133,29 @@ class ReplyStream extends Readable {
133
133
 
134
134
  if (properties.type === 'control')
135
135
  this._control(payload, properties)
136
+ else if (properties.type === batch.values)
137
+ this._values(payload)
138
+ else if (properties.type === batch.buffers)
139
+ this._values(split(payload))
136
140
  else if (!this.push(payload))
137
141
  this._throttle()
138
142
  }
139
143
 
144
+ /**
145
+ * The values of one message, pushed whatever the consumer's room: they have arrived, and a
146
+ * pause asked for now holds back the messages after them.
147
+ *
148
+ * @param {unknown[]} values
149
+ * @private
150
+ */
151
+ _values (values) {
152
+ let room = true
153
+
154
+ for (const value of values) room = this.push(value) && room
155
+
156
+ if (!room) this._throttle()
157
+ }
158
+
140
159
  /**
141
160
  * Values arriving out of order are held until the gap is filled. The hold is
142
161
  * bounded by their number and, roughly, by their size: the size is that of
@@ -213,6 +232,26 @@ class ReplyStream extends Readable {
213
232
  }
214
233
  }
215
234
 
235
+ /**
236
+ * The buffers of a message that carries each prefixed with its length.
237
+ *
238
+ * @param {Buffer} buffer
239
+ * @returns {Buffer[]}
240
+ */
241
+ function split (buffer) {
242
+ const buffers = []
243
+
244
+ for (let offset = 0; offset < buffer.length;) {
245
+ const length = buffer.readUInt32BE(offset)
246
+ const start = offset + 4
247
+
248
+ buffers.push(buffer.subarray(start, start + length))
249
+ offset = start + length
250
+ }
251
+
252
+ return buffers
253
+ }
254
+
216
255
  const MAX_BUFFER_SIZE = 1000
217
256
 
218
257
  /** @type {number} */
@@ -14,5 +14,11 @@ exports.control = {
14
14
  */
15
15
  exports.FLOW_HEADER = 'x-flow'
16
16
 
17
+ /** The type of a message carrying values: a list of them, or buffers prefixed with their length. */
18
+ exports.batch = {
19
+ values: 'batch',
20
+ buffers: 'buffers'
21
+ }
22
+
17
23
  exports.HEARTBEAT_INTERVAL = 5_000
18
24
  exports.IDLE_INTERVAL = 12_000
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { generate } = require('randomstring')
4
- const { Promex } = require('promex')
4
+ const { Promex } = require('../promex')
5
5
 
6
6
  const { track } = require('./track')
7
7
 
package/source/channel.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
- const { Promex } = require('promex')
4
- const { retry } = require('reretry')
3
+ const { Promex } = require('./promex')
4
+ const { retry } = require('./retry')
5
5
  const { failsafe, lazy, recall } = require('./attributes')
6
6
  const { verdictOf, PARK } = require('./verdicts')
7
7
  const emitter = require('./emitter')
@@ -2,8 +2,8 @@
2
2
 
3
3
  const { setTimeout: delay } = require('node:timers/promises')
4
4
  const amqp = require('@toa.io/amqplib')
5
- const { Promex } = require('promex')
6
- const { retry } = require('reretry')
5
+ const { Promex } = require('./promex')
6
+ const { retry } = require('./retry')
7
7
 
8
8
  const { failsafe } = require('./attributes')
9
9
  const presets = require('./topology')
package/source/io.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const stream = require('node:stream')
4
4
  const { setTimeout } = require('node:timers/promises')
5
- const { Promex } = require('promex')
5
+ const { Promex } = require('./promex')
6
6
  const { memo, failsafe, lazy, track } = require('./attributes')
7
7
  const { verdictOf, PARK } = require('./verdicts')
8
8
  const { Unroutable } = require('./unroutable')
@@ -0,0 +1,47 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * A promise settled by whoever holds it, rather than only from within its executor.
5
+ * `callback` settles it the way node-style callbacks report: an error, or a value.
6
+ *
7
+ * @template T
8
+ * @extends {Promise<T>}
9
+ */
10
+ class Promex extends Promise {
11
+ /** @type {(value?: T | PromiseLike<T>) => void} */
12
+ resolve
13
+
14
+ /** @type {(reason?: any) => void} */
15
+ reject
16
+
17
+ /**
18
+ * @param {(resolve: (value?: any) => void, reject: (reason?: any) => void) => void} [executor]
19
+ */
20
+ constructor (executor = noop) {
21
+ let ok
22
+ let oh
23
+
24
+ super((resolve, reject) => {
25
+ ok = resolve
26
+ oh = reject
27
+
28
+ return executor(resolve, reject)
29
+ })
30
+
31
+ this.resolve = ok
32
+ this.reject = oh
33
+ }
34
+
35
+ /**
36
+ * @param {any} error
37
+ * @param {T} [value]
38
+ */
39
+ callback = (error, value) => {
40
+ if (error !== null && error !== undefined) this.reject(error)
41
+ else this.resolve(value)
42
+ }
43
+ }
44
+
45
+ function noop () {}
46
+
47
+ exports.Promex = Promex
@@ -0,0 +1,79 @@
1
+ 'use strict'
2
+
3
+ const { Promex } = require('./promex')
4
+
5
+ it('should resolve', async () => {
6
+ const promise = new Promex()
7
+
8
+ setImmediate(() => promise.resolve('ok'))
9
+
10
+ await expect(promise).resolves.toStrictEqual('ok')
11
+ })
12
+
13
+ it('should reject', async () => {
14
+ const promise = new Promex()
15
+
16
+ setImmediate(() => promise.reject(new Error('oh')))
17
+
18
+ await expect(promise).rejects.toThrow('oh')
19
+ })
20
+
21
+ it('should resolve using the callback', async () => {
22
+ const promise = new Promex()
23
+
24
+ setImmediate(() => promise.callback(null, 'ok'))
25
+
26
+ await expect(promise).resolves.toStrictEqual('ok')
27
+ })
28
+
29
+ it('should reject using the callback', async () => {
30
+ const promise = new Promex()
31
+
32
+ setImmediate(() => promise.callback('oh'))
33
+
34
+ await expect(promise).rejects.toStrictEqual('oh')
35
+ })
36
+
37
+ it('should resolve using the callback given no error', async () => {
38
+ const promise = new Promex()
39
+
40
+ setImmediate(() => promise.callback(undefined, 'ok'))
41
+
42
+ await expect(promise).resolves.toStrictEqual('ok')
43
+ })
44
+
45
+ it('should run the executor it is given', async () => {
46
+ const promise = new Promex((resolve) => resolve('ok'))
47
+
48
+ await expect(promise).resolves.toStrictEqual('ok')
49
+ })
50
+
51
+ it('should stay settled by whoever holds it', async () => {
52
+ const promise = new Promex()
53
+
54
+ promise.resolve('first')
55
+ promise.resolve('second')
56
+
57
+ await expect(promise).resolves.toStrictEqual('first')
58
+ })
59
+
60
+ it('should be a promise', async () => {
61
+ const promise = new Promex()
62
+
63
+ expect(promise).toBeInstanceOf(Promise)
64
+
65
+ promise.resolve('ok')
66
+
67
+ await expect(Promise.all([promise])).resolves.toStrictEqual(['ok'])
68
+ })
69
+
70
+ it('should derive promises of its own kind', async () => {
71
+ const promise = new Promex()
72
+ const derived = promise.then((value) => value + '!')
73
+
74
+ expect(derived).toBeInstanceOf(Promex)
75
+
76
+ promise.resolve('ok')
77
+
78
+ await expect(derived).resolves.toStrictEqual('ok!')
79
+ })
@@ -0,0 +1,42 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Runs an operation until it stops asking for another attempt, waiting longer before
5
+ * each one. The operation is handed the token it returns to ask for a retry; anything
6
+ * else it returns is the result, and whatever it throws is thrown on.
7
+ *
8
+ * @template T
9
+ * @param {(again: symbol) => Promise<T | symbol>} operation
10
+ * @param {Partial<typeof DEFAULTS>} [options]
11
+ * @returns {Promise<T>}
12
+ */
13
+ async function retry (operation, options) {
14
+ const { attempts, base, max, factor, dispersion } = { ...DEFAULTS, ...options }
15
+
16
+ for (let attempt = 0; attempt < attempts; attempt++) {
17
+ const result = await operation(again)
18
+
19
+ if (result !== again) return /** @type {T} */ (result)
20
+
21
+ const interval = Math.min(base * factor ** attempt, max)
22
+
23
+ // attempts that started together are spread out rather than kept in step
24
+ const spread = interval * dispersion * (Math.random() - 0.5)
25
+
26
+ await new Promise((resolve) => setTimeout(resolve, interval + spread))
27
+ }
28
+
29
+ throw new Error('Maximum attempts exceeded')
30
+ }
31
+
32
+ const again = Symbol('again')
33
+
34
+ const DEFAULTS = {
35
+ attempts: Infinity,
36
+ base: 1000,
37
+ max: 30000,
38
+ factor: 1.5,
39
+ dispersion: 0.1
40
+ }
41
+
42
+ exports.retry = retry
@@ -0,0 +1,120 @@
1
+ 'use strict'
2
+
3
+ const { retry } = require('./retry')
4
+
5
+ const immediate = { base: 0, max: 0, dispersion: 0 }
6
+
7
+ it('should return the result', async () => {
8
+ const result = await retry(async () => 'ok', immediate)
9
+
10
+ expect(result).toStrictEqual('ok')
11
+ })
12
+
13
+ it('should retry while the operation asks for it', async () => {
14
+ let attempts = 0
15
+
16
+ const result = await retry(async (again) => {
17
+ attempts++
18
+
19
+ return attempts < 3 ? again : 'ok'
20
+ }, immediate)
21
+
22
+ expect(attempts).toStrictEqual(3)
23
+ expect(result).toStrictEqual('ok')
24
+ })
25
+
26
+ it('should pass the same token on every attempt', async () => {
27
+ const tokens = []
28
+
29
+ await retry(async (again) => {
30
+ tokens.push(again)
31
+
32
+ return tokens.length < 3 ? again : 'ok'
33
+ }, immediate)
34
+
35
+ expect(tokens).toHaveLength(3)
36
+ expect(new Set(tokens).size).toStrictEqual(1)
37
+ })
38
+
39
+ it('should throw what the operation throws', async () => {
40
+ const exception = new Error('nope')
41
+
42
+ await expect(retry(async () => { throw exception }, immediate)).rejects.toThrow(exception)
43
+ })
44
+
45
+ it('should give up after the last attempt', async () => {
46
+ let attempts = 0
47
+
48
+ const attempt = retry(async (again) => {
49
+ attempts++
50
+
51
+ return again
52
+ }, { ...immediate, attempts: 3 })
53
+
54
+ await expect(attempt).rejects.toThrow('Maximum attempts exceeded')
55
+ expect(attempts).toStrictEqual(3)
56
+ })
57
+
58
+ it('should not run the operation without an attempt to spare', async () => {
59
+ const operation = jest.fn(async (again) => again)
60
+
61
+ await expect(retry(operation, { ...immediate, attempts: 0 }))
62
+ .rejects.toThrow('Maximum attempts exceeded')
63
+
64
+ expect(operation).not.toHaveBeenCalled()
65
+ })
66
+
67
+ it('should back off, up to the maximum, before each attempt', async () => {
68
+ jest.useFakeTimers()
69
+
70
+ try {
71
+ const waits = []
72
+
73
+ let last = Date.now()
74
+ let attempts = 0
75
+
76
+ const attempt = retry(async (again) => {
77
+ waits.push(Date.now() - last)
78
+ last = Date.now()
79
+ attempts++
80
+
81
+ return attempts < 5 ? again : 'ok'
82
+ }, { base: 100, factor: 2, max: 300, dispersion: 0 })
83
+
84
+ await jest.advanceTimersByTimeAsync(10_000)
85
+
86
+ expect(await attempt).toStrictEqual('ok')
87
+
88
+ // the first attempt waits for nothing; then 100, 200, and 400 capped at 300
89
+ expect(waits).toStrictEqual([0, 100, 200, 300, 300])
90
+ } finally {
91
+ jest.useRealTimers()
92
+ }
93
+ })
94
+
95
+ it('should spread the wait around the interval', async () => {
96
+ const waits = []
97
+
98
+ const timer = global.setTimeout
99
+
100
+ global.setTimeout = (callback, ms) => {
101
+ waits.push(ms)
102
+
103
+ return timer(callback, 0)
104
+ }
105
+
106
+ try {
107
+ let attempts = 0
108
+
109
+ await retry(async (again) => {
110
+ attempts++
111
+
112
+ return attempts < 20 ? again : 'ok'
113
+ }, { base: 1000, factor: 1, max: 1000, dispersion: 0.1 })
114
+ } finally {
115
+ global.setTimeout = timer
116
+ }
117
+
118
+ expect(waits.every((ms) => ms >= 950 && ms <= 1050)).toStrictEqual(true)
119
+ expect(new Set(waits).size).toBeGreaterThan(1)
120
+ })
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { Promex } = require('promex')
3
+ const { Promex } = require('../promex')
4
4
  const { RETRY_PREFIX } = require('../channel')
5
5
  const events = require('../events')
6
6
  const emitter = require('../emitter')
@@ -0,0 +1,22 @@
1
+ import * as mock from 'jest-mock'
2
+
3
+ // The test files are written against Jest's mock types, and keep being: the mocks are still
4
+ // Jest's, taken as the standalone `jest-mock` package. This is what @types/jest declared of it.
5
+
6
+ declare global {
7
+
8
+ namespace jest {
9
+
10
+ type Mock<T extends mock.FunctionLike = mock.UnknownFunction> = mock.Mock<T>
11
+
12
+ type Mocked<T extends object> = mock.Mocked<T>
13
+
14
+ type MockedClass<T extends mock.ClassLike> = mock.MockedClass<T>
15
+
16
+ type MockedFunction<T extends mock.FunctionLike = mock.UnknownFunction> = mock.MockedFunction<T>
17
+
18
+ type MockedFn<T extends mock.FunctionLike = mock.UnknownFunction> = mock.MockedFunction<T>
19
+
20
+ type MockedObject<T extends object> = mock.MockedObject<T>
21
+ }
22
+ }
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { Readable } = require('node:stream')
4
- const { Promex } = require('promex')
4
+ const { Promex } = require('../source/promex')
5
5
  const { ReplyPipe } = require('../source/.io/ReplyPipe')
6
6
  const { createReplyEmitter } = require('../source/.io/createReplyEmitter')
7
7
  const { control, FLOW_HEADER } = require('../source/.io/const')
@@ -96,10 +96,14 @@ it('should pull the source no faster than the channel takes', async () => {
96
96
  })
97
97
 
98
98
  it('should hold the source while the consumer has asked for a pause', async () => {
99
- const pipe = await ReplyPipe.create(request, Readable.from([1, 2, 3]), channel, feedback, reply)
99
+ // the pause is asked for while the confirmation is still being published: the pipe does not
100
+ // read its source until that has been published, so nothing can be in flight yet
101
+ const opening = ReplyPipe.create(request, Readable.from([1, 2, 3]), channel, feedback, reply)
100
102
 
101
103
  feedback.emit(request.properties.correlationId, control.pause, {})
102
104
 
105
+ const pipe = await opening
106
+
103
107
  await timeout(10)
104
108
 
105
109
  expect(messages()).toStrictEqual([control.ok])
@@ -208,3 +212,68 @@ it('should destroy the source if the confirmation cannot be sent', async () => {
208
212
 
209
213
  expect(source.destroyed).toStrictEqual(true)
210
214
  })
215
+
216
+ describe('batches', () => {
217
+ beforeEach(() => {
218
+ request.properties.contentType = 'application/json'
219
+ })
220
+
221
+ it('should send the values its source holds as one message', async () => {
222
+ const pipe = await ReplyPipe.create(request, Readable.from([1, 2, 3]), channel, feedback, reply)
223
+
224
+ await closing(pipe)
225
+
226
+ expect(messages()).toStrictEqual([control.ok, [1, 2, 3], control.end])
227
+ expect(sent[1][1].type).toStrictEqual('batch')
228
+ })
229
+
230
+ it('should send each value that arrives on its own as it arrives', async () => {
231
+ async function * source () {
232
+ yield 1
233
+ await timeout(10)
234
+ yield 2
235
+ }
236
+
237
+ const pipe = await ReplyPipe.create(request, Readable.from(source()), channel, feedback, reply)
238
+
239
+ await closing(pipe)
240
+
241
+ expect(messages()).toStrictEqual([control.ok, 1, 2, control.end])
242
+ expect(sent[1][1].type).toBeUndefined()
243
+ })
244
+
245
+ it('should send buffers as one message, each prefixed with its length', async () => {
246
+ const source = Readable.from([Buffer.from('ab'), Buffer.from('cde')])
247
+ const pipe = await ReplyPipe.create(request, source, channel, feedback, reply)
248
+
249
+ await closing(pipe)
250
+
251
+ const expected = Buffer.from([0, 0, 0, 2, 0x61, 0x62, 0, 0, 0, 3, 0x63, 0x64, 0x65])
252
+
253
+ expect(messages()[1]).toStrictEqual(expected)
254
+ expect(sent[1][1].type).toStrictEqual('buffers')
255
+ })
256
+
257
+ it('should send values one at a time in an encoding that holds no list', async () => {
258
+ request.properties.contentType = 'text/plain'
259
+
260
+ const pipe = await ReplyPipe.create(request, Readable.from(['a', 'b']), channel, feedback, reply)
261
+
262
+ await closing(pipe)
263
+
264
+ expect(messages()).toStrictEqual([control.ok, 'a', 'b', control.end])
265
+ })
266
+
267
+ it('should keep a batch within its limit', async () => {
268
+ const values = Array.from({ length: 300 }, (_, i) => i)
269
+ const source = Readable.from(values, { highWaterMark: 1000 })
270
+ const pipe = await ReplyPipe.create(request, source, channel, feedback, reply)
271
+
272
+ await closing(pipe)
273
+
274
+ const batches = messages().slice(1, -1)
275
+
276
+ expect(batches.flat()).toStrictEqual(values)
277
+ expect(Math.max(...batches.map((batch) => [batch].flat().length))).toBeLessThanOrEqual(128)
278
+ })
279
+ })
@@ -204,3 +204,36 @@ describe('flow', () => {
204
204
  }
205
205
  })
206
206
  })
207
+
208
+ describe('batches', () => {
209
+ const ok = { headers: { index: 0, [FLOW_HEADER]: true }, type: 'control' }
210
+ const end = (index) => [control.end, { headers: { index }, type: 'control' }]
211
+
212
+ it('should yield each value of a batch, in order', async () => {
213
+ stream.arrange(control.ok, ok)
214
+ stream.arrange([1, 2, 3], { headers: { index: 1 }, type: 'batch' })
215
+ stream.arrange(4, { headers: { index: 2 } })
216
+ stream.arrange(...end(3))
217
+
218
+ expect(await stream.toArray()).toStrictEqual([1, 2, 3, 4])
219
+ })
220
+
221
+ it('should yield each buffer of a batch of buffers', async () => {
222
+ const buffers = Buffer.from([0, 0, 0, 2, 0x61, 0x62, 0, 0, 0, 0, 0, 0, 0, 1, 0x63])
223
+
224
+ stream.arrange(control.ok, ok)
225
+ stream.arrange(buffers, { headers: { index: 1 }, type: 'buffers' })
226
+ stream.arrange(...end(2))
227
+
228
+ expect(await stream.toArray()).toStrictEqual([Buffer.from('ab'), Buffer.alloc(0), Buffer.from('c')])
229
+ })
230
+
231
+ it('should hold a batch that arrives out of order until the gap is filled', async () => {
232
+ stream.arrange(control.ok, ok)
233
+ stream.arrange([3, 4], { headers: { index: 2 }, type: 'batch' })
234
+ stream.arrange([1, 2], { headers: { index: 1 }, type: 'batch' })
235
+ stream.arrange(...end(3))
236
+
237
+ expect(await stream.toArray()).toStrictEqual([1, 2, 3, 4])
238
+ })
239
+ })
@@ -4,7 +4,7 @@
4
4
 
5
5
  const { generate } = require('randomstring')
6
6
 
7
- const { Promex } = require('promex')
7
+ const { Promex } = require('../source/promex')
8
8
  const { timeout, random } = require('./helpers')
9
9
  const { amqplib, connect } = require('./amqplib.mock')
10
10
  const { channel: create } = require('./connection.mock')
@@ -0,0 +1,143 @@
1
+ 'use strict'
2
+
3
+ // Jest's globals, on top of node:test.
4
+ //
5
+ // The runner is Node's own; the assertions, the mocks and the fake timers are still Jest's,
6
+ // taken as the standalone packages they are published as. Loaded with `--import`, so that
7
+ // the test files themselves keep seeing `it`, `expect` and `jest` as globals.
8
+
9
+ const { createRequire } = require('node:module')
10
+ const { format } = require('node:util')
11
+ const nodeTest = require('node:test')
12
+ const { expect } = require('expect')
13
+ const { ModuleMocker } = require('jest-mock')
14
+ const { ModernFakeTimers } = require('@jest/fake-timers')
15
+
16
+ // node:test drives its own queue with process.nextTick: faking it, as Jest does inside its
17
+ // sandbox, stops the runner mid-file and the tests left behind are never reported as missing
18
+ const DO_NOT_FAKE = ['nextTick']
19
+
20
+ const mocker = new ModuleMocker(globalThis)
21
+ const timers = new ModernFakeTimers({ global: globalThis, config: { rootDir: process.cwd() } })
22
+
23
+ /**
24
+ * Where `jest.mock` was called from, so that its specifier resolves
25
+ * relative to the test file rather than to this one.
26
+ *
27
+ * @returns {string}
28
+ */
29
+ function origin () {
30
+ const prepare = Error.prepareStackTrace
31
+
32
+ Error.prepareStackTrace = (_, frames) => frames
33
+
34
+ const { stack } = new Error('origin')
35
+
36
+ Error.prepareStackTrace = prepare
37
+
38
+ for (const frame of stack) {
39
+ const file = frame.getFileName()
40
+
41
+ if (file !== null && file !== undefined && file !== __filename && !file.startsWith('node:')) {
42
+ return file
43
+ }
44
+ }
45
+
46
+ throw new Error('cannot tell which file has called jest.mock')
47
+ }
48
+
49
+ /**
50
+ * Replaces a module, as `jest.mock` does. Without a factory the module is automocked:
51
+ * every function it exports becomes a mock function.
52
+ *
53
+ * @param {string} specifier
54
+ * @param {() => object} [factory]
55
+ */
56
+ function mockModule (specifier, factory) {
57
+ if (nodeTest.mock.module === undefined) {
58
+ throw new Error('jest.mock requires node --experimental-test-module-mocks')
59
+ }
60
+
61
+ const path = createRequire(origin()).resolve(specifier)
62
+
63
+ const namedExports = factory === undefined
64
+ ? mocker.generateFromMetadata(mocker.getMetadata(require(path)))
65
+ : factory()
66
+
67
+ nodeTest.mock.module(path, { namedExports, cache: false })
68
+ }
69
+
70
+ const jest = {
71
+ fn: (implementation) => mocker.fn(implementation),
72
+ spyOn: (object, property, accessType) => mocker.spyOn(object, property, accessType),
73
+ clearAllMocks: () => mocker.clearAllMocks(),
74
+ resetAllMocks: () => mocker.resetAllMocks(),
75
+ restoreAllMocks: () => mocker.restoreAllMocks(),
76
+ mock: mockModule,
77
+ useFakeTimers: (config) => timers.useFakeTimers({ doNotFake: DO_NOT_FAKE, ...config }),
78
+ useRealTimers: () => timers.useRealTimers(),
79
+ advanceTimersByTime: (ms) => timers.advanceTimersByTime(ms),
80
+ advanceTimersByTimeAsync: (ms) => timers.advanceTimersByTimeAsync(ms),
81
+ runAllTimers: () => timers.runAllTimers(),
82
+ runAllTimersAsync: () => timers.runAllTimersAsync(),
83
+ runOnlyPendingTimers: () => timers.runOnlyPendingTimers(),
84
+ getTimerCount: () => timers.getTimerCount(),
85
+ setSystemTime: (now) => timers.setSystemTime(now)
86
+ }
87
+
88
+ /**
89
+ * `it.each` / `describe.each`: one test per row, the row spread into the callback,
90
+ * the title formatted with the row's values.
91
+ *
92
+ * @param {Function} declare
93
+ * @returns {(table: any[]) => (title: string, fn: Function, options?: object) => void}
94
+ */
95
+ const each = (declare) => (table) => (title, fn, options) => {
96
+ const placeholders = (title.replace(/%%/g, '').match(/%[sdifjoOc]/g) ?? []).length
97
+
98
+ for (const row of table) {
99
+ const values = Array.isArray(row) ? row : [row]
100
+
101
+ declare(format(title, ...values.slice(0, placeholders)), options, () => fn(...values))
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Jest's `it` and `describe` carry `.each`; node:test's do not.
107
+ *
108
+ * @param {Function} declare
109
+ * @returns {Function}
110
+ */
111
+ function extend (declare) {
112
+ // Jest takes a timeout where node:test takes options
113
+ const call = (...args) => declare(...(typeof args[2] === 'number'
114
+ ? [args[0], { timeout: args[2] }, args[1]]
115
+ : args))
116
+
117
+ const extended = Object.assign(call, declare, { each: each(call) })
118
+
119
+ extended.only = Object.assign((...args) => declare.only(...args), { each: each(declare.only) })
120
+ extended.skip = Object.assign((...args) => declare.skip(...args), { each: each(declare.skip) })
121
+ extended.todo = (...args) => declare.todo(...args)
122
+
123
+ return extended
124
+ }
125
+
126
+ Object.assign(globalThis, {
127
+ jest,
128
+ expect,
129
+ it: extend(nodeTest.it),
130
+ test: extend(nodeTest.test),
131
+ describe: extend(nodeTest.describe),
132
+ beforeEach: nodeTest.beforeEach,
133
+ afterEach: nodeTest.afterEach,
134
+ beforeAll: nodeTest.before,
135
+ afterAll: nodeTest.after
136
+ })
137
+
138
+ // `expect.assertions` is only enforced by whoever runs the test
139
+ nodeTest.afterEach(() => {
140
+ const errors = expect.extractExpectedAssertionsErrors()
141
+
142
+ if (errors.length > 0) throw errors[0].error
143
+ })
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { randomBytes } = require('node:crypto')
4
4
  const { generate } = require('randomstring')
5
- const { Promex } = require('promex')
5
+ const { Promex } = require('../source/promex')
6
6
  const { immediate } = require('./helpers')
7
7
 
8
8
  const mock = require('./connection.mock')
@@ -5,7 +5,7 @@
5
5
  const stream = require('node:stream')
6
6
  const { randomBytes } = require('node:crypto')
7
7
  const { generate } = require('randomstring')
8
- const { Promex } = require('promex')
8
+ const { Promex } = require('../source/promex')
9
9
  const { immediate } = require('./helpers')
10
10
  const { encode } = require('../source/encode')
11
11
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { randomBytes } = require('node:crypto')
4
4
  const { generate } = require('randomstring')
5
- const { Promex } = require('promex')
5
+ const { Promex } = require('../../source/promex')
6
6
  const { sample, immediate, random } = require('../helpers')
7
7
 
8
8
  const mock = require('../connection.mock')
@@ -1,15 +1,14 @@
1
1
  'use strict'
2
2
 
3
3
  const { generate } = require('randomstring')
4
- const { Promex } = require('promex')
4
+ const { Promex } = require('../../source/promex')
5
5
  const { random, immediate } = require('../helpers')
6
6
 
7
- const { Connection } = require('../../source/shards')
8
-
9
7
  const mock = require('../connection.mock')
10
8
 
11
9
  jest.mock('../../source/shards/channel')
12
10
 
11
+ const { Connection } = require('../../source/shards')
13
12
  const { create } = require('../../source/shards/channel')
14
13
 
15
14
  it('should be', async () => {