vrpc 3.9.0 → 3.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vrpc",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "description": "Variadic Remote Procedure Calls",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -12,13 +12,14 @@
12
12
  "build:addon": "ln -sf binding.gyp.no-auto binding.gyp && node-gyp rebuild && rm binding.gyp",
13
13
  "build:browser": "./node_modules/.bin/webpack --stats-error-details",
14
14
  "build:doc": "./node_modules/.bin/jsdoc2md -f vrpc/VrpcAdapter.js vrpc/VrpcAgent.js vrpc/VrpcClient.js vrpc/VrpcNative.js --param-list-format=list --separators > docs/api.md",
15
- "test": "npm run test:adapter && npm run test:agent && npm run test:client && npm run test:persistor && npm run test:production",
15
+ "test": "npm run test:adapter && npm run test:agent && npm run test:client && npm run test:persistor && npm run test:lifecycle && npm run test:production",
16
16
  "test:adapter": "./node_modules/.bin/mocha tests/adapter/*.js --exit",
17
17
  "test:native": "./node_modules/.bin/mocha tests/native/*.js --exit",
18
18
  "test:agent": "tests/agent/test.sh",
19
19
  "test:client": "tests/client/test.sh",
20
20
  "test:performance": "tests/performance/test.sh",
21
21
  "test:persistor": "./node_modules/.bin/mocha tests/persistor/*.js --timeout 30000 --exit",
22
+ "test:lifecycle": "./node_modules/.bin/mocha tests/lifecycle/*.js --timeout 30000 --exit",
22
23
  "test:production": "./node_modules/.bin/mocha tests/production/lifeCycleTest.js --timeout 30000 --exit && tests/production/test.sh"
23
24
  },
24
25
  "bin": {
@@ -276,6 +276,15 @@ class VrpcAdapter {
276
276
  * @param {String} description Parameter description
277
277
  * @param {String} [type] Parameter type
278
278
  * @param {Any} [default] The default to be injected when not provided
279
+ * @param {Callback} [callback] The signature of a function-typed
280
+ * parameter, when a `@callback` typedef of that name documents it
281
+ */
282
+
283
+ /**
284
+ * @typedef {Object} Callback
285
+ * @param {String} name Typedef name (the parameter's type)
286
+ * @param {String} description Callback description
287
+ * @param {Array.<Param>} params The arguments the function is called with
279
288
  */
280
289
 
281
290
  /**
@@ -341,27 +350,35 @@ class VrpcAdapter {
341
350
  static _parseComments (content) {
342
351
  const comments = commentParser(content, { assocFunctions: true })
343
352
  const meta = {}
353
+ const paramsOf = tags =>
354
+ tags
355
+ .filter(
356
+ ({ tag }) => tag === 'param' || tag === 'arg' || tag === 'argument'
357
+ )
358
+ .map(({ name, optional, description, type, default: defaultValue }) => {
359
+ return { name, optional, description, type, defaultValue }
360
+ })
361
+ // `@callback Name` blocks document the signature of a function-typed
362
+ // parameter; a parameter whose type names one carries it as `callback`
363
+ const callbacks = {}
364
+ comments.forEach(({ tags, description }) => {
365
+ const typedef = tags && tags.find(({ tag }) => tag === 'callback')
366
+ if (!typedef || !typedef.name) return
367
+ callbacks[typedef.name] = {
368
+ name: typedef.name,
369
+ description,
370
+ params: paramsOf(tags)
371
+ }
372
+ })
344
373
  comments.forEach(({ tags, description, functionName }) => {
374
+ if (tags && tags.some(({ tag }) => tag === 'callback')) return
345
375
  if (functionName) {
346
376
  if (tags) {
347
- let params = tags.filter(
348
- ({ tag }) => tag === 'param' || tag === 'arg' || tag === 'argument'
377
+ const params = paramsOf(tags).map(param =>
378
+ callbacks[param.type]
379
+ ? { ...param, callback: callbacks[param.type] }
380
+ : param
349
381
  )
350
- if (params.length > 0) {
351
- params = params.map(
352
- ({
353
- name,
354
- optional,
355
- description,
356
- type,
357
- default: defaultValue
358
- }) => {
359
- return { name, optional, description, type, defaultValue }
360
- }
361
- )
362
- } else {
363
- params = []
364
- }
365
382
  let ret = tags.filter(
366
383
  ({ tag }) => tag === 'returns' || tag === 'return'
367
384
  )
package/vrpc/VrpcAgent.js CHANGED
@@ -614,7 +614,14 @@ class VrpcAgent extends EventEmitter {
614
614
  // Intersecting life-cycle functions
615
615
  switch (method) {
616
616
  case '__createIsolated__': {
617
- // TODO handle instantiation errors
617
+ if (json.e) {
618
+ // a constructor that threw leaves nothing behind: no instance,
619
+ // no subscription, no lifetime bookkeeping
620
+ this._log.warn(
621
+ `Instantiation of ${className} failed: ${json.e.message}`
622
+ )
623
+ break
624
+ }
618
625
  const instanceId = json.r
619
626
  // TODO await this
620
627
  this._subscribeToMethodsOfNewInstance(className, instanceId)
@@ -622,7 +629,12 @@ class VrpcAgent extends EventEmitter {
622
629
  break
623
630
  }
624
631
  case '__createShared__': {
625
- // TODO handle instantiation errors
632
+ if (json.e) {
633
+ this._log.warn(
634
+ `Instantiation of ${className} failed: ${json.e.message}`
635
+ )
636
+ break
637
+ }
626
638
  const instanceId = json.r
627
639
  if (!this._hasSharedInstance(instanceId)) {
628
640
  this._subscribeToMethodsOfNewInstance(className, instanceId)
@@ -1013,7 +1013,11 @@ class VrpcClient extends EventEmitter {
1013
1013
  this._eventEmitter.once(i, ({ e, r }) => {
1014
1014
  clearTimeout(timer)
1015
1015
  if (e) {
1016
- reject(new Error(e))
1016
+ // the agent answers a throwing constructor with { message, cause }
1017
+ const { message, cause } = VrpcClient._prepareError(e)
1018
+ reject(
1019
+ new Error(`[vrpc ${agent}-${className}-${f}]: ${message}`, { cause })
1020
+ )
1017
1021
  } else {
1018
1022
  const proxy = this._createProxy(agent, className, r)
1019
1023
  resolve(proxy)
@@ -9,12 +9,10 @@ __\/\\\_______\/\\\__/\\\///////\\\___\/\\\/////////\\\____/\\\////////__
9
9
  _______\//\\\_______\/\\\______\//\\\_\/\\\_______________\////\\\\\\\\\_
10
10
  ________\///________\///________\///__\///___________________\/////////__
11
11
 
12
-
13
12
  Non-intrusively adapts code and provides access in form of asynchronous remote
14
13
  procedure calls (RPC).
15
14
  Author: Dr. Burkhard C. Heisen (https://github.com/heisenware/vrpc)
16
15
 
17
-
18
16
  Licensed under the MIT License <http://opensource.org/licenses/MIT>.
19
17
  Copyright (c) 2018 - 2022 Dr. Burkhard C. Heisen <burkhard.heisen@heisenware.com>.
20
18
 
@@ -39,12 +37,22 @@ SOFTWARE.
39
37
 
40
38
  const VrpcAdapter = require('./VrpcAdapter')
41
39
 
40
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
41
+
42
42
  /**
43
43
  * Provides a persistence layer for VRPC instances.
44
44
  *
45
45
  * This class automatically saves the constructor arguments of newly created
46
- * instances and re-creates them when the application restarts. It also listens
47
- * for an 'update' event on instances to persist their state after creation.
46
+ * shared instances and re-creates them when the application restarts. It also
47
+ * listens for an 'update' event on instances to persist their state after
48
+ * creation (the event's payload is used as the single constructor argument
49
+ * upon restoration). Isolated instances belong to the connection that
50
+ * created them and are never persisted.
51
+ *
52
+ * A record that cannot be restored is never deleted: it is quarantined
53
+ * (marked with the error, kept on disk), retried once per start so it heals
54
+ * by itself once the cause is fixed, and can be inspected (`status`), retried
55
+ * (`retry`) or removed on purpose (`forget`).
48
56
  *
49
57
  * @requires @heisenware/storage - This peer dependency must be installed.
50
58
  * Storage 1.x (synchronous constructor) and >= 2.x (async `Storage.open`)
@@ -59,8 +67,16 @@ class VrpcPersistor {
59
67
  * @param {VrpcAgent} options.agentInstance The VRPC agent whose instances should be persisted.
60
68
  * @param {string} [options.dir] Optional directory for storage. Defaults to a path derived from the agent's name.
61
69
  * @param {object} [options.log] Optional logger object (e.g. console) with info, warn, and error methods.
70
+ * @param {number} [options.retries=5] How often a record that fails on a fresh restore is retried before it is quarantined.
71
+ * @param {number} [options.retryDelay=1000] Milliseconds between retries, multiplied by the attempt number.
62
72
  */
63
- constructor ({ agentInstance, dir, log = console }) {
73
+ constructor ({
74
+ agentInstance,
75
+ dir,
76
+ log = console,
77
+ retries = 5,
78
+ retryDelay = 1000
79
+ }) {
64
80
  let Storage
65
81
  try {
66
82
  Storage = require('@heisenware/storage')
@@ -72,6 +88,8 @@ class VrpcPersistor {
72
88
 
73
89
  this._agentInstance = agentInstance
74
90
  this._log = log
91
+ this._retries = retries
92
+ this._retryDelay = retryDelay
75
93
 
76
94
  this._dir =
77
95
  dir ||
@@ -90,79 +108,149 @@ class VrpcPersistor {
90
108
  /**
91
109
  * Restores all persisted instances from storage.
92
110
  *
93
- * It attempts to recreate each instance using its saved className and args.
94
- * If an instance fails to restore after several retries with exponential backoff,
95
- * it is considered "broken" and removed from storage to prevent startup loops.
111
+ * A record seen for the first time is retried with a growing delay; a
112
+ * record that still fails is quarantined: it stays on disk, marked with
113
+ * the error and the number of attempts. A quarantined record gets one
114
+ * attempt per restore, so it heals on the next start once its cause is
115
+ * fixed, and never turns a start into a retry storm.
116
+ *
117
+ * @returns {Promise<{restored: string[], quarantined: Array<{instance: string, className: string, error: string, attempts: number, since: string}>}>}
96
118
  */
97
119
  async restore () {
98
120
  await this._isInitialized
121
+ const summary = { restored: [], quarantined: [] }
99
122
  const allIds = this._storage.keys()
100
123
  if (allIds.length === 0) {
101
124
  this._log.info('[VrpcPersistor] No instances to restore.')
102
- return
125
+ return summary
103
126
  }
104
-
105
127
  this._log.info(
106
128
  `[VrpcPersistor] Found ${allIds.length} persisted instance(s) to restore.`
107
129
  )
108
130
 
109
- let failedIds = []
110
- const restorationPromises = allIds.map(async id => {
111
- try {
112
- const { className, args } = await this._storage.getItem(id)
113
- this._log.info(
114
- `[VrpcPersistor] Restoring instance: ${id} (${className})`
115
- )
116
- this._agentInstance.create({ className, args, instance: id })
117
- } catch (err) {
131
+ let failing = []
132
+ for (const id of allIds) {
133
+ const record = await this._storage.getItem(id)
134
+ if (!record || !record.className) {
135
+ this._log.warn(`[VrpcPersistor] Skipping ${id}: record is missing or broken.`)
136
+ continue
137
+ }
138
+ if (record.restoreError) {
139
+ // quarantined: one attempt per start
140
+ const err = this._tryCreate(id, record)
141
+ if (!err) {
142
+ summary.restored.push(id)
143
+ this._log.info(
144
+ `[VrpcPersistor] Healed ${id} (${record.className}) after ${record.restoreError.attempts} failed attempt(s).`
145
+ )
146
+ } else {
147
+ summary.quarantined.push(await this._quarantine(id, record, err, 1))
148
+ }
149
+ continue
150
+ }
151
+ this._log.info(`[VrpcPersistor] Restoring instance: ${id} (${record.className})`)
152
+ const err = this._tryCreate(id, record)
153
+ if (!err) {
154
+ summary.restored.push(id)
155
+ } else {
118
156
  this._log.warn(
119
157
  `[VrpcPersistor] Could not restore ${id}: ${err.message}. Will retry.`
120
158
  )
121
- failedIds.push(id)
159
+ failing.push({ id, record, err })
122
160
  }
123
- })
124
- await Promise.all(restorationPromises)
161
+ }
125
162
 
126
- // Retry failed instances
163
+ // Retry fresh failures with a growing delay
127
164
  let trial = 0
128
- const MAX_TRIALS = 5
129
- while (failedIds.length > 0 && trial++ < MAX_TRIALS) {
165
+ while (failing.length > 0 && trial++ < this._retries) {
130
166
  this._log.info(
131
- `[VrpcPersistor] Retrying ${failedIds.length} failed instance(s), attempt ${trial}.`
167
+ `[VrpcPersistor] Retrying ${failing.length} failed instance(s), attempt ${trial} of ${this._retries}.`
132
168
  )
133
- await new Promise(resolve => setTimeout(resolve, 1000 * trial)) // Exponential backoff
169
+ await sleep(this._retryDelay * trial)
134
170
  const stillFailing = []
135
- for (const id of failedIds) {
136
- try {
137
- const { className, args } = await this._storage.getItem(id)
138
- this._agentInstance.create({ className, args, instance: id })
139
- this._log.info(
140
- `[VrpcPersistor] Successfully restored ${id} on retry.`
141
- )
142
- } catch (err) {
143
- stillFailing.push(id)
171
+ for (const entry of failing) {
172
+ const err = this._tryCreate(entry.id, entry.record)
173
+ if (!err) {
174
+ summary.restored.push(entry.id)
175
+ this._log.info(`[VrpcPersistor] Successfully restored ${entry.id} on retry.`)
176
+ } else {
177
+ stillFailing.push({ ...entry, err })
144
178
  }
145
179
  }
146
- failedIds = stillFailing
180
+ failing = stillFailing
147
181
  }
148
182
 
149
- // Cleanup instances that could not be restored
150
- if (failedIds.length > 0) {
151
- this._log.warn(
152
- '[VrpcPersistor] The following instances could not be restored and will be removed:'
183
+ for (const { id, record, err } of failing) {
184
+ summary.quarantined.push(
185
+ await this._quarantine(id, record, err, 1 + this._retries)
153
186
  )
154
- const deletionPromises = failedIds.map(id => {
155
- this._log.warn(` - ${id}`)
156
- return this._storage.removeItem(id)
187
+ }
188
+
189
+ this._log.info(
190
+ `[VrpcPersistor] Restored ${summary.restored.length} of ${allIds.length} instance(s)` +
191
+ (summary.quarantined.length > 0
192
+ ? `, ${summary.quarantined.length} quarantined: ${summary.quarantined
193
+ .map(x => `${x.instance} (${x.className}): ${x.error}`)
194
+ .join('; ')}`
195
+ : '.')
196
+ )
197
+ return summary
198
+ }
199
+
200
+ /**
201
+ * Lists every persisted record with its quarantine mark, if any.
202
+ *
203
+ * @returns {Promise<{dir: string, instances: Array<{instance: string, className: string, restoreError: (object|null)}>}>}
204
+ */
205
+ async status () {
206
+ await this._isInitialized
207
+ const instances = []
208
+ for (const id of this._storage.keys()) {
209
+ const record = await this._storage.getItem(id)
210
+ if (!record) continue
211
+ instances.push({
212
+ instance: id,
213
+ className: record.className,
214
+ restoreError: record.restoreError || null
157
215
  })
158
- try {
159
- await Promise.all(deletionPromises)
160
- } catch (err) {
161
- this._log.error(
162
- `[VrpcPersistor] Failed to delete broken instances: ${err.message}`
163
- )
164
- }
165
216
  }
217
+ return { dir: this._dir, instances }
218
+ }
219
+
220
+ /**
221
+ * Attempts one more time to restore a persisted instance (quarantined or
222
+ * not). A success clears the quarantine mark, a failure updates it.
223
+ *
224
+ * @param {string} id The instance id
225
+ * @returns {Promise<boolean>} true when the instance exists afterwards
226
+ */
227
+ async retry (id) {
228
+ await this._isInitialized
229
+ const record = await this._storage.getItem(id)
230
+ if (!record) throw new Error(`Unknown persisted instance: ${id}`)
231
+ const err = this._tryCreate(id, record)
232
+ if (!err) {
233
+ this._log.info(`[VrpcPersistor] Restored ${id} (${record.className}) on request.`)
234
+ return true
235
+ }
236
+ await this._quarantine(id, record, err, 1)
237
+ return false
238
+ }
239
+
240
+ /**
241
+ * Removes a persisted record on purpose, the only way a record leaves the
242
+ * storage other than the deletion of a live instance.
243
+ *
244
+ * @param {string} id The instance id
245
+ * @returns {Promise<boolean>} true
246
+ */
247
+ async forget (id) {
248
+ await this._isInitialized
249
+ const record = await this._storage.getItem(id)
250
+ if (!record) throw new Error(`Unknown persisted instance: ${id}`)
251
+ await this._storage.removeItem(id)
252
+ this._log.info(`[VrpcPersistor] Forgot ${id} (${record.className}).`)
253
+ return true
166
254
  }
167
255
 
168
256
  /**
@@ -175,8 +263,15 @@ class VrpcPersistor {
175
263
  // after construction must be caught. The operations they trigger wait
176
264
  // for the storage layer opened below.
177
265
 
178
- // Persist new instance creation
179
- VrpcAdapter.on('create', async ({ instance, className, args }) => {
266
+ // Persist new instance creation (a restore passes here too, which
267
+ // rewrites the record and thereby clears a quarantine mark)
268
+ VrpcAdapter.on('create', async ({ instance, className, args, isIsolated }) => {
269
+ if (isIsolated) {
270
+ this._log.info(
271
+ `[VrpcPersistor] Not persisting isolated instance: ${instance} (${className})`
272
+ )
273
+ return
274
+ }
180
275
  this._log.info(
181
276
  `[VrpcPersistor] Persisting new instance: ${instance} (${className})`
182
277
  )
@@ -235,6 +330,49 @@ class VrpcPersistor {
235
330
  }
236
331
  }
237
332
 
333
+ /**
334
+ * Creates the instance from its record; returns the error instead of
335
+ * throwing (an existing instance counts as success).
336
+ * @private
337
+ */
338
+ _tryCreate (id, { className, args }) {
339
+ try {
340
+ this._agentInstance.create({ className, args, instance: id })
341
+ return null
342
+ } catch (err) {
343
+ return err
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Marks a record as not restorable and keeps it.
349
+ * @private
350
+ */
351
+ async _quarantine (id, record, err, attempts) {
352
+ const previous = record.restoreError || { attempts: 0, since: new Date().toISOString() }
353
+ const restoreError = {
354
+ message: err.message,
355
+ at: new Date().toISOString(),
356
+ since: previous.since,
357
+ attempts: previous.attempts + attempts
358
+ }
359
+ await this._storage.setItem(
360
+ id,
361
+ { className: record.className, args: record.args, restoreError },
362
+ { folder: record.className }
363
+ )
364
+ this._log.warn(
365
+ `[VrpcPersistor] Quarantined ${id} (${record.className}) after ${restoreError.attempts} attempt(s): ${err.message}. The record is kept; fix the cause and restart, or call retry()/forget().`
366
+ )
367
+ return {
368
+ instance: id,
369
+ className: record.className,
370
+ error: err.message,
371
+ attempts: restoreError.attempts,
372
+ since: restoreError.since
373
+ }
374
+ }
375
+
238
376
  /**
239
377
  * Saves an instance's details to storage.
240
378
  * @private
@@ -219,12 +219,15 @@ function mkextract (opts) {
219
219
 
220
220
  // if open marker detected and it's not, skip one
221
221
  if (startPos !== -1 && line.indexOf(MARKER_START_SKIP) !== startPos) {
222
- chunk = []
223
- indent = startPos + MARKER_START.length
222
+ // A block still waiting for its function is followed by another
223
+ // block: it documents no function (a typedef, a callback) and is
224
+ // returned as it is - parsed BEFORE the chunk makes way
224
225
  if (findMethod) {
225
226
  result = parse_block(chunk, opts)
226
227
  findMethod = false
227
228
  }
229
+ chunk = []
230
+ indent = startPos + MARKER_START.length
228
231
  }
229
232
 
230
233
  if (findMethod) {