comq 0.26.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,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
@@ -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. A name it does not know is looked up
10
- * the way a socket would have, which is how a name from the hosts file or one completed by the
11
- * search list is found; such an answer carries no TTL.
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 answers = await Promise.allSettled([
20
- dns.resolve4(host, { ttl: true }),
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
- const records = answers.flatMap((answer) => answer.status === 'fulfilled' ? answer.value : [])
25
-
26
- if (records.length > 0) {
27
- return {
28
- addresses: records.map((record) => record.address),
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
 
@@ -77,23 +77,6 @@ describe('sharded connection', () => {
77
77
  })
78
78
  })
79
79
 
80
- describe('ranges', () => {
81
- it('should expand a range into the urls of a sharded connection', async () => {
82
- await connect('amqp://rmq[0..3].example.com')
83
-
84
- const expanded = ['amqp://rmq0.example.com', 'amqp://rmq1.example.com', 'amqp://rmq2.example.com']
85
-
86
- expect(shards.Connection).toHaveBeenCalledWith(expanded, expect.any(Function), undefined)
87
- })
88
-
89
- it('should connect to a range of one as to a single url', async () => {
90
- await connect('amqp://rmq[0..1].example.com')
91
-
92
- expect(Connection).toHaveBeenCalledWith('amqp://rmq0.example.com', {})
93
- expect(shards.Connection).not.toHaveBeenCalled()
94
- })
95
- })
96
-
97
80
  describe('resolution', () => {
98
81
  it('should pass how the names are followed, apart from the overrides', async () => {
99
82
  const urls = [generate(), generate()]
@@ -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
- global.COMQ_TESTING_LOOKUP = async (host) => ({ addresses: [addresses[host]], ttl: 1 })
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')