vrpc 3.8.1 → 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 +1 -1
- package/vrpc/SubscribeRetrier.js +115 -0
- package/vrpc/VrpcAgent.js +13 -0
- package/vrpc/VrpcClient.js +13 -0
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
|
|
@@ -783,6 +791,7 @@ class VrpcClient extends EventEmitter {
|
|
|
783
791
|
* @returns {Promise} Resolves when ended
|
|
784
792
|
*/
|
|
785
793
|
async end () {
|
|
794
|
+
this._subscribeRetrier.cancel()
|
|
786
795
|
if (!this._client) return
|
|
787
796
|
this._mqttPublish(
|
|
788
797
|
`${this._vrpcConnectionId}/__clientInfo__`,
|
|
@@ -920,7 +929,11 @@ class VrpcClient extends EventEmitter {
|
|
|
920
929
|
err.subscribeOptions = options
|
|
921
930
|
this._log.error(err.message)
|
|
922
931
|
this.emit('error', err)
|
|
932
|
+
this._subscribeRetrier.schedule(erroneousGranted, options)
|
|
923
933
|
}
|
|
934
|
+
this._subscribeRetrier.granted(
|
|
935
|
+
granted.filter(x => x.qos !== 128).map(x => x.topic)
|
|
936
|
+
)
|
|
924
937
|
const reducedQos = granted.filter(x => x.qos < this._qos)
|
|
925
938
|
if (reducedQos.length > 0) {
|
|
926
939
|
err = new Error(
|