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.
- package/.github/workflows/features.yaml +25 -0
- package/docs/shards.md +72 -0
- package/features/steps/call.js +25 -1
- package/package.json +1 -1
- package/readme.md +15 -12
- package/source/shards/connection.js +110 -23
- package/source/shards/name.js +299 -0
- package/source/shards/resolve.js +12 -13
- package/test/shards/connection.test.js +68 -1
- package/test/shards/name.test.js +348 -0
- package/test/shards/resolve.test.js +47 -1
- package/types/shards.d.ts +16 -3
- package/source/shards/watch.js +0 -257
- package/test/shards/watch.test.js +0 -264
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { retry } = require('../retry')
|
|
4
|
+
const { resolve, choose } = require('./resolve')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Follows what one name resolves to, for everyone in the process who follows it: a name is
|
|
8
|
+
* resolved once however many connections stand on it, and tells each of them when it has
|
|
9
|
+
* changed for good.
|
|
10
|
+
*
|
|
11
|
+
* A change is seldom seen whole. Records are updated one after another, and until their TTL has
|
|
12
|
+
* run out the caches on the way answer with the old address and the new one in turn. So what is
|
|
13
|
+
* observed is a candidate, and it replaces what is in effect only once it has been observed
|
|
14
|
+
* unchanged for the settle time; anything else observed in between starts that time anew.
|
|
15
|
+
*/
|
|
16
|
+
class Name {
|
|
17
|
+
/** @type {string} */
|
|
18
|
+
#host
|
|
19
|
+
|
|
20
|
+
/** @type {comq.shards.Timing} */
|
|
21
|
+
#options
|
|
22
|
+
|
|
23
|
+
/** @type {string} what the registry knows it by */
|
|
24
|
+
#key
|
|
25
|
+
|
|
26
|
+
/** @type {Set<comq.shards.Follower>} */
|
|
27
|
+
#followers = new Set()
|
|
28
|
+
|
|
29
|
+
/** @type {string | undefined} what is in effect */
|
|
30
|
+
#current
|
|
31
|
+
|
|
32
|
+
/** @type {string | undefined} what was observed last */
|
|
33
|
+
#candidate
|
|
34
|
+
|
|
35
|
+
/** @type {number} when the candidate was first observed as it is */
|
|
36
|
+
#since = 0
|
|
37
|
+
|
|
38
|
+
/** @type {number} the longest TTL the name has been answered with, in seconds */
|
|
39
|
+
#ttl = 0
|
|
40
|
+
|
|
41
|
+
/** @type {boolean} whether the name was answered without a TTL */
|
|
42
|
+
#untimed = false
|
|
43
|
+
|
|
44
|
+
/** @type {Promise<void> | undefined} */
|
|
45
|
+
#starting
|
|
46
|
+
|
|
47
|
+
/** @type {ReturnType<setTimeout> | undefined} */
|
|
48
|
+
#timer
|
|
49
|
+
|
|
50
|
+
#polling = false
|
|
51
|
+
#poked = false
|
|
52
|
+
#stopped = false
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} host
|
|
56
|
+
* @param {comq.shards.Timing} options
|
|
57
|
+
* @param {string} key
|
|
58
|
+
*/
|
|
59
|
+
constructor (host, options, key) {
|
|
60
|
+
this.#host = host
|
|
61
|
+
this.#options = options
|
|
62
|
+
this.#key = key
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The name everyone in the process follows `host` by with the same timing: the timing is
|
|
67
|
+
* part of what a name is, so that nobody is followed at a pace they did not ask for.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} host
|
|
70
|
+
* @param {comq.shards.Timing} [options]
|
|
71
|
+
* @return {Name}
|
|
72
|
+
*/
|
|
73
|
+
static of (host, options = {}) {
|
|
74
|
+
const key = host + ' ' + JSON.stringify([options.interval, options.settle])
|
|
75
|
+
let name = names.get(key)
|
|
76
|
+
|
|
77
|
+
if (name === undefined) {
|
|
78
|
+
name = new Name(host, options, key)
|
|
79
|
+
names.set(key, name)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return name
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get host () {
|
|
86
|
+
return this.#host
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @type {string | undefined} the address in effect, once started */
|
|
90
|
+
get address () {
|
|
91
|
+
return this.#current
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get interval () {
|
|
95
|
+
return this.#options.interval ?? INTERVAL_MS
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How long an answer is to hold before it is acted upon. A TTL bounds how long a cache may
|
|
100
|
+
* answer with what is no longer true, and says nothing about records that are being updated
|
|
101
|
+
* one by one, hence the floor.
|
|
102
|
+
*/
|
|
103
|
+
get settle () {
|
|
104
|
+
return this.#options.settle ?? this.period
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** How long what the name was last answered with may be cached. */
|
|
108
|
+
get period () {
|
|
109
|
+
return period(this.#ttl, this.#untimed)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Follows the name until the returned function is called. The last one to stop following
|
|
114
|
+
* it stops it.
|
|
115
|
+
*
|
|
116
|
+
* @param {comq.shards.Follower} follower
|
|
117
|
+
* @return {() => void}
|
|
118
|
+
*/
|
|
119
|
+
follow (follower) {
|
|
120
|
+
this.#followers.add(follower)
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
this.#followers.delete(follower)
|
|
124
|
+
|
|
125
|
+
if (this.#followers.size === 0) this.#stop()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolves the name, as many times as it takes: a name that does not resolve is no more a
|
|
131
|
+
* reason to give up than a broker that is not up yet.
|
|
132
|
+
*
|
|
133
|
+
* @return {Promise<string>} the address in effect
|
|
134
|
+
*/
|
|
135
|
+
async start () {
|
|
136
|
+
this.#starting ??= this.#begin()
|
|
137
|
+
|
|
138
|
+
await this.#starting
|
|
139
|
+
|
|
140
|
+
return /** @type {string} */ (this.#current)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Looks now rather than when the interval is up.
|
|
145
|
+
*/
|
|
146
|
+
poke () {
|
|
147
|
+
if (this.#stopped || this.#starting === undefined) return
|
|
148
|
+
|
|
149
|
+
if (this.#polling) {
|
|
150
|
+
this.#poked = true
|
|
151
|
+
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
clearTimeout(this.#timer)
|
|
156
|
+
this.#poll().catch(noop)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async #begin () {
|
|
160
|
+
await retry(async (again) => {
|
|
161
|
+
if (this.#stopped) return
|
|
162
|
+
|
|
163
|
+
const address = await this.#observe()
|
|
164
|
+
|
|
165
|
+
if (address === undefined) return again
|
|
166
|
+
|
|
167
|
+
this.#current = address
|
|
168
|
+
this.#candidate = address
|
|
169
|
+
this.#since = Date.now()
|
|
170
|
+
}, global.COMQ_TESTING_RESOLVE_BACKOFF)
|
|
171
|
+
|
|
172
|
+
this.#schedule()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#stop () {
|
|
176
|
+
this.#stopped = true
|
|
177
|
+
clearTimeout(this.#timer)
|
|
178
|
+
|
|
179
|
+
if (names.get(this.#key) === this) names.delete(this.#key)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#schedule () {
|
|
183
|
+
if (this.#stopped) return
|
|
184
|
+
|
|
185
|
+
this.#timer = setTimeout(() => this.#poll().catch(noop), this.interval)
|
|
186
|
+
this.#timer.unref()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async #poll () {
|
|
190
|
+
this.#polling = true
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const observed = (await this.#observe()) ?? this.#candidate
|
|
194
|
+
|
|
195
|
+
if (!this.#stopped) this.#consider(/** @type {string} */ (observed))
|
|
196
|
+
} finally {
|
|
197
|
+
this.#polling = false
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (this.#poked) {
|
|
201
|
+
this.#poked = false
|
|
202
|
+
|
|
203
|
+
return await this.#poll()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.#schedule()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A name whose broker is out of reach for any of those who follow it has nothing to wait for:
|
|
211
|
+
* the connection to a broker is one for the whole process.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} observed
|
|
214
|
+
*/
|
|
215
|
+
#consider (observed) {
|
|
216
|
+
const now = Date.now()
|
|
217
|
+
|
|
218
|
+
if (observed !== this.#candidate) {
|
|
219
|
+
this.#candidate = observed
|
|
220
|
+
this.#since = now
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (this.#candidate === this.#current) return
|
|
224
|
+
|
|
225
|
+
const settled = now - this.#since >= this.settle
|
|
226
|
+
|
|
227
|
+
if (!settled && !this.#urgent()) return
|
|
228
|
+
|
|
229
|
+
this.#current = this.#candidate
|
|
230
|
+
|
|
231
|
+
for (const follower of this.#followers) follower.change(this.#host, this.#current)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @return {boolean}
|
|
236
|
+
*/
|
|
237
|
+
#urgent () {
|
|
238
|
+
for (const follower of this.#followers) if (follower.urgent(this.#host)) return true
|
|
239
|
+
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A name that fails to resolve keeps the address it was observed with: a resolver that is
|
|
245
|
+
* briefly out of reach has not said that the name is gone.
|
|
246
|
+
*
|
|
247
|
+
* @return {Promise<string | undefined>} the address the name is observed with, unless it is
|
|
248
|
+
* not known
|
|
249
|
+
*/
|
|
250
|
+
async #observe () {
|
|
251
|
+
try {
|
|
252
|
+
const { addresses, ttl } = await resolve(this.#host)
|
|
253
|
+
|
|
254
|
+
if (addresses.length === 0) throw new Error(`${this.#host} resolves to no address`)
|
|
255
|
+
|
|
256
|
+
if (ttl === undefined) this.#untimed = true
|
|
257
|
+
else this.#ttl = Math.max(this.#ttl, ttl)
|
|
258
|
+
|
|
259
|
+
return choose(addresses, this.#current)
|
|
260
|
+
} catch (exception) {
|
|
261
|
+
for (const follower of this.#followers) follower.error(exception)
|
|
262
|
+
|
|
263
|
+
return undefined
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Singletons are evil.
|
|
268
|
+
static __lets_pretend_this_method_doesnt_exist () {
|
|
269
|
+
for (const name of names.values()) name.#stop()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* How long an answer may be cached: the longest TTL seen, and no less than the floor.
|
|
275
|
+
*
|
|
276
|
+
* @param {number} ttl the longest TTL seen, in seconds
|
|
277
|
+
* @param {boolean} untimed whether an answer came without a TTL
|
|
278
|
+
* @return {number}
|
|
279
|
+
*/
|
|
280
|
+
function period (ttl, untimed) {
|
|
281
|
+
return Math.max(FLOOR_MS, ttl * 1000, untimed ? UNTIMED_MS : 0)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @type {Map<string, Name>} */
|
|
285
|
+
const names = new Map()
|
|
286
|
+
|
|
287
|
+
const INTERVAL_MS = 300_000
|
|
288
|
+
|
|
289
|
+
/** No TTL is short enough to act on at once. */
|
|
290
|
+
const FLOOR_MS = 900_000
|
|
291
|
+
|
|
292
|
+
/** What a name answered without a TTL is given. */
|
|
293
|
+
const UNTIMED_MS = 1_800_000
|
|
294
|
+
|
|
295
|
+
function noop () {}
|
|
296
|
+
|
|
297
|
+
exports.Name = Name
|
|
298
|
+
exports.INTERVAL_MS = INTERVAL_MS
|
|
299
|
+
exports.FLOOR_MS = FLOOR_MS
|
package/source/shards/resolve.js
CHANGED
|
@@ -6,9 +6,11 @@ const dns = require('node:dns').promises
|
|
|
6
6
|
/**
|
|
7
7
|
* The addresses a name stands for, and for how long the answer may be cached, in seconds.
|
|
8
8
|
*
|
|
9
|
-
* DNS is asked directly, since nothing else tells the TTL
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* DNS is asked directly, since nothing else tells the TTL: for IPv4 addresses first, and for
|
|
10
|
+
* IPv6 ones only when there are none, as an IPv4 address is the one a broker is connected
|
|
11
|
+
* through whenever it has both. A name DNS does not know is looked up the way a socket would
|
|
12
|
+
* have, which is how a name from the hosts file or one completed by the search list is found;
|
|
13
|
+
* such an answer carries no TTL.
|
|
12
14
|
*
|
|
13
15
|
* @param {string} host
|
|
14
16
|
* @return {Promise<comq.shards.Resolution>}
|
|
@@ -16,17 +18,14 @@ const dns = require('node:dns').promises
|
|
|
16
18
|
async function resolve (host) {
|
|
17
19
|
if (global.COMQ_TESTING_LOOKUP !== undefined) return await global.COMQ_TESTING_LOOKUP(host)
|
|
18
20
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
dns.resolve6(host, { ttl: true })
|
|
22
|
-
])
|
|
21
|
+
for (const family of [dns.resolve4, dns.resolve6]) {
|
|
22
|
+
const records = await family.call(dns, host, { ttl: true }).catch(() => [])
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
ttl: Math.max(...records.map((record) => record.ttl))
|
|
24
|
+
if (records.length > 0) {
|
|
25
|
+
return {
|
|
26
|
+
addresses: records.map((record) => record.address),
|
|
27
|
+
ttl: Math.max(...records.map((record) => record.ttl))
|
|
28
|
+
}
|
|
30
29
|
}
|
|
31
30
|
}
|
|
32
31
|
|
|
@@ -221,6 +221,9 @@ describe('names', () => {
|
|
|
221
221
|
/** @type {Record<string, jest.MockedFunction<Function>>} */
|
|
222
222
|
let heard
|
|
223
223
|
|
|
224
|
+
/** @type {string[]} the names resolved, in the order they were */
|
|
225
|
+
let lookups
|
|
226
|
+
|
|
224
227
|
const tick = (ms = INTERVAL) => jest.advanceTimersByTimeAsync(ms)
|
|
225
228
|
|
|
226
229
|
/**
|
|
@@ -237,7 +240,13 @@ describe('names', () => {
|
|
|
237
240
|
|
|
238
241
|
addresses = { one: '10.0.0.1', two: '10.0.0.1', three: '10.0.0.2', four: '10.0.0.2' }
|
|
239
242
|
|
|
240
|
-
|
|
243
|
+
lookups = []
|
|
244
|
+
|
|
245
|
+
global.COMQ_TESTING_LOOKUP = async (host) => {
|
|
246
|
+
lookups.push(host)
|
|
247
|
+
|
|
248
|
+
return { addresses: [addresses[host]], ttl: 1 }
|
|
249
|
+
}
|
|
241
250
|
|
|
242
251
|
connection = new Connection(names, make, { interval: INTERVAL, settle: SETTLE, linger: LINGER })
|
|
243
252
|
heard = { join: jest.fn(), retire: jest.fn(), leave: jest.fn() }
|
|
@@ -435,6 +444,64 @@ describe('names', () => {
|
|
|
435
444
|
expect(heard.join).toHaveBeenCalledWith(2, '10.0.0.3')
|
|
436
445
|
})
|
|
437
446
|
|
|
447
|
+
it('should resolve a name once for every connection that stands on it', async () => {
|
|
448
|
+
const other = new Connection(names, make, { interval: INTERVAL, settle: SETTLE, linger: LINGER })
|
|
449
|
+
|
|
450
|
+
lookups = []
|
|
451
|
+
|
|
452
|
+
await other.open()
|
|
453
|
+
|
|
454
|
+
expect(lookups).toStrictEqual([])
|
|
455
|
+
|
|
456
|
+
await tick()
|
|
457
|
+
|
|
458
|
+
expect(lookups.toSorted()).toStrictEqual(['four', 'one', 'three', 'two'])
|
|
459
|
+
|
|
460
|
+
await other.close()
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('should follow a name that another connection follows as well', async () => {
|
|
464
|
+
const other = new Connection(names, make, { interval: INTERVAL, settle: SETTLE, linger: LINGER })
|
|
465
|
+
const joined = jest.fn()
|
|
466
|
+
|
|
467
|
+
other.diagnose('join', joined)
|
|
468
|
+
|
|
469
|
+
await other.open()
|
|
470
|
+
await other.createChannel(generate())
|
|
471
|
+
|
|
472
|
+
addresses.four = '10.0.0.3'
|
|
473
|
+
|
|
474
|
+
await tick(SETTLE + INTERVAL)
|
|
475
|
+
|
|
476
|
+
expect(heard.join).toHaveBeenCalledWith(2, '10.0.0.3')
|
|
477
|
+
expect(joined).toHaveBeenCalledWith(2, '10.0.0.3')
|
|
478
|
+
|
|
479
|
+
await other.close()
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('should go on following a name that another connection has stopped following', async () => {
|
|
483
|
+
const other = new Connection(names, make, { interval: INTERVAL, settle: SETTLE, linger: LINGER })
|
|
484
|
+
|
|
485
|
+
await other.open()
|
|
486
|
+
await other.close()
|
|
487
|
+
|
|
488
|
+
addresses.four = '10.0.0.3'
|
|
489
|
+
|
|
490
|
+
await tick(SETTLE + INTERVAL)
|
|
491
|
+
|
|
492
|
+
expect(heard.join).toHaveBeenCalledWith(2, '10.0.0.3')
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('should look again only at the names of a broker that is lost', async () => {
|
|
496
|
+
lookups = []
|
|
497
|
+
|
|
498
|
+
tell(connections[1], 'close', new Error('lost'))
|
|
499
|
+
|
|
500
|
+
await tick(0)
|
|
501
|
+
|
|
502
|
+
expect(lookups.toSorted()).toStrictEqual(['four', 'three'])
|
|
503
|
+
})
|
|
504
|
+
|
|
438
505
|
it('should report a name that fails to resolve', async () => {
|
|
439
506
|
const listener = jest.fn()
|
|
440
507
|
const exception = new Error('ENOTFOUND')
|