comq 0.24.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.
@@ -5,6 +5,7 @@
5
5
  const stream = require('node:stream')
6
6
  const { randomBytes } = require('node:crypto')
7
7
  const { generate } = require('randomstring')
8
+ const { Promex } = require('promex')
8
9
  const { immediate } = require('./helpers')
9
10
  const { encode } = require('../source/encode')
10
11
 
@@ -327,15 +328,276 @@ describe('send', () => {
327
328
  })
328
329
  })
329
330
 
331
+ describe('shards', () => {
332
+ beforeEach(async () => {
333
+ jest.clearAllMocks()
334
+
335
+ connection = mock.connection(true)
336
+ io = new IO(connection)
337
+ })
338
+
339
+ /**
340
+ * Sends a Request through a shard.
341
+ *
342
+ * @param {number} index
343
+ * @param {string} target
344
+ * @return {Promise<void>}
345
+ */
346
+ async function ask (index, target) {
347
+ requests = await requestChannel()
348
+
349
+ requests.send.mockImplementationOnce(async (_queue, _buffer, _options, via) => via(index))
350
+
351
+ io.request(target, payload)
352
+
353
+ await immediate()
354
+ }
355
+
356
+ /**
357
+ * @return {Promise<jest.MockedObject<comq.Channel>>}
358
+ */
359
+ async function requestChannel () {
360
+ if (connection.createChannel.mock.calls.length === 0) {
361
+ // the channels are created with the first Request
362
+ io.request(generate(), payload)
363
+
364
+ await immediate()
365
+ }
366
+
367
+ return findChannel('request')
368
+ }
369
+
370
+ /**
371
+ * @param {jest.MockedObject<comq.Channel>} channel
372
+ * @return {string[]} the queues each publish went to
373
+ */
374
+ const targets = (channel) => channel.send.mock.calls.map(([queue]) => queue)
375
+
376
+ it('should re-send only the requests the lost shard holds', async () => {
377
+ const a = generate()
378
+ const b = generate()
379
+
380
+ await ask(0, a)
381
+ await ask(1, b)
382
+
383
+ requests.send.mockClear()
384
+
385
+ await lose(1)
386
+
387
+ expect(targets(requests)).toStrictEqual([b])
388
+
389
+ requests.send.mockClear()
390
+
391
+ // b has been re-sent through the shard that stays, and is held by it now
392
+ await lose(0)
393
+
394
+ expect(targets(requests)).toContain(a)
395
+ expect(targets(requests)).toContain(b)
396
+ })
397
+
398
+ it('should re-send only the requests of a shard whose reply channel recovers', async () => {
399
+ const a = generate()
400
+ const b = generate()
401
+
402
+ await ask(0, a)
403
+ await ask(1, b)
404
+
405
+ replies = await findChannel('reply')
406
+ requests.send.mockClear()
407
+
408
+ const calls = replies.diagnose.mock.calls.filter((call) => call[0] === 'recover')
409
+
410
+ for (const [, listener] of calls) listener(1)
411
+
412
+ await immediate()
413
+
414
+ expect(targets(requests)).toStrictEqual([b])
415
+ })
416
+
417
+ it('should re-send only the requests of a shard removed from the pool', async () => {
418
+ const a = generate()
419
+ const b = generate()
420
+
421
+ await ask(0, a)
422
+ await ask(1, b)
423
+
424
+ requests.send.mockClear()
425
+
426
+ const calls = requests.diagnose.mock.calls.filter((call) => call[0] === 'remove')
427
+
428
+ for (const [, listener] of calls) listener(0)
429
+
430
+ await immediate()
431
+
432
+ expect(targets(requests).includes(a)).toStrictEqual(true)
433
+ expect(targets(requests).includes(b)).toStrictEqual(false)
434
+ })
435
+
436
+ it('should re-send a request the lost shard held while it was being published', async () => {
437
+ requests = await requestChannel()
438
+
439
+ const published = new Promex()
440
+
441
+ requests.send.mockImplementationOnce(async (_queue, _buffer, _options, via) => {
442
+ via(1)
443
+
444
+ await published
445
+ })
446
+
447
+ const target = generate()
448
+
449
+ io.request(target, payload)
450
+
451
+ await immediate()
452
+ await lose(1)
453
+
454
+ // not before it is published
455
+ expect(targets(requests).filter((queue) => queue === target)).toHaveLength(1)
456
+
457
+ published.resolve()
458
+
459
+ await immediate()
460
+ await immediate()
461
+
462
+ expect(targets(requests).filter((queue) => queue === target)).toHaveLength(2)
463
+ })
464
+
465
+ it('should not re-send a request that failed over to another shard', async () => {
466
+ requests = await requestChannel()
467
+
468
+ const published = new Promex()
469
+
470
+ requests.send.mockImplementationOnce(async (_queue, _buffer, _options, via) => {
471
+ via(1)
472
+
473
+ await published
474
+
475
+ // the pool fails over from the lost shard
476
+ via(0)
477
+ })
478
+
479
+ const target = generate()
480
+
481
+ io.request(target, payload)
482
+
483
+ await immediate()
484
+ await lose(1)
485
+
486
+ published.resolve()
487
+
488
+ await immediate()
489
+ await immediate()
490
+
491
+ expect(targets(requests).filter((queue) => queue === target)).toHaveLength(1)
492
+
493
+ // it is held by the shard it went through
494
+ await lose(0)
495
+
496
+ expect(targets(requests).filter((queue) => queue === target)).toHaveLength(2)
497
+ })
498
+
499
+ it('should not re-send a request that went through another shard than the one lost as it was published', async () => {
500
+ requests = await requestChannel()
501
+
502
+ const published = new Promex()
503
+
504
+ requests.send.mockImplementationOnce(async (_queue, _buffer, _options, via) => {
505
+ via(0)
506
+
507
+ await published
508
+ })
509
+
510
+ const target = generate()
511
+
512
+ io.request(target, payload)
513
+
514
+ await immediate()
515
+ await lose(1)
516
+
517
+ published.resolve()
518
+
519
+ await immediate()
520
+ await immediate()
521
+
522
+ expect(targets(requests).filter((queue) => queue === target)).toHaveLength(1)
523
+ })
524
+
525
+ it('should re-send a returned call from the shard it was published on next', async () => {
526
+ requests = await requestChannel()
527
+
528
+ const key = generate()
529
+
530
+ requests.route.mockImplementationOnce(async (_exchange, _key, _buffer, _options, via) => via(0))
531
+
532
+ io.call(generate(), key, payload)
533
+
534
+ await immediate()
535
+
536
+ const [,,, properties] = requests.route.mock.calls[0]
537
+ const message = { fields: { exchange: generate(), routingKey: key }, properties }
538
+ const calls = requests.diagnose.mock.calls.filter((call) => call[0] === 'reroute')
539
+
540
+ expect(calls.length).toBeGreaterThan(0)
541
+
542
+ for (const [, listener] of calls) listener(message, 1)
543
+
544
+ await lose(0)
545
+
546
+ expect(requests.route).toHaveBeenCalledTimes(1)
547
+
548
+ await lose(1)
549
+
550
+ expect(requests.route).toHaveBeenCalledTimes(2)
551
+ })
552
+
553
+ it('should re-send a call the lost shard holds', async () => {
554
+ requests = await requestChannel()
555
+
556
+ requests.route.mockImplementationOnce(async (_exchange, _key, _buffer, _options, via) => via(1))
557
+
558
+ io.call(generate(), generate(), payload)
559
+
560
+ await immediate()
561
+ await lose(0)
562
+
563
+ expect(requests.route).toHaveBeenCalledTimes(1)
564
+
565
+ await lose(1)
566
+
567
+ expect(requests.route).toHaveBeenCalledTimes(2)
568
+ })
569
+ })
570
+
571
+ describe('recovery', () => {
572
+ it('should re-send every unanswered Request of a connection that is not sharded', async () => {
573
+ const other = generate()
574
+
575
+ io.request(other, payload)
576
+
577
+ await immediate()
578
+
579
+ requests.send.mockClear()
580
+
581
+ const calls = requests.diagnose.mock.calls.filter((call) => call[0] === 'recover')
582
+
583
+ for (const [, listener] of calls) listener()
584
+
585
+ await immediate()
586
+
587
+ expect(requests.send.mock.calls.map(([queue]) => queue).sort()).toStrictEqual([queue, other].sort())
588
+ })
589
+ })
590
+
330
591
  /**
331
592
  * Fires the listeners the IO has attached to the shard loss diagnostic.
332
593
  *
594
+ * @param {number} [index]
333
595
  * @return {Promise<void>}
334
596
  */
335
- async function lose () {
597
+ async function lose (index = 0) {
336
598
  const calls = requests.diagnose.mock.calls.filter((call) => call[0] === 'lost')
337
599
 
338
- for (const [, listener] of calls) listener(0)
600
+ for (const [, listener] of calls) listener(index)
339
601
 
340
602
  await immediate()
341
603
  await immediate()
@@ -29,14 +29,12 @@ beforeEach(() => {
29
29
  amqplib.connect.mockImplementation(async (url) => wired(await connect(url)))
30
30
 
31
31
  global.COMQ_TESTING_SHUTDOWN_TIMEOUT = 1
32
- global.COMQ_TESTING_WATCHDOG_INTERVAL = 60_000
33
32
  })
34
33
 
35
34
  afterEach(() => {
36
35
  SingletonConnection.__lets_pretend_this_method_doesnt_exist()
37
36
 
38
37
  delete global.COMQ_TESTING_SHUTDOWN_TIMEOUT
39
- delete global.COMQ_TESTING_WATCHDOG_INTERVAL
40
38
  })
41
39
 
42
40
  afterAll(() => {
@@ -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
+ }
@@ -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,6 +35,15 @@ 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
49
  /** How long a caller waits for the Reply to a call. */