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/browser/vrpc.js +1 -1
- package/package.json +1 -1
- package/vrpc/SubscribeRetrier.js +115 -0
- package/vrpc/VrpcAgent.js +13 -0
- package/vrpc/VrpcClient.js +49 -7
package/package.json
CHANGED
|
@@ -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)
|
package/vrpc/VrpcClient.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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]
|