comq 0.17.1 → 0.19.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/.github/workflows/{dev.yaml → features.yaml} +14 -3
- package/discussions/poison-message-handling.md +1124 -0
- package/docs/headers.md +37 -0
- package/features/events.poison.feature +43 -12
- package/features/steps/context.js +18 -2
- package/features/steps/events.js +20 -1
- package/features/steps/poison.js +66 -4
- package/package.json +1 -1
- package/readme.md +138 -14
- package/source/channel.js +218 -30
- package/source/connect.js +31 -21
- package/source/connection.js +11 -2
- package/source/events.js +1 -1
- package/source/index.js +4 -0
- package/source/io.js +27 -1
- package/source/singleton.js +47 -4
- package/source/topology/event.json +7 -1
- package/source/topology/request.json +7 -1
- package/source/verdicts.js +54 -0
- package/test/assert.test.js +2 -2
- package/test/channel.fixtures.js +2 -1
- package/test/channel.routed.test.js +7 -3
- package/test/channel.test.js +697 -5
- package/test/connect.test.js +31 -2
- package/test/helpers.js +15 -0
- package/test/io.diagnostics.test.js +1 -1
- package/test/io.reply.test.js +66 -0
- package/test/presets.test.js +4 -2
- package/test/singleton.test.js +1 -1
- package/test/verdicts.test.js +57 -0
- package/types/connection.d.ts +6 -1
- package/types/diagnostic.d.ts +1 -1
- package/types/index.d.ts +17 -0
- package/types/io.d.ts +3 -1
- package/types/topology.d.ts +16 -0
package/test/connect.test.js
CHANGED
|
@@ -40,7 +40,7 @@ describe('single connection', () => {
|
|
|
40
40
|
})
|
|
41
41
|
|
|
42
42
|
it('should pass active connection', async () => {
|
|
43
|
-
expect(Connection).toHaveBeenCalledWith(url)
|
|
43
|
+
expect(Connection).toHaveBeenCalledWith(url, {})
|
|
44
44
|
|
|
45
45
|
/** @type {jest.MockedObject<comq.Connection>} */
|
|
46
46
|
const instance = Connection.mock.instances[0]
|
|
@@ -62,10 +62,39 @@ describe('sharded connection', () => {
|
|
|
62
62
|
})
|
|
63
63
|
|
|
64
64
|
it('should pass single connection instances', async () => {
|
|
65
|
-
urls.forEach((url) => expect(Connection).toHaveBeenCalledWith(url))
|
|
65
|
+
urls.forEach((url) => expect(Connection).toHaveBeenCalledWith(url, {}))
|
|
66
66
|
|
|
67
67
|
const instances = Connection.mock.instances
|
|
68
68
|
|
|
69
69
|
expect(shards.Connection).toHaveBeenCalledWith(instances)
|
|
70
70
|
})
|
|
71
71
|
})
|
|
72
|
+
|
|
73
|
+
describe('topology overrides', () => {
|
|
74
|
+
it('should pass the trailing argument to the connection', async () => {
|
|
75
|
+
const overrides = { event: { delay: 100 } }
|
|
76
|
+
|
|
77
|
+
await connect(url, overrides)
|
|
78
|
+
|
|
79
|
+
expect(Connection).toHaveBeenCalledWith(url, overrides)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should pass them to every shard', async () => {
|
|
83
|
+
const urls = [generate(), generate()]
|
|
84
|
+
const overrides = { request: { attempts: 1 } }
|
|
85
|
+
|
|
86
|
+
await connect(...urls, overrides)
|
|
87
|
+
|
|
88
|
+
urls.forEach((url) => expect(Connection).toHaveBeenCalledWith(url, overrides))
|
|
89
|
+
expect(shards.Connection).toHaveBeenCalled()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should not take a url as overrides', async () => {
|
|
93
|
+
const urls = [generate(), generate()]
|
|
94
|
+
|
|
95
|
+
await connect(...urls)
|
|
96
|
+
|
|
97
|
+
expect(Connection).toHaveBeenCalledTimes(2)
|
|
98
|
+
urls.forEach((url) => expect(Connection).toHaveBeenCalledWith(url, {}))
|
|
99
|
+
})
|
|
100
|
+
})
|
package/test/helpers.js
CHANGED
|
@@ -4,6 +4,20 @@ const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
4
4
|
|
|
5
5
|
const immediate = () => new Promise((resolve) => setImmediate(resolve))
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Waits for a condition rather than for a duration.
|
|
9
|
+
*
|
|
10
|
+
* @param {() => boolean} condition
|
|
11
|
+
* @param {number} [ms] how long to keep asking
|
|
12
|
+
*/
|
|
13
|
+
const until = async (condition, ms = 5000) => {
|
|
14
|
+
const deadline = Date.now() + ms
|
|
15
|
+
|
|
16
|
+
while (!condition() && Date.now() < deadline) await timeout(20)
|
|
17
|
+
|
|
18
|
+
return condition()
|
|
19
|
+
}
|
|
20
|
+
|
|
7
21
|
const random = (max = 100) => Math.floor(Math.random() * max)
|
|
8
22
|
|
|
9
23
|
const flip = () => Math.random() < 0.5
|
|
@@ -70,6 +84,7 @@ const MULTIPLIERS = Object.entries({
|
|
|
70
84
|
|
|
71
85
|
exports.timeout = timeout
|
|
72
86
|
exports.immediate = immediate
|
|
87
|
+
exports.until = until
|
|
73
88
|
exports.random = random
|
|
74
89
|
exports.flip = flip
|
|
75
90
|
exports.sample = sample
|
|
@@ -28,7 +28,7 @@ describe.each(['request', 'reply', 'event'])('%s channel events',
|
|
|
28
28
|
* @param {comq.topology.type} type
|
|
29
29
|
*/
|
|
30
30
|
(type) => {
|
|
31
|
-
it.each(['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'lost', 'remove'])('should re-emit %s',
|
|
31
|
+
it.each(['flow', 'drain', 'recover', 'discard', 'retry', 'pause', 'resume', 'lost', 'remove'])('should re-emit %s',
|
|
32
32
|
/**
|
|
33
33
|
* @param {comq.diagnostics.Event} event
|
|
34
34
|
*/
|
package/test/io.reply.test.js
CHANGED
|
@@ -10,6 +10,7 @@ const mock = require('./connection.mock')
|
|
|
10
10
|
const { encodings } = require('./encodings')
|
|
11
11
|
|
|
12
12
|
const { IO } = require('../source/io')
|
|
13
|
+
const { Retry, Park } = require('../source/verdicts')
|
|
13
14
|
|
|
14
15
|
/** @type {comq.IO} */
|
|
15
16
|
let io
|
|
@@ -240,3 +241,68 @@ const findChannel = (type) => {
|
|
|
240
241
|
|
|
241
242
|
return connection.createChannel.mock.results[index].value
|
|
242
243
|
}
|
|
244
|
+
|
|
245
|
+
describe('verdicts', () => {
|
|
246
|
+
/** @type {jest.MockedObject<comq.Channel>} */
|
|
247
|
+
let requests
|
|
248
|
+
|
|
249
|
+
const message = () => /** @type {comq.amqp.Message} */ ({
|
|
250
|
+
content: randomBytes(10),
|
|
251
|
+
properties: { replyTo: generate(), contentType: 'text/plain' }
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const findChannel = async (type) => {
|
|
255
|
+
const index = connection.createChannel.mock.calls.findIndex(([argument]) => argument === type)
|
|
256
|
+
|
|
257
|
+
return await connection.createChannel.mock.results[index].value
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
beforeEach(async () => {
|
|
261
|
+
await io.reply(queue, produce)
|
|
262
|
+
|
|
263
|
+
requests = await findChannel('request')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('should refuse Park from a producer', async () => {
|
|
267
|
+
// a Request has a caller awaiting a reply, so a verdict is a category error there
|
|
268
|
+
const park = new Park(generate())
|
|
269
|
+
|
|
270
|
+
produce.mockImplementationOnce(async () => { throw park })
|
|
271
|
+
|
|
272
|
+
const consumer = requests.consume.mock.calls[0][1]
|
|
273
|
+
|
|
274
|
+
await expect(consumer(message())).rejects.toThrow(/not applicable to a reply producer/)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('should keep the Park as the cause of what it throws instead', async () => {
|
|
278
|
+
const park = new Park(generate())
|
|
279
|
+
|
|
280
|
+
produce.mockImplementationOnce(async () => { throw park })
|
|
281
|
+
|
|
282
|
+
const consumer = requests.consume.mock.calls[0][1]
|
|
283
|
+
const exception = await consumer(message()).catch((error) => error)
|
|
284
|
+
|
|
285
|
+
// the explanation reaches the parked message as its reason, and this as its cause
|
|
286
|
+
expect(exception.cause).toStrictEqual(park)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('should pass an ordinary rejection through untouched', async () => {
|
|
290
|
+
const failure = new Error(generate())
|
|
291
|
+
|
|
292
|
+
produce.mockImplementationOnce(async () => { throw failure })
|
|
293
|
+
|
|
294
|
+
const consumer = requests.consume.mock.calls[0][1]
|
|
295
|
+
|
|
296
|
+
await expect(consumer(message())).rejects.toStrictEqual(failure)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('should pass Retry through untouched', async () => {
|
|
300
|
+
const retry = new Retry(generate())
|
|
301
|
+
|
|
302
|
+
produce.mockImplementationOnce(async () => { throw retry })
|
|
303
|
+
|
|
304
|
+
const consumer = requests.consume.mock.calls[0][1]
|
|
305
|
+
|
|
306
|
+
await expect(consumer(message())).rejects.toStrictEqual(retry)
|
|
307
|
+
})
|
|
308
|
+
})
|
package/test/presets.test.js
CHANGED
|
@@ -8,7 +8,8 @@ it('should define request preset', async () => {
|
|
|
8
8
|
confirms: false,
|
|
9
9
|
durable: true,
|
|
10
10
|
acknowledgments: true,
|
|
11
|
-
persistent: false
|
|
11
|
+
persistent: false,
|
|
12
|
+
delay: [1000, 3000, 5000, 10000]
|
|
12
13
|
})
|
|
13
14
|
})
|
|
14
15
|
|
|
@@ -28,6 +29,7 @@ it('should define event preset', async () => {
|
|
|
28
29
|
confirms: true,
|
|
29
30
|
durable: true,
|
|
30
31
|
acknowledgments: true,
|
|
31
|
-
persistent: true
|
|
32
|
+
persistent: true,
|
|
33
|
+
delay: [1000, 10000, 30000, 90000]
|
|
32
34
|
})
|
|
33
35
|
})
|
package/test/singleton.test.js
CHANGED
|
@@ -27,7 +27,7 @@ beforeEach(() => {
|
|
|
27
27
|
|
|
28
28
|
it('should extend Connection', async () => {
|
|
29
29
|
expect(connection).toBeInstanceOf(mock.Connection)
|
|
30
|
-
expect(mock.Connection.ctor).toHaveBeenCalledWith(url)
|
|
30
|
+
expect(mock.Connection.ctor).toHaveBeenCalledWith(url, {})
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
describe('connection', () => {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { generate } = require('randomstring')
|
|
4
|
+
const { Retry, Park, verdictOf, RETRY, PARK, VERDICT } = require('../source/verdicts')
|
|
5
|
+
|
|
6
|
+
it('should be', async () => {
|
|
7
|
+
expect(Retry).toBeDefined()
|
|
8
|
+
expect(Park).toBeDefined()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('verdictOf', () => {
|
|
12
|
+
it.each([
|
|
13
|
+
['undefined', undefined],
|
|
14
|
+
['null', null],
|
|
15
|
+
['a string', 'nope'],
|
|
16
|
+
['a plain Error', new Error(generate())],
|
|
17
|
+
['an object branded with something else', { [Symbol.for('other.verdict')]: 'park' }],
|
|
18
|
+
['an object whose brand is not a verdict', { [VERDICT]: generate() }]
|
|
19
|
+
])('should read %s as a retry', async (_, exception) => {
|
|
20
|
+
expect(verdictOf(exception)).toStrictEqual(RETRY)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should read Retry as a retry', async () => {
|
|
24
|
+
expect(verdictOf(new Retry(generate()))).toStrictEqual(RETRY)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should read Park as a park', async () => {
|
|
28
|
+
expect(verdictOf(new Park(generate()))).toStrictEqual(PARK)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should read a Park thrown by another copy of comq', async () => {
|
|
32
|
+
// a dependency pinning comq while the application resolves its own tree makes two
|
|
33
|
+
// copies the ordinary outcome, and `instanceof` is silently false across them
|
|
34
|
+
const foreign = { [Symbol.for('comq.verdict')]: 'park' }
|
|
35
|
+
|
|
36
|
+
expect(verdictOf(foreign)).toStrictEqual(PARK)
|
|
37
|
+
expect(foreign instanceof Park).toStrictEqual(false)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('the classes', () => {
|
|
42
|
+
it.each([['Retry', Retry], ['Park', Park]])('should make %s an Error', async (name, Verdict) => {
|
|
43
|
+
const message = generate()
|
|
44
|
+
const verdict = new Verdict(message)
|
|
45
|
+
|
|
46
|
+
expect(verdict).toBeInstanceOf(Error)
|
|
47
|
+
expect(verdict.message).toStrictEqual(message)
|
|
48
|
+
expect(verdict.name).toStrictEqual(name)
|
|
49
|
+
expect(typeof verdict.stack).toStrictEqual('string')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it.each([['Retry', Retry], ['Park', Park]])('should let %s carry a cause', async (_, Verdict) => {
|
|
53
|
+
const cause = new Error(generate())
|
|
54
|
+
|
|
55
|
+
expect(new Verdict(generate(), { cause }).cause).toStrictEqual(cause)
|
|
56
|
+
})
|
|
57
|
+
})
|
package/types/connection.d.ts
CHANGED
|
@@ -22,7 +22,12 @@ declare namespace comq {
|
|
|
22
22
|
forget(event: _diagnostics.Event, listener: Function): void
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
type Connect =
|
|
25
|
+
type Connect = {
|
|
26
|
+
(...urls: string[]): Promise<_io.IO>
|
|
27
|
+
|
|
28
|
+
/** The trailing argument overrides the topology presets, per channel type. */
|
|
29
|
+
(...args: [...urls: string[], overrides: _topology.Overrides]): Promise<_io.IO>
|
|
30
|
+
}
|
|
26
31
|
|
|
27
32
|
}
|
|
28
33
|
|
package/types/diagnostic.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
declare namespace comq.diagnostics {
|
|
2
2
|
|
|
3
3
|
type Event = 'open' | 'close' | 'error' | 'reconnect' | 'exhausted' | 'flow' | 'drain' |
|
|
4
|
-
'remove' | 'lost' | 'recover' | 'discard' | 'pause' | 'resume' | 'return'
|
|
4
|
+
'remove' | 'lost' | 'recover' | 'discard' | 'retry' | 'pause' | 'resume' | 'return'
|
|
5
5
|
|
|
6
6
|
interface Diagnosable {
|
|
7
7
|
diagnose(event: Event, listener: Function): void
|
package/types/index.d.ts
CHANGED
|
@@ -4,3 +4,20 @@ export type { IO, Consumer, Producer } from './io'
|
|
|
4
4
|
|
|
5
5
|
export const connect: Connect
|
|
6
6
|
export const assert: Connect
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Thrown by a consumer to say that whatever it needed was briefly not there. This is
|
|
10
|
+
* what an unclassified rejection already means, so throwing it changes nothing but the
|
|
11
|
+
* reader's certainty.
|
|
12
|
+
*/
|
|
13
|
+
export class Retry extends Error {}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Thrown by a consumer to say that it will never process this message, however many
|
|
17
|
+
* times it is handed over. The message is kept rather than retried.
|
|
18
|
+
*
|
|
19
|
+
* Not applicable to a `Producer` given to `IO.reply`: a Request has a caller awaiting a
|
|
20
|
+
* reply, so a verdict is a category error there and the message is retried and parked on
|
|
21
|
+
* the count like any other failure.
|
|
22
|
+
*/
|
|
23
|
+
export class Park extends Error {}
|
package/types/io.d.ts
CHANGED
|
@@ -103,7 +103,9 @@ declare namespace comq {
|
|
|
103
103
|
|
|
104
104
|
diagnose(event: 'recover', listener: (channel: _topology.type, index?: number) => void): void
|
|
105
105
|
|
|
106
|
-
diagnose(event: 'discard', listener: (channel: _topology.type, message: any, index?: number) => void): void
|
|
106
|
+
diagnose(event: 'discard', listener: (channel: _topology.type, message: any, error: Error, index?: number) => void): void
|
|
107
|
+
|
|
108
|
+
diagnose(event: 'retry', listener: (channel: _topology.type, message: any, error: Error, attempt: number, index?: number) => void): void
|
|
107
109
|
|
|
108
110
|
diagnose(event: 'pause', listener: (channel: _topology.type) => void): void
|
|
109
111
|
|
package/types/topology.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ declare namespace comq {
|
|
|
8
8
|
[K in type]: Topology
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** Per channel type overrides of the presets, as passed to `connect`. */
|
|
12
|
+
type Overrides = {
|
|
13
|
+
[K in type]?: Partial<Topology>
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
type Topology = {
|
|
@@ -16,8 +21,19 @@ declare namespace comq {
|
|
|
16
21
|
durable: boolean
|
|
17
22
|
acknowledgments: boolean
|
|
18
23
|
persistent: boolean
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How long a failed message waits before it is delivered again, in milliseconds:
|
|
27
|
+
* one entry per retry, so **the length of the ladder is the number of retries** and
|
|
28
|
+
* four rungs is five attempts. A bare number is a ladder of one, hence a single retry.
|
|
29
|
+
*
|
|
30
|
+
* Each distinct value is a retry queue of its own, because the wait is the queue's
|
|
31
|
+
* `x-message-ttl` rather than the message's, and a queue holds one of those.
|
|
32
|
+
*/
|
|
33
|
+
delay: number | number[]
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
}
|
|
22
37
|
|
|
23
38
|
export type type = comq.topology.type
|
|
39
|
+
export type Overrides = comq.topology.Overrides
|