comq 0.27.0 → 0.28.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.
@@ -0,0 +1,348 @@
1
+ 'use strict'
2
+
3
+ const { Name } = require('../../source/shards/name')
4
+
5
+ /** @type {Record<string, comq.shards.Resolution | Error>} */
6
+ let records
7
+
8
+ /** @type {Record<string, number>} how many times each name has been resolved */
9
+ let resolved
10
+
11
+ /** @type {Name} */
12
+ let name
13
+
14
+ /** @type {comq.shards.Follower & Record<string, jest.MockedFunction<any>>} */
15
+ let follower
16
+
17
+ /** @type {() => void} */
18
+ let leave
19
+
20
+ const INTERVAL = 1000
21
+ const SETTLE = 5000
22
+ const timing = { interval: INTERVAL, settle: SETTLE }
23
+
24
+ const tick = (ms = INTERVAL) => jest.advanceTimersByTimeAsync(ms)
25
+
26
+ /**
27
+ * @param {boolean} [urgent]
28
+ * @return {comq.shards.Follower & Record<string, jest.MockedFunction<any>>}
29
+ */
30
+ const followerOf = (urgent = false) => ({ change: jest.fn(), error: jest.fn(), urgent: jest.fn(() => urgent) })
31
+
32
+ /**
33
+ * @param {comq.shards.Timing} [options]
34
+ * @param {boolean} [urgent]
35
+ */
36
+ async function start (options = timing, urgent = false) {
37
+ name = Name.of('two', options)
38
+ follower = followerOf(urgent)
39
+ leave = name.follow(follower)
40
+
41
+ return await name.start()
42
+ }
43
+
44
+ beforeEach(() => {
45
+ jest.useFakeTimers()
46
+
47
+ records = { one: { addresses: ['10.0.0.1'], ttl: 5 }, two: { addresses: ['10.0.0.2'], ttl: 5 } }
48
+ resolved = { one: 0, two: 0 }
49
+
50
+ global.COMQ_TESTING_LOOKUP = async (host) => {
51
+ resolved[host]++
52
+
53
+ if (records[host] instanceof Error) throw records[host]
54
+
55
+ return records[host]
56
+ }
57
+ })
58
+
59
+ afterEach(() => {
60
+ Name.__lets_pretend_this_method_doesnt_exist()
61
+ jest.useRealTimers()
62
+
63
+ delete global.COMQ_TESTING_LOOKUP
64
+ })
65
+
66
+ it('should tell the address of the name', async () => {
67
+ const address = await start()
68
+
69
+ expect(address).toStrictEqual('10.0.0.2')
70
+ })
71
+
72
+ it('should not tell of what has not changed', async () => {
73
+ await start()
74
+ await tick(SETTLE * 2)
75
+
76
+ expect(follower.change).not.toHaveBeenCalled()
77
+ })
78
+
79
+ it('should tell of a change once it has held for the settle time', async () => {
80
+ await start()
81
+
82
+ records.two = { addresses: ['10.0.0.3'], ttl: 5 }
83
+
84
+ await tick(SETTLE)
85
+
86
+ expect(follower.change).not.toHaveBeenCalled()
87
+
88
+ await tick()
89
+
90
+ expect(follower.change).toHaveBeenCalledTimes(1)
91
+ expect(follower.change).toHaveBeenCalledWith('two', '10.0.0.3')
92
+ })
93
+
94
+ it('should start the settle time anew whenever the answer changes', async () => {
95
+ await start()
96
+
97
+ records.two = { addresses: ['10.0.0.3'], ttl: 5 }
98
+
99
+ await tick(SETTLE - INTERVAL)
100
+
101
+ records.two = { addresses: ['10.0.0.4'], ttl: 5 }
102
+
103
+ await tick(SETTLE - INTERVAL)
104
+
105
+ expect(follower.change).not.toHaveBeenCalled()
106
+
107
+ await tick(INTERVAL * 2)
108
+
109
+ expect(follower.change).toHaveBeenCalledTimes(1)
110
+ expect(follower.change).toHaveBeenCalledWith('two', '10.0.0.4')
111
+ })
112
+
113
+ it('should not act on an answer that goes back and forth', async () => {
114
+ await start()
115
+
116
+ for (let i = 0; i < 20; i++) {
117
+ records.two = { addresses: [i % 2 === 0 ? '10.0.0.3' : '10.0.0.2'], ttl: 5 }
118
+
119
+ await tick()
120
+ }
121
+
122
+ expect(follower.change).not.toHaveBeenCalled()
123
+ })
124
+
125
+ it('should forget a change that was taken back', async () => {
126
+ await start()
127
+
128
+ records.two = { addresses: ['10.0.0.3'], ttl: 5 }
129
+
130
+ await tick(INTERVAL * 2)
131
+
132
+ records.two = { addresses: ['10.0.0.2'], ttl: 5 }
133
+
134
+ await tick(SETTLE * 2)
135
+
136
+ expect(follower.change).not.toHaveBeenCalled()
137
+ })
138
+
139
+ it('should keep the address of a name that fails to resolve', async () => {
140
+ await start()
141
+
142
+ const exception = new Error('ENOTFOUND')
143
+
144
+ records.two = exception
145
+
146
+ await tick(SETTLE * 2)
147
+
148
+ expect(follower.change).not.toHaveBeenCalled()
149
+ expect(follower.error).toHaveBeenCalledWith(exception)
150
+ })
151
+
152
+ it('should keep the address of a name that resolves to nothing', async () => {
153
+ await start()
154
+
155
+ records.two = { addresses: [] }
156
+
157
+ await tick(SETTLE * 2)
158
+
159
+ expect(follower.change).not.toHaveBeenCalled()
160
+ expect(follower.error).toHaveBeenCalled()
161
+ })
162
+
163
+ it('should keep the address in use while it is listed', async () => {
164
+ await start()
165
+
166
+ records.two = { addresses: ['10.0.0.1', '10.0.0.2'], ttl: 5 }
167
+
168
+ await tick(SETTLE * 2)
169
+
170
+ expect(follower.change).not.toHaveBeenCalled()
171
+ })
172
+
173
+ it('should take what an urgent name resolves to at once', async () => {
174
+ await start(timing, true)
175
+
176
+ records.two = { addresses: ['10.0.0.3'], ttl: 5 }
177
+
178
+ await tick()
179
+
180
+ expect(follower.change).toHaveBeenCalledWith('two', '10.0.0.3')
181
+ expect(follower.urgent).toHaveBeenCalledWith('two')
182
+ })
183
+
184
+ it('should look at once when poked', async () => {
185
+ await start(timing, true)
186
+
187
+ records.two = { addresses: ['10.0.0.3'], ttl: 5 }
188
+
189
+ name.poke()
190
+
191
+ await tick(0)
192
+
193
+ expect(follower.change).toHaveBeenCalledTimes(1)
194
+ })
195
+
196
+ it('should resolve until the name is known', async () => {
197
+ global.COMQ_TESTING_RESOLVE_BACKOFF = { base: 10, dispersion: 0 }
198
+
199
+ records.two = new Error('ENOTFOUND')
200
+
201
+ let address
202
+
203
+ const starting = start().then((result) => { address = result })
204
+
205
+ await tick(100)
206
+
207
+ expect(address).toBeUndefined()
208
+
209
+ records.two = { addresses: ['10.0.0.2'], ttl: 5 }
210
+
211
+ await tick(100)
212
+ await starting
213
+
214
+ expect(address).toStrictEqual('10.0.0.2')
215
+
216
+ delete global.COMQ_TESTING_RESOLVE_BACKOFF
217
+ })
218
+
219
+ it('should stop once nobody follows it', async () => {
220
+ await start()
221
+
222
+ records.two = { addresses: ['10.0.0.3'], ttl: 5 }
223
+ leave()
224
+
225
+ await tick(SETTLE * 2)
226
+
227
+ expect(follower.change).not.toHaveBeenCalled()
228
+ expect(jest.getTimerCount()).toStrictEqual(0)
229
+ expect(Name.of('two', timing)).not.toBe(name)
230
+ })
231
+
232
+ describe('shared', () => {
233
+ it('should be one for everyone with the same timing', async () => {
234
+ expect(Name.of('two', timing)).toBe(Name.of('two', { ...timing }))
235
+ expect(Name.of('two', timing)).not.toBe(Name.of('two', { interval: INTERVAL * 2 }))
236
+ expect(Name.of('two', timing)).not.toBe(Name.of('one', timing))
237
+ })
238
+
239
+ it('should be resolved once however many follow it', async () => {
240
+ await start()
241
+
242
+ const others = Array.from({ length: 10 }, () => followerOf())
243
+
244
+ for (const other of others) Name.of('two', timing).follow(other)
245
+
246
+ await Promise.all(others.map(() => Name.of('two', timing).start()))
247
+
248
+ expect(resolved.two).toStrictEqual(1)
249
+
250
+ await tick(INTERVAL * 3)
251
+
252
+ expect(resolved.two).toStrictEqual(4)
253
+ })
254
+
255
+ it('should tell everyone who follows it of a change', async () => {
256
+ await start()
257
+
258
+ const other = followerOf()
259
+
260
+ Name.of('two', timing).follow(other)
261
+ records.two = { addresses: ['10.0.0.3'], ttl: 5 }
262
+
263
+ await tick(SETTLE + INTERVAL)
264
+
265
+ expect(follower.change).toHaveBeenCalledWith('two', '10.0.0.3')
266
+ expect(other.change).toHaveBeenCalledWith('two', '10.0.0.3')
267
+ })
268
+
269
+ it('should tell one who comes later what is in effect', async () => {
270
+ await start()
271
+
272
+ records.two = { addresses: ['10.0.0.3'], ttl: 5 }
273
+
274
+ await tick(SETTLE + INTERVAL)
275
+
276
+ Name.of('two', timing).follow(followerOf())
277
+
278
+ expect(await Name.of('two', timing).start()).toStrictEqual('10.0.0.3')
279
+ })
280
+
281
+ it('should be urgent for everyone when it is for one of them', async () => {
282
+ await start()
283
+
284
+ Name.of('two', timing).follow(followerOf(true))
285
+ records.two = { addresses: ['10.0.0.3'], ttl: 5 }
286
+
287
+ await tick()
288
+
289
+ expect(follower.change).toHaveBeenCalledWith('two', '10.0.0.3')
290
+ })
291
+
292
+ it('should go on while anyone follows it', async () => {
293
+ await start()
294
+
295
+ const other = followerOf()
296
+
297
+ Name.of('two', timing).follow(other)
298
+ leave()
299
+
300
+ records.two = { addresses: ['10.0.0.3'], ttl: 5 }
301
+
302
+ await tick(SETTLE + INTERVAL)
303
+
304
+ expect(other.change).toHaveBeenCalledWith('two', '10.0.0.3')
305
+ expect(follower.change).not.toHaveBeenCalled()
306
+ })
307
+ })
308
+
309
+ describe('timing', () => {
310
+ it('should settle for the longest TTL seen', async () => {
311
+ records.two = { addresses: ['10.0.0.2'], ttl: 3600 }
312
+
313
+ await start({})
314
+
315
+ records.two = { addresses: ['10.0.0.2'], ttl: 10 }
316
+
317
+ await tick(300_000)
318
+
319
+ expect(name.settle).toStrictEqual(3_600_000)
320
+ expect(name.period).toStrictEqual(3_600_000)
321
+ })
322
+
323
+ it('should settle for no less than 15 minutes', async () => {
324
+ await start({})
325
+
326
+ expect(name.settle).toStrictEqual(900_000)
327
+ })
328
+
329
+ it('should give a name answered without a TTL 30 minutes', async () => {
330
+ records.two = { addresses: ['10.0.0.2'] }
331
+
332
+ await start({})
333
+
334
+ expect(name.settle).toStrictEqual(1_800_000)
335
+ })
336
+
337
+ it('should resolve every 5 minutes unless told otherwise', async () => {
338
+ await start({})
339
+
340
+ expect(name.interval).toStrictEqual(300_000)
341
+ })
342
+
343
+ it('should take what it is told', async () => {
344
+ await start({ interval: 1, settle: 2 })
345
+
346
+ expect([name.interval, name.settle]).toStrictEqual([1, 2])
347
+ })
348
+ })
@@ -1,6 +1,52 @@
1
1
  'use strict'
2
2
 
3
- const { choose, hostOf, locate } = require('../../source/shards/resolve')
3
+ const dns = require('node:dns').promises
4
+ const { resolve, choose, hostOf, locate } = require('../../source/shards/resolve')
5
+
6
+ describe('resolve', () => {
7
+ /** @type {Record<string, jest.MockedFunction<any>>} */
8
+ let spies
9
+
10
+ /**
11
+ * @param {Array<{ address: string, ttl: number }> | Error} v4
12
+ * @param {Array<{ address: string, ttl: number }> | Error} v6
13
+ * @param {Array<{ address: string }>} [found]
14
+ */
15
+ const answer = (v4, v6, found = []) => {
16
+ const respond = (value) => async () => { if (value instanceof Error) throw value; return value }
17
+
18
+ spies = {
19
+ resolve4: jest.spyOn(dns, 'resolve4').mockImplementation(respond(v4)),
20
+ resolve6: jest.spyOn(dns, 'resolve6').mockImplementation(respond(v6)),
21
+ lookup: jest.spyOn(dns, 'lookup').mockImplementation(respond(found))
22
+ }
23
+ }
24
+
25
+ afterEach(() => {
26
+ jest.restoreAllMocks()
27
+ })
28
+
29
+ it('should not ask for IPv6 addresses of a name that has IPv4 ones', async () => {
30
+ answer([{ address: '10.0.0.1', ttl: 30 }, { address: '10.0.0.2', ttl: 10 }], [{ address: '::1', ttl: 30 }])
31
+
32
+ expect(await resolve('rmq.example.com')).toStrictEqual({ addresses: ['10.0.0.1', '10.0.0.2'], ttl: 30 })
33
+ expect(spies.resolve6).not.toHaveBeenCalled()
34
+ expect(spies.lookup).not.toHaveBeenCalled()
35
+ })
36
+
37
+ it('should ask for IPv6 addresses of a name that has no IPv4 ones', async () => {
38
+ answer(Object.assign(new Error('ENODATA'), { code: 'ENODATA' }), [{ address: '::1', ttl: 30 }])
39
+
40
+ expect(await resolve('rmq.example.com')).toStrictEqual({ addresses: ['::1'], ttl: 30 })
41
+ expect(spies.lookup).not.toHaveBeenCalled()
42
+ })
43
+
44
+ it('should look up a name that DNS does not know, with no TTL', async () => {
45
+ answer(new Error('ENOTFOUND'), new Error('ENOTFOUND'), [{ address: '127.0.0.1' }])
46
+
47
+ expect(await resolve('localhost')).toStrictEqual({ addresses: ['127.0.0.1'] })
48
+ })
49
+ })
4
50
 
5
51
  describe('choose', () => {
6
52
  it('should choose the same address whichever order they came in', async () => {
package/types/shards.d.ts CHANGED
@@ -5,11 +5,12 @@ declare namespace comq {
5
5
  namespace shards {
6
6
 
7
7
  /**
8
- * How the names of the shards are followed, in milliseconds. `settle` and `linger` default
9
- * to the longest TTL the names are answered with, and to no less than 30 seconds.
8
+ * How the names of the shards are followed, in milliseconds. `settle` defaults to the longest
9
+ * TTL the name is answered with, `linger` to the longest of the names, and both to no less
10
+ * than 15 minutes.
10
11
  */
11
12
  type Timing = {
12
- /** how often the names are resolved, 10 seconds by default */
13
+ /** how often the names are resolved, 5 minutes by default */
13
14
  interval?: number
14
15
 
15
16
  /** how long an answer is to hold before a broker is joined or retired on it */
@@ -19,6 +20,18 @@ declare namespace comq {
19
20
  linger?: number
20
21
  }
21
22
 
23
+ /** Who follows a name, which is shared by everyone in the process who stands on it. */
24
+ type Follower = {
25
+ /** what the name stands for has changed for good */
26
+ change (host: string, address: string): void
27
+
28
+ /** the name failed to resolve, and keeps its address */
29
+ error (exception: Error): void
30
+
31
+ /** whether the name is of no use as it is resolved now, so that its next answer is taken at once */
32
+ urgent (host: string): boolean
33
+ }
34
+
22
35
  type Resolution = {
23
36
  addresses: string[]
24
37
 
@@ -1,257 +0,0 @@
1
- 'use strict'
2
-
3
- const { retry } = require('../retry')
4
- const emitter = require('../emitter')
5
- const { resolve, choose } = require('./resolve')
6
-
7
- /**
8
- * Watches what a set of names resolve to, and tells once it has changed for good.
9
- *
10
- * A change is seldom seen whole. Records are updated one after another, and until their TTL has
11
- * run out the caches on the way answer with the old address and the new one in turn. So what is
12
- * observed is a candidate, and it replaces what is in effect only once it has been observed
13
- * unchanged for the settle time; anything else observed in between starts that time anew.
14
- */
15
- class Watch {
16
- /** @type {string[]} */
17
- #hosts
18
-
19
- /** @type {comq.shards.Timing} */
20
- #options
21
-
22
- /** @type {(host: string) => boolean} */
23
- #urgent
24
-
25
- /** @type {Map<string, string>} what is in effect */
26
- #current = new Map()
27
-
28
- /** @type {Map<string, string>} what was observed last */
29
- #candidate = new Map()
30
-
31
- /** @type {number} when the candidate was first observed as it is */
32
- #since = 0
33
-
34
- /** @type {number} the longest TTL any name has been answered with, in seconds */
35
- #ttl = 0
36
-
37
- /** @type {boolean} whether a name was answered without a TTL */
38
- #untimed = false
39
-
40
- /** @type {ReturnType<setTimeout> | undefined} */
41
- #timer
42
-
43
- #polling = false
44
- #poked = false
45
- #stopped = false
46
-
47
- #events = emitter.create()
48
-
49
- /**
50
- * @param {string[]} hosts
51
- * @param {comq.shards.Timing} [options]
52
- * @param {(host: string) => boolean} [urgent] whether a name is of no use as it is resolved
53
- * now, so that what it resolves to next is taken at once
54
- */
55
- constructor (hosts, options = {}, urgent = () => false) {
56
- this.#hosts = Array.from(new Set(hosts))
57
- this.#options = options
58
- this.#urgent = urgent
59
- }
60
-
61
- get interval () {
62
- return this.#options.interval ?? INTERVAL_MS
63
- }
64
-
65
- /**
66
- * How long an answer is to hold before it is acted upon. A TTL bounds how long a cache may
67
- * answer with what is no longer true, and says nothing about records that are being updated
68
- * one by one, hence the floor.
69
- */
70
- get settle () {
71
- return this.#options.settle ?? this.#period()
72
- }
73
-
74
- /** How long a broker no name points at is to stay idle before it is left. */
75
- get linger () {
76
- return this.#options.linger ?? this.#period()
77
- }
78
-
79
- /**
80
- * Resolves every name, as many times as it takes: a name that does not resolve is no more a
81
- * reason to give up than a broker that is not up yet.
82
- *
83
- * @return {Promise<Map<string, string>>} the address of each name
84
- */
85
- async start () {
86
- this.#stopped = false
87
-
88
- await retry(async (again) => {
89
- if (this.#stopped) return
90
-
91
- const [observed, failed] = await this.#observe()
92
-
93
- if (failed) return again
94
-
95
- this.#current = observed
96
- this.#candidate = observed
97
- this.#since = Date.now()
98
- }, global.COMQ_TESTING_RESOLVE_BACKOFF)
99
-
100
- this.#schedule()
101
-
102
- return new Map(this.#current)
103
- }
104
-
105
- stop () {
106
- this.#stopped = true
107
-
108
- clearTimeout(this.#timer)
109
- }
110
-
111
- /**
112
- * Looks now rather than when the interval is up.
113
- */
114
- poke () {
115
- if (this.#stopped) return
116
-
117
- if (this.#polling) {
118
- this.#poked = true
119
-
120
- return
121
- }
122
-
123
- clearTimeout(this.#timer)
124
-
125
- this.#poll().catch(noop)
126
- }
127
-
128
- /**
129
- * @param {'change' | 'error'} event
130
- * @param {Function} listener
131
- */
132
- on (event, listener) {
133
- this.#events.on(event, listener)
134
- }
135
-
136
- #schedule () {
137
- if (this.#stopped || this.#hosts.length === 0) return
138
-
139
- this.#timer = setTimeout(() => this.#poll().catch(noop), this.interval)
140
- this.#timer.unref()
141
- }
142
-
143
- async #poll () {
144
- this.#polling = true
145
-
146
- try {
147
- const [observed] = await this.#observe()
148
-
149
- if (!this.#stopped) this.#consider(observed)
150
- } finally {
151
- this.#polling = false
152
- }
153
-
154
- if (this.#poked) {
155
- this.#poked = false
156
-
157
- return await this.#poll()
158
- }
159
-
160
- this.#schedule()
161
- }
162
-
163
- /**
164
- * @param {Map<string, string>} observed
165
- */
166
- #consider (observed) {
167
- const now = Date.now()
168
-
169
- if (!same(observed, this.#candidate)) {
170
- this.#candidate = observed
171
- this.#since = now
172
- }
173
-
174
- const settled = now - this.#since >= this.settle
175
-
176
- let changed = false
177
-
178
- for (const [host, address] of this.#candidate) {
179
- if (address === this.#current.get(host)) continue
180
- if (!settled && !this.#urgent(host)) continue
181
-
182
- this.#current.set(host, address)
183
-
184
- changed = true
185
- }
186
-
187
- if (changed) this.#events.emit('change', new Map(this.#current))
188
- }
189
-
190
- /**
191
- * A name that fails to resolve keeps the address it was observed with: a resolver that is
192
- * briefly out of reach has not said that the name is gone.
193
- *
194
- * @return {Promise<[Map<string, string>, boolean]>} the address of each name, and whether any
195
- * of them is not known
196
- */
197
- async #observe () {
198
- const observed = new Map()
199
-
200
- let failed = false
201
-
202
- await Promise.all(this.#hosts.map(async (host) => {
203
- try {
204
- const { addresses, ttl } = await resolve(host)
205
-
206
- if (addresses.length === 0) throw new Error(`${host} resolves to no address`)
207
-
208
- if (ttl === undefined) this.#untimed = true
209
- else this.#ttl = Math.max(this.#ttl, ttl)
210
-
211
- observed.set(host, choose(addresses, this.#current.get(host)))
212
- } catch (exception) {
213
- this.#events.emit('error', exception)
214
-
215
- if (this.#candidate.has(host)) observed.set(host, this.#candidate.get(host))
216
- else failed = true
217
- }
218
- }))
219
-
220
- return [observed, failed]
221
- }
222
-
223
- /**
224
- * A cache answers with the time its record has left, so the longest ever seen is the closest
225
- * to what the record was published with.
226
- *
227
- * @return {number}
228
- */
229
- #period () {
230
- return Math.max(FLOOR_MS, this.#ttl * 1000, this.#untimed ? UNTIMED_MS : 0)
231
- }
232
- }
233
-
234
- /**
235
- * @param {Map<string, string>} one
236
- * @param {Map<string, string>} another
237
- * @return {boolean}
238
- */
239
- function same (one, another) {
240
- if (one.size !== another.size) return false
241
-
242
- for (const [host, address] of one) if (another.get(host) !== address) return false
243
-
244
- return true
245
- }
246
-
247
- const INTERVAL_MS = 10_000
248
-
249
- /** No TTL is short enough to act on at once. */
250
- const FLOOR_MS = 30_000
251
-
252
- /** What a name answered without a TTL is given. */
253
- const UNTIMED_MS = 60_000
254
-
255
- function noop () {}
256
-
257
- exports.Watch = Watch