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.
- package/.husky/commit-msg +5 -0
- package/benchmarks/run.js +66 -4
- package/benchmarks/server.js +14 -0
- package/commitlint.config.js +6 -0
- package/features/heartbeat.feature +16 -0
- package/features/rpc.call.recovery.feature +2 -2
- package/features/rpc.shards.retransmission.feature +46 -0
- package/features/rpc.streams.feature +28 -0
- package/features/shards.feature +1 -1
- package/features/silence.feature +3 -2
- package/features/steps/.types/context.d.ts +6 -1
- package/features/steps/brokers.js +1 -1
- package/features/steps/connection.js +23 -0
- package/features/steps/context.js +1 -2
- package/features/steps/hooks.js +0 -1
- package/features/steps/network.js +11 -2
- package/features/steps/networks.js +31 -2
- package/features/steps/retransmission.js +165 -0
- package/features/steps/rpc.js +31 -7
- package/package.json +31 -9
- package/readme.md +15 -4
- package/source/.io/ReplyPipe.js +130 -11
- package/source/.io/ReplyStream.js +41 -2
- package/source/.io/const.js +6 -0
- package/source/attributes/track.test.js +1 -1
- package/source/channel.js +5 -3
- package/source/connection.js +11 -88
- package/source/io.js +91 -16
- package/source/promex.js +47 -0
- package/source/promex.test.js +79 -0
- package/source/retry.js +42 -0
- package/source/retry.test.js +120 -0
- package/source/shards/channel.js +74 -13
- package/test/.types/jest.d.ts +22 -0
- package/test/ReplyPipe.test.js +71 -2
- package/test/ReplyStream.test.js +33 -0
- package/test/connection.mock.js +3 -2
- package/test/connection.test.js +16 -78
- package/test/globals.js +143 -0
- package/test/io.call.test.js +5 -3
- package/test/io.close.test.js +1 -1
- package/test/io.reply.test.js +5 -3
- package/test/io.request.test.js +264 -2
- package/test/memory.test.js +0 -2
- package/test/shards/channel.test.js +169 -3
- package/test/shards/connection.test.js +2 -3
- package/types/channel.d.ts +13 -4
- package/types/io.d.ts +9 -0
package/source/io.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const stream = require('node:stream')
|
|
4
4
|
const { setTimeout } = require('node:timers/promises')
|
|
5
|
-
const { Promex } = require('promex')
|
|
5
|
+
const { Promex } = require('./promex')
|
|
6
6
|
const { memo, failsafe, lazy, track } = require('./attributes')
|
|
7
7
|
const { verdictOf, PARK } = require('./verdicts')
|
|
8
8
|
const { Unroutable } = require('./unroutable')
|
|
@@ -154,7 +154,7 @@ class IO {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
return await this.#ask(queue, payload, terms,
|
|
157
|
-
(buffer, properties) => this.#requests.send(queue, buffer, properties))
|
|
157
|
+
(buffer, properties, via) => this.#requests.send(queue, buffer, properties, via))
|
|
158
158
|
}))
|
|
159
159
|
|
|
160
160
|
// the terms are taken once, so a re-sent call keeps the deadline of the first
|
|
@@ -170,7 +170,8 @@ class IO {
|
|
|
170
170
|
async (exchange, key, payload, terms) => {
|
|
171
171
|
// mandatory: a Request nobody holds the key of is returned rather than dropped
|
|
172
172
|
return await this.#ask(exchange, payload, terms,
|
|
173
|
-
(buffer, properties) =>
|
|
173
|
+
(buffer, properties, via) =>
|
|
174
|
+
this.#requests.route(exchange, key, buffer, { ...properties, mandatory: true }, via))
|
|
174
175
|
}))
|
|
175
176
|
|
|
176
177
|
consume = lazy(this, this.#createEventChannel,
|
|
@@ -357,12 +358,20 @@ class IO {
|
|
|
357
358
|
return channel
|
|
358
359
|
}
|
|
359
360
|
|
|
361
|
+
/**
|
|
362
|
+
* On a sharded connection, what is re-sent is what the shard in question holds: the index of
|
|
363
|
+
* the shard comes with each of the events, and a Request that went through another shard is
|
|
364
|
+
* answered through that one.
|
|
365
|
+
*/
|
|
360
366
|
#setupRetransmission () {
|
|
361
367
|
if (this.#requests.sharded === true) {
|
|
362
368
|
// a shard leaves the pool when it rejects a publish, and is lost when its
|
|
363
369
|
// connection drops, which leaves an already sent request unanswered
|
|
364
370
|
this.#requests.diagnose('remove', this.#retransmit)
|
|
365
371
|
this.#requests.diagnose('lost', this.#retransmit)
|
|
372
|
+
|
|
373
|
+
// a returned call is published on another shard, and is held by that one from then on
|
|
374
|
+
this.#requests.diagnose('reroute', this.#rerouted)
|
|
366
375
|
} else {
|
|
367
376
|
this.#requests.diagnose('recover', this.#retransmit)
|
|
368
377
|
}
|
|
@@ -462,11 +471,33 @@ class IO {
|
|
|
462
471
|
|
|
463
472
|
if (signal !== undefined) this.#abandon(request, reply, signal)
|
|
464
473
|
|
|
465
|
-
|
|
474
|
+
request.publishing = true
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
await abortable(publish(buffer, properties, (index) => this.#board(request, index)), signal)
|
|
478
|
+
} finally {
|
|
479
|
+
request.publishing = false
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// the shard it went through was lost while it was being published, and it has not failed
|
|
483
|
+
// over to another since
|
|
484
|
+
if (request.lost === true) this.#resend(request, reply)
|
|
466
485
|
|
|
467
486
|
return reply
|
|
468
487
|
}
|
|
469
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Records the shard a Request is published through. It is recorded as the shard is chosen,
|
|
491
|
+
* rather than once the publish is done, so that a shard lost in between is known to hold it.
|
|
492
|
+
*
|
|
493
|
+
* @param {comq.Request} request
|
|
494
|
+
* @param {number} index
|
|
495
|
+
*/
|
|
496
|
+
#board (request, index) {
|
|
497
|
+
request.shard = index
|
|
498
|
+
request.lost = false
|
|
499
|
+
}
|
|
500
|
+
|
|
470
501
|
/**
|
|
471
502
|
* Stops waiting for a Reply once the signal aborts. The Request is forgotten at once, so a
|
|
472
503
|
* retransmission leaves it be, and a Reply arriving later finds nobody waiting for it.
|
|
@@ -494,17 +525,41 @@ class IO {
|
|
|
494
525
|
* @param {comq.amqp.Message} message
|
|
495
526
|
*/
|
|
496
527
|
#returned = (message) => {
|
|
528
|
+
const pending = this.#pendingOf(message)
|
|
529
|
+
|
|
530
|
+
if (pending === undefined) return
|
|
531
|
+
|
|
532
|
+
const [reply, request] = pending
|
|
533
|
+
|
|
534
|
+
request.emitter.off(request.properties.correlationId)
|
|
535
|
+
this.#pendingReplies.delete(reply)
|
|
536
|
+
reply.reject(new Unroutable(message.fields.exchange, message.fields.routingKey))
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* A Request one shard returned is published on another.
|
|
541
|
+
*
|
|
542
|
+
* @param {comq.amqp.Message} message
|
|
543
|
+
* @param {number} index
|
|
544
|
+
*/
|
|
545
|
+
#rerouted = (message, index) => {
|
|
546
|
+
const pending = this.#pendingOf(message)
|
|
547
|
+
|
|
548
|
+
if (pending !== undefined) this.#board(pending[1], index)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* @param {comq.amqp.Message} message
|
|
553
|
+
* @return {[Promex, comq.Request] | undefined}
|
|
554
|
+
*/
|
|
555
|
+
#pendingOf (message) {
|
|
497
556
|
const { correlationId, replyTo } = message.properties
|
|
498
557
|
|
|
499
558
|
for (const [reply, request] of this.#pendingReplies) {
|
|
500
559
|
if (request.properties.correlationId !== correlationId) continue
|
|
501
560
|
if (request.emitter.queue !== replyTo) continue
|
|
502
561
|
|
|
503
|
-
request
|
|
504
|
-
this.#pendingReplies.delete(reply)
|
|
505
|
-
reply.reject(new Unroutable(message.fields.exchange, message.fields.routingKey))
|
|
506
|
-
|
|
507
|
-
return
|
|
562
|
+
return [reply, request]
|
|
508
563
|
}
|
|
509
564
|
}
|
|
510
565
|
|
|
@@ -665,24 +720,44 @@ class IO {
|
|
|
665
720
|
// an unroutable reply must be returned by the broker rather than dropped
|
|
666
721
|
properties.mandatory = true
|
|
667
722
|
|
|
668
|
-
|
|
723
|
+
// back the way the request came: see `fire` of a sharded channel
|
|
724
|
+
return await this.#replies.fire(replyTo, buffer, properties, request)
|
|
669
725
|
}
|
|
670
726
|
|
|
671
727
|
#recover (exception) {
|
|
672
728
|
if (exception !== RETRANSMISSION) return false
|
|
673
729
|
}
|
|
674
730
|
|
|
675
|
-
|
|
731
|
+
/**
|
|
732
|
+
* Re-sends the Requests awaiting their replies on a shard, or on the connection when no shard
|
|
733
|
+
* is given. A Request that went through another shard is not re-sent: it is answered through
|
|
734
|
+
* that shard, and it is not for comq to assume that it is idempotent.
|
|
735
|
+
*
|
|
736
|
+
* @param {number} [index]
|
|
737
|
+
*/
|
|
738
|
+
#retransmit = (index) => {
|
|
676
739
|
for (const [reply, request] of this.#pendingReplies) {
|
|
677
|
-
|
|
678
|
-
// that are still flowing over the other shards in place
|
|
679
|
-
request.emitter.off(request.properties.correlationId)
|
|
740
|
+
if (index !== undefined && request.shard !== index) continue
|
|
680
741
|
|
|
681
|
-
//
|
|
682
|
-
|
|
742
|
+
// one still being published is re-sent once it is, unless it fails over to another shard
|
|
743
|
+
if (request.publishing === true) request.lost = true
|
|
744
|
+
else this.#resend(request, reply)
|
|
683
745
|
}
|
|
684
746
|
}
|
|
685
747
|
|
|
748
|
+
/**
|
|
749
|
+
* @param {comq.Request} request
|
|
750
|
+
* @param {Promex} reply
|
|
751
|
+
*/
|
|
752
|
+
#resend (request, reply) {
|
|
753
|
+
// detaching this attempt alone leaves the listeners of the reply streams
|
|
754
|
+
// that are still flowing over the other shards in place
|
|
755
|
+
request.emitter.off(request.properties.correlationId)
|
|
756
|
+
|
|
757
|
+
// trigger failsafe attribute
|
|
758
|
+
reply.reject(RETRANSMISSION)
|
|
759
|
+
}
|
|
760
|
+
|
|
686
761
|
/**
|
|
687
762
|
* @param {any} payload
|
|
688
763
|
* @param {comq.Encoding} [contentType]
|
package/source/promex.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A promise settled by whoever holds it, rather than only from within its executor.
|
|
5
|
+
* `callback` settles it the way node-style callbacks report: an error, or a value.
|
|
6
|
+
*
|
|
7
|
+
* @template T
|
|
8
|
+
* @extends {Promise<T>}
|
|
9
|
+
*/
|
|
10
|
+
class Promex extends Promise {
|
|
11
|
+
/** @type {(value?: T | PromiseLike<T>) => void} */
|
|
12
|
+
resolve
|
|
13
|
+
|
|
14
|
+
/** @type {(reason?: any) => void} */
|
|
15
|
+
reject
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {(resolve: (value?: any) => void, reject: (reason?: any) => void) => void} [executor]
|
|
19
|
+
*/
|
|
20
|
+
constructor (executor = noop) {
|
|
21
|
+
let ok
|
|
22
|
+
let oh
|
|
23
|
+
|
|
24
|
+
super((resolve, reject) => {
|
|
25
|
+
ok = resolve
|
|
26
|
+
oh = reject
|
|
27
|
+
|
|
28
|
+
return executor(resolve, reject)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
this.resolve = ok
|
|
32
|
+
this.reject = oh
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {any} error
|
|
37
|
+
* @param {T} [value]
|
|
38
|
+
*/
|
|
39
|
+
callback = (error, value) => {
|
|
40
|
+
if (error !== null && error !== undefined) this.reject(error)
|
|
41
|
+
else this.resolve(value)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function noop () {}
|
|
46
|
+
|
|
47
|
+
exports.Promex = Promex
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Promex } = require('./promex')
|
|
4
|
+
|
|
5
|
+
it('should resolve', async () => {
|
|
6
|
+
const promise = new Promex()
|
|
7
|
+
|
|
8
|
+
setImmediate(() => promise.resolve('ok'))
|
|
9
|
+
|
|
10
|
+
await expect(promise).resolves.toStrictEqual('ok')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should reject', async () => {
|
|
14
|
+
const promise = new Promex()
|
|
15
|
+
|
|
16
|
+
setImmediate(() => promise.reject(new Error('oh')))
|
|
17
|
+
|
|
18
|
+
await expect(promise).rejects.toThrow('oh')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should resolve using the callback', async () => {
|
|
22
|
+
const promise = new Promex()
|
|
23
|
+
|
|
24
|
+
setImmediate(() => promise.callback(null, 'ok'))
|
|
25
|
+
|
|
26
|
+
await expect(promise).resolves.toStrictEqual('ok')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should reject using the callback', async () => {
|
|
30
|
+
const promise = new Promex()
|
|
31
|
+
|
|
32
|
+
setImmediate(() => promise.callback('oh'))
|
|
33
|
+
|
|
34
|
+
await expect(promise).rejects.toStrictEqual('oh')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should resolve using the callback given no error', async () => {
|
|
38
|
+
const promise = new Promex()
|
|
39
|
+
|
|
40
|
+
setImmediate(() => promise.callback(undefined, 'ok'))
|
|
41
|
+
|
|
42
|
+
await expect(promise).resolves.toStrictEqual('ok')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should run the executor it is given', async () => {
|
|
46
|
+
const promise = new Promex((resolve) => resolve('ok'))
|
|
47
|
+
|
|
48
|
+
await expect(promise).resolves.toStrictEqual('ok')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should stay settled by whoever holds it', async () => {
|
|
52
|
+
const promise = new Promex()
|
|
53
|
+
|
|
54
|
+
promise.resolve('first')
|
|
55
|
+
promise.resolve('second')
|
|
56
|
+
|
|
57
|
+
await expect(promise).resolves.toStrictEqual('first')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should be a promise', async () => {
|
|
61
|
+
const promise = new Promex()
|
|
62
|
+
|
|
63
|
+
expect(promise).toBeInstanceOf(Promise)
|
|
64
|
+
|
|
65
|
+
promise.resolve('ok')
|
|
66
|
+
|
|
67
|
+
await expect(Promise.all([promise])).resolves.toStrictEqual(['ok'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should derive promises of its own kind', async () => {
|
|
71
|
+
const promise = new Promex()
|
|
72
|
+
const derived = promise.then((value) => value + '!')
|
|
73
|
+
|
|
74
|
+
expect(derived).toBeInstanceOf(Promex)
|
|
75
|
+
|
|
76
|
+
promise.resolve('ok')
|
|
77
|
+
|
|
78
|
+
await expect(derived).resolves.toStrictEqual('ok!')
|
|
79
|
+
})
|
package/source/retry.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runs an operation until it stops asking for another attempt, waiting longer before
|
|
5
|
+
* each one. The operation is handed the token it returns to ask for a retry; anything
|
|
6
|
+
* else it returns is the result, and whatever it throws is thrown on.
|
|
7
|
+
*
|
|
8
|
+
* @template T
|
|
9
|
+
* @param {(again: symbol) => Promise<T | symbol>} operation
|
|
10
|
+
* @param {Partial<typeof DEFAULTS>} [options]
|
|
11
|
+
* @returns {Promise<T>}
|
|
12
|
+
*/
|
|
13
|
+
async function retry (operation, options) {
|
|
14
|
+
const { attempts, base, max, factor, dispersion } = { ...DEFAULTS, ...options }
|
|
15
|
+
|
|
16
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
17
|
+
const result = await operation(again)
|
|
18
|
+
|
|
19
|
+
if (result !== again) return /** @type {T} */ (result)
|
|
20
|
+
|
|
21
|
+
const interval = Math.min(base * factor ** attempt, max)
|
|
22
|
+
|
|
23
|
+
// attempts that started together are spread out rather than kept in step
|
|
24
|
+
const spread = interval * dispersion * (Math.random() - 0.5)
|
|
25
|
+
|
|
26
|
+
await new Promise((resolve) => setTimeout(resolve, interval + spread))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new Error('Maximum attempts exceeded')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const again = Symbol('again')
|
|
33
|
+
|
|
34
|
+
const DEFAULTS = {
|
|
35
|
+
attempts: Infinity,
|
|
36
|
+
base: 1000,
|
|
37
|
+
max: 30000,
|
|
38
|
+
factor: 1.5,
|
|
39
|
+
dispersion: 0.1
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
exports.retry = retry
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { retry } = require('./retry')
|
|
4
|
+
|
|
5
|
+
const immediate = { base: 0, max: 0, dispersion: 0 }
|
|
6
|
+
|
|
7
|
+
it('should return the result', async () => {
|
|
8
|
+
const result = await retry(async () => 'ok', immediate)
|
|
9
|
+
|
|
10
|
+
expect(result).toStrictEqual('ok')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should retry while the operation asks for it', async () => {
|
|
14
|
+
let attempts = 0
|
|
15
|
+
|
|
16
|
+
const result = await retry(async (again) => {
|
|
17
|
+
attempts++
|
|
18
|
+
|
|
19
|
+
return attempts < 3 ? again : 'ok'
|
|
20
|
+
}, immediate)
|
|
21
|
+
|
|
22
|
+
expect(attempts).toStrictEqual(3)
|
|
23
|
+
expect(result).toStrictEqual('ok')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should pass the same token on every attempt', async () => {
|
|
27
|
+
const tokens = []
|
|
28
|
+
|
|
29
|
+
await retry(async (again) => {
|
|
30
|
+
tokens.push(again)
|
|
31
|
+
|
|
32
|
+
return tokens.length < 3 ? again : 'ok'
|
|
33
|
+
}, immediate)
|
|
34
|
+
|
|
35
|
+
expect(tokens).toHaveLength(3)
|
|
36
|
+
expect(new Set(tokens).size).toStrictEqual(1)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should throw what the operation throws', async () => {
|
|
40
|
+
const exception = new Error('nope')
|
|
41
|
+
|
|
42
|
+
await expect(retry(async () => { throw exception }, immediate)).rejects.toThrow(exception)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should give up after the last attempt', async () => {
|
|
46
|
+
let attempts = 0
|
|
47
|
+
|
|
48
|
+
const attempt = retry(async (again) => {
|
|
49
|
+
attempts++
|
|
50
|
+
|
|
51
|
+
return again
|
|
52
|
+
}, { ...immediate, attempts: 3 })
|
|
53
|
+
|
|
54
|
+
await expect(attempt).rejects.toThrow('Maximum attempts exceeded')
|
|
55
|
+
expect(attempts).toStrictEqual(3)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should not run the operation without an attempt to spare', async () => {
|
|
59
|
+
const operation = jest.fn(async (again) => again)
|
|
60
|
+
|
|
61
|
+
await expect(retry(operation, { ...immediate, attempts: 0 }))
|
|
62
|
+
.rejects.toThrow('Maximum attempts exceeded')
|
|
63
|
+
|
|
64
|
+
expect(operation).not.toHaveBeenCalled()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should back off, up to the maximum, before each attempt', async () => {
|
|
68
|
+
jest.useFakeTimers()
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const waits = []
|
|
72
|
+
|
|
73
|
+
let last = Date.now()
|
|
74
|
+
let attempts = 0
|
|
75
|
+
|
|
76
|
+
const attempt = retry(async (again) => {
|
|
77
|
+
waits.push(Date.now() - last)
|
|
78
|
+
last = Date.now()
|
|
79
|
+
attempts++
|
|
80
|
+
|
|
81
|
+
return attempts < 5 ? again : 'ok'
|
|
82
|
+
}, { base: 100, factor: 2, max: 300, dispersion: 0 })
|
|
83
|
+
|
|
84
|
+
await jest.advanceTimersByTimeAsync(10_000)
|
|
85
|
+
|
|
86
|
+
expect(await attempt).toStrictEqual('ok')
|
|
87
|
+
|
|
88
|
+
// the first attempt waits for nothing; then 100, 200, and 400 capped at 300
|
|
89
|
+
expect(waits).toStrictEqual([0, 100, 200, 300, 300])
|
|
90
|
+
} finally {
|
|
91
|
+
jest.useRealTimers()
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should spread the wait around the interval', async () => {
|
|
96
|
+
const waits = []
|
|
97
|
+
|
|
98
|
+
const timer = global.setTimeout
|
|
99
|
+
|
|
100
|
+
global.setTimeout = (callback, ms) => {
|
|
101
|
+
waits.push(ms)
|
|
102
|
+
|
|
103
|
+
return timer(callback, 0)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
let attempts = 0
|
|
108
|
+
|
|
109
|
+
await retry(async (again) => {
|
|
110
|
+
attempts++
|
|
111
|
+
|
|
112
|
+
return attempts < 20 ? again : 'ok'
|
|
113
|
+
}, { base: 1000, factor: 1, max: 1000, dispersion: 0.1 })
|
|
114
|
+
} finally {
|
|
115
|
+
global.setTimeout = timer
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
expect(waits.every((ms) => ms >= 950 && ms <= 1050)).toStrictEqual(true)
|
|
119
|
+
expect(new Set(waits).size).toBeGreaterThan(1)
|
|
120
|
+
})
|
package/source/shards/channel.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { Promex } = require('promex')
|
|
3
|
+
const { Promex } = require('../promex')
|
|
4
4
|
const { RETRY_PREFIX } = require('../channel')
|
|
5
5
|
const events = require('../events')
|
|
6
6
|
const emitter = require('../emitter')
|
|
@@ -57,8 +57,12 @@ class Channel {
|
|
|
57
57
|
await Promise.any(promises)
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A message is told which shard it arrived on, so that whatever answers it can go back the same
|
|
62
|
+
* way. See `fire`.
|
|
63
|
+
*/
|
|
60
64
|
async consume (queue, consumer) {
|
|
61
|
-
return await this.#every((channel) => channel.consume(queue, consumer))
|
|
65
|
+
return await this.#every((channel) => channel.consume(queue, arrival(consumer, channel)))
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
async subscribe (queue, group, consumer) {
|
|
@@ -74,24 +78,51 @@ class Channel {
|
|
|
74
78
|
* claiming it, and holds it as well once it is let go.
|
|
75
79
|
*/
|
|
76
80
|
async held (exchange, queue, key, consumer) {
|
|
77
|
-
await Promise.any(this.#apply((channel) => channel.held(exchange, queue, key, consumer)))
|
|
81
|
+
await Promise.any(this.#apply((channel) => channel.held(exchange, queue, key, arrival(consumer, channel))))
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} queue
|
|
86
|
+
* @param {Buffer} buffer
|
|
87
|
+
* @param {comq.amqp.options.Publish} [options]
|
|
88
|
+
* @param {(index: number) => void} [via] told which shard the message is published through,
|
|
89
|
+
* as soon as one is chosen and again whenever the publish fails over to another
|
|
90
|
+
*/
|
|
91
|
+
async send (queue, buffer, options, via) {
|
|
92
|
+
await this.#one((channel) => channel.send(queue, buffer, options), { via })
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
async publish (exchange, buffer, options) {
|
|
85
96
|
await this.#one((channel) => channel.publish(exchange, buffer, options))
|
|
86
97
|
}
|
|
87
98
|
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} exchange
|
|
101
|
+
* @param {string} key
|
|
102
|
+
* @param {Buffer} buffer
|
|
103
|
+
* @param {comq.amqp.options.Publish} [options]
|
|
104
|
+
* @param {(index: number) => void} [via] as `send` has it
|
|
105
|
+
*/
|
|
106
|
+
async route (exchange, key, buffer, options, via) {
|
|
107
|
+
await this.#one((channel) => channel.route(exchange, key, buffer, options), { via })
|
|
90
108
|
}
|
|
91
109
|
|
|
92
|
-
|
|
110
|
+
/**
|
|
111
|
+
* A reply goes back through the shard the message it answers arrived on. Whoever sent that
|
|
112
|
+
* message re-sends what it sent through a shard once the shard is lost, and nothing else, so a
|
|
113
|
+
* reply taking another way could be lost with a shard that nothing is re-sent for. It takes
|
|
114
|
+
* another way when that shard is not reachable, which leaves its message unacknowledged there,
|
|
115
|
+
* to be delivered and answered again.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} queue
|
|
118
|
+
* @param {Buffer} buffer
|
|
119
|
+
* @param {comq.amqp.options.Publish} [options]
|
|
120
|
+
* @param {comq.amqp.Message} [origin] the message this one answers
|
|
121
|
+
* @return {Promise<boolean>}
|
|
122
|
+
*/
|
|
123
|
+
async fire (queue, buffer, options, origin) {
|
|
93
124
|
// noinspection JSValidateTypes
|
|
94
|
-
return await this.#one((channel) => channel.fire(queue, buffer, options))
|
|
125
|
+
return await this.#one((channel) => channel.fire(queue, buffer, options), { prefer: origin?.[ARRIVAL] })
|
|
95
126
|
}
|
|
96
127
|
|
|
97
128
|
async close () {
|
|
@@ -230,6 +261,9 @@ class Channel {
|
|
|
230
261
|
|
|
231
262
|
const next = rest[Math.floor(Math.random() * rest.length)]
|
|
232
263
|
|
|
264
|
+
// the message is on that shard now, and is lost with it rather than with this one
|
|
265
|
+
this.#diagnostics.emit(REROUTE, message, next.index)
|
|
266
|
+
|
|
233
267
|
// a Request under a key goes through its routed exchange, which `route` declares on that
|
|
234
268
|
// shard before publishing: publishing to an exchange a broker lacks closes the connection
|
|
235
269
|
const retried = exchange === DEFAULT
|
|
@@ -374,9 +408,13 @@ class Channel {
|
|
|
374
408
|
}
|
|
375
409
|
|
|
376
410
|
/**
|
|
411
|
+
* Publishes through a shard chosen at random, or through the one preferred while it is
|
|
412
|
+
* reachable, and fails over to another when that one fails.
|
|
413
|
+
*
|
|
377
414
|
* @param {(channel: comq.Channel) => void} fn
|
|
415
|
+
* @param {{ via?: (index: number) => void, prefer?: number }} [route]
|
|
378
416
|
*/
|
|
379
|
-
async #one (fn) {
|
|
417
|
+
async #one (fn, route = {}) {
|
|
380
418
|
// capture before the check: recover may replace `#recovery` in between
|
|
381
419
|
const waiting = this.#recovery
|
|
382
420
|
const pool = this.#reachable()
|
|
@@ -384,21 +422,38 @@ class Channel {
|
|
|
384
422
|
if (pool.length === 0) {
|
|
385
423
|
await waiting
|
|
386
424
|
|
|
387
|
-
return this.#one(fn)
|
|
425
|
+
return this.#one(fn, route)
|
|
388
426
|
}
|
|
389
427
|
|
|
390
|
-
const channel = pool
|
|
428
|
+
const channel = pool.find((channel) => channel.index === route.prefer) ??
|
|
429
|
+
pool[Math.floor(Math.random() * pool.length)]
|
|
430
|
+
|
|
431
|
+
// told before the publish is awaited: a shard lost while it is under way has it on board
|
|
432
|
+
route.via?.(channel.index)
|
|
391
433
|
|
|
392
434
|
try {
|
|
393
435
|
return await fn(channel)
|
|
394
436
|
} catch {
|
|
395
437
|
this.#remove(channel)
|
|
396
438
|
|
|
397
|
-
return this.#one(fn)
|
|
439
|
+
return this.#one(fn, route)
|
|
398
440
|
}
|
|
399
441
|
}
|
|
400
442
|
}
|
|
401
443
|
|
|
444
|
+
/**
|
|
445
|
+
* @param {comq.channels.Consumer} consumer
|
|
446
|
+
* @param {comq.Channel} channel
|
|
447
|
+
* @return {comq.channels.Consumer}
|
|
448
|
+
*/
|
|
449
|
+
function arrival (consumer, channel) {
|
|
450
|
+
return (message) => {
|
|
451
|
+
message[ARRIVAL] = channel.index
|
|
452
|
+
|
|
453
|
+
return consumer(message)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
402
457
|
/**
|
|
403
458
|
* @param {comq.Connection[]} connections
|
|
404
459
|
* @param {comq.topology.type} type
|
|
@@ -418,6 +473,12 @@ const REMOVE = 'remove'
|
|
|
418
473
|
const RETURN_HEADER = 'x-return'
|
|
419
474
|
const LOST = 'lost'
|
|
420
475
|
|
|
476
|
+
/** A returned message published on another shard, which is where it can be lost from now on. */
|
|
477
|
+
const REROUTE = 'reroute'
|
|
478
|
+
|
|
479
|
+
/** Which shard a consumed message arrived on. */
|
|
480
|
+
const ARRIVAL = Symbol('arrival')
|
|
481
|
+
|
|
421
482
|
function noop () {}
|
|
422
483
|
|
|
423
484
|
exports.create = create
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as mock from 'jest-mock'
|
|
2
|
+
|
|
3
|
+
// The test files are written against Jest's mock types, and keep being: the mocks are still
|
|
4
|
+
// Jest's, taken as the standalone `jest-mock` package. This is what @types/jest declared of it.
|
|
5
|
+
|
|
6
|
+
declare global {
|
|
7
|
+
|
|
8
|
+
namespace jest {
|
|
9
|
+
|
|
10
|
+
type Mock<T extends mock.FunctionLike = mock.UnknownFunction> = mock.Mock<T>
|
|
11
|
+
|
|
12
|
+
type Mocked<T extends object> = mock.Mocked<T>
|
|
13
|
+
|
|
14
|
+
type MockedClass<T extends mock.ClassLike> = mock.MockedClass<T>
|
|
15
|
+
|
|
16
|
+
type MockedFunction<T extends mock.FunctionLike = mock.UnknownFunction> = mock.MockedFunction<T>
|
|
17
|
+
|
|
18
|
+
type MockedFn<T extends mock.FunctionLike = mock.UnknownFunction> = mock.MockedFunction<T>
|
|
19
|
+
|
|
20
|
+
type MockedObject<T extends object> = mock.MockedObject<T>
|
|
21
|
+
}
|
|
22
|
+
}
|