vrpc 3.8.0 → 3.8.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vrpc",
3
- "version": "3.8.0",
3
+ "version": "3.8.2",
4
4
  "description": "Variadic Remote Procedure Calls",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Retries MQTT subscriptions the broker refused (SUBACK qos=128).
3
+ *
4
+ * A refusal is not always final: the broker asks an authorization
5
+ * service for every subscription, and while that service is unavailable
6
+ * it refuses everything. An agent or client that subscribed in such a
7
+ * window used to stay deaf for good - connected, looking online, never
8
+ * receiving a message on the refused topic. This retries with a growing
9
+ * delay until the broker grants the topic, and forgets everything when a
10
+ * (re)connect subscribes afresh anyway.
11
+ *
12
+ * Browser-safe: timers only.
13
+ */
14
+ class SubscribeRetrier {
15
+ /**
16
+ * @param {Object} options
17
+ * @param {Function} options.subscribe (topics: String[], options: Object) => void
18
+ * the subscribe call to repeat; its own refusal handling schedules again
19
+ * @param {Object} [options.log] logger with warn()
20
+ * @param {Number} [options.initialDelayMs=1000]
21
+ * @param {Number} [options.maxDelayMs=30000]
22
+ */
23
+ constructor ({ subscribe, log, initialDelayMs = 1000, maxDelayMs = 30000 }) {
24
+ this._subscribe = subscribe
25
+ this._log = log
26
+ this._initialDelayMs = initialDelayMs
27
+ this._maxDelayMs = maxDelayMs
28
+ this._pending = new Map() // options key -> { options, topics: Set }
29
+ this._inFlight = new Set() // topics of the retry round under way
30
+ this._timer = null
31
+ this._attempt = 0
32
+ }
33
+
34
+ /** Topics waiting for a retry. */
35
+ get pending () {
36
+ const topics = []
37
+ for (const group of this._pending.values()) topics.push(...group.topics)
38
+ return topics
39
+ }
40
+
41
+ /** The delay the next retry will wait, in ms. */
42
+ get nextDelayMs () {
43
+ return Math.min(this._initialDelayMs * 2 ** this._attempt, this._maxDelayMs)
44
+ }
45
+
46
+ /**
47
+ * Remembers refused topics and arms the retry timer if none is armed.
48
+ *
49
+ * @param {String[]} topics
50
+ * @param {Object} [options] the subscribe options to repeat with
51
+ */
52
+ schedule (topics, options = {}) {
53
+ if (!topics || topics.length === 0) return
54
+ for (const topic of topics) this._inFlight.delete(topic)
55
+ const key = JSON.stringify(options)
56
+ const group = this._pending.get(key) || { options, topics: new Set() }
57
+ for (const topic of topics) group.topics.add(topic)
58
+ this._pending.set(key, group)
59
+ if (this._timer) return
60
+ const delayMs = this.nextDelayMs
61
+ if (this._log) {
62
+ this._log.warn(
63
+ `Retrying ${this.pending.length} refused subscription(s) in ${delayMs} ms: ${this.pending}`
64
+ )
65
+ }
66
+ this._timer = setTimeout(() => this._fire(), delayMs)
67
+ }
68
+
69
+ /**
70
+ * Called with the topics a subscribe granted: once nothing is pending
71
+ * any more, the backoff starts over.
72
+ */
73
+ granted (topics) {
74
+ for (const topic of topics) this._inFlight.delete(topic)
75
+ for (const group of this._pending.values()) {
76
+ for (const topic of topics) group.topics.delete(topic)
77
+ }
78
+ for (const [key, group] of this._pending) {
79
+ if (group.topics.size === 0) this._pending.delete(key)
80
+ }
81
+ // nothing waiting, nothing under way, nothing armed: start over
82
+ if (this._pending.size === 0 && this._inFlight.size === 0 && !this._timer) {
83
+ this._attempt = 0
84
+ }
85
+ }
86
+
87
+ /** Drops every pending retry: a (re)connect subscribes afresh, an end() stops. */
88
+ cancel () {
89
+ clearTimeout(this._timer)
90
+ this._timer = null
91
+ this._pending.clear()
92
+ this._inFlight.clear()
93
+ this._attempt = 0
94
+ }
95
+
96
+ _fire () {
97
+ this._timer = null
98
+ this._attempt += 1
99
+ const groups = Array.from(this._pending.values())
100
+ this._pending.clear()
101
+ for (const { topics } of groups) {
102
+ for (const topic of topics) this._inFlight.add(topic)
103
+ }
104
+ for (const { options, topics } of groups) {
105
+ try {
106
+ this._subscribe(Array.from(topics), options)
107
+ } catch (err) {
108
+ if (this._log) this._log.warn(`Retrying subscription failed: ${err.message}`)
109
+ this.schedule(Array.from(topics), options)
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ module.exports = SubscribeRetrier
package/vrpc/VrpcAgent.js CHANGED
@@ -6,6 +6,7 @@ const { ArgumentParser } = require('argparse')
6
6
  const EventEmitter = require('events')
7
7
  const jsonStringifySafe = require('json-stringify-safe')
8
8
  const VrpcAdapter = require('./VrpcAdapter')
9
+ const SubscribeRetrier = require('./SubscribeRetrier')
9
10
 
10
11
  const VRPC_PROTOCOL_VERSION = 3
11
12
 
@@ -144,6 +145,11 @@ class VrpcAgent extends EventEmitter {
144
145
  // maps clientId to instanceId
145
146
  this._isolatedInstances = new Map()
146
147
  this._sharedInstances = new Map()
148
+ // refused subscriptions (SUBACK qos=128) are retried until granted
149
+ this._subscribeRetrier = new SubscribeRetrier({
150
+ subscribe: (topics, options) => this._mqttSubscribe(topics, options),
151
+ log: this._log
152
+ })
147
153
 
148
154
  // Handle the internal error event in case the user forgot to implement it
149
155
  this.on('error', err => {
@@ -213,6 +219,7 @@ class VrpcAgent extends EventEmitter {
213
219
  * @returns {Promise} Resolves when disconnected and ended
214
220
  */
215
221
  async end ({ unregister = false } = {}) {
222
+ this._subscribeRetrier.cancel()
216
223
  try {
217
224
  if (!this._client || !this._client.connected) {
218
225
  this.emit('end')
@@ -370,7 +377,11 @@ class VrpcAgent extends EventEmitter {
370
377
  err.subscribeOptions = options
371
378
  this._log.error(err)
372
379
  this.emit('error', err)
380
+ this._subscribeRetrier.schedule(erroneousGranted, options)
373
381
  }
382
+ this._subscribeRetrier.granted(
383
+ granted.filter(x => x.qos !== 128).map(x => x.topic)
384
+ )
374
385
  const reducedQos = granted.filter(x => x.qos < this._qos)
375
386
  if (reducedQos.length > 0) {
376
387
  err = new Error(
@@ -471,6 +482,8 @@ class VrpcAgent extends EventEmitter {
471
482
 
472
483
  _handleConnect () {
473
484
  this._log.info('[OK]')
485
+ // everything is subscribed afresh: pending retries are moot
486
+ this._subscribeRetrier.cancel()
474
487
  try {
475
488
  const topics = this._generateTopics()
476
489
  if (topics.length > 0) this._mqttSubscribe(topics)
@@ -41,6 +41,7 @@ const os = require('os')
41
41
  const { nanoid } = require('nanoid')
42
42
  const mqtt = require('mqtt')
43
43
  const EventEmitter = require('events')
44
+ const SubscribeRetrier = require('./SubscribeRetrier')
44
45
 
45
46
  const VRPC_PROTOCOL_VERSION = 3
46
47
 
@@ -135,6 +136,11 @@ class VrpcClient extends EventEmitter {
135
136
  this._client = null
136
137
  this._cachedSubscriptions = {}
137
138
  this._proxies = {}
139
+ // refused subscriptions (SUBACK qos=128) are retried until granted
140
+ this._subscribeRetrier = new SubscribeRetrier({
141
+ subscribe: (topics, options) => this._mqttSubscribe(topics, options).catch(() => {}),
142
+ log: this._log
143
+ })
138
144
  this._callbackIds = new WeakMap()
139
145
  this._emitterListener = new WeakMap()
140
146
  this._pendingSubscriptions = new Map()
@@ -247,6 +253,8 @@ class VrpcClient extends EventEmitter {
247
253
  this._client.on('connect', async () => {
248
254
  // forward the good news
249
255
  this.emit('connect')
256
+ // everything is subscribed afresh below: pending retries are moot
257
+ this._subscribeRetrier.cancel()
250
258
  try {
251
259
  // This will give us an overview of all remotely available classes
252
260
  const agent = this._agent === '*' ? '+' : this._agent
@@ -321,7 +329,7 @@ class VrpcClient extends EventEmitter {
321
329
  if (removed.length !== 0) {
322
330
  this.emit('instanceGone', removed, { domain, agent, className })
323
331
  removed.forEach(lostInstance =>
324
- this._clearCachedSubscriptions({ lostInstance })
332
+ this._clearCachedSubscriptions({ lostInstance, agent, className })
325
333
  )
326
334
  }
327
335
  if (added.length !== 0) {
@@ -438,7 +446,14 @@ class VrpcClient extends EventEmitter {
438
446
  v: VRPC_PROTOCOL_VERSION
439
447
  }
440
448
  const proxy = await this._getProxy(agent, className, json)
441
- if (instance && cacheProxy) this._proxies[instance] = proxy
449
+ if (instance && cacheProxy) {
450
+ this._proxies[`${agent}/${className}/${instance}`] = {
451
+ agent,
452
+ className,
453
+ instance,
454
+ proxy
455
+ }
456
+ }
442
457
  return proxy
443
458
  }
444
459
 
@@ -460,7 +475,8 @@ class VrpcClient extends EventEmitter {
460
475
  if (!this._isConnected()) {
461
476
  throw new Error('Client is not connected')
462
477
  }
463
- if (this._proxies[instance]) return this._proxies[instance]
478
+ const cached = this._cachedProxy(instance, options)
479
+ if (cached) return cached
464
480
  const { agent, className } = await this._getInstanceData(instance, options)
465
481
  return this._createProxy(agent, className, instance)
466
482
  }
@@ -492,7 +508,7 @@ class VrpcClient extends EventEmitter {
492
508
  }
493
509
  const topic = `${this._domain}/${agent}/${className}/__static__/__delete__`
494
510
  this._mqttPublish(topic, JSON.stringify(json))
495
- if (this._proxies[instance]) delete this._proxies[instance]
511
+ delete this._proxies[`${agent}/${className}/${instance}`]
496
512
  return this._handleAgentAnswer(json, agent)
497
513
  }
498
514
 
@@ -775,6 +791,7 @@ class VrpcClient extends EventEmitter {
775
791
  * @returns {Promise} Resolves when ended
776
792
  */
777
793
  async end () {
794
+ this._subscribeRetrier.cancel()
778
795
  if (!this._client) return
779
796
  this._mqttPublish(
780
797
  `${this._vrpcConnectionId}/__clientInfo__`,
@@ -912,7 +929,11 @@ class VrpcClient extends EventEmitter {
912
929
  err.subscribeOptions = options
913
930
  this._log.error(err.message)
914
931
  this.emit('error', err)
932
+ this._subscribeRetrier.schedule(erroneousGranted, options)
915
933
  }
934
+ this._subscribeRetrier.granted(
935
+ granted.filter(x => x.qos !== 128).map(x => x.topic)
936
+ )
916
937
  const reducedQos = granted.filter(x => x.qos < this._qos)
917
938
  if (reducedQos.length > 0) {
918
939
  err = new Error(
@@ -1374,11 +1395,19 @@ class VrpcClient extends EventEmitter {
1374
1395
  return foundId
1375
1396
  }
1376
1397
 
1377
- _clearCachedSubscriptions ({ lostAgent, lostInstance }) {
1398
+ _clearCachedSubscriptions ({ lostAgent, lostInstance, agent, className }) {
1378
1399
  const obsoleteTopics = Object.keys(this._cachedSubscriptions).filter(
1379
1400
  topic => {
1380
1401
  const sub = this._cachedSubscriptions[topic]
1381
- return sub.agent === lostAgent || sub.instance === lostInstance
1402
+ if (lostAgent !== undefined) return sub.agent === lostAgent
1403
+ // Instance ids are unique per agent only: a twin of the same id on
1404
+ // another agent (a deployed app next to its builder) keeps its
1405
+ // subscriptions when this one goes
1406
+ return (
1407
+ sub.instance === lostInstance &&
1408
+ sub.agent === agent &&
1409
+ sub.className === className
1410
+ )
1382
1411
  }
1383
1412
  )
1384
1413
 
@@ -1432,12 +1461,25 @@ class VrpcClient extends EventEmitter {
1432
1461
  return instanceData
1433
1462
  }
1434
1463
 
1464
+ _cachedProxy (instance, { agent, className } = {}) {
1465
+ // Instance ids are unique per agent only: a cached proxy is served
1466
+ // solely when it is the one match for the given agent and class - twins
1467
+ // on several agents are resolved through the topology instead
1468
+ const hits = Object.values(this._proxies).filter(
1469
+ x =>
1470
+ x.instance === instance &&
1471
+ (!agent || x.agent === agent) &&
1472
+ (!className || x.className === className)
1473
+ )
1474
+ return hits.length === 1 ? hits[0].proxy : null
1475
+ }
1476
+
1435
1477
  _getInstanceFromCache (instance, options = {}) {
1436
1478
  // when no agent is specified, but an explicit class default exists give
1437
1479
  // such instances priority in being found
1438
1480
  if (!options.agent && this._agent !== '*') {
1439
1481
  const { classes, status } = this._agents[this._agent] || {}
1440
- if (!classes || status === 'offline') {
1482
+ if (classes && status !== 'offline') {
1441
1483
  for (const className in classes) {
1442
1484
  if (options.className && className !== options.className) continue
1443
1485
  const { instances } = classes[className]