@qvac/registry-client 0.5.0 → 0.6.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.
- package/lib/client.js +89 -2
- package/package.json +5 -12
- package/utils/retry.js +5 -1
package/lib/client.js
CHANGED
|
@@ -16,6 +16,12 @@ const fs = require('#fs')
|
|
|
16
16
|
const DEFAULT_DOWNLOAD_MAX_RETRIES = 3
|
|
17
17
|
const RETRIABLE_DOWNLOAD_CODES = ['REQUEST_TIMEOUT']
|
|
18
18
|
|
|
19
|
+
// While the app is backgrounded the swarm is suspended; a retry must wait for
|
|
20
|
+
// resume rather than burn its (small) retry budget timing out against a dead
|
|
21
|
+
// swarm. Bounded so a never-resumed runtime still fails instead of hanging.
|
|
22
|
+
const RESUME_WAIT_MAX_MS = 5 * 60 * 1000
|
|
23
|
+
const RESUME_WAIT_POLL_MS = 200
|
|
24
|
+
|
|
19
25
|
class QVACRegistryClient extends ReadyResource {
|
|
20
26
|
constructor (opts = {}) {
|
|
21
27
|
super()
|
|
@@ -209,6 +215,81 @@ class QVACRegistryClient extends ReadyResource {
|
|
|
209
215
|
return { core, blobs }
|
|
210
216
|
}
|
|
211
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Blocks while the swarm is suspended (app backgrounded), so a retry does not
|
|
220
|
+
* fire against a swarm that cannot connect yet and exhaust the retry budget.
|
|
221
|
+
* Bounded by RESUME_WAIT_MAX_MS; returns (and lets the retry proceed/fail) if
|
|
222
|
+
* the runtime never resumes.
|
|
223
|
+
*/
|
|
224
|
+
async _waitForSwarmResumed (signal) {
|
|
225
|
+
if (!this.hyperswarm || !this.hyperswarm.suspended) return
|
|
226
|
+
|
|
227
|
+
const start = Date.now()
|
|
228
|
+
while (this.hyperswarm.suspended) {
|
|
229
|
+
if (signal && signal.aborted) throw new Error('Download cancelled')
|
|
230
|
+
if (Date.now() - start > RESUME_WAIT_MAX_MS) {
|
|
231
|
+
this.logger.warn('Swarm still suspended after resume wait; retrying anyway')
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
await new Promise(resolve => setTimeout(resolve, RESUME_WAIT_POLL_MS))
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Blocks until at least one peer is replicating the core, so a retry after a
|
|
240
|
+
* network drop waits for the network to actually return instead of firing
|
|
241
|
+
* (and timing out) against zero peers and burning the retry budget. Bounded
|
|
242
|
+
* by RESUME_WAIT_MAX_MS. No-op when peer info is unavailable.
|
|
243
|
+
*/
|
|
244
|
+
async _waitForPeers (core, signal) {
|
|
245
|
+
if (!core || !Array.isArray(core.peers)) return
|
|
246
|
+
if (core.peers.length > 0) return
|
|
247
|
+
|
|
248
|
+
const start = Date.now()
|
|
249
|
+
while (core.peers.length === 0) {
|
|
250
|
+
if (signal && signal.aborted) throw new Error('Download cancelled')
|
|
251
|
+
if (Date.now() - start > RESUME_WAIT_MAX_MS) {
|
|
252
|
+
this.logger.warn('No peers after reconnect wait; retrying anyway')
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
await new Promise(resolve => setTimeout(resolve, RESUME_WAIT_POLL_MS))
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Re-establish peers for a blobs core before retrying a download. After the
|
|
261
|
+
* app backgrounds (or the network drops) the swarm connection for this core
|
|
262
|
+
* is gone; this waits for the swarm to resume and for a peer to be replicating
|
|
263
|
+
* the core, then re-runs the join + findingPeers + update sequence and awaits
|
|
264
|
+
* it. Resume itself is cheap: the next attempt reuses the blocks already
|
|
265
|
+
* cached in the core (the output file is re-streamed from those blocks, not
|
|
266
|
+
* appended to).
|
|
267
|
+
*
|
|
268
|
+
* Honours `signal`: a foreground cancel during the (bounded but up to
|
|
269
|
+
* RESUME_WAIT_MAX_MS) swarm/peer waits aborts promptly instead of blocking
|
|
270
|
+
* until peers return or the cap elapses.
|
|
271
|
+
*/
|
|
272
|
+
async _reconnectCore (core, signal) {
|
|
273
|
+
if (!core || !this.hyperswarm) return
|
|
274
|
+
if (signal && signal.aborted) throw new Error('Download cancelled')
|
|
275
|
+
|
|
276
|
+
await this._waitForSwarmResumed(signal)
|
|
277
|
+
|
|
278
|
+
this.logger.debug('Re-establishing peers before download retry', {
|
|
279
|
+
discoveryKey: IdEnc.normalize(core.discoveryKey)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const done = core.findingPeers()
|
|
283
|
+
this.hyperswarm.join(core.discoveryKey, { client: true, server: false })
|
|
284
|
+
try {
|
|
285
|
+
await this.hyperswarm.flush()
|
|
286
|
+
} finally {
|
|
287
|
+
done()
|
|
288
|
+
}
|
|
289
|
+
await this._waitForPeers(core, signal)
|
|
290
|
+
await core.update()
|
|
291
|
+
}
|
|
292
|
+
|
|
212
293
|
async downloadModel (path, source, options = {}) {
|
|
213
294
|
this._validateString(path, 'path')
|
|
214
295
|
this._validateString(source, 'source')
|
|
@@ -266,7 +347,11 @@ class QVACRegistryClient extends ReadyResource {
|
|
|
266
347
|
{
|
|
267
348
|
maxRetries: options.maxRetries != null ? options.maxRetries : DEFAULT_DOWNLOAD_MAX_RETRIES,
|
|
268
349
|
retryCodes: RETRIABLE_DOWNLOAD_CODES,
|
|
269
|
-
|
|
350
|
+
// Wait for the swarm to resume + reconnect peers before retrying,
|
|
351
|
+
// so the retry doesn't immediately time out again against a dead
|
|
352
|
+
// swarm (e.g. after the app backgrounded). The core's cached blocks
|
|
353
|
+
// are not cleared until success, so the retry re-streams cheaply.
|
|
354
|
+
beforeRetry: () => this._reconnectCore(core, options.signal),
|
|
270
355
|
logger: this.logger
|
|
271
356
|
}
|
|
272
357
|
)
|
|
@@ -398,7 +483,9 @@ class QVACRegistryClient extends ReadyResource {
|
|
|
398
483
|
{
|
|
399
484
|
maxRetries: options.maxRetries != null ? options.maxRetries : DEFAULT_DOWNLOAD_MAX_RETRIES,
|
|
400
485
|
retryCodes: RETRIABLE_DOWNLOAD_CODES,
|
|
401
|
-
|
|
486
|
+
// Wait for swarm resume + peer reconnect before retrying (see
|
|
487
|
+
// downloadModel). Cached blocks are reused; the file is re-streamed.
|
|
488
|
+
beforeRetry: () => this._reconnectCore(core, options.signal),
|
|
402
489
|
logger: this.logger
|
|
403
490
|
}
|
|
404
491
|
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qvac/registry-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "QVAC Registry client library for read-only queries via Hyperswarm",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -53,12 +53,15 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@qvac/error": "^0.1.0",
|
|
56
|
-
"@qvac/registry-schema": "^0.
|
|
56
|
+
"@qvac/registry-schema": "^0.3.0",
|
|
57
57
|
"b4a": "^1.6.7",
|
|
58
58
|
"bare-fs": "^4.5.2",
|
|
59
59
|
"bare-os": "^3.6.2",
|
|
60
60
|
"bare-path": "^3.0.0",
|
|
61
61
|
"bare-process": "^4.2.2",
|
|
62
|
+
"corestore": "^7.4.5",
|
|
63
|
+
"hyperblobs": "^2.8.0",
|
|
64
|
+
"hyperswarm": "^4.14.0",
|
|
62
65
|
"hypercore-id-encoding": "^1.3.0",
|
|
63
66
|
"hypercore-stats": "^2.4.0",
|
|
64
67
|
"hyperswarm-stats": "^1.3.0",
|
|
@@ -66,19 +69,9 @@
|
|
|
66
69
|
"ready-resource": "^1.0.1",
|
|
67
70
|
"tiny-byte-size": "^1.1.0"
|
|
68
71
|
},
|
|
69
|
-
"peerDependencies": {
|
|
70
|
-
"corestore": "^7.4.5",
|
|
71
|
-
"hyperblobs": "^2.8.0",
|
|
72
|
-
"hyperdb": "^4.16.1",
|
|
73
|
-
"hyperswarm": "^4.14.0"
|
|
74
|
-
},
|
|
75
72
|
"devDependencies": {
|
|
76
73
|
"brittle": "^3.4.0",
|
|
77
|
-
"corestore": "^7.4.5",
|
|
78
74
|
"dotenv": "^17.2.3",
|
|
79
|
-
"hyperblobs": "^2.8.0",
|
|
80
|
-
"hyperdb": "^4.16.1",
|
|
81
|
-
"hyperswarm": "^4.14.0",
|
|
82
75
|
"standard": "^17.1.0",
|
|
83
76
|
"test-tmp": "^1.4.0",
|
|
84
77
|
"typescript": "^5.9.3"
|
package/utils/retry.js
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
* @param {number} [opts.maxRetries=3] - Maximum number of attempts (including the first)
|
|
9
9
|
* @param {string[]} [opts.retryCodes=[]] - Error codes that trigger a retry
|
|
10
10
|
* @param {() => Promise<void>} [opts.onRetry] - Called before each retry attempt (e.g. cleanup)
|
|
11
|
+
* @param {() => Promise<void>} [opts.beforeRetry] - Awaited before the next attempt runs.
|
|
12
|
+
* Use to re-establish prerequisites (e.g. wait for peers to reconnect) so the
|
|
13
|
+
* retry doesn't immediately fail again against a dead connection.
|
|
11
14
|
* @param {{ warn: Function }} [opts.logger] - Logger instance for retry warnings
|
|
12
15
|
* @returns {Promise<unknown>}
|
|
13
16
|
*/
|
|
14
17
|
async function withRetry (fn, opts = {}) {
|
|
15
|
-
const { maxRetries = 3, retryCodes = [], onRetry, logger } = opts
|
|
18
|
+
const { maxRetries = 3, retryCodes = [], onRetry, beforeRetry, logger } = opts
|
|
16
19
|
|
|
17
20
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
18
21
|
try {
|
|
@@ -22,6 +25,7 @@ async function withRetry (fn, opts = {}) {
|
|
|
22
25
|
if (!isRetriable || attempt >= maxRetries) throw err
|
|
23
26
|
logger && logger.warn(`Retrying after ${err.code} (attempt ${attempt}/${maxRetries}): ${err.message}`)
|
|
24
27
|
if (onRetry) await onRetry()
|
|
28
|
+
if (beforeRetry) await beforeRetry()
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
31
|
}
|