comq 0.25.1 → 0.26.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.
Files changed (44) hide show
  1. package/docs/headers.md +16 -1
  2. package/features/properties.feature +39 -0
  3. package/features/shards.virtual.feature +89 -0
  4. package/features/steps/context.js +29 -2
  5. package/features/steps/hooks.js +1 -0
  6. package/features/steps/names.js +245 -0
  7. package/features/steps/networks.js +23 -5
  8. package/features/steps/rpc.js +14 -2
  9. package/package.json +1 -1
  10. package/readme.md +102 -4
  11. package/source/channel.js +147 -2
  12. package/source/connect.js +18 -15
  13. package/source/connection.js +8 -5
  14. package/source/events.js +7 -0
  15. package/source/io.js +31 -9
  16. package/source/shards/channel.js +289 -19
  17. package/source/shards/connection.js +404 -18
  18. package/source/shards/expand.js +57 -0
  19. package/source/shards/index.js +2 -0
  20. package/source/shards/resolve.js +134 -0
  21. package/source/shards/watch.js +257 -0
  22. package/source/singleton.js +3 -2
  23. package/test/amqplib.mock.js +1 -0
  24. package/test/assert.test.js +7 -2
  25. package/test/channel.quiet.test.js +301 -0
  26. package/test/connect.test.js +49 -8
  27. package/test/connection.mock.js +3 -0
  28. package/test/connection.test.js +15 -0
  29. package/test/io.call.test.js +1 -1
  30. package/test/io.diagnostics.test.js +1 -1
  31. package/test/io.reply.test.js +12 -1
  32. package/test/io.request.test.js +18 -0
  33. package/test/io.shards.test.js +85 -0
  34. package/test/shards/channel.test.js +411 -0
  35. package/test/shards/connection.test.js +372 -17
  36. package/test/shards/expand.test.js +58 -0
  37. package/test/shards/resolve.test.js +73 -0
  38. package/test/shards/watch.test.js +264 -0
  39. package/test/singleton.test.js +1 -1
  40. package/types/channel.d.ts +23 -0
  41. package/types/connection.d.ts +13 -2
  42. package/types/diagnostic.d.ts +2 -1
  43. package/types/io.d.ts +17 -1
  44. package/types/shards.d.ts +58 -0
@@ -11,12 +11,40 @@ const emitter = require('../emitter')
11
11
  class Channel {
12
12
  sharded = true
13
13
 
14
- /** @type {comq.Connection[]} */
14
+ /** @type {Map<number, comq.Connection>} the connection of each shard, by its number */
15
15
  #connections
16
16
 
17
+ /** @type {Map<number, comq.Channel>} the channel of each shard, once it has one */
18
+ #shards = new Map()
19
+
17
20
  /** @type {Set<comq.Channel>} */
18
21
  #channels = new Set()
19
22
 
23
+ /** @type {Set<comq.Channel>} the channels of the shards that are joining */
24
+ #joining = new Set()
25
+
26
+ /** @type {WeakSet<comq.Channel>} the channels of the shards that were let go */
27
+ #gone = new WeakSet()
28
+
29
+ /** @type {Set<number>} the shards nothing new is published to */
30
+ #retiring = new Set()
31
+
32
+ /**
33
+ * What has been consumed, to be consumed from a shard that joins later as well.
34
+ *
35
+ * @type {{ apply: (channel: comq.Channel) => Promise<any>, awaited: boolean }[]}
36
+ */
37
+ #consumptions = []
38
+
39
+ /** @type {Map<number, [comq.Connection, Function, Function]>} */
40
+ #watchers = new Map()
41
+
42
+ /** @type {((index: number) => boolean) | undefined} */
43
+ #occupied
44
+
45
+ /** @type {boolean} */
46
+ #shut = false
47
+
20
48
  /** @type {comq.Channel[]} */
21
49
  #pool
22
50
 
@@ -43,34 +71,179 @@ class Channel {
43
71
  #diagnostics = emitter.create()
44
72
 
45
73
  /**
46
- * @param {comq.Connection[]} connections
74
+ * @param {comq.Connection[] | Map<number, comq.Connection>} connections of the shards, which
75
+ * are numbered by their position unless they are given by number
47
76
  * @param {comq.topology.type} type
48
77
  */
49
78
  constructor (connections, type) {
50
- this.#connections = connections
79
+ this.#connections = new Map(Array.isArray(connections) ? connections.entries() : connections)
51
80
  this.#type = type
52
81
  }
53
82
 
54
83
  async create () {
55
- const promises = this.#connections.map(this.#create)
84
+ const promises = Array.from(this.#connections,
85
+ ([index, connection]) => this.#create(connection, index))
56
86
 
57
87
  await Promise.any(promises)
58
88
  }
59
89
 
90
+ /**
91
+ * @param {number} index
92
+ * @return {boolean} whether the shard is one of this channel's
93
+ */
94
+ has (index) {
95
+ return this.#connections.has(index)
96
+ }
97
+
98
+ /**
99
+ * Takes a shard in. Whatever has been consumed is consumed from it as well, and only then is
100
+ * anything published through it: a Reply comes back through the shard its Request went, where
101
+ * somebody must be waiting for it by then.
102
+ *
103
+ * @param {comq.Connection} connection
104
+ * @param {number} index
105
+ * @return {Promise<void>}
106
+ */
107
+ async join (connection, index) {
108
+ if (this.#shut || this.#connections.has(index)) return
109
+
110
+ this.#connections.set(index, connection)
111
+ this.#watch(connection, index)
112
+
113
+ /** @type {comq.Channel} */
114
+ let channel
115
+
116
+ try {
117
+ channel = await connection.createChannel(this.#type, index)
118
+ } catch (exception) {
119
+ // not its shard, so that it may be joined again
120
+ if (!this.#abandoned(connection, index)) await this.leave(index)
121
+
122
+ throw exception
123
+ }
124
+
125
+ if (this.#abandoned(connection, index)) return await channel.close()
126
+
127
+ this.#joining.add(channel)
128
+ this.#attach(channel, index)
129
+
130
+ try {
131
+ // what is consumed while this is under way is consumed here too, as the list grows
132
+ for (let i = 0; i < this.#consumptions.length; i++) {
133
+ const { apply, awaited } = this.#consumptions[i]
134
+ const applied = apply(channel)
135
+
136
+ if (awaited) await applied
137
+ else applied.catch(noop)
138
+ }
139
+ } catch (exception) {
140
+ // a shard that cannot be consumed from is not one to publish through: whoever took the
141
+ // shard for joined would let go of the one that is consumed from
142
+ if (!this.#abandoned(connection, index)) await this.leave(index)
143
+
144
+ throw exception
145
+ } finally {
146
+ this.#joining.delete(channel)
147
+ }
148
+
149
+ // sealed or closed with the rest while it was joining, or let go
150
+ if (this.#abandoned(connection, index)) return await channel.close()
151
+
152
+ this.#add(channel)
153
+ }
154
+
155
+ /**
156
+ * Stops publishing through a shard, which goes on being consumed from. A Reply still goes
157
+ * back through it: see `fire`.
158
+ *
159
+ * @param {number} index
160
+ */
161
+ retire (index) {
162
+ this.#retiring.add(index)
163
+ this.#update()
164
+ }
165
+
166
+ /**
167
+ * @param {number} index
168
+ */
169
+ restore (index) {
170
+ this.#retiring.delete(index)
171
+ this.#update()
172
+ }
173
+
174
+ /**
175
+ * @param {number} index
176
+ * @return {Promise<number>} since when the shard has had nothing to do, which is now while
177
+ * it has
178
+ */
179
+ async quiet (index) {
180
+ if (this.#occupied?.(index) === true) return Date.now()
181
+
182
+ const channel = this.#shards.get(index)
183
+
184
+ return channel === undefined ? 0 : await channel.quiet()
185
+ }
186
+
187
+ /**
188
+ * Whoever publishes through this channel may be waiting for something a shard owes it, which
189
+ * the channel itself cannot tell.
190
+ *
191
+ * @param {(index: number) => boolean} occupied
192
+ */
193
+ occupy (occupied) {
194
+ this.#occupied = occupied
195
+ }
196
+
197
+ /**
198
+ * Lets a shard go, with the channel it had.
199
+ *
200
+ * @param {number} index
201
+ * @return {Promise<void>}
202
+ */
203
+ async leave (index) {
204
+ const channel = this.#shards.get(index)
205
+
206
+ // what it has delivered is done with while it is still the way back for the answers
207
+ if (channel !== undefined) {
208
+ await channel.seal().catch(noop)
209
+ await channel.settled()
210
+ }
211
+
212
+ this.#connections.delete(index)
213
+ this.#shards.delete(index)
214
+ this.#retiring.delete(index)
215
+ this.#unwatch(index)
216
+
217
+ if (channel === undefined) return
218
+
219
+ this.#gone.add(channel)
220
+ this.#channels.delete(channel)
221
+ this.#bench.delete(channel)
222
+ this.#update()
223
+
224
+ await channel.close()
225
+ }
226
+
60
227
  /**
61
228
  * A message is told which shard it arrived on, so that whatever answers it can go back the same
62
229
  * way. See `fire`.
63
230
  */
64
231
  async consume (queue, consumer) {
65
- return await this.#every((channel) => channel.consume(queue, arrival(consumer, channel)))
232
+ return await this.#consumption(
233
+ (channel) => channel.consume(queue, arrival(consumer, channel)),
234
+ (apply) => this.#every(apply))
66
235
  }
67
236
 
68
237
  async subscribe (queue, group, consumer) {
69
- await this.#every((channel) => channel.subscribe(queue, group, consumer))
238
+ await this.#consumption(
239
+ (channel) => channel.subscribe(queue, group, consumer),
240
+ (apply) => this.#every(apply))
70
241
  }
71
242
 
72
243
  async bound (exchange, queue, key, consumer) {
73
- await this.#every((channel) => channel.bound(exchange, queue, key, consumer))
244
+ await this.#consumption(
245
+ (channel) => channel.bound(exchange, queue, key, consumer),
246
+ (apply) => this.#every(apply))
74
247
  }
75
248
 
76
249
  /**
@@ -78,7 +251,10 @@ class Channel {
78
251
  * claiming it, and holds it as well once it is let go.
79
252
  */
80
253
  async held (exchange, queue, key, consumer) {
81
- await Promise.any(this.#apply((channel) => channel.held(exchange, queue, key, arrival(consumer, channel))))
254
+ // a shard that joins is not waited for either: the key may be held there by another
255
+ await this.#consumption(
256
+ (channel) => channel.held(exchange, queue, key, arrival(consumer, channel)),
257
+ (apply) => Promise.any(this.#apply(apply)), false)
82
258
  }
83
259
 
84
260
  /**
@@ -126,7 +302,11 @@ class Channel {
126
302
  }
127
303
 
128
304
  async close () {
305
+ this.#shut = true
306
+
129
307
  await this.#all((channel) => channel.close())
308
+
309
+ for (const index of this.#watchers.keys()) this.#unwatch(index)
130
310
  }
131
311
 
132
312
  get closed () {
@@ -134,6 +314,9 @@ class Channel {
134
314
  }
135
315
 
136
316
  async seal () {
317
+ // a sealed channel is not going to consume again, from a shard that joins either
318
+ this.#consumptions = []
319
+
137
320
  await this.#all((channel) => channel.seal())
138
321
  }
139
322
 
@@ -156,7 +339,18 @@ class Channel {
156
339
  const pending = connection.createChannel(this.#type, index)
157
340
  const channel = await this.#pend(pending, index)
158
341
 
342
+ if (this.#abandoned(connection, index)) return await channel.close()
343
+
344
+ this.#attach(channel, index)
159
345
  this.#add(channel)
346
+ }
347
+
348
+ /**
349
+ * @param {comq.Channel} channel
350
+ * @param {number} index
351
+ */
352
+ #attach (channel, index) {
353
+ this.#shards.set(index, channel)
160
354
  this.#pipe(channel)
161
355
 
162
356
  channel.diagnose('flow', () => this.#remove((channel)))
@@ -164,6 +358,42 @@ class Channel {
164
358
  channel.diagnose('recover', () => this.#recover(channel))
165
359
  }
166
360
 
361
+ /**
362
+ * @param {comq.Connection} connection
363
+ * @param {number} index
364
+ * @return {boolean} whether the shard was let go, or the channel closed, while a channel was
365
+ * being made for it
366
+ */
367
+ #abandoned (connection, index) {
368
+ return this.#shut || this.#connections.get(index) !== connection
369
+ }
370
+
371
+ /**
372
+ * Consumes from the shards there are, and from the ones that join later. What no shard could
373
+ * consume is not consumed from those either: it has failed for whoever asked for it.
374
+ *
375
+ * @param {(channel: comq.Channel) => Promise<any>} apply
376
+ * @param {(apply: (channel: comq.Channel) => Promise<any>) => Promise<any>} consume
377
+ * @param {boolean} [awaited] whether a shard that joins waits for it before it is published to
378
+ * @return {Promise<any>}
379
+ */
380
+ async #consumption (apply, consume, awaited = true) {
381
+ const consumption = { apply, awaited }
382
+
383
+ // before it is applied, for a shard that joins meanwhile
384
+ this.#consumptions.push(consumption)
385
+
386
+ try {
387
+ return await consume(apply)
388
+ } catch (exception) {
389
+ const index = this.#consumptions.indexOf(consumption)
390
+
391
+ if (index !== -1) this.#consumptions.splice(index, 1)
392
+
393
+ throw exception
394
+ }
395
+ }
396
+
167
397
  /**
168
398
  * Tracks whether a shard is reachable, so that subscribing does not wait for
169
399
  * one that is not. The pool itself is left alone: a channel is only benched
@@ -181,7 +411,7 @@ class Channel {
181
411
 
182
412
  if (connection.connected === false) this.#down[index].resolve()
183
413
 
184
- connection.diagnose('close', (error) => {
414
+ const closed = (error) => {
185
415
  this.#alive[index] = false
186
416
  this.#down[index].resolve()
187
417
 
@@ -191,14 +421,41 @@ class Channel {
191
421
  if (error !== undefined && connection.closed !== true) {
192
422
  this.#diagnostics.emit(LOST, index)
193
423
  }
194
- })
424
+ }
195
425
 
196
- connection.diagnose('open', () => {
426
+ const opened = () => {
197
427
  this.#alive[index] = true
198
428
  this.#down[index] = new Promex()
199
429
 
200
430
  this.#update()
201
- })
431
+ }
432
+
433
+ connection.diagnose('close', closed)
434
+ connection.diagnose('open', opened)
435
+
436
+ this.#watchers.set(index, [connection, closed, opened])
437
+ }
438
+
439
+ /**
440
+ * @param {number} index
441
+ */
442
+ #unwatch (index) {
443
+ const watcher = this.#watchers.get(index)
444
+
445
+ if (watcher === undefined) return
446
+
447
+ const [connection, closed, opened] = watcher
448
+
449
+ connection.forget('close', closed)
450
+ connection.forget('open', opened)
451
+
452
+ this.#watchers.delete(index)
453
+
454
+ // whatever waits for the shard is not to wait for it any longer
455
+ this.#down[index].resolve()
456
+
457
+ delete this.#down[index]
458
+ delete this.#alive[index]
202
459
  }
203
460
 
204
461
  /**
@@ -248,7 +505,7 @@ class Channel {
248
505
  // a failed message waits on an exchange of comq's own, a fanout that is declared as one
249
506
  // where it is consumed, and declaring it on another shard as anything else is refused
250
507
  const exhausted = exchange.startsWith(RETRY_PREFIX) ||
251
- attempt >= this.#connections.length ||
508
+ attempt >= this.#connections.size ||
252
509
  rest.length === 0
253
510
 
254
511
  if (exhausted) return report()
@@ -313,6 +570,10 @@ class Channel {
313
570
  * @param {comq.Channel} channel
314
571
  */
315
572
  #recover (channel) {
573
+ // one that is joining is added once it consumes what the rest do, and one that has been
574
+ // let go is not added at all
575
+ if (this.#joining.has(channel) || this.#gone.has(channel)) return
576
+
316
577
  if (this.#bench.has(channel)) this.#comeback(channel)
317
578
 
318
579
  this.#add(channel)
@@ -379,6 +640,9 @@ class Channel {
379
640
  async #all (fn) {
380
641
  const promises = this.#apply(fn)
381
642
 
643
+ // a shard that is joining is sealed and closed with the rest
644
+ for (const channel of this.#joining) promises.push(fn(channel))
645
+
382
646
  await Promise.all(promises)
383
647
  }
384
648
 
@@ -397,14 +661,15 @@ class Channel {
397
661
  }
398
662
 
399
663
  /**
400
- * The channels of the shards that are known to be connected.
664
+ * The channels of the shards that are known to be connected, and are not retiring.
401
665
  * An empty result means every shard is down or benched — wait rather than
402
666
  * publish: a destroyed socket accepts a write without complaining.
403
667
  *
404
668
  * @return {comq.Channel[]}
405
669
  */
406
670
  #reachable () {
407
- return this.#pool.filter((channel) => this.#alive[channel.index] !== false)
671
+ return this.#pool.filter((channel) =>
672
+ this.#alive[channel.index] !== false && !this.#retiring.has(channel.index))
408
673
  }
409
674
 
410
675
  /**
@@ -419,14 +684,19 @@ class Channel {
419
684
  const waiting = this.#recovery
420
685
  const pool = this.#reachable()
421
686
 
422
- if (pool.length === 0) {
687
+ // a shard that is retiring is still the way back for what arrived on it
688
+ const preferred = route.prefer === undefined
689
+ ? undefined
690
+ : this.#pool.find((channel) =>
691
+ channel.index === route.prefer && this.#alive[channel.index] !== false)
692
+
693
+ if (preferred === undefined && pool.length === 0) {
423
694
  await waiting
424
695
 
425
696
  return this.#one(fn, route)
426
697
  }
427
698
 
428
- const channel = pool.find((channel) => channel.index === route.prefer) ??
429
- pool[Math.floor(Math.random() * pool.length)]
699
+ const channel = preferred ?? pool[Math.floor(Math.random() * pool.length)]
430
700
 
431
701
  // told before the publish is awaited: a shard lost while it is under way has it on board
432
702
  route.via?.(channel.index)
@@ -455,7 +725,7 @@ function arrival (consumer, channel) {
455
725
  }
456
726
 
457
727
  /**
458
- * @param {comq.Connection[]} connections
728
+ * @param {comq.Connection[] | Map<number, comq.Connection>} connections
459
729
  * @param {comq.topology.type} type
460
730
  * @return {comq.Channel}
461
731
  */