@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 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
- onRetry: () => fs.promises.unlink(options.outputFile).catch(() => {}),
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
- onRetry: () => fs.promises.unlink(options.outputFile).catch(() => {}),
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.5.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.2.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
  }