comq 0.20.1 → 0.21.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.
package/.husky/pre-commit CHANGED
@@ -1,5 +1,3 @@
1
- #!/usr/bin/env sh
2
- . "$(dirname -- "$0")/_/husky.sh"
3
-
4
1
  [ -n "$CI" ] && exit 0
2
+
5
3
  npm test
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,65 @@
1
+ # Benchmarks
2
+
3
+ What one request costs the peer that answers it: the CPU it spends, the socket writes it makes,
4
+ and what the caller waits.
5
+
6
+ ```shell
7
+ $ docker compose up -d
8
+ $ npm run benchmark
9
+ ```
10
+
11
+ The peer runs in a process of its own — that is where its CPU is read from — and answers an echo
12
+ of a small object over `request`/`reply` on the broker `docker-compose.yaml` starts.
13
+
14
+ ## What it reports
15
+
16
+ Two tables, because the two say different things.
17
+
18
+ **At a fixed rate** sends what a rate calls for and does not wait for the answers, which is how
19
+ traffic arrives. A rate below what the peer can serve is the honest place to read latency.
20
+
21
+ **With requests in flight** keeps a fixed number of them outstanding, which is how a queue of
22
+ callers behaves, and at a high enough number it is where the peer saturates: the rate column is
23
+ then what the peer can serve, and the CPU column is what one message costs it there.
24
+
25
+ Each row carries:
26
+
27
+ | column | what it is |
28
+ | --- | --- |
29
+ | `messages/s` | answered in the window, over its length |
30
+ | `CPU µs` | user and system time of the answering process, per message |
31
+ | `writes`, `writev` | socket write calls of the answering process, per message |
32
+ | `p50`, `p99` | what the caller waited, milliseconds |
33
+
34
+ `writes` and `writev` are the mechanism, not the outcome: a message the library writes as its own
35
+ syscall costs more than one written together with its neighbours.
36
+
37
+ ## Reading a result
38
+
39
+ A number here means something only beside another number from the same machine. Run it on the
40
+ revision you are changing, then on your change, and compare the columns; a difference under about
41
+ 5% between two rounds of the same revision is what this machine's noise looks like.
42
+
43
+ Both rounds are printed rather than averaged, so a run that drifted is visible as a run that
44
+ drifted.
45
+
46
+ ## Options
47
+
48
+ | option | default | what it does |
49
+ | --- | --- | --- |
50
+ | `--rates` | `1000,5000,20000,40000` | messages a second for the fixed-rate table |
51
+ | `--concurrency` | `1,8,64,256` | requests in flight for the second table |
52
+ | `--messages` | `20000` | messages per window, in flight mode |
53
+ | `--seconds` | `6` | length of a window, fixed-rate mode |
54
+ | `--rounds` | `2` | how many times the whole matrix is measured |
55
+
56
+ `COMQ_BENCHMARK_URL` points the run at another broker.
57
+
58
+ ```shell
59
+ $ npm run benchmark -- --rates 5000 --concurrency 64 --rounds 3
60
+ ```
61
+
62
+ ## What it does not measure
63
+
64
+ Streams, confirms, sharded or singleton connections, and recovery. It measures the path a request
65
+ and its reply take, which is the one every other path is built on.
@@ -0,0 +1,196 @@
1
+ // What one request costs the process that answers it. See readme.md.
2
+ //
3
+ // node benchmarks/run.js [--rates 1000,5000] [--concurrency 1,8] [--messages 20000]
4
+ // [--seconds 6] [--rounds 2]
5
+
6
+ import { fork } from 'node:child_process'
7
+ import { parseArgs } from 'node:util'
8
+ import { createRequire } from 'node:module'
9
+ import { setTimeout as sleep } from 'node:timers/promises'
10
+
11
+ const require = createRequire(import.meta.url)
12
+ const { connect } = require('../source/index.js')
13
+
14
+ const { values } = parseArgs({
15
+ options: {
16
+ rates: { type: 'string', default: '1000,5000,20000,40000' },
17
+ concurrency: { type: 'string', default: '1,8,64,256' },
18
+ messages: { type: 'string', default: '20000' },
19
+ seconds: { type: 'string', default: '6' },
20
+ rounds: { type: 'string', default: '2' }
21
+ }
22
+ })
23
+
24
+ const url = process.env.COMQ_BENCHMARK_URL ?? 'amqp://developer:secret@localhost:5673'
25
+ const rates = numbers(values.rates)
26
+ const concurrencies = numbers(values.concurrency)
27
+ const messages = Number(values.messages)
28
+ const seconds = Number(values.seconds)
29
+ const rounds = Number(values.rounds)
30
+
31
+ const durations = []
32
+
33
+ /** What a request carries, which is small, as a call between components is. */
34
+ const payload = {
35
+ id: '4c4759e6f9c74da989d64511df42d6f4',
36
+ title: 'First pot',
37
+ rank: 7,
38
+ public: true,
39
+ tags: ['one', 'two', 'three']
40
+ }
41
+
42
+ const server = await start()
43
+ const io = await connect(url)
44
+
45
+ try {
46
+ const open = []
47
+ const closed = []
48
+
49
+ for (let round = 1; round <= rounds; round++) {
50
+ for (const rate of rates) open.push(await atRate(rate, round))
51
+ for (const concurrency of concurrencies) closed.push(await inFlight(concurrency, round))
52
+ }
53
+
54
+ report('At a fixed rate', ['rate'], open)
55
+ report('With requests in flight', ['in flight'], closed)
56
+ } finally {
57
+ await io.close()
58
+
59
+ server.kill()
60
+ }
61
+
62
+ /**
63
+ * Requests sent at a rate, as traffic arrives: a millisecond's worth at a time, none of them
64
+ * waited for. What a peer costs per message is read from the peer itself.
65
+ */
66
+ async function atRate (rate, round) {
67
+ await load(() => send(rate, 2, false))
68
+
69
+ const measured = await load(() => send(rate, seconds, true))
70
+
71
+ return { round, of: rate, ...measured }
72
+ }
73
+
74
+ /** Requests kept in flight, as a queue of callers is: the peer is never idle between them. */
75
+ async function inFlight (concurrency, round) {
76
+ await load(() => saturate(concurrency, Math.min(5000, messages), false))
77
+
78
+ const measured = await load(() => saturate(concurrency, messages, true))
79
+
80
+ return { round, of: concurrency, ...measured }
81
+ }
82
+
83
+ /** A window: what the peer spent, and what the caller waited, between two readings. */
84
+ async function load (work) {
85
+ durations.length = 0
86
+
87
+ const before = await io.request('comq.benchmark.meter', null)
88
+ const started = process.hrtime.bigint()
89
+
90
+ await work()
91
+
92
+ const elapsed = Number(process.hrtime.bigint() - started) / 1e9
93
+ const after = await io.request('comq.benchmark.meter', null)
94
+ const answered = after.answered - before.answered
95
+ const cpu = after.cpu.user + after.cpu.system - before.cpu.user - before.cpu.system
96
+
97
+ return {
98
+ answered,
99
+ rate: Math.round(answered / elapsed),
100
+ cpu: round2(cpu / answered),
101
+ writes: round3((after.write - before.write) / answered),
102
+ writevs: round3((after.writev - before.writev) / answered),
103
+ p50: percentile(0.5),
104
+ p99: percentile(0.99)
105
+ }
106
+ }
107
+
108
+ async function send (rate, duration, record) {
109
+ const started = process.hrtime.bigint()
110
+ const pending = []
111
+
112
+ let sent = 0
113
+
114
+ while (elapsed(started) < duration) {
115
+ const due = Math.round(elapsed(started) * rate)
116
+
117
+ while (sent < due) {
118
+ sent++
119
+ pending.push(request(record))
120
+ }
121
+
122
+ await sleep(1)
123
+ }
124
+
125
+ await Promise.all(pending)
126
+ }
127
+
128
+ async function saturate (concurrency, count, record) {
129
+ let sent = 0
130
+
131
+ const caller = async () => {
132
+ while (sent < count) {
133
+ sent++
134
+
135
+ await request(record)
136
+ }
137
+ }
138
+
139
+ await Promise.all(Array.from({ length: concurrency }, caller))
140
+ }
141
+
142
+ async function request (record) {
143
+ const at = process.hrtime.bigint()
144
+
145
+ await io.request('comq.benchmark.echo', payload)
146
+
147
+ if (record) durations.push(Number(process.hrtime.bigint() - at) / 1e6)
148
+ }
149
+
150
+ function report (title, columns, rows) {
151
+ console.log(`\n## ${title}\n`)
152
+ console.log(`| ${columns[0]} | round | messages/s | CPU µs | writes | writev | p50 ms | p99 ms |`)
153
+ console.log('| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |')
154
+
155
+ for (const row of rows) {
156
+ console.log(`| ${row.of} | ${row.round} | ${row.rate} | ${row.cpu} | ${row.writes} | ` +
157
+ `${row.writevs} | ${row.p50} | ${row.p99} |`)
158
+ }
159
+ }
160
+
161
+ /** The peer in a process of its own, which is where its CPU is measured. */
162
+ async function start () {
163
+ const child = fork(new URL('server.js', import.meta.url), { stdio: 'inherit' })
164
+
165
+ await new Promise((resolve, reject) => {
166
+ child.once('message', resolve)
167
+ child.once('exit', (code) => reject(new Error(`The peer exited with ${code}`)))
168
+ })
169
+
170
+ return child
171
+ }
172
+
173
+ function percentile (share) {
174
+ // a warm-up window records nothing, and its result is discarded
175
+ if (durations.length === 0) return 0
176
+
177
+ const sorted = durations.slice().sort((a, b) => a - b)
178
+
179
+ return round3(sorted[Math.floor(sorted.length * share)])
180
+ }
181
+
182
+ function elapsed (since) {
183
+ return Number(process.hrtime.bigint() - since) / 1e9
184
+ }
185
+
186
+ function numbers (list) {
187
+ return list.split(',').map(Number)
188
+ }
189
+
190
+ function round2 (value) {
191
+ return Number(value.toFixed(2))
192
+ }
193
+
194
+ function round3 (value) {
195
+ return Number(value.toFixed(3))
196
+ }
@@ -0,0 +1,47 @@
1
+ // The peer under measurement: it answers an echo, and reports what answering cost it.
2
+ //
3
+ // The socket's write methods are counted here rather than in the library, because what a message
4
+ // costs a process is mostly the syscalls it makes: a reply and the acknowledgement of the request
5
+ // it answers are two writes unless something puts them together.
6
+
7
+ import net from 'node:net'
8
+ import { createRequire } from 'node:module'
9
+
10
+ const require = createRequire(import.meta.url)
11
+ const { connect } = require('../source/index.js')
12
+
13
+ const counters = { write: 0, writev: 0, chunks: 0 }
14
+ const write = net.Socket.prototype._write
15
+ const writev = net.Socket.prototype._writev
16
+
17
+ net.Socket.prototype._write = function (...args) {
18
+ counters.write++
19
+ counters.chunks++
20
+
21
+ return write.apply(this, args)
22
+ }
23
+
24
+ net.Socket.prototype._writev = function (chunks, ...rest) {
25
+ counters.writev++
26
+ counters.chunks += chunks.length
27
+
28
+ return writev.apply(this, [chunks, ...rest])
29
+ }
30
+
31
+ const url = process.env.COMQ_BENCHMARK_URL ?? 'amqp://developer:secret@localhost:5673'
32
+
33
+ let answered = 0
34
+
35
+ const io = await connect(url)
36
+
37
+ await io.reply('comq.benchmark.echo', (payload) => {
38
+ answered++
39
+
40
+ return payload
41
+ })
42
+
43
+ // read before and after a window, so what is reported is what that window cost
44
+ await io.reply('comq.benchmark.meter', () => ({ answered, cpu: process.cpuUsage(), ...counters }))
45
+
46
+ process.send?.('ready')
47
+ console.log('ready')
@@ -49,12 +49,13 @@ Feature: Reply streams
49
49
  Given heartbeat interval is set to 300ms
50
50
  And a number generator with 100ms increasing delay replying `get_numbers` queue
51
51
  When the consumer requests a stream with request to the `get_numbers` queue
52
- Then the consumer receives the stream:
53
- """yaml
54
- [0, 1, 3]
55
- """
56
- And after 500ms
57
- Then the generator is destroyed
52
+ And the consumer receives the stream
53
+ # a stream that stopped arriving is not a stream that ended
54
+ Then the consumer's stream terminates
55
+ And a stream that lost values has raised
56
+ And the consumer has received a prefix of the stream
57
+ And after 200ms
58
+ And the generator is destroyed
58
59
 
59
60
  Scenario: Reply stream heartbeat
60
61
  # delay will exceed the idle timeout on the 3rd reply
@@ -71,12 +72,9 @@ Feature: Reply streams
71
72
  Then the consumer receives the stream
72
73
  And after 300ms
73
74
  Then the broker has crashed
74
- # idle timeout
75
- Then after 150ms
76
- Then the consumer has received the stream:
77
- """yaml
78
- [0, 1, 2]
79
- """
75
+ Then the consumer's stream terminates
76
+ And a stream that lost values has raised
77
+ And the consumer has received a prefix of the stream
80
78
  Then after 200ms
81
79
  Then the broker is up
82
80
  And the generator is destroyed
@@ -29,10 +29,10 @@ Feature: Reply streams over shards
29
29
  And after 300ms
30
30
  And the broker has crashed
31
31
  And the broker is up
32
- Then the consumer interrupts the stream
33
- And the consumer has received the stream containing:
34
- """yaml
35
- [0, 1, 2, 3, 4, 5, 6]
36
- """
32
+ # a stream whose shard carried a value away with it is cut short; one over a shard that
33
+ # survived is not, and neither of them ends as though it had been answered whole
34
+ Then the consumer's stream terminates
35
+ And a stream that lost values has raised
36
+ And the consumer has received a prefix of the stream
37
37
  And after 1000ms
38
38
  And the generator is destroyed
@@ -31,6 +31,8 @@ declare namespace comq.features {
31
31
  stream: Readable
32
32
  streamValues: any[]
33
33
  streamEnded: boolean
34
+ streamError?: Error
35
+ streamLength?: number
34
36
  streams: Record<number, Readable>
35
37
  streamsValues: Record<number, any[]>
36
38
  streamsEnded: Record<number, boolean>
@@ -48,8 +48,10 @@ Given('a number generator with {number}ms increasing delay replying {token} queu
48
48
  async function (delay, queue) {
49
49
  const that = this
50
50
 
51
+ this.streamLength = MAX
52
+
51
53
  class Stream extends Readable {
52
- #MAX = 10
54
+ #MAX = MAX
53
55
  #count = 0
54
56
 
55
57
  constructor () {
@@ -305,6 +307,62 @@ Then('the consumer receives the stream',
305
307
  async function () {
306
308
  this.stream.on('data', (data) => this.streamValues.push(data))
307
309
  this.stream.on('end', () => (this.streamEnded = true))
310
+ this.stream.on('error', (error) => (this.streamError = error))
311
+ })
312
+
313
+ Then('the consumer\'s stream terminates',
314
+ /**
315
+ * Either way it terminates: whole, or cut short. Bounded, so that a stream that never does
316
+ * fails the scenario rather than hanging it.
317
+ *
318
+ * @this {comq.features.Context}
319
+ */
320
+ async function () {
321
+ if (this.stream.destroyed || this.streamEnded === true) return
322
+
323
+ // both ways it terminates, and `once` would reject on the one that raises
324
+ const terminated = new Promise((resolve) => {
325
+ this.stream.once('close', resolve)
326
+ this.stream.once('error', resolve)
327
+ })
328
+ const expired = timeout(TERMINATION_MS).then(() => 'expired')
329
+
330
+ assert.notEqual(await Promise.race([terminated, expired]), 'expired',
331
+ 'The stream has neither ended nor raised')
332
+ })
333
+
334
+ Then('the consumer has received a prefix of the stream',
335
+ /**
336
+ * The order of yielded values is preserved and the tail may be lost, so what arrived is the
337
+ * sequence from its start, with nothing missing in between and nothing twice.
338
+ *
339
+ * @this {comq.features.Context}
340
+ */
341
+ async function () {
342
+ const expected = this.streamValues.map((_, index) => index)
343
+
344
+ assert.deepEqual(this.streamValues, expected,
345
+ `Received ${JSON.stringify(this.streamValues)}, which is not a prefix of the stream`)
346
+ })
347
+
348
+ Then('a stream that lost values has raised',
349
+ /**
350
+ * A stream that did not deliver everything its producer yielded must not end as one that did:
351
+ * a caller cannot tell them apart otherwise.
352
+ *
353
+ * @this {comq.features.Context}
354
+ */
355
+ async function () {
356
+ const whole = this.streamValues.length === this.streamLength
357
+
358
+ if (whole) assert.equal(this.streamError, undefined, 'A whole stream has raised')
359
+ else {
360
+ assert.notEqual(this.streamError, undefined,
361
+ `The stream ended with ${this.streamValues.length} of ${this.streamLength} values and did not raise`)
362
+
363
+ assert.equal(this.streamError.name, 'Interrupted',
364
+ `The stream raised ${this.streamError.name}`)
365
+ }
308
366
  })
309
367
 
310
368
  Then('the consumer{number} receives the stream',
@@ -508,5 +566,10 @@ async function fetch (queue, payload, number) {
508
566
  }
509
567
  }
510
568
 
569
+ const MAX = 10
570
+
571
+ /** What a stream is given to terminate in, which is longer than this generator takes. */
572
+ const TERMINATION_MS = 5000
573
+
511
574
  global.COMQ_TESTING_IDLE_INTERVAL = 150
512
575
  global.COMQ_TESTING_HEARTBEAT_INTERVAL = 100
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -58,6 +58,7 @@
58
58
  "test": "standard && jest",
59
59
  "features": "cucumber-js --fail-fast -t 'not @manual'",
60
60
  "features:fast": "cucumber-js --fail-fast -t 'not @manual and not @heavy'",
61
+ "benchmark": "node benchmarks/run.js",
61
62
  "lint": "standard --fix --verbose | snazzy"
62
63
  },
63
64
  "standard": {
package/readme.md CHANGED
@@ -377,7 +377,7 @@ the request will be retransmitted upon reconnection.
377
377
 
378
378
  A heartbeat message is sent to the `replyTo` queue whenever a Reply stream idles for 5 seconds.
379
379
  If the Consumer of the reply stream doesn't receive a reply or a heartbeat message for 12 seconds, the stream returned
380
- by `IO.request` is destroyed.
380
+ by `IO.request` is destroyed with an `Interrupted` error.
381
381
  These intervals are not configurable.
382
382
 
383
383
  An "end stream" message is sent to the `replyTo` queue when the Reply stream is finished.
@@ -397,6 +397,17 @@ While consuming the Reply stream if the broker connection is lost,
397
397
  or if the Consumer crashes or destroys the stream returned by `IO.request`,
398
398
  some of the values yielded by the Reply stream may be lost.
399
399
 
400
+ A stream that lost values raises `Interrupted` rather than ending, so that a stream cut short is never taken for one
401
+ that was answered whole. What it delivered before that is the sequence from its start, in order.
402
+
403
+ ```javascript
404
+ try {
405
+ for await (const number of stream) console.log(number)
406
+ } catch (error) {
407
+ if (error instanceof Interrupted) console.log('the rest is not coming')
408
+ }
409
+ ```
410
+
400
411
  To avoid inconsistency, it is strongly recommended to use the Reply stream only with _safe_ Producers, which do not
401
412
  change the application state.
402
413
 
@@ -3,6 +3,7 @@
3
3
  const { Readable } = require('node:stream')
4
4
  const { Promex } = require('promex')
5
5
  const { IDLE_INTERVAL, FLOW_HEADER, control } = require('./const')
6
+ const { Interrupted } = require('../interrupted')
6
7
 
7
8
  class ReplyStream extends Readable {
8
9
  confirmation = new Promex()
@@ -36,6 +37,9 @@ class ReplyStream extends Readable {
36
37
  /** Whether the producer honours `pause` and `resume`. */
37
38
  #flow = false
38
39
 
40
+ /** Whether this stream has been handed to whoever asked for it, which is what confirms it. */
41
+ #confirmed = false
42
+
39
43
  /** Whether the producer has been asked to pause. */
40
44
  #throttled = false
41
45
 
@@ -68,13 +72,16 @@ class ReplyStream extends Readable {
68
72
  this._clear()
69
73
  this.push(null)
70
74
 
71
- // a no-op once control.ok has been received
72
- this.confirmation.reject(error ?? new Error(UNCONFIRMED))
75
+ // a no-op once control.ok has been received, and until then this is what it says: the
76
+ // stream never started, so the Request is re-sent rather than raised to anybody
77
+ this.confirmation.reject(new Error(UNCONFIRMED))
73
78
 
74
79
  if (this.#control !== undefined)
75
80
  void this.#reply(this.#control, control.end)
76
81
 
77
- super._destroy(error, callback)
82
+ // a stream nobody has received yet cannot raise to anybody: the Request is re-sent instead,
83
+ // and the rejected confirmation is what says so
84
+ super._destroy(this.#confirmed ? error : null, callback)
78
85
  }
79
86
 
80
87
  /**
@@ -142,7 +149,7 @@ class ReplyStream extends Readable {
142
149
  */
143
150
  _buffer (payload, properties, size) {
144
151
  if (this.#buffered > this.#maxBufferSize || this.#bufferedBytes + size > this.#maxBufferBytes) {
145
- this.destroy()
152
+ this.destroy(new Interrupted('the values held until the missing one arrives no longer fit'))
146
153
 
147
154
  return
148
155
  }
@@ -178,6 +185,7 @@ class ReplyStream extends Readable {
178
185
  case control.ok:
179
186
  this.#control = { properties }
180
187
  this.#flow = properties.headers?.[FLOW_HEADER] === true
188
+ this.#confirmed = true
181
189
  this.confirmation.resolve()
182
190
  break
183
191
  case control.heartbeat:
@@ -193,7 +201,10 @@ class ReplyStream extends Readable {
193
201
  _heartbeat () {
194
202
  if (this.#timeout !== null) clearTimeout(this.#timeout)
195
203
 
196
- this.#timeout = setTimeout(() => this.destroy(), this.#idleInterval)
204
+ this.#timeout = setTimeout(
205
+ () => this.destroy(new Interrupted(`nothing arrived within ${this.#idleInterval}ms`)),
206
+ this.#idleInterval
207
+ )
197
208
  }
198
209
 
199
210
  _clear () {
@@ -0,0 +1,62 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Writes as one what a connection sends in one turn of the event loop.
5
+ *
6
+ * amqplib hands its frames to the socket one write at a time, and a request served is two of
7
+ * them: the reply, and the acknowledgement of the request it answers. With Nagle's algorithm off,
8
+ * which is what a socket sending without delay runs under, each is a syscall and a packet of its
9
+ * own. Corked, they are held in the socket's buffer and leave together, in one `writev`, at the
10
+ * end of the same turn: nothing waits for a timer, and nothing waits for a write that may never
11
+ * come.
12
+ *
13
+ * This belongs in amqplib, whose loop does the writing, and is proposed there:
14
+ * https://github.com/temich/amqplib/tree/perf/one-write-per-turn. It is done here until that
15
+ * lands, and goes when it does.
16
+ *
17
+ * @param {import('node:net').Socket} socket
18
+ */
19
+ function batch (socket) {
20
+ // whatever a connection is carried over is left as it is unless it is a stream that corks
21
+ if (!corkable(socket) || socket[BATCHED] === true) return
22
+
23
+ socket[BATCHED] = true
24
+
25
+ const write = socket.write.bind(socket)
26
+
27
+ const flush = () => {
28
+ socket[CORKED] = false
29
+ socket.uncork()
30
+ }
31
+
32
+ const cork = () => {
33
+ if (socket[CORKED] === true) return
34
+
35
+ socket[CORKED] = true
36
+ socket.cork()
37
+
38
+ process.nextTick(flush)
39
+ }
40
+
41
+ socket.write = (...args) => {
42
+ cork()
43
+
44
+ return write(...args)
45
+ }
46
+ }
47
+
48
+ /**
49
+ * @param {any} socket
50
+ * @returns {boolean}
51
+ */
52
+ function corkable (socket) {
53
+ return socket !== null && socket !== undefined &&
54
+ typeof socket.write === 'function' &&
55
+ typeof socket.cork === 'function' &&
56
+ typeof socket.uncork === 'function'
57
+ }
58
+
59
+ const BATCHED = Symbol('comq.batched')
60
+ const CORKED = Symbol('comq.corked')
61
+
62
+ exports.batch = batch
@@ -6,6 +6,7 @@ const { Promex } = require('promex')
6
6
  const { retry } = require('reretry')
7
7
 
8
8
  const { failsafe } = require('./attributes')
9
+ const { batch } = require('./batch')
9
10
  const presets = require('./topology')
10
11
  const channels = require('./channel')
11
12
  const emitter = require('./emitter')
@@ -146,6 +147,9 @@ class Connection {
146
147
 
147
148
  connection.on('close', (error) => this.#close(connection, error))
148
149
  this.#connection = connection
150
+
151
+ batch(connection.connection?.stream)
152
+
149
153
  this.#armWatchdog(connection)
150
154
  this.#diagnostics.emit('open')
151
155
 
package/source/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { connect, assert } = require('./connect')
4
4
  const { Retry, Park } = require('./verdicts')
5
5
  const { Unroutable } = require('./unroutable')
6
+ const { Interrupted } = require('./interrupted')
6
7
 
7
8
  exports.connect = connect
8
9
  exports.assert = assert
@@ -10,3 +11,4 @@ exports.assert = assert
10
11
  exports.Retry = Retry
11
12
  exports.Park = Park
12
13
  exports.Unroutable = Unroutable
14
+ exports.Interrupted = Interrupted
@@ -0,0 +1,23 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * A Reply stream that ended before its producer did: what was received is a prefix of what was
5
+ * yielded, and the rest is not coming. A stream that completes ends instead, so that the two
6
+ * cannot be taken for one another.
7
+ */
8
+ class Interrupted extends Error {
9
+ /** @type {string} */
10
+ reason
11
+
12
+ /**
13
+ * @param {string} reason
14
+ */
15
+ constructor (reason) {
16
+ super(`Reply stream has been interrupted: ${reason}`)
17
+
18
+ this.name = 'Interrupted'
19
+ this.reason = reason
20
+ }
21
+ }
22
+
23
+ exports.Interrupted = Interrupted
@@ -4,6 +4,8 @@ const { once } = require('node:events')
4
4
  const { ReplyStream, UNCONFIRMED } = require('../source/.io/ReplyStream')
5
5
  const { createReplyEmitter } = require('../source/.io/createReplyEmitter')
6
6
  const { control, FLOW_HEADER } = require('../source/.io/const')
7
+ const { Interrupted } = require('../source/interrupted')
8
+ const { timeout } = require('./helpers')
7
9
 
8
10
  /** @type {jest.Mock} */
9
11
  let reply
@@ -72,6 +74,49 @@ it('should reject confirmation when nothing arrives within the idle interval', a
72
74
  await expect(idle.confirmation).rejects.toThrow(UNCONFIRMED)
73
75
  })
74
76
 
77
+ it('should raise when nothing arrives within the idle interval', async () => {
78
+ global.COMQ_TESTING_IDLE_INTERVAL = 10
79
+
80
+ const request = {
81
+ emitter: createReplyEmitter('test'),
82
+ properties: { correlationId: 'raising-correlation' }
83
+ }
84
+
85
+ const idle = new ReplyStream(request, reply)
86
+
87
+ // the stream is answered, which is when it is handed to whoever asked for it
88
+ idle.arrange(control.ok, { headers: { index: 0 }, type: 'control' })
89
+
90
+ const [error] = await once(idle, 'error')
91
+
92
+ expect(error).toBeInstanceOf(Interrupted)
93
+ expect(idle.readableEnded).toStrictEqual(false)
94
+ })
95
+
96
+ it('should raise when the values held until the missing one arrives no longer fit', async () => {
97
+ stream.arrange(control.ok, { headers: { index: 0 }, type: 'control' })
98
+
99
+ for (let index = 2; index <= 6; index++) {
100
+ stream.arrange(index, { headers: { index } })
101
+ }
102
+
103
+ const [error] = await once(stream, 'error')
104
+
105
+ expect(error).toBeInstanceOf(Interrupted)
106
+ })
107
+
108
+ it('should end without raising when the consumer destroys it', async () => {
109
+ const raised = jest.fn()
110
+
111
+ stream.on('error', raised)
112
+ stream.arrange(control.ok, { headers: { index: 0 }, type: 'control' })
113
+ stream.destroy()
114
+
115
+ await timeout(10)
116
+
117
+ expect(raised).not.toHaveBeenCalled()
118
+ })
119
+
75
120
  it('should keep confirmation resolved after control.ok', async () => {
76
121
  stream.arrange(control.ok, { headers: { index: 0 }, type: 'control' })
77
122
 
@@ -0,0 +1,94 @@
1
+ 'use strict'
2
+
3
+ const { Writable } = require('node:stream')
4
+
5
+ const { batch } = require('../source/batch')
6
+
7
+ /** A socket, as far as writing to it goes: what it was given, and how many times. */
8
+ function socket () {
9
+ const writes = []
10
+
11
+ return Object.assign(new Writable({
12
+ write (chunk, _encoding, callback) {
13
+ writes.push([chunk.toString()])
14
+ callback()
15
+ },
16
+ writev (chunks, callback) {
17
+ writes.push(chunks.map(({ chunk }) => chunk.toString()))
18
+ callback()
19
+ }
20
+ }), { writes })
21
+ }
22
+
23
+ const tick = () => new Promise((resolve) => process.nextTick(resolve))
24
+
25
+ it('should be', async () => {
26
+ expect(batch).toBeDefined()
27
+ })
28
+
29
+ it('should write what a turn wrote as one', async () => {
30
+ const stream = socket()
31
+
32
+ batch(stream)
33
+
34
+ stream.write('a')
35
+ stream.write('b')
36
+
37
+ await tick()
38
+
39
+ expect(stream.writes).toStrictEqual([['a', 'b']])
40
+ })
41
+
42
+ it('should write what a later turn wrote on its own', async () => {
43
+ const stream = socket()
44
+
45
+ batch(stream)
46
+
47
+ stream.write('a')
48
+
49
+ await tick()
50
+
51
+ stream.write('b')
52
+
53
+ await tick()
54
+
55
+ expect(stream.writes).toStrictEqual([['a'], ['b']])
56
+ })
57
+
58
+ it('should not hold a write past its turn', async () => {
59
+ const stream = socket()
60
+
61
+ batch(stream)
62
+
63
+ stream.write('a')
64
+
65
+ await tick()
66
+
67
+ expect(stream.writes).toStrictEqual([['a']])
68
+ })
69
+
70
+ it('should take a socket once', async () => {
71
+ const stream = socket()
72
+
73
+ batch(stream)
74
+
75
+ const wrapped = stream.write
76
+
77
+ batch(stream)
78
+
79
+ expect(stream.write).toStrictEqual(wrapped)
80
+ })
81
+
82
+ it('should leave alone what does not cork', async () => {
83
+ for (const value of [undefined, null, {}, { write: () => undefined }]) {
84
+ expect(() => batch(value)).not.toThrow()
85
+ }
86
+
87
+ const written = []
88
+ const plain = { write: (chunk) => written.push(chunk) }
89
+
90
+ batch(plain)
91
+ plain.write('a')
92
+
93
+ expect(written).toStrictEqual(['a'])
94
+ })
package/types/index.d.ts CHANGED
@@ -30,3 +30,11 @@ export class Unroutable extends Error {
30
30
  readonly exchange: string
31
31
  readonly key: string
32
32
  }
33
+
34
+ /**
35
+ * A Reply stream that ended before its producer did: what was received is a prefix of what was
36
+ * yielded, and the rest is not coming. A stream that completes ends instead.
37
+ */
38
+ export class Interrupted extends Error {
39
+ readonly reason: string
40
+ }