comq 0.8.0 → 0.10.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 (67) hide show
  1. package/.github/dependabot.yml +2 -2
  2. package/.github/workflows/codeql.yml +1 -1
  3. package/.github/workflows/release.yaml +16 -15
  4. package/docs/reply-stream-topology-dark.jpg +0 -0
  5. package/docs/reply-stream-topology-light.jpg +0 -0
  6. package/examples/readme.md +19 -3
  7. package/examples/streams/consumer.js +36 -0
  8. package/examples/streams/producer.js +44 -0
  9. package/features/backpressure.feature +2 -2
  10. package/features/events.feature +5 -0
  11. package/features/readme.md +2 -2
  12. package/features/recovery.feature +0 -3
  13. package/features/rpc.feature +15 -10
  14. package/features/rpc.streams.feature +81 -0
  15. package/features/rpc.streams.shards.feature +37 -0
  16. package/features/steps/.test/context.test.js +1 -1
  17. package/features/steps/.test/events.test.js +2 -2
  18. package/features/steps/.test/rpc.test.js +1 -1
  19. package/features/steps/.types/context.d.ts +9 -1
  20. package/features/steps/common.js +12 -0
  21. package/features/steps/context.js +9 -2
  22. package/features/steps/events.js +29 -0
  23. package/features/steps/rpc.js +273 -0
  24. package/lerna.json +1 -2
  25. package/package.json +2 -2
  26. package/readme.md +159 -31
  27. package/source/.io/ReplyPipe.js +158 -0
  28. package/source/.io/ReplyStream.js +142 -0
  29. package/source/.io/const.js +10 -0
  30. package/source/.io/{replies.js → createReplyEmitter.js} +3 -6
  31. package/source/.io/index.js +5 -1
  32. package/source/attributes/failsafe.js +34 -0
  33. package/source/attributes/failsafe.test.js +133 -0
  34. package/source/attributes/index.js +13 -0
  35. package/source/attributes/lazy.js +79 -0
  36. package/source/attributes/lazy.test.js +299 -0
  37. package/source/attributes/memo.js +13 -0
  38. package/source/attributes/memo.test.js +36 -0
  39. package/source/attributes/recall.js +42 -0
  40. package/source/attributes/recall.test.js +92 -0
  41. package/source/attributes/track.js +32 -0
  42. package/source/attributes/track.test.js +133 -0
  43. package/source/channel.js +33 -18
  44. package/source/connect.js +2 -2
  45. package/source/connection.js +4 -11
  46. package/source/emitter.js +13 -0
  47. package/source/encode.js +1 -1
  48. package/source/events.js +3 -3
  49. package/source/io.js +184 -48
  50. package/source/pipeline.js +43 -0
  51. package/source/shards/channel.js +28 -12
  52. package/source/shards/connection.js +2 -2
  53. package/test/channel.test.js +9 -1
  54. package/test/encode.test.js +2 -2
  55. package/test/encodings.js +1 -1
  56. package/test/io.diagnostics.test.js +3 -3
  57. package/test/io.emit.test.js +18 -3
  58. package/test/io.request.test.js +45 -23
  59. package/test/pipeline.test.js +81 -0
  60. package/test/shards/channel.test.js +39 -3
  61. package/test/shards/connection.test.js +1 -1
  62. package/types/channel.d.ts +13 -11
  63. package/types/connection.d.ts +3 -3
  64. package/types/diagnostic.d.ts +4 -3
  65. package/types/encoding.d.ts +2 -2
  66. package/types/index.d.ts +4 -4
  67. package/types/io.d.ts +40 -32
@@ -0,0 +1,158 @@
1
+ 'use strict'
2
+
3
+ const { EventEmitter } = require('node:events')
4
+ const { control, HEARTBEAT_INTERVAL } = require('./const')
5
+
6
+ /** @typedef {(message: any, properties?: comq.amqp.options.Publish) => Promise<void>} Reply */
7
+
8
+ class ReplyPipe extends EventEmitter {
9
+ #index = -1
10
+ #interrupted = false
11
+ #heartbeatInterval = global['COMQ_TESTING_HEARTBEAT_INTERVAL'] || HEARTBEAT_INTERVAL
12
+
13
+ /** @type {ReturnType<setInterval> | null} */
14
+ #interval = null
15
+
16
+ /** @type {string} */
17
+ #replyTo
18
+
19
+ /** @type {Record<string, comq.amqp.options.Publish>} */
20
+ #properties
21
+
22
+ /** @type {import('node:stream').Readable} */
23
+ #stream
24
+
25
+ /** @type {comq.Channel} */
26
+ #channel
27
+
28
+ /** @type {comq.ReplyEmitter} */
29
+ #feedback
30
+
31
+ /** @type {Reply} */
32
+ #reply
33
+
34
+ /**
35
+ * @param {comq.amqp.Message} request
36
+ * @param {stream.Readable} stream
37
+ * @param {comq.Channel} channel
38
+ * @param {comq.ReplyEmitter} feedback
39
+ * @param {Reply} reply
40
+ */
41
+ constructor (request, stream, channel, feedback, reply) {
42
+ super()
43
+
44
+ const { correlationId, replyTo } = request.properties
45
+
46
+ this.#stream = stream
47
+ this.#channel = channel
48
+ this.#feedback = feedback
49
+ this.#reply = reply
50
+ this.#replyTo = replyTo
51
+
52
+ this.#properties = {
53
+ chunk: { correlationId, ...CHUNK },
54
+ control: { correlationId, replyTo: feedback.queue, ...CONTROL }
55
+ }
56
+
57
+ channel.diagnose('return', this.#onReturn)
58
+ feedback.on(correlationId, this.#control)
59
+ }
60
+
61
+ async pipe () {
62
+ await this.#transmit(control.ok, this.#properties.control)
63
+
64
+ this.#stream.on('data', this.#onData)
65
+ this.#stream.on('close', this.#close)
66
+ this.#heartbeat()
67
+ }
68
+
69
+ destroy () {
70
+ this.#close()
71
+ this.#stream.destroy()
72
+ }
73
+
74
+ async #transmit (data, properties) {
75
+ this.#index++
76
+
77
+ const ok = await this.#reply(data,
78
+ { ...properties, headers: { index: this.#index } })
79
+
80
+ if (!ok) this.#interrupt()
81
+ }
82
+
83
+ #heartbeat () {
84
+ if (this.#interval !== null) clearInterval(this.#interval)
85
+
86
+ this.#interval = setInterval(
87
+ () => this.#transmit(control.heartbeat, this.#properties.control),
88
+ this.#heartbeatInterval
89
+ )
90
+ }
91
+
92
+ #interrupt () {
93
+ this.#interrupted = true
94
+ this.destroy()
95
+ }
96
+
97
+ #clear () {
98
+ clearInterval(this.#interval)
99
+
100
+ this.#stream.removeAllListeners()
101
+ this.#channel.forget('return', this.#onReturn)
102
+ this.#feedback.off(this.#properties.control.correlationId, this.#control)
103
+ }
104
+
105
+ #close = () => {
106
+ this.emit('close')
107
+ this.#clear()
108
+
109
+ if (!this.#interrupted) {
110
+ void this.#transmit(control.end, this.#properties.control)
111
+ }
112
+ }
113
+
114
+ #onData = async (chunk) => {
115
+ await this.#transmit(chunk, this.#properties.chunk)
116
+
117
+ this.#heartbeat()
118
+ }
119
+
120
+ #onReturn = (message) => {
121
+ if (message.fields.routingKey === this.#replyTo)
122
+ this.#interrupt()
123
+ }
124
+
125
+ #control = (message) => {
126
+ switch (message) {
127
+ case control.end:
128
+ this.#interrupt()
129
+ break
130
+ default:
131
+ throw new Error(`Unknown control message: ${message}`)
132
+ }
133
+ }
134
+
135
+ /**
136
+ * @param {comq.amqp.Message} request
137
+ * @param {stream.Readable} stream
138
+ * @param {comq.Channel} channel
139
+ * @param {comq.ReplyEmitter} control
140
+ * @param {Reply} reply
141
+ * @return {Promise<comq.Destroyable>}
142
+ */
143
+ static async create (request, stream, channel, control, reply) {
144
+ const pipe = new ReplyPipe(request, stream, channel, control, reply)
145
+
146
+ await pipe.pipe()
147
+
148
+ return /** @type {comq.Destroyable} */ pipe
149
+ }
150
+ }
151
+
152
+ /** @type {comq.amqp.options.Publish} */
153
+ const CHUNK = { mandatory: true }
154
+
155
+ /** @type {comq.amqp.options.Publish} */
156
+ const CONTROL = { type: 'control', mandatory: true }
157
+
158
+ exports.ReplyPipe = ReplyPipe
@@ -0,0 +1,142 @@
1
+ 'use strict'
2
+
3
+ const { IDLE_INTERVAL, control } = require('./const')
4
+
5
+ const { Readable } = require('node:stream')
6
+
7
+ class ReplyStream extends Readable {
8
+ #confirmation
9
+
10
+ /** @type {ReturnType<setTimeout> | null} */
11
+ #timeout = null
12
+
13
+ /** @type {comq.ReplyEmitter} */
14
+ #emitter
15
+
16
+ /** @type {string} */
17
+ #correlationId
18
+
19
+ #control
20
+
21
+ #reply
22
+
23
+ #idleInterval
24
+
25
+ #index = 0
26
+
27
+ #buffered = 0
28
+
29
+ /** @type {Record<number, unknown>} */
30
+ #queue = {}
31
+
32
+ /**
33
+ * @param {comq.Request} request
34
+ * @param {any} reply
35
+ */
36
+ constructor (request, reply) {
37
+ super({ objectMode: true })
38
+
39
+ this.#confirmation = request.reply
40
+ this.#emitter = request.emitter
41
+ this.#correlationId = request.properties.correlationId
42
+ this.#idleInterval = global['COMQ_TESTING_IDLE_INTERVAL'] || IDLE_INTERVAL
43
+ this.#reply = reply
44
+
45
+ this.#emitter.on(this.#correlationId, this._arrange.bind(this))
46
+ this.#confirmation.catch(this._clear.bind(this))
47
+ }
48
+
49
+ _destroy (error, callback) {
50
+ this._clear()
51
+ this.push(null)
52
+
53
+ void this.#reply(this.#control, control.end)
54
+ super._destroy(error, callback)
55
+ }
56
+
57
+ _read (_) {}
58
+
59
+ /**
60
+ * @param {unknown} payload
61
+ * @param {comq.amqp.Properties} properties
62
+ * @private
63
+ */
64
+ _arrange (payload, properties) {
65
+ if (properties.headers.index !== this.#index) {
66
+ this._buffer(payload, properties)
67
+
68
+ return
69
+ }
70
+
71
+ this._add(payload, properties)
72
+
73
+ if (this.#buffered > 0) {
74
+ let message
75
+
76
+ while ((message = this.#queue[this.#index])) {
77
+ delete this.#queue[this.#index]
78
+ this._add(message.payload, message.properties)
79
+ }
80
+
81
+ this.#buffered = Object.keys(this.#queue).length
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @param {unknown} payload
87
+ * @param {comq.amqp.Properties} properties
88
+ * @private
89
+ */
90
+ _add (payload, properties) {
91
+ this._heartbeat()
92
+ this.#index++
93
+
94
+ if (properties.type === 'control')
95
+ this._control(payload, properties)
96
+ else if (!this.push(payload))
97
+ this._clear()
98
+ }
99
+
100
+ _buffer (payload, properties) {
101
+ if (this.#buffered > MAX_BUFFER_SIZE) this.destroy()
102
+
103
+ this.#buffered++
104
+ this.#queue[properties.headers.index] = { payload, properties }
105
+ }
106
+
107
+ /**
108
+ * @param {string} message
109
+ * @param {comq.amqp.Properties} properties
110
+ * @private
111
+ */
112
+ _control (message, properties) {
113
+ switch (message) {
114
+ case control.ok:
115
+ this.#control = { properties }
116
+ this.#confirmation.resolve()
117
+ break
118
+ case control.heartbeat:
119
+ break
120
+ case control.end:
121
+ this.push(null)
122
+ break
123
+ default:
124
+ throw new Error(`Unknown reply stream control message: '${message}'`)
125
+ }
126
+ }
127
+
128
+ _heartbeat () {
129
+ if (this.#timeout !== null) clearTimeout(this.#timeout)
130
+
131
+ this.#timeout = setTimeout(() => this.destroy(), this.#idleInterval)
132
+ }
133
+
134
+ _clear () {
135
+ clearTimeout(this.#timeout)
136
+ this.#emitter.removeAllListeners(this.#correlationId)
137
+ }
138
+ }
139
+
140
+ const MAX_BUFFER_SIZE = 1000
141
+
142
+ exports.ReplyStream = ReplyStream
@@ -0,0 +1,10 @@
1
+ 'use strict'
2
+
3
+ exports.control = {
4
+ ok: 'ok',
5
+ heartbeat: 'heartbeat',
6
+ end: 'end'
7
+ }
8
+
9
+ exports.HEARTBEAT_INTERVAL = 5_000
10
+ exports.IDLE_INTERVAL = 12_000
@@ -2,24 +2,21 @@
2
2
 
3
3
  const { randomBytes } = require('node:crypto')
4
4
  const { EventEmitter } = require('node:events')
5
-
6
5
  const { concat } = require('./concat')
7
6
 
8
7
  /**
9
8
  * @param {string} label
10
9
  * @returns {comq.ReplyEmitter}
11
10
  */
12
- const createReplyEmitter = (label) => {
11
+ function createReplyEmitter (label) {
13
12
  const id = randomBytes(8).toString('hex')
14
13
  const queue = concat(label, id)
15
14
  const emitter = new EventEmitter()
16
- const once = (name, callback) => emitter.once(name, callback)
17
- const emit = (name, value) => emitter.emit(name, value)
18
- const clear = () => emitter.removeAllListeners()
19
15
 
20
16
  emitter.setMaxListeners(0)
17
+ emitter.queue = queue
21
18
 
22
- return { queue, once, emit, clear }
19
+ return /** @type {comq.ReplyEmitter} */ emitter
23
20
  }
24
21
 
25
22
  exports.createReplyEmitter = createReplyEmitter
@@ -1,7 +1,11 @@
1
1
  'use strict'
2
2
 
3
3
  const { concat } = require('./concat')
4
- const { createReplyEmitter } = require('./replies')
4
+ const { createReplyEmitter } = require('./createReplyEmitter')
5
+ const { ReplyStream } = require('./ReplyStream')
6
+ const { ReplyPipe } = require('./ReplyPipe')
5
7
 
6
8
  exports.concat = concat
7
9
  exports.createReplyEmitter = createReplyEmitter
10
+ exports.ReplyStream = ReplyStream
11
+ exports.ReplyPipe = ReplyPipe
@@ -0,0 +1,34 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * @param {object} context
5
+ * @param {(exception: Error) => Promise<unknown>} recover
6
+ * @param {(...args: unknown[]) => Promise<unknown>} method
7
+ */
8
+ function failsafe (context, recover, method = undefined) {
9
+ // two arguments passed
10
+ if (method === undefined) {
11
+ method = recover
12
+ recover = undefined
13
+ }
14
+
15
+ return async function call (...args) {
16
+ if (call[DISABLED] === true) return await method.apply(context, args)
17
+
18
+ try {
19
+ return await method.apply(context, args)
20
+ } catch (exception) {
21
+ if (recover !== undefined && await recover.call(context, exception) === false) throw exception
22
+
23
+ return call.apply(this, args)
24
+ }
25
+ }
26
+ }
27
+
28
+ failsafe.disable = (...methods) => {
29
+ for (const method of methods) method[DISABLED] = true
30
+ }
31
+
32
+ const DISABLED = Symbol('disabled')
33
+
34
+ exports.failsafe = failsafe
@@ -0,0 +1,133 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+ const { failsafe } = require('./failsafe')
5
+
6
+ it('should be', async () => {
7
+ expect(failsafe).toBeDefined()
8
+ })
9
+
10
+ beforeEach(() => {
11
+ jest.clearAllMocks()
12
+ })
13
+
14
+ const fn = /** @type {jest.MockedFunction} */
15
+ jest.fn(async () => generate())
16
+ const recover = /** @type {jest.MockedFunction<(...args: any[]) => Promise<any>>} */
17
+ jest.fn(async () => true)
18
+
19
+ class FailsafeTest {
20
+ doWithRecovery = failsafe(this, this.#recover, async (...args) => {
21
+ return fn(...args)
22
+ })
23
+
24
+ doWithoutRecovery = failsafe(this, (...args) => {
25
+ return fn(...args)
26
+ })
27
+
28
+ async #recover (exception) {
29
+ return recover(exception)
30
+ }
31
+ }
32
+
33
+ /** @type {FailsafeTest} */
34
+ let instance
35
+
36
+ beforeEach(() => {
37
+ jest.clearAllMocks()
38
+
39
+ instance = new FailsafeTest()
40
+ })
41
+
42
+ describe.each([
43
+ ['with', 'doWithRecovery'],
44
+ ['without', 'doWithoutRecovery']
45
+ ])('%s recovery', (_, method) => {
46
+ it('should run fn', async () => {
47
+ await instance[method]()
48
+
49
+ expect(fn).toHaveBeenCalled()
50
+ })
51
+
52
+ it('should return value', async () => {
53
+ const value = await instance[method]()
54
+
55
+ expect(value).toStrictEqual(await fn.mock.results[0].value)
56
+ })
57
+
58
+ it('should pass arguments', async () => {
59
+ const args = [generate(), generate()]
60
+
61
+ await instance[method](...args)
62
+
63
+ expect(fn).toHaveBeenCalledWith(...args)
64
+ })
65
+
66
+ it('should call again', async () => {
67
+ fn.mockImplementationOnce(async () => { throw new Error() })
68
+
69
+ await instance[method]()
70
+
71
+ expect(fn).toHaveBeenCalledTimes(2)
72
+ })
73
+ })
74
+
75
+ describe('recovery function', () => {
76
+ it('should recover on exception', async () => {
77
+ fn.mockImplementationOnce(async () => { throw new Error() })
78
+
79
+ const value = await instance.doWithRecovery()
80
+
81
+ expect(value).toStrictEqual(await fn.mock.results[1].value)
82
+ })
83
+
84
+ it('should throw on recovery failure', async () => {
85
+ const exception = generate()
86
+
87
+ fn.mockImplementationOnce(async () => { throw exception })
88
+ recover.mockImplementationOnce(async () => false)
89
+
90
+ await expect(instance.doWithRecovery()).rejects.toStrictEqual(exception)
91
+ })
92
+
93
+ it('should pass exception to recover', async () => {
94
+ const exception = generate()
95
+
96
+ fn.mockImplementationOnce(async () => { throw exception })
97
+
98
+ await instance.doWithRecovery()
99
+
100
+ expect(recover).toHaveBeenCalledWith(exception)
101
+ })
102
+
103
+ it('should call recover within context', async () => {
104
+ class Test {
105
+ foo = 1
106
+
107
+ do = failsafe(this, this.recover, fn)
108
+
109
+ recover () {
110
+ expect(this.foo).toStrictEqual(1)
111
+ }
112
+ }
113
+
114
+ const instance = new Test()
115
+
116
+ fn.mockImplementationOnce(() => { throw new Error() })
117
+
118
+ instance.do()
119
+ })
120
+ })
121
+
122
+ describe('disable', () => {
123
+ it('should throw exception', async () => {
124
+ const exception = generate()
125
+
126
+ failsafe.disable(instance.doWithRecovery, instance.doWithoutRecovery)
127
+
128
+ fn.mockImplementation(async () => { throw exception })
129
+
130
+ await expect(instance.doWithRecovery()).rejects.toStrictEqual(exception)
131
+ await expect(instance.doWithoutRecovery()).rejects.toStrictEqual(exception)
132
+ })
133
+ })
@@ -0,0 +1,13 @@
1
+ 'use strict'
2
+
3
+ const { memo } = require('./memo')
4
+ const { failsafe } = require('./failsafe')
5
+ const { lazy } = require('./lazy')
6
+ const { track } = require('./track')
7
+ const { recall } = require('./recall')
8
+
9
+ exports.memo = memo
10
+ exports.failsafe = failsafe
11
+ exports.lazy = lazy
12
+ exports.track = track
13
+ exports.recall = recall
@@ -0,0 +1,79 @@
1
+ 'use strict'
2
+
3
+ function lazy (context, initializers, method) {
4
+ if (context[LOCK] === undefined) context[LOCK] = Symbol('methods locking key')
5
+
6
+ if (!Array.isArray(initializers)) initializers = [initializers]
7
+
8
+ return async function (...args) {
9
+ const override = await call(context, initializers, args)
10
+
11
+ if (override !== undefined) args.splice(0, override.length, ...override)
12
+
13
+ return method.apply(this, args)
14
+ }
15
+ }
16
+
17
+ /**
18
+ * @param {object} context
19
+ * @param {Function[]} initializers
20
+ * @param {any[]} args
21
+ * @returns {Promise<any[]>}
22
+ */
23
+ async function call (context, initializers, args) {
24
+ let override
25
+
26
+ for (const init of initializers) {
27
+ const result = await resolve(context, init, args)
28
+
29
+ if (result !== undefined) override = result
30
+ }
31
+
32
+ return override
33
+ }
34
+
35
+ /**
36
+ * @param {object} context
37
+ * @param {Function} init
38
+ * @param {any[]} args
39
+ * @returns {Promise}
40
+ */
41
+ function resolve (context, init, args) {
42
+ const key = context[LOCK]
43
+ const expected = args.slice(0, init.length)
44
+
45
+ return lock(context, init, expected, key)
46
+ }
47
+
48
+ /**
49
+ * @param {object} context
50
+ * @param {(...args: unknown[]) => Promise<unknown>} init
51
+ * @param {unknown[]} args
52
+ * @param {Symbol} key
53
+ * @returns {Promise<unknown>}
54
+ */
55
+ function lock (context, init, args, key) {
56
+ if (init[key] === undefined) init[key] = []
57
+
58
+ const locks = init[key]
59
+ const found = locks.find((lock) => lock.args.reduce((match, argument, i) => match && argument === args[i], true))
60
+
61
+ if (found !== undefined) return found.promise
62
+
63
+ const promise = init.apply(context, args)
64
+ const lock = { args, promise }
65
+
66
+ locks.push(lock)
67
+
68
+ return promise
69
+ }
70
+
71
+ function reset (context) {
72
+ context[LOCK] = context[LOCK] = Symbol('methods locking key')
73
+ }
74
+
75
+ const LOCK = Symbol('context locking key')
76
+
77
+ lazy.reset = reset
78
+
79
+ exports.lazy = lazy