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.
@@ -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
  */
@@ -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
+ })
@@ -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
  })
@@ -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
+ })
@@ -22,7 +22,12 @@ declare namespace comq {
22
22
  forget(event: _diagnostics.Event, listener: Function): void
23
23
  }
24
24
 
25
- type Connect = (...urls: string[]) => Promise<_io.IO>
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
 
@@ -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
 
@@ -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