vrpc 3.8.3 → 3.9.1

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.
@@ -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