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
@@ -4,13 +4,13 @@ updates:
4
4
  directory: "/"
5
5
  schedule:
6
6
  interval: "weekly"
7
- target-branch: "bumps"
7
+ target-branch: "dev"
8
8
  labels:
9
9
  - "Bump"
10
10
  - package-ecosystem: "npm"
11
11
  directory: "/"
12
12
  schedule:
13
13
  interval: "daily"
14
- target-branch: "bumps"
14
+ target-branch: "dev"
15
15
  labels:
16
16
  - "Bump"
@@ -42,7 +42,7 @@ jobs:
42
42
 
43
43
  steps:
44
44
  - name: Checkout repository
45
- uses: actions/checkout@v3
45
+ uses: actions/checkout@v4
46
46
 
47
47
  # Initializes the CodeQL tools for scanning.
48
48
  - name: Initialize CodeQL
@@ -4,8 +4,8 @@ on:
4
4
  pull_request:
5
5
  types: [closed]
6
6
  branches:
7
- - main
8
- - dev
7
+ - release
8
+ - alpha
9
9
  paths:
10
10
  - 'source/**'
11
11
  - 'types/**'
@@ -27,16 +27,16 @@ jobs:
27
27
  registry-url: 'https://registry.npmjs.org'
28
28
 
29
29
  - name: Checkout
30
- uses: actions/checkout@v3
30
+ uses: actions/checkout@v4
31
31
  with:
32
32
  fetch-depth: 0
33
33
 
34
- # - name: Run tests
35
- # run: npm i && npm test
34
+ - name: Run tests
35
+ run: npm i && npm test
36
36
 
37
37
  - name: Import GPG key
38
38
  id: gpg
39
- uses: crazy-max/ghaction-import-gpg@v5
39
+ uses: crazy-max/ghaction-import-gpg@v6
40
40
  with:
41
41
  gpg_private_key: ${{ secrets.TOABOT_GPG_PRIVATE_KEY }}
42
42
  passphrase: ${{ secrets.TOABOT_GPG_PASSPHRASE }}
@@ -45,15 +45,16 @@ jobs:
45
45
 
46
46
  - name: Bump version
47
47
  run: |
48
- if [ ${{ github.base_ref }} = main ]; then
49
- npx lerna@6.5.1 version --conventional-graduate --no-commit-hooks --no-changelog --yes --message "ci: %s" --sign-git-commit --sign-git-tag
50
- git fetch
51
- git checkout dev
52
- git merge main
53
- git push --all origin --no-verify
48
+ if [ ${{ github.base_ref }} = release ]; then
49
+ npx lerna@7.3.0 version --conventional-graduate --no-commit-hooks --no-changelog --yes --message "ci: %s" --sign-git-commit --sign-git-tag
54
50
  else
55
- npx lerna@6.5.1 version --conventional-prerelease --preid dev --dist-tag dev --no-commit-hooks --no-changelog --yes --message "ci: %s" --git-tag-command "echo %s" --sign-git-commit
51
+ npx lerna@7.3.0 version --conventional-prerelease --preid alpha --dist-tag alpha --no-commit-hooks --no-changelog --yes --message "ci: %s" --git-tag-command "echo %s" --sign-git-commit
56
52
  fi
53
+
54
+ git fetch
55
+ git checkout dev
56
+ git merge ${{ github.base_ref }}
57
+ git push --all origin --no-verify
57
58
  env:
58
59
  GITHUB_TOKEN: ${{ secrets.TOABOT_GITHUB_TOKEN }}
59
60
  GIT_AUTHOR_NAME: ${{ steps.gpg.outputs.name }}
@@ -65,8 +66,8 @@ jobs:
65
66
  env:
66
67
  NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
67
68
  run: |
68
- if [ ${{ github.base_ref }} = main ]; then
69
+ if [ ${{ github.base_ref }} = release ]; then
69
70
  npm publish
70
71
  else
71
- npm publish --tag dev
72
+ npm publish --tag alpha
72
73
  fi
@@ -1,19 +1,19 @@
1
1
  # Examples
2
2
 
3
- To run examples please start RabbitMQ server with `docker compose up -d` in the package root.
3
+ To run the examples, please start RabbitMQ server with `docker compose up -d` in the package root.
4
4
 
5
5
  ## RPC
6
6
 
7
7
  Run in two terminals:
8
8
 
9
9
  ```shell
10
- $ node exampels/rpc/producer
10
+ $ node examples/rpc/producer
11
11
  ```
12
12
 
13
13
  [source](rpc/producer.js)
14
14
 
15
15
  ```shell
16
- $ node exampels/rpc/consumer
16
+ $ node examples/rpc/consumer
17
17
  ```
18
18
 
19
19
  [source](rpc/consumer.js)
@@ -41,3 +41,19 @@ $ node examples/events/producer
41
41
  `A` and `B` are consumer groups.
42
42
 
43
43
  > Try to run multiple instances with the same consumer group.
44
+
45
+ ## Reply streams
46
+
47
+ Run in two terminals:
48
+
49
+ ```shell
50
+ $ node examples/streams/producer
51
+ ```
52
+
53
+ [source](streams/producer.js)
54
+
55
+ ```shell
56
+ $ node examples/streams/consumer
57
+ ```
58
+
59
+ [source](streams/consumer.js)
@@ -0,0 +1,36 @@
1
+ import { connect } from 'comq'
2
+ import { url } from '../const.js'
3
+
4
+ const limit = Number(process.argv[2]) || Math.round(Math.random() * 8 + 2)
5
+
6
+ let io
7
+ let stream
8
+
9
+ async function run () {
10
+ io = await connect(url)
11
+
12
+ console.log('Connected')
13
+ process.on('SIGINT', exit)
14
+
15
+ await fetch()
16
+ await io.close()
17
+
18
+ console.log('Disconnected')
19
+ }
20
+
21
+ async function fetch () {
22
+ console.log(`Fetching stream with ${limit} numbers`)
23
+
24
+ stream = await io.fetch('get_numbers', { limit })
25
+
26
+ for await (const number of stream) console.log('Received:', number)
27
+
28
+ console.log('Stream finished')
29
+ }
30
+
31
+ async function exit () {
32
+ console.log()
33
+ await io.close()
34
+ }
35
+
36
+ await run()
@@ -0,0 +1,44 @@
1
+ import { connect } from 'comq'
2
+ import { url } from '../const.js'
3
+
4
+ let io
5
+
6
+ async function run () {
7
+ io = await connect(url)
8
+
9
+ console.log('Connected')
10
+
11
+ await io.reply('get_numbers', produce)
12
+
13
+ console.log('Waiting for requests...')
14
+
15
+ process.on('SIGINT', exit)
16
+ }
17
+
18
+ async function * produce ({ limit }) {
19
+ console.log('Request received with limit:', limit)
20
+
21
+ for (let i = 0; i < limit; i++) {
22
+ yield i
23
+
24
+ console.log('Yielded:', i)
25
+
26
+ await timeout()
27
+ }
28
+
29
+ console.log('Stream ended')
30
+ }
31
+
32
+ async function timeout () {
33
+ const ms = Math.round(Math.random() * 500)
34
+
35
+ await new Promise((resolve) => setTimeout(resolve, ms))
36
+ }
37
+
38
+ async function exit () {
39
+ await io.close()
40
+
41
+ console.log('\nDisconnected')
42
+ }
43
+
44
+ await run()
@@ -6,7 +6,7 @@ Feature: Back pressure handling
6
6
  Scenario: Flooding a queue
7
7
  Given function replying `flood` queue:
8
8
  """
9
- () => { return new Buffer.from('ok') }
9
+ () => { return Buffer.from('ok') }
10
10
  """
11
- When I'm sending 20kB requests to the `flood` queue at 5kHz for 0.5 seconds
11
+ When I'm sending 100kB requests to the `flood` queue at 5kHz for 0.5 seconds
12
12
  Then back pressure was applied
@@ -19,3 +19,8 @@ Feature: Events
19
19
  Given that events are exclusively consumed from the `numbers_added` exchange
20
20
  When an event is emitted to the `numbers_added` exchange
21
21
  Then the event is received
22
+
23
+ Scenario: Streaming requests
24
+ Given that events are exclusively consumed from the `numbers_added` exchange
25
+ When a stream of 1k events is emitted to the `numbers_added` exchange
26
+ Then 1k events is received
@@ -1,4 +1,4 @@
1
1
  # Features
2
2
 
3
- To run features please start RabbitMQ server with `docker compose up -d` in the package root, then
4
- execute `npm run test:features`.
3
+ To run features, start the RabbitMQ server by running `docker compose up -d` in the package root,
4
+ then execute `npm run features`.
@@ -98,9 +98,6 @@ Feature: Request-Reply Topology Recovery
98
98
  And `someone` receives the event
99
99
 
100
100
  Scenario: Start consuming events while broker is down
101
-
102
- First consume will assert queue, so error will happen while calling AMQP.consume()
103
-
104
101
  Given that `first` is consuming events from the `numbers_added` exchange
105
102
  And `second` consuming events from the `numbers_added` exchange is expected
106
103
  When the broker has crashed
@@ -5,18 +5,18 @@ Feature: Request-reply (RPC)
5
5
 
6
6
  Scenario: Sending request and getting reply
7
7
  Given function replying `add_numbers` queue:
8
- """
9
- ({ a, b }) => { return a + b }
10
- """
8
+ """
9
+ ({ a, b }) => { return a + b }
10
+ """
11
11
  When the consumer sends the following request to the `add_numbers` queue:
12
- """yaml
13
- a: 1
14
- b: 2
15
- """
12
+ """yaml
13
+ a: 1
14
+ b: 2
15
+ """
16
16
  Then the consumer receives the reply:
17
- """yaml
18
- 3
19
- """
17
+ """yaml
18
+ 3
19
+ """
20
20
 
21
21
  Scenario: Request causing an exception is discarded
22
22
  Given function replying `throw_error` queue:
@@ -26,3 +26,8 @@ Feature: Request-reply (RPC)
26
26
  When the consumer sends a request to the `throw_error` queue
27
27
  Then the message is discarded
28
28
  And the consumer does not receive the reply
29
+
30
+ Scenario: Streaming requests
31
+ Given a producer replying `ping` queue
32
+ When the consumer sends 1k requests to the `ping` queue as a stream
33
+ Then the consumer receives 1k replies as a stream
@@ -0,0 +1,81 @@
1
+ Feature: Reply streams
2
+
3
+ Background:
4
+ Given an active connection to the broker
5
+ And heartbeat interval is set to 100ms
6
+ And idle timeout is set to 150ms
7
+
8
+ Scenario: Fetching a stream
9
+ Given function replying `get_numbers` queue:
10
+ """
11
+ function * ({ limit }) {
12
+ for (let i = 0; i < limit; i++) yield i
13
+ }
14
+ """
15
+ When the consumer fetches a stream with the following request to the `get_numbers` queue:
16
+ """yaml
17
+ limit: 5
18
+ """
19
+ Then the consumer receives the stream:
20
+ """yaml
21
+ [0, 1, 2, 3, 4]
22
+ """
23
+
24
+ Scenario: Fetching streams concurrently
25
+ Given a number generator with 10ms increasing delay replying `get_numbers` queue
26
+ When the consumer0 fetches a stream with request to the `get_numbers` queue
27
+ And the consumer0 receives the stream
28
+ And the consumer1 fetches a stream with request to the `get_numbers` queue
29
+ And the consumer1 receives the stream
30
+ And after 300ms
31
+ Then the consumer0 has received the stream:
32
+ """yaml
33
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
34
+ """
35
+ And the consumer1 has received the stream:
36
+ """yaml
37
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
38
+ """
39
+
40
+ Scenario: Interrupting a stream
41
+ Given a number generator with 20ms increasing delay replying `get_numbers` queue
42
+ When the consumer fetches a stream with request to the `get_numbers` queue
43
+ And the consumer interrupts the stream after 3 replies
44
+ # feedback message must be delivered
45
+ And after 10ms
46
+ Then the generator is destroyed
47
+
48
+ Scenario: Reply stream idle timeout
49
+ Given heartbeat interval is set to 300ms
50
+ And a number generator with 100ms increasing delay replying `get_numbers` queue
51
+ When the consumer fetches 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
58
+
59
+ Scenario: Reply stream heartbeat
60
+ # delay will exceed the idle timeout on the 3rd reply
61
+ Given a number generator with 90ms increasing delay replying `get_numbers` queue
62
+ When the consumer fetches a stream with request to the `get_numbers` queue
63
+ Then the consumer interrupts the stream after 5 replies
64
+ And after 200ms
65
+ Then the generator is destroyed
66
+
67
+ Scenario: Broker crashes while streaming reply
68
+ Given a number generator with 90ms increasing delay replying `get_numbers` queue
69
+ When the consumer fetches a stream with request to the `get_numbers` queue
70
+ Then the consumer receives the stream
71
+ And after 300ms
72
+ Then the broker has crashed
73
+ # idle timeout
74
+ Then after 150ms
75
+ Then the consumer has received the stream:
76
+ """yaml
77
+ [0, 1, 2]
78
+ """
79
+ Then after 200ms
80
+ Then the broker is up
81
+ And the generator is destroyed
@@ -0,0 +1,37 @@
1
+ Feature: Reply streams over shards
2
+
3
+ Background:
4
+ Given an active sharded connection
5
+ And heartbeat interval is set to 100ms
6
+ And idle timeout is set to 150ms
7
+
8
+ Scenario: Fetching a stream using sharded connection
9
+ Given a generator replying `get_numbers` queue:
10
+ """
11
+ function * ({ amount }) {
12
+ for (let i = 0; i < amount; i++) yield i
13
+ }
14
+ """
15
+ When the consumer fetches a stream with the following request to the `get_numbers` queue:
16
+ """yaml
17
+ amount: 5
18
+ """
19
+ Then the consumer receives the stream:
20
+ """yaml
21
+ [0, 1, 2, 3, 4]
22
+ """
23
+
24
+ Scenario: Broker crashes while fetching a stream
25
+ Given a number generator with 90ms increasing delay replying `get_numbers` queue
26
+ When the consumer fetches a stream with request to the `get_numbers` queue
27
+ And the consumer receives the stream
28
+ And after 300ms
29
+ And the broker has crashed
30
+ And the broker is up
31
+ Then the consumer interrupts the stream
32
+ And the consumer has received the stream containing:
33
+ """yaml
34
+ [0, 1, 2, 3, 4, 5, 6]
35
+ """
36
+ And after 1000ms
37
+ And the generator is destroyed
@@ -59,7 +59,7 @@ describe.each([false, true])('connect (shards: %s)', (sharded) => {
59
59
 
60
60
  it.each(['open', 'close', 'flow', 'discard'])('should store %s event',
61
61
  /**
62
- * @param {comq.diagnostics.event} event
62
+ * @param {comq.diagnostics.Event} event
63
63
  */
64
64
  async (event) => {
65
65
  expect(io.diagnose).toHaveBeenCalledWith(event, expect.any(Function))
@@ -232,9 +232,9 @@ describe('Given I\'m publishing {quantity}B events to the {token} exchange at {q
232
232
 
233
233
  step.call(context, bytesQ, exchange, frequencyQ)
234
234
 
235
- await timeout(100 + 12)
235
+ await timeout(100)
236
236
 
237
- const callsAmountExpected = [10, 11]
237
+ const callsAmountExpected = [8, 9, 10, 11]
238
238
 
239
239
  expect(callsAmountExpected).toContain(context.io.emit.mock.calls.length)
240
240
  expect(context.publishing).toBeDefined()
@@ -221,7 +221,7 @@ describe('Given I\'m sending {quantity}B requests to the {token} queue at {quant
221
221
 
222
222
  await timeout(100 + 12)
223
223
 
224
- const callsAmountExpected = [10, 11]
224
+ const callsAmountExpected = [9, 10, 11, 12]
225
225
 
226
226
  expect(callsAmountExpected).toContain(context.io.request.mock.calls.length)
227
227
  expect(context.sending).toBeDefined()
@@ -1,3 +1,4 @@
1
+ import { Readable } from 'node:stream'
1
2
  import * as _diagnostics from '../../../types/diagnostic'
2
3
  import * as _io from '../../../types/io'
3
4
  import * as _amqp from '../../../types/amqp'
@@ -15,7 +16,7 @@ declare namespace comq.features {
15
16
  eventsConsumedCount: number
16
17
  consumed?: Record<string, { payload: any, properties?: _amqp.Properties }>
17
18
  consumedCount: number
18
- events?: { [K in _diagnostics.event]?: boolean }
19
+ events?: { [K in _diagnostics.Event]?: boolean }
19
20
  exception?: Error
20
21
  consumptionPromise?: Promise<any>
21
22
  sharded: boolean
@@ -23,6 +24,13 @@ declare namespace comq.features {
23
24
  sealing: Promise<any>
24
25
  sending: any
25
26
  publishing: any
27
+ stream: Readable
28
+ streamValues: any[]
29
+ streamEnded: boolean
30
+ streams: Record<number, Readable>
31
+ streamsValues: Record<number, any[]>
32
+ streamsEnded: Record<number, boolean>
33
+ generatorDestroyed: boolean
26
34
 
27
35
  connect(user?: string, password?: string): Promise<void>
28
36
  assert(user?: string, password?: string): Promise<void>
@@ -0,0 +1,12 @@
1
+ 'use strict'
2
+
3
+ const { When } = require('@cucumber/cucumber')
4
+ const { timeout } = require('@toa.io/generic')
5
+
6
+ When('after {number}ms',
7
+ /**
8
+ * @param {number} delay
9
+ */
10
+ async function (delay) {
11
+ await timeout(delay)
12
+ })
@@ -22,6 +22,13 @@ class Context extends World {
22
22
  sharded
23
23
  shard
24
24
  sealing
25
+ stream
26
+ streamValues = []
27
+ streamEnded = false
28
+ streams = {}
29
+ streamsValues = {}
30
+ streamsEnded = {}
31
+ generatorDestroyed = false
25
32
 
26
33
  async connect (user, password) {
27
34
  const urls = this.#urls(user, password)
@@ -47,7 +54,7 @@ class Context extends World {
47
54
 
48
55
  /**
49
56
  * @param {string[]} urls
50
- * @param {comq.connect} [method]
57
+ * @param {comq.Connect} [method]
51
58
  * @return {Promise<void>}
52
59
  */
53
60
  async #connect (urls, method = connect) {
@@ -87,7 +94,7 @@ const SHARDS = ['localhost:5673', 'localhost:5674']
87
94
  const USER = 'developer'
88
95
  const PASSWORD = 'secret'
89
96
 
90
- /** @type {comq.diagnostics.event[]} */
97
+ /** @type {comq.diagnostics.Event[]} */
91
98
  const EVENTS = ['open', 'close', 'flow', 'discard']
92
99
 
93
100
  exports.Context = Context
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const stream = require('node:stream')
3
4
  const assert = require('node:assert')
4
5
  const { randomBytes } = require('node:crypto')
5
6
  const { timeout, quantity, match } = require('@toa.io/generic')
@@ -61,6 +62,23 @@ When('an event is emitted to the {token} exchange with properties:',
61
62
  await emit.call(this, exchange, message, properties)
62
63
  })
63
64
 
65
+ When('a stream of {quantity} events is emitted to the {token} exchange',
66
+ /**
67
+ * @param {string} exchange
68
+ * @this {comq.features.Context}
69
+ */
70
+ async function (amountQ, exchange) {
71
+ const amount = quantity(amountQ)
72
+
73
+ function * generate () {
74
+ for (let i = 0; i < amount; i++) yield randomBytes(8)
75
+ }
76
+
77
+ const events = stream.Readable.from(generate())
78
+
79
+ await emit.call(this, exchange, events)
80
+ })
81
+
64
82
  Then('{token} receives the event',
65
83
  /**
66
84
  * @param {string} group
@@ -93,6 +111,17 @@ Then('the event is received',
93
111
  await consumed.call(this)
94
112
  })
95
113
 
114
+ Then('{quantity} events is received',
115
+ /**
116
+ * @this {comq.features.Context}
117
+ */
118
+ async function (expectedQ) {
119
+ const expected = quantity(expectedQ)
120
+
121
+ await timeout(100) // let it consume
122
+ assert.equal(this.eventsConsumedCount, expected, 'Not all events have been consumed')
123
+ })
124
+
96
125
  Given('I\'m publishing {quantity}B events to the {token} exchange at {quantity}Hz',
97
126
  /**
98
127
  * @param {string} bytesQ