comq 0.23.0 → 0.24.1

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 (41) hide show
  1. package/discussions/amqp-1.0.md +127 -0
  2. package/features/heartbeat.feature +16 -0
  3. package/features/rpc.call.feature +23 -1
  4. package/features/rpc.call.recovery.feature +2 -23
  5. package/features/rpc.parked.feature +6 -6
  6. package/features/rpc.shards.retransmission.feature +46 -0
  7. package/features/shards.feature +1 -1
  8. package/features/silence.feature +3 -2
  9. package/features/steps/.types/context.d.ts +6 -1
  10. package/features/steps/brokers.js +1 -1
  11. package/features/steps/call.js +90 -30
  12. package/features/steps/connection.js +23 -0
  13. package/features/steps/context.js +1 -2
  14. package/features/steps/events.js +1 -1
  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/parking.js +5 -5
  19. package/features/steps/retransmission.js +165 -0
  20. package/features/steps/rpc.js +0 -6
  21. package/package.json +20 -4
  22. package/readme.md +46 -46
  23. package/source/channel.js +3 -1
  24. package/source/connection.js +10 -90
  25. package/source/io.js +106 -25
  26. package/source/shards/channel.js +73 -12
  27. package/test/amqplib.mock.js +1 -1
  28. package/test/connection.mock.js +3 -2
  29. package/test/connection.test.js +16 -78
  30. package/test/{io.request.terms.test.js → io.call.terms.test.js} +31 -30
  31. package/test/io.call.test.js +5 -13
  32. package/test/io.reply.test.js +5 -3
  33. package/test/io.request.test.js +277 -2
  34. package/test/memory.test.js +1 -3
  35. package/test/shards/channel.test.js +168 -2
  36. package/types/amqp.d.ts +1 -1
  37. package/types/channel.d.ts +14 -5
  38. package/types/io.d.ts +18 -8
  39. package/features/rpc.timeout.feature +0 -19
  40. package/source/batch.js +0 -62
  41. package/test/batch.test.js +0 -94
@@ -230,7 +230,17 @@ describe.each(['consume', 'subscribe'])('%s', (method) => {
230
230
 
231
231
  await channel[method](...args)
232
232
 
233
- for (const chan of channels) expect(chan[method]).toHaveBeenCalledWith(...args)
233
+ for (const chan of channels) {
234
+ const call = chan[method].mock.calls[0]
235
+ const message = /** @type {comq.amqp.Message} */ {}
236
+
237
+ expect(call.slice(0, -1)).toStrictEqual(args.slice(0, -1))
238
+
239
+ // a consumed message may be told which shard it arrived on on its way
240
+ await call.at(-1)(message)
241
+
242
+ expect(processor).toHaveBeenCalledWith(message)
243
+ }
234
244
  })
235
245
 
236
246
  it(`should not resolve ${method} until pending channels are created`, async () => {
@@ -593,6 +603,23 @@ describe('diagnose', () => {
593
603
  expect(listener).not.toHaveBeenCalled()
594
604
  })
595
605
 
606
+ it('should tell where a returned message is published next', async () => {
607
+ const listener = /** @type {jest.MockedFunction} */ jest.fn()
608
+ const index = random(channels.length)
609
+ const chan = channels[index]
610
+ const message = returned()
611
+
612
+ message.fields.exchange = generate()
613
+ channel.diagnose('reroute', listener)
614
+
615
+ emitReturn(chan, message)
616
+
617
+ const next = channels.find((one) => one.route.mock.calls.length > 0)
618
+
619
+ expect(next).not.toBe(chan)
620
+ expect(listener).toHaveBeenCalledWith(message, next.index)
621
+ })
622
+
596
623
  it('should report a returned retry at once', async () => {
597
624
  const listener = /** @type {jest.MockedFunction} */ jest.fn()
598
625
  const index = random(channels.length)
@@ -620,7 +647,15 @@ describe('diagnose', () => {
620
647
 
621
648
  await channel.held(exchange, queue, key, consumer)
622
649
 
623
- for (const one of channels) expect(one.held).toHaveBeenCalledWith(exchange, queue, key, consumer)
650
+ for (const one of channels) {
651
+ expect(one.held).toHaveBeenCalledWith(exchange, queue, key, expect.any(Function))
652
+
653
+ const message = /** @type {comq.amqp.Message} */ {}
654
+
655
+ await one.held.mock.calls[0][3](message)
656
+
657
+ expect(consumer).toHaveBeenCalledWith(message)
658
+ }
624
659
  })
625
660
 
626
661
  it('should hold a queue once any shard holds it', async () => {
@@ -819,3 +854,134 @@ describe('routed', () => {
819
854
  expect(used[0].route).toHaveBeenCalledWith(exchange, key, buffer, undefined)
820
855
  })
821
856
  })
857
+
858
+ describe('shards of messages', () => {
859
+ const queue = generate()
860
+ const buffer = randomBytes(8)
861
+
862
+ /** @type {jest.MockedObject<comq.Channel>[]} */
863
+ let channels
864
+
865
+ beforeEach(async () => {
866
+ channel = await create(connections, type)
867
+ channels = await getCreatedChannels()
868
+ })
869
+
870
+ it.each(['send', 'route'])('should tell `%s` which shard a message goes through', async (method) => {
871
+ const via = /** @type {jest.MockedFunction} */ jest.fn()
872
+ const args = method === 'send' ? [queue, buffer, {}] : [generate(), queue, buffer, {}]
873
+
874
+ await channel[method](...args, via)
875
+
876
+ const used = channels.find((chan) => chan[method].mock.calls.length > 0)
877
+
878
+ expect(via).toHaveBeenCalledTimes(1)
879
+ expect(via).toHaveBeenCalledWith(used.index)
880
+ })
881
+
882
+ it('should tell which shard a message goes through before it is published', async () => {
883
+ const via = /** @type {jest.MockedFunction} */ jest.fn()
884
+ const publishing = new Promex()
885
+
886
+ for (const chan of channels) chan.send.mockImplementationOnce(() => publishing)
887
+
888
+ const sending = channel.send(queue, buffer, {}, via)
889
+
890
+ await immediate()
891
+
892
+ // a shard lost from now on has it on board
893
+ expect(via).toHaveBeenCalledTimes(1)
894
+
895
+ publishing.resolve()
896
+
897
+ await sending
898
+ })
899
+
900
+ it('should tell the shard a message fails over to', async () => {
901
+ const via = /** @type {jest.MockedFunction} */ jest.fn()
902
+
903
+ for (const chan of channels) chan.send.mockImplementationOnce(async () => { throw new Error() })
904
+
905
+ // both shards fail once, and the one recovered first takes the message
906
+ setImmediate(() => emitRecover(channels[1]))
907
+
908
+ await channel.send(queue, buffer, {}, via)
909
+
910
+ const [first, second] = via.mock.calls.map(([index]) => index)
911
+
912
+ expect(via).toHaveBeenCalledTimes(3)
913
+ expect(second).not.toStrictEqual(first)
914
+ expect(via).toHaveBeenLastCalledWith(1)
915
+ })
916
+
917
+ it.each([0, 1])('should fire a reply through the shard its request arrived on (%i)', async (index) => {
918
+ const consumer = /** @type {jest.MockedFunction} */ jest.fn()
919
+
920
+ await channel.consume(queue, consumer)
921
+
922
+ const request = /** @type {comq.amqp.Message} */ {}
923
+
924
+ await channels[index].consume.mock.calls[0][1](request)
925
+
926
+ for (let i = 0; i < 20; i++) await channel.fire(queue, buffer, {}, request)
927
+
928
+ expect(channels[index].fire).toHaveBeenCalledTimes(20)
929
+ expect(channels[1 - index].fire).not.toHaveBeenCalled()
930
+ })
931
+
932
+ it('should fire a reply through the shard a held request arrived on', async () => {
933
+ await channel.held(generate(), generate(), generate(), jest.fn())
934
+
935
+ const request = /** @type {comq.amqp.Message} */ {}
936
+
937
+ await channels[1].held.mock.calls[0][3](request)
938
+
939
+ for (let i = 0; i < 20; i++) await channel.fire(queue, buffer, {}, request)
940
+
941
+ expect(channels[1].fire).toHaveBeenCalledTimes(20)
942
+ expect(channels[0].fire).not.toHaveBeenCalled()
943
+ })
944
+
945
+ it('should fire a reply through another shard when its own is lost', async () => {
946
+ await channel.consume(queue, jest.fn())
947
+
948
+ const request = /** @type {comq.amqp.Message} */ {}
949
+
950
+ await channels[1].consume.mock.calls[0][1](request)
951
+
952
+ lose(connections[1])
953
+
954
+ await channel.fire(queue, buffer, {}, request)
955
+
956
+ expect(channels[0].fire).toHaveBeenCalledTimes(1)
957
+ expect(channels[1].fire).not.toHaveBeenCalled()
958
+
959
+ restore(connections[1])
960
+ })
961
+
962
+ it('should fire a reply through another shard when its own fails', async () => {
963
+ await channel.consume(queue, jest.fn())
964
+
965
+ const request = /** @type {comq.amqp.Message} */ {}
966
+
967
+ await channels[1].consume.mock.calls[0][1](request)
968
+
969
+ channels[1].fire.mockImplementationOnce(async () => { throw new Error() })
970
+
971
+ await channel.fire(queue, buffer, {}, request)
972
+
973
+ expect(channels[1].fire).toHaveBeenCalledTimes(1)
974
+ expect(channels[0].fire).toHaveBeenCalledTimes(1)
975
+
976
+ emitRecover(channels[1])
977
+ })
978
+ })
979
+
980
+ /**
981
+ * @param {jest.MockedObject<comq.Channel>} chan
982
+ */
983
+ function emitRecover (chan) {
984
+ const calls = chan.diagnose.mock.calls.filter((call) => call[0] === 'recover')
985
+
986
+ for (const call of calls) call[1]()
987
+ }
package/types/amqp.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import * as amqplib from 'amqplib'
1
+ import * as amqplib from '@toa.io/amqplib'
2
2
 
3
3
  declare namespace comq.amqp {
4
4
 
@@ -1,4 +1,4 @@
1
- import { Message, Options, Connection } from 'amqplib'
1
+ import { Message, Options, Connection } from '@toa.io/amqplib'
2
2
  import * as _diagnostics from './diagnostic'
3
3
 
4
4
  declare namespace comq {
@@ -25,14 +25,22 @@ declare namespace comq {
25
25
  /** consumes `queue`, exclusive to this connection and bound to a routed `exchange` under `key` */
26
26
  held (exchange: string, queue: string, key: string, consumer: channels.Consumer): Promise<void>
27
27
 
28
- send (queue: string, buffer: Buffer, options?: Options.Publish): Promise<void>
28
+ /**
29
+ * Over a sharded connection, `via` is told the index of the shard the message is published
30
+ * through, as soon as it is chosen and again whenever the publish fails over to another.
31
+ */
32
+ send (queue: string, buffer: Buffer, options?: Options.Publish, via?: (index: number) => void): Promise<void>
29
33
 
30
34
  publish (exchange: string, buffer: Buffer, options?: Options.Publish): Promise<void>
31
35
 
32
36
  /** publishes to a routed exchange under `key`, where `publish` fans out */
33
- route (exchange: string, key: string, buffer: Buffer, options?: Options.Publish): Promise<void>
37
+ route (exchange: string, key: string, buffer: Buffer, options?: Options.Publish, via?: (index: number) => void): Promise<void>
34
38
 
35
- fire (queue: string, buffer: Buffer, options?: Options.Publish): Promise<boolean>
39
+ /**
40
+ * Over a sharded connection, the message goes back through the shard `origin`, the message it
41
+ * answers, arrived on, while that shard is reachable.
42
+ */
43
+ fire (queue: string, buffer: Buffer, options?: Options.Publish, origin?: Message): Promise<boolean>
36
44
 
37
45
  seal (): Promise<void>
38
46
 
@@ -40,7 +48,8 @@ declare namespace comq {
40
48
 
41
49
  readonly closed: boolean
42
50
 
43
- diagnose (event: _diagnostics.Event, listener: Function): void
51
+ /** `reroute` is a sharded channel's own: a returned message is published on another shard */
52
+ diagnose (event: _diagnostics.Event | 'reroute', listener: Function): void
44
53
 
45
54
  forget (event: _diagnostics.Event, listener: Function): void
46
55
 
package/types/io.d.ts CHANGED
@@ -35,21 +35,30 @@ declare namespace comq {
35
35
  emitter: ReplyEmitter
36
36
  properties: _amqp.Properties
37
37
  signal?: AbortSignal
38
+
39
+ /** The shard the Request is published through, over a sharded connection. */
40
+ shard?: number
41
+
42
+ /** Whether the Request is being published. */
43
+ publishing?: boolean
44
+
45
+ /** Whether its shard was lost while it was being published, which re-sends it once it is. */
46
+ lost?: boolean
38
47
  }
39
48
 
40
- /** How long a caller waits for a Reply. */
41
- interface RequestOptions {
49
+ /** How long a caller waits for the Reply to a call. */
50
+ interface CallOptions {
42
51
  encoding?: _encoding.Encoding
43
52
 
44
53
  /**
45
- * Milliseconds to wait for the Reply. A Request nobody has taken by then is dropped by the
54
+ * Milliseconds to wait for the Reply. A call nobody has taken by then is dropped by the
46
55
  * broker; one already taken may still be processed, and its Reply is discarded.
47
56
  */
48
57
  timeout?: number
49
58
 
50
59
  /**
51
- * Ends the wait when aborted, within the timeout. The Request stays in its queue until the
52
- * timeout passes, or, without one, until a Producer takes it.
60
+ * Ends the wait when aborted, within the timeout. The call stays in its queue until the
61
+ * timeout passes, or, without one, until its holder takes it.
53
62
  */
54
63
  signal?: AbortSignal
55
64
  }
@@ -64,15 +73,16 @@ declare namespace comq {
64
73
  interface IO extends _diagnostics.Diagnosable {
65
74
  reply(queue: string, produce: Producer): Promise<void>
66
75
 
67
- request<Reply = any, Request = any>(queue: string, payload: Request, options?: _encoding.Encoding | RequestOptions): Promise<Reply> | Promise<Readable>
76
+ /** Waits for the Reply for as long as it takes. */
77
+ request<Reply = any, Request = any>(queue: string, payload: Request, encoding?: _encoding.Encoding): Promise<Reply> | Promise<Readable>
68
78
 
69
- request(queue: string, stream: Readable, options?: _encoding.Encoding | RequestOptions): Promise<Readable>
79
+ request(queue: string, stream: Readable, encoding?: _encoding.Encoding): Promise<Readable>
70
80
 
71
81
  /**
72
82
  * Sends a Request to whoever holds `key` on the routed `exchange`, through `back`. One that
73
83
  * nobody holds is refused with `Unroutable`.
74
84
  */
75
- call<Reply = any, Request = any>(exchange: string, key: string, payload: Request, options?: _encoding.Encoding | RequestOptions): Promise<Reply> | Promise<Readable>
85
+ call<Reply = any, Request = any>(exchange: string, key: string, payload: Request, options?: _encoding.Encoding | CallOptions): Promise<Reply> | Promise<Readable>
76
86
 
77
87
  /**
78
88
  * Answers the Requests `call` sends under `key`, from a queue this connection alone holds.
@@ -1,19 +0,0 @@
1
- Feature: Request timeout
2
-
3
- A caller may say how long it waits for a reply. A request nobody took by then is dropped by the
4
- broker; one already taken is still processed, and its reply is discarded.
5
-
6
- Background:
7
- Given an active connection to the broker
8
-
9
- Scenario: A caller stops waiting at its timeout
10
- Given a producer replying `slow` queue in 1000ms
11
- When the consumer sends a request to the `slow` queue with a 200ms timeout
12
- Then the consumer stops waiting
13
-
14
- Scenario: A request nobody took in time is never processed
15
- When the consumer sends a request to the `later` queue with a 200ms timeout
16
- Then the consumer stops waiting
17
- When after 300ms
18
- And a producer counting requests to the `later` queue
19
- Then the producer receives nothing
package/source/batch.js DELETED
@@ -1,62 +0,0 @@
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 `Mux` writes the frames one at a time, and where corking that
14
- * loop is six lines and needs no wrapping of a socket the library does not own. It is done here
15
- * until amqplib carries it, 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
@@ -1,94 +0,0 @@
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
- })