comq 0.24.0 → 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.
Files changed (48) hide show
  1. package/.husky/commit-msg +5 -0
  2. package/benchmarks/run.js +66 -4
  3. package/benchmarks/server.js +14 -0
  4. package/commitlint.config.js +6 -0
  5. package/features/heartbeat.feature +16 -0
  6. package/features/rpc.call.recovery.feature +2 -2
  7. package/features/rpc.shards.retransmission.feature +46 -0
  8. package/features/rpc.streams.feature +28 -0
  9. package/features/shards.feature +1 -1
  10. package/features/silence.feature +3 -2
  11. package/features/steps/.types/context.d.ts +6 -1
  12. package/features/steps/brokers.js +1 -1
  13. package/features/steps/connection.js +23 -0
  14. package/features/steps/context.js +1 -2
  15. package/features/steps/hooks.js +0 -1
  16. package/features/steps/network.js +11 -2
  17. package/features/steps/networks.js +31 -2
  18. package/features/steps/retransmission.js +165 -0
  19. package/features/steps/rpc.js +31 -7
  20. package/package.json +31 -9
  21. package/readme.md +15 -4
  22. package/source/.io/ReplyPipe.js +130 -11
  23. package/source/.io/ReplyStream.js +41 -2
  24. package/source/.io/const.js +6 -0
  25. package/source/attributes/track.test.js +1 -1
  26. package/source/channel.js +5 -3
  27. package/source/connection.js +11 -88
  28. package/source/io.js +91 -16
  29. package/source/promex.js +47 -0
  30. package/source/promex.test.js +79 -0
  31. package/source/retry.js +42 -0
  32. package/source/retry.test.js +120 -0
  33. package/source/shards/channel.js +74 -13
  34. package/test/.types/jest.d.ts +22 -0
  35. package/test/ReplyPipe.test.js +71 -2
  36. package/test/ReplyStream.test.js +33 -0
  37. package/test/connection.mock.js +3 -2
  38. package/test/connection.test.js +16 -78
  39. package/test/globals.js +143 -0
  40. package/test/io.call.test.js +5 -3
  41. package/test/io.close.test.js +1 -1
  42. package/test/io.reply.test.js +5 -3
  43. package/test/io.request.test.js +264 -2
  44. package/test/memory.test.js +0 -2
  45. package/test/shards/channel.test.js +169 -3
  46. package/test/shards/connection.test.js +2 -3
  47. package/types/channel.d.ts +13 -4
  48. package/types/io.d.ts +9 -0
@@ -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
+ })
@@ -6,13 +6,14 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
6
6
  sharded,
7
7
  consume: jest.fn(async () => undefined),
8
8
  deliver: jest.fn(async () => undefined),
9
- send: jest.fn(async () => undefined),
9
+ // a sharded channel tells which shard a message goes through, the first one unless told otherwise
10
+ send: jest.fn(async (_queue, _buffer, _options, via) => { if (sharded) via?.(0) }),
10
11
  fire: jest.fn(async () => undefined),
11
12
  subscribe: jest.fn(async () => undefined),
12
13
  bound: jest.fn(async () => undefined),
13
14
  held: jest.fn(async () => undefined),
14
15
  publish: jest.fn(async () => undefined),
15
- route: jest.fn(async () => undefined),
16
+ route: jest.fn(async (_exchange, _key, _buffer, _options, via) => { if (sharded) via?.(0) }),
16
17
  diagnose: jest.fn(async () => undefined),
17
18
  forget: jest.fn(() => undefined),
18
19
  seal: jest.fn(async () => undefined),
@@ -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')
@@ -543,109 +543,47 @@ describe('channel exhaustion', () => {
543
543
  })
544
544
  })
545
545
 
546
- describe('watchdog', () => {
546
+ describe('silence', () => {
547
547
  afterEach(() => {
548
548
  jest.useRealTimers()
549
549
  })
550
550
 
551
- it('should destroy silent connection when watchdog expires', async () => {
551
+ // amqplib reads the socket through a callback rather than 'data' events, and tells a
552
+ // connection gone silent by its heartbeat
553
+ it('should keep an idle connection', async () => {
552
554
  jest.useFakeTimers()
553
555
 
554
556
  await connection.open()
555
557
 
556
558
  const conn = await amqplib.connect.mock.results[0].value
557
559
 
558
- await jest.advanceTimersByTimeAsync(46_000)
559
-
560
- expect(conn.connection.stream.destroy).toHaveBeenCalled()
561
- })
562
-
563
- it('should reconnect after the watchdog destroys a silent connection', async () => {
564
- jest.useFakeTimers()
565
-
566
- await connection.open()
567
-
568
- await jest.advanceTimersByTimeAsync(46_000)
569
-
570
- // a socket destroyed without an error is a socket amqplib never reports,
571
- // which leaves the connection silently dead instead of recovering
572
- expect(amqplib.connect).toHaveBeenCalledTimes(2)
573
- })
574
-
575
- it('should not let a replaced socket disarm the watchdog', async () => {
576
- jest.useFakeTimers()
577
-
578
- await connection.open()
579
-
580
- const stale = await amqplib.connect.mock.results[0].value
581
-
582
- stale.emit('close', new Error('lost'))
583
-
584
- await jest.advanceTimersByTimeAsync(1)
585
-
586
- const live = await amqplib.connect.mock.results[1].value
587
-
588
- await jest.advanceTimersByTimeAsync(30_000)
589
-
590
- stale.connection.stream.emit('data', Buffer.alloc(0))
591
-
592
- await jest.advanceTimersByTimeAsync(30_000)
593
-
594
- expect(live.connection.stream.destroy).toHaveBeenCalled()
595
- })
596
-
597
- it('should reset watchdog on socket data', async () => {
598
- jest.useFakeTimers()
599
-
600
- await connection.open()
601
-
602
- const conn = await amqplib.connect.mock.results[0].value
603
-
604
- await jest.advanceTimersByTimeAsync(40_000)
605
-
606
- conn.connection.stream.emit('data', Buffer.alloc(0))
607
-
608
- await jest.advanceTimersByTimeAsync(40_000)
560
+ await jest.advanceTimersByTimeAsync(600_000)
609
561
 
610
562
  expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
611
-
612
- await jest.advanceTimersByTimeAsync(10_000)
613
-
614
- expect(conn.connection.stream.destroy).toHaveBeenCalled()
563
+ expect(amqplib.connect).toHaveBeenCalledTimes(1)
615
564
  })
616
565
 
617
- it('should derive the watchdog from the negotiated heartbeat', async () => {
618
- jest.useFakeTimers()
619
-
620
- const connection = new Connection('amqp://localhost?heartbeat=30')
621
-
566
+ it('should destroy the socket of a connection lost with an error', async () => {
622
567
  await connection.open()
623
568
 
624
569
  const conn = await amqplib.connect.mock.results[0].value
570
+ const stream = conn.connection.stream
625
571
 
626
- // the broker is only expected to say something every 15 seconds
627
- await jest.advanceTimersByTimeAsync(60_000)
628
-
629
- expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
630
-
631
- await jest.advanceTimersByTimeAsync(31_000)
572
+ conn.emit('close', new Error('Heartbeat timeout'))
632
573
 
633
- expect(conn.connection.stream.destroy).toHaveBeenCalled()
574
+ expect(stream.destroy).toHaveBeenCalled()
634
575
  })
635
576
 
636
- it('should not arm the watchdog when heartbeats are disabled', async () => {
637
- jest.useFakeTimers()
638
-
639
- const connection = new Connection('amqp://localhost?heartbeat=0')
640
-
577
+ it('should reconnect once a connection is lost with an error', async () => {
641
578
  await connection.open()
642
579
 
643
580
  const conn = await amqplib.connect.mock.results[0].value
644
581
 
645
- // an idle connection that has agreed to no heartbeats is silent by design
646
- await jest.advanceTimersByTimeAsync(600_000)
582
+ conn.emit('close', new Error('Heartbeat timeout'))
647
583
 
648
- expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
584
+ await timeout(0)
585
+
586
+ expect(amqplib.connect).toHaveBeenCalledTimes(2)
649
587
  })
650
588
  })
651
589
 
@@ -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
+ })
@@ -52,7 +52,7 @@ describe('call', () => {
52
52
 
53
53
  it('should publish to the exchange under the key', async () => {
54
54
  expect(requests.route).toHaveBeenCalledWith(exchange, key, expect.any(Buffer),
55
- expect.objectContaining({ mandatory: true }))
55
+ expect.objectContaining({ mandatory: true }), expect.any(Function))
56
56
  })
57
57
 
58
58
  it('should declare no queue to publish to', async () => {
@@ -116,14 +116,16 @@ describe('back', () => {
116
116
  const contentType = 'application/json'
117
117
  const properties = { contentType, correlationId: generate(), replyTo: generate() }
118
118
 
119
- await consumer({ content: encode(payload, contentType), properties })
119
+ const request = { content: encode(payload, contentType), properties }
120
+
121
+ await consumer(request)
120
122
 
121
123
  replies = await findChannel('reply')
122
124
 
123
125
  expect(producer).toHaveBeenCalledWith(payload)
124
126
 
125
127
  expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, expect.any(Buffer),
126
- expect.objectContaining({ correlationId: properties.correlationId }))
128
+ expect.objectContaining({ correlationId: properties.correlationId }), request)
127
129
  })
128
130
  })
129
131
 
@@ -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')
@@ -98,7 +98,8 @@ describe('reply', () => {
98
98
  const reply = await produce.mock.results[0].value
99
99
  const buffer = encode(reply, properties.contentType)
100
100
 
101
- expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, buffer, expect.anything())
101
+ // the request goes along, so that the reply goes back the way it came
102
+ expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, buffer, expect.anything(), message)
102
103
  })
103
104
 
104
105
  it('should throw if producer returned undefined', async () => {
@@ -190,7 +191,7 @@ describe('encoding', () => {
190
191
  mandatory: true
191
192
  }
192
193
 
193
- expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, reply, props)
194
+ expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, reply, props, message)
194
195
  })
195
196
 
196
197
  it('should set octet-stream for Buffer reply', async () => {
@@ -215,7 +216,8 @@ describe('encoding', () => {
215
216
  .toHaveBeenCalledWith(
216
217
  expect.any(String),
217
218
  buffer,
218
- expect.objectContaining({ contentType: 'application/octet-stream' })
219
+ expect.objectContaining({ contentType: 'application/octet-stream' }),
220
+ message
219
221
  )
220
222
  })
221
223