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.
@@ -37,3 +37,28 @@ jobs:
37
37
 
38
38
  - name: Run fast feature tests
39
39
  run: npm run features:fast
40
+
41
+ # a minor or patch bump by Dependabot is merged once its features pass
42
+ automerge:
43
+ needs: features-fast
44
+ if: github.actor == 'dependabot[bot]' && startsWith(github.ref_name, 'dependabot/')
45
+ runs-on: ubuntu-latest
46
+ timeout-minutes: 5
47
+ permissions:
48
+ contents: write
49
+ pull-requests: write
50
+
51
+ steps:
52
+ - name: Merge
53
+ env:
54
+ GH_TOKEN: ${{ github.token }}
55
+ MESSAGE: ${{ github.event.head_commit.message }}
56
+ run: |
57
+ types=$(grep -oP 'update-type: version-update:semver-\K\w+' <<< "$MESSAGE" || true)
58
+
59
+ if [ -z "$types" ] || grep -qvx -e minor -e patch <<< "$types"; then
60
+ echo "Not a minor or patch bump, left for a review"
61
+ exit 0
62
+ fi
63
+
64
+ gh pr merge "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --merge --match-head-commit "$GITHUB_SHA"
package/docs/shards.md ADDED
@@ -0,0 +1,72 @@
1
+ # Changing the number of brokers
2
+
3
+ How brokers are added to and removed from under a connection that follows
4
+ [names](../readme.md#virtual-shards), with everyone connected staying as they are.
5
+
6
+ ## Names
7
+
8
+ - **As many names as there may ever be brokers.** A broker is in the pool while at least one name
9
+ stands for it, so the number of names is the most brokers there can be. Names cost nothing:
10
+ thirty-two of them over two brokers are two connections.
11
+ - **How many names a broker has does not matter.** A publication goes to a broker chosen at random
12
+ among those in the pool, not to a name: a broker with one name out of thirty-two takes as much as
13
+ one with the rest.
14
+ - **One address per name.** A name with several addresses is still one broker, connected through
15
+ one of them. Records that rotate between brokers spread nothing.
16
+ - **A short TTL.** Unless [given](../readme.md#timing), the `settle` and `linger` times are the
17
+ longest TTL of the names and no less than 15 minutes, so the TTL is what a change takes.
18
+ - **The same names for everyone.** One who connects by addresses, or by names of their own, follows
19
+ nothing, and is cut off once a broker is removed.
20
+
21
+ ## Adding a broker
22
+
23
+ 1. **Start the broker and give it what the others have**: the virtual hosts, the users and their
24
+ permissions.
25
+ 2. **Copy the exchanges, the queues and the bindings between them** from a broker in the pool.
26
+ Everyone follows the names on their own, so the one who emits an Event may join the new broker
27
+ before the one who consumes it has: an exchange with no queue bound to it yet drops what is
28
+ published to it. A Request needs none of this, its queue being declared by the one who sends it.
29
+
30
+ Queues exclusive to a connection, and their bindings, are not to be copied: they are declared
31
+ by the connection that owns them when it joins.
32
+ 3. **Point names at it.** Which ones and how many is of no consequence, so move as few as it
33
+ takes for the names to be spread evenly: these are the ones to move back once it is removed.
34
+ 4. **See the connections arrive.** A `settle` time after the records have changed, everyone who
35
+ follows the names is connected to the new broker as well as to the others.
36
+
37
+ Nothing is taken away while a broker is being added: a name pointing at a broker that is not there
38
+ yet, or not reachable, retires no one.
39
+
40
+ ## Removing a broker
41
+
42
+ 1. **Point its names at the others.** A `settle` time later nothing new is published to it. It is
43
+ still consumed from, and what was sent through it is still answered through it.
44
+ 2. **Wait until it has no connections.** Each one leaves by itself once, for the `linger` time,
45
+ it has had nothing to do there. There is no deadline: a connection with a message still in
46
+ work, or a Request still unanswered, stays.
47
+ 3. **Look at what is left** before the broker is stopped:
48
+ - *a connection that stays* is of one who does not follow the names, or of one whose work
49
+ there does not end;
50
+ - *a message in a queue nobody consumes* had no consumer before the broker was retired
51
+ either, and goes with the broker.
52
+ 4. **Stop the broker.**
53
+
54
+ Until it is stopped, the removal is undone by pointing a name back at the broker: it is in the
55
+ pool again once the answer has settled.
56
+
57
+ ## Replacing a broker
58
+
59
+ Both of the above in one change of the records: the names of the old broker are pointed at the new
60
+ one. The new broker is joined before the old one is retired, so the order need not be kept by hand.
61
+ The new broker is prepared as [one being added](#adding-a-broker) is, and the old one is stopped as
62
+ [one being removed](#removing-a-broker) is.
63
+
64
+ ## What to expect
65
+
66
+ - A change of the records may be made in any order and need not be made at once: nothing is acted
67
+ upon until the answers have stayed the same for the `settle` time.
68
+ - A broker that is in the pool and goes out of reach is a [lost shard](../readme.md#sharded-connection),
69
+ whatever the names say: what was sent through it and is not answered is sent again through the
70
+ others. Retiring a broker first is what makes its removal lose nothing.
71
+ - No one is restarted or reconfigured. One who starts while the names are moving connects to what
72
+ they resolve to then, and follows them from there.
@@ -6,7 +6,7 @@ const { Given, When, Then, After } = require('@cucumber/cucumber')
6
6
 
7
7
  const { connect, Unroutable } = require('../../')
8
8
  const { parse } = require('./yaml')
9
- const { getAddress, USER, PASSWORD } = require('./brokers')
9
+ const { getAddress, USER, PASSWORD, BROKERS_AMOUNT } = require('./brokers')
10
10
  const { timeout, until } = require('../../test/helpers')
11
11
 
12
12
  Given('a holder answering {token} under the {token} key:',
@@ -35,6 +35,8 @@ Given('a holder on another connection answering {token} under the {token} key',
35
35
  this.holder = await another(this)
36
36
 
37
37
  await this.holder.back(exchange, key, echo)
38
+
39
+ if (this.sharded) await everywhere(exchange, key)
38
40
  })
39
41
 
40
42
  Given('a holder on another connection answering {token} under the {token} key in {number}ms',
@@ -494,6 +496,28 @@ function url (broker) {
494
496
  return `amqp://${USER}:${PASSWORD}@${getAddress(broker)}`
495
497
  }
496
498
 
499
+ /**
500
+ * Waits for a key to be held on every broker. It is held once any shard holds it, and on a
501
+ * broker where the holder of the scenario before still has it, it is claimed for a while yet:
502
+ * a scenario that takes the other broker down by then has nobody to call.
503
+ *
504
+ * @param {string} exchange
505
+ * @param {string} key
506
+ */
507
+ async function everywhere (exchange, key) {
508
+ for (let broker = 0; broker < BROKERS_AMOUNT; broker++) {
509
+ const caller = await connect(url(broker))
510
+
511
+ try {
512
+ const held = await eventually(() => caller.call(exchange, key, { key }, { timeout: 1000 }), 30)
513
+
514
+ assert.equal(held, true, `The key is not held on broker ${broker}`)
515
+ } finally {
516
+ await caller.close()
517
+ }
518
+ }
519
+ }
520
+
497
521
  /**
498
522
  * Makes an attempt until it succeeds, or until the time is up.
499
523
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
package/readme.md CHANGED
@@ -514,7 +514,7 @@ await io.close()
514
514
 
515
515
  ### Virtual shards
516
516
 
517
- The URLs of a sharded connection are *names*, and several of them may stand for one broker. A
517
+ Where the URLs of a sharded connection are *names*, several of them may stand for one broker. A
518
518
  connection is made to each broker rather than to each name: thirty-two names resolving to two
519
519
  addresses are two connections.
520
520
 
@@ -523,7 +523,7 @@ const io = await connect('amqp://rmq0.example.com', 'amqp://rmq1.example.com', /
523
523
  ```
524
524
 
525
525
  This is what lets the number of brokers change without anyone being reconfigured: the names stay,
526
- and what they resolve to moves.
526
+ and what they resolve to moves. See [how a broker is added, removed or replaced](docs/shards.md).
527
527
 
528
528
  A broker is told by its address, port, virtual host and credentials. A name with several
529
529
  addresses is still one broker, connected through one of them: two connections to one broker
@@ -534,9 +534,12 @@ for different brokers.
534
534
 
535
535
  The names are resolved again every `interval`. What they resolve to is seldom seen changing
536
536
  whole: records are updated one after another, and until their TTL has run out caches answer
537
- with the old address and the new one in turn. So a change is acted upon only once the answer has
538
- stayed the same for the `settle` time, and anything else observed meanwhile starts that time
539
- anew. A name that fails to resolve keeps its address, which is reported as `error`.
537
+ with the old address and the new one in turn. So a change of a name is acted upon only once its
538
+ answer has stayed the same for the `settle` time, and anything else observed meanwhile starts
539
+ that time anew. A name that fails to resolve keeps its address, which is reported as `error`.
540
+
541
+ A name is resolved once per process, however many connections stand on it: those made with the
542
+ same timing share what it resolves to, and each is told when it has changed.
540
543
 
541
544
  Then the brokers are brought in line with the names, making before breaking:
542
545
 
@@ -570,18 +573,18 @@ The trailing argument of `connect` takes it next to the [topology settings](#set
570
573
  milliseconds:
571
574
 
572
575
  ```javascript
573
- await connect(...urls, { resolution: { interval: 10_000, settle: 60_000, linger: 60_000 } })
576
+ await connect(...urls, { resolution: { interval: 300_000, settle: 900_000, linger: 900_000 } })
574
577
  ```
575
578
 
576
- - `interval`: how often the names are resolved, 10 seconds by default.
579
+ - `interval`: how often the names are resolved, 5 minutes by default.
577
580
  - `settle`: how long an answer is to hold before it is acted upon.
578
581
  - `linger`: how long a retired broker is to stay idle before it is left.
579
582
 
580
- Unless given, `settle` and `linger` are the longest TTL the names have been answered with, and
581
- no less than 30 seconds: a TTL bounds how long a cache may answer with what is no longer true,
582
- and says nothing of records being updated one by one. A name that is not found in DNS itself (one
583
- from the hosts file, or one completed by the resolver's search list) has no TTL, and makes them
584
- 60 seconds.
583
+ Unless given, `settle` is the longest TTL a name has been answered with, `linger` the longest
584
+ of the names, and both no less than 15 minutes: a TTL bounds how long a cache may answer with what
585
+ is no longer true, and says nothing of records being updated one by one. A name that is not found
586
+ in DNS itself (one from the hosts file, or one completed by the resolver's search list) has no
587
+ TTL, and makes them 30 minutes.
585
588
 
586
589
  ## Singleton connection
587
590
 
@@ -3,7 +3,7 @@
3
3
  const events = require('../events')
4
4
  const channel = require('./channel')
5
5
  const emitter = require('../emitter')
6
- const { Watch } = require('./watch')
6
+ const { Name, INTERVAL_MS, FLOOR_MS } = require('./name')
7
7
  const { hostOf, locate } = require('./resolve')
8
8
 
9
9
  /**
@@ -23,8 +23,17 @@ class Connection {
23
23
  /** @type {(url: string, servername?: string) => comq.Connection} */
24
24
  #create
25
25
 
26
- /** @type {Watch} */
27
- #watch
26
+ /** @type {string[]} the names the URLs are of */
27
+ #hosts
28
+
29
+ /** @type {comq.shards.Timing} */
30
+ #timing
31
+
32
+ /** @type {Name[]} the names followed while open */
33
+ #names = []
34
+
35
+ /** @type {Array<() => void>} what stops following each of them */
36
+ #leaving = []
28
37
 
29
38
  /** @type {Map<string, string>} the address of each name */
30
39
  #addresses = new Map()
@@ -38,6 +47,9 @@ class Connection {
38
47
  /** @type {number} the number the next shard gets: one is never given twice */
39
48
  #next = 0
40
49
 
50
+ /** @type {Set<string>} the brokers that failed to join, asked again only once an interval is up */
51
+ #refused = new Set()
52
+
41
53
  /** @type {Set<comq.Channel>} */
42
54
  #channels = new Set()
43
55
 
@@ -58,17 +70,14 @@ class Connection {
58
70
  this.#urls = urls
59
71
  this.#create = create
60
72
 
61
- const hosts = urls.map(hostOf).filter((host) => host !== undefined)
62
-
63
- this.#watch = new Watch(hosts, timing, this.#urgent)
64
- this.#watch.on('change', (addresses) => this.#follow(addresses))
65
- this.#watch.on('error', (exception) => this.#diagnostics.emit('error', exception))
73
+ this.#hosts = Array.from(new Set(urls.map(hostOf).filter((host) => host !== undefined)))
74
+ this.#timing = timing ?? {}
66
75
  }
67
76
 
68
77
  async open () {
69
78
  this.#closed = false
70
79
 
71
- const addresses = await this.#watch.start()
80
+ const addresses = await this.#start()
72
81
 
73
82
  if (this.#closed) return
74
83
 
@@ -95,7 +104,7 @@ class Connection {
95
104
  async close () {
96
105
  this.#closed = true
97
106
  this.#opened = false
98
- this.#watch.stop()
107
+ this.#unfollow()
99
108
 
100
109
  clearTimeout(this.#inspection)
101
110
 
@@ -191,7 +200,7 @@ class Connection {
191
200
  }
192
201
 
193
202
  // a connection that is lost may be so because its broker has moved
194
- const lost = (error) => { if (error !== undefined) this.#watch.poke() }
203
+ const lost = (error) => { if (error !== undefined) this.#poke(shard.address) }
195
204
 
196
205
  connection.diagnose('close', lost)
197
206
  shard.listeners.push(['close', lost])
@@ -241,14 +250,74 @@ class Connection {
241
250
  }
242
251
 
243
252
  /**
244
- * @param {Map<string, string>} addresses
253
+ * Follows the names, each of which is shared with whoever else in the process follows it.
254
+ *
255
+ * @return {Promise<Map<string, string>>} the address of each name
245
256
  */
246
- #follow (addresses) {
247
- this.#addresses = addresses
248
- this.#wanted = this.#locations(addresses)
257
+ async #start () {
258
+ this.#unfollow()
259
+
260
+ const follower = { change: this.#follow, error: this.#error, urgent: this.#urgent }
261
+
262
+ this.#names = this.#hosts.map((host) => Name.of(host, this.#timing))
263
+ this.#leaving = this.#names.map((name) => name.follow(follower))
264
+
265
+ await Promise.all(this.#names.map((name) => name.start()))
266
+
267
+ // a name followed by others may have moved while the rest were being resolved
268
+ return new Map(this.#names.map((name) => [name.host, /** @type {string} */ (name.address)]))
269
+ }
270
+
271
+ #unfollow () {
272
+ for (const leave of this.#leaving) leave()
273
+
274
+ this.#names = []
275
+ this.#leaving = []
276
+ }
277
+
278
+ /**
279
+ * Looks now at the names that stand for a broker.
280
+ *
281
+ * @param {string | undefined} address
282
+ */
283
+ #poke (address) {
284
+ for (const name of this.#names) {
285
+ if (this.#addresses.get(name.host) === address) name.poke()
286
+ }
287
+ }
288
+
289
+ /**
290
+ * @param {string} host
291
+ * @param {string} address
292
+ */
293
+ #follow = (host, address) => {
294
+ if (this.#addresses.get(host) === address) return
295
+
296
+ this.#addresses.set(host, address)
297
+ this.#wanted = this.#locations(this.#addresses)
249
298
  this.#reconcile()
250
299
  }
251
300
 
301
+ /**
302
+ * @param {Error} exception
303
+ */
304
+ #error = (exception) => {
305
+ this.#diagnostics.emit('error', exception)
306
+ }
307
+
308
+ /** How often the shards are looked at again. */
309
+ get #interval () {
310
+ return this.#timing.interval ?? INTERVAL_MS
311
+ }
312
+
313
+ /**
314
+ * How long a retired broker is to stay idle before it is left: as long as any of the names
315
+ * may be cached for, unless told.
316
+ */
317
+ get #linger () {
318
+ return this.#timing.linger ?? Math.max(FLOOR_MS, ...this.#names.map((name) => name.period))
319
+ }
320
+
252
321
  /**
253
322
  * Brings the shards in line with the brokers the names stand for, making before breaking:
254
323
  * nothing is retired until every broker that is named has joined, so a name that points at
@@ -257,13 +326,16 @@ class Connection {
257
326
  #reconcile () {
258
327
  if (!this.#opened || this.#closed) return
259
328
 
329
+ let made = true
330
+
260
331
  for (const [url, location] of this.#wanted) {
332
+ if (this.#shardOf(url) !== undefined) continue
333
+
261
334
  // one that is being left is joined anew once it has been
262
- if (this.#shardOf(url) === undefined) this.#join(location).catch(noop)
335
+ if (this.#refused.has(url)) made = false
336
+ else this.#join(location).catch(noop)
263
337
  }
264
338
 
265
- let made = true
266
-
267
339
  for (const shard of this.#shards.values()) {
268
340
  const named = this.#wanted.has(shard.url)
269
341
 
@@ -295,7 +367,7 @@ class Connection {
295
367
  this.#diagnostics.emit('error', exception, shard.index)
296
368
  this.#dismiss(shard)
297
369
 
298
- return this.#later(() => this.#reconcile())
370
+ return this.#defer(location.url)
299
371
  }
300
372
 
301
373
  if (shard.state !== JOINING) return
@@ -369,10 +441,25 @@ class Connection {
369
441
  // one that never joined is not said to have left
370
442
  if (retired) this.#diagnostics.emit(LEAVE, shard.index, shard.address)
371
443
 
372
- if (failed) this.#later(() => this.#reconcile())
444
+ if (failed) this.#defer(shard.url)
373
445
  else this.#reconcile()
374
446
  }
375
447
 
448
+ /**
449
+ * Asks a broker that failed to join again once an interval is up, rather than whenever
450
+ * another name moves meanwhile.
451
+ *
452
+ * @param {string} url
453
+ */
454
+ #defer (url) {
455
+ this.#refused.add(url)
456
+
457
+ this.#later(() => {
458
+ this.#refused.delete(url)
459
+ this.#reconcile()
460
+ })
461
+ }
462
+
376
463
  /**
377
464
  * Looks at the retiring shards for as long as there are any.
378
465
  */
@@ -403,7 +490,7 @@ class Connection {
403
490
  */
404
491
  async #idle (shard) {
405
492
  const now = Date.now()
406
- const linger = this.#watch.linger
493
+ const linger = this.#linger
407
494
 
408
495
  if (shard.connection.connected === false) {
409
496
  shard.down ??= now
@@ -424,7 +511,7 @@ class Connection {
424
511
  * @return {ReturnType<setTimeout>}
425
512
  */
426
513
  #later (fn) {
427
- const timer = setTimeout(() => Promise.resolve().then(fn).catch(noop), this.#watch.interval)
514
+ const timer = setTimeout(() => Promise.resolve().then(fn).catch(noop), this.#interval)
428
515
 
429
516
  timer.unref()
430
517
 
@@ -437,7 +524,7 @@ class Connection {
437
524
  * @param {Error} exception
438
525
  */
439
526
  async #cancel (openings, shards, exception) {
440
- this.#watch.stop()
527
+ this.#unfollow()
441
528
 
442
529
  const settled = await Promise.allSettled(openings)
443
530
  const opened = shards.filter((_, index) => settled[index].status === 'fulfilled')