@qvac/registry-client 0.2.1 → 0.3.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/README.md CHANGED
@@ -226,6 +226,47 @@ Downloading ... -> /absolute/path/ggml-tiny-q8_0.bin
226
226
  Download complete: 41.52 MB
227
227
  ```
228
228
 
229
+ ### Profile download performance
230
+
231
+ Diagnose slow downloads by collecting UDX network stats, connection info, and hypercore metrics — similar to [hyperdrive-profiler](https://github.com/holepunchto/hyperdrive-profiler) but for registry Hyperblobs:
232
+
233
+ ```bash
234
+ $ qvac-registry profile \
235
+ "ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-tiny.bin" hf
236
+
237
+ --- 5.0s elapsed ---
238
+ Network (UDX)
239
+ Bytes received: 42.1MB (8.4MB/s)
240
+ Bytes transmitted: 128kB (25.6kB/s)
241
+ Packets rx/tx: 29034 / 1842
242
+ Packets dropped: 0
243
+ Connection
244
+ Firewalled: false
245
+ Blob peers: 2
246
+ Issues: rto=0 fast-recoveries=0 retransmits=0
247
+ Hypercore
248
+ Blob core: 665 / 665 (contiguous / length)
249
+ Hotswaps: 0
250
+ ...
251
+ ==================================================
252
+ FINAL SUMMARY
253
+ ==================================================
254
+ Download
255
+ Model: ggerganov/whisper.cpp/resolve/.../ggml-tiny.bin
256
+ Size: 73.5MB (1120 blocks)
257
+ Metadata: 2.15s
258
+ Transfer: 8.72s
259
+ Avg speed: 8.4MB/s
260
+ Total: 10.87s
261
+ ```
262
+
263
+ Flags:
264
+
265
+ ```
266
+ --interval|-i [seconds] Stats print interval (default: 5)
267
+ --timeout|-t [ms] Stream read timeout (default: 120000)
268
+ ```
269
+
229
270
  ### JSON output
230
271
 
231
272
  All commands support `--json` for machine-readable output:
@@ -250,6 +291,7 @@ See the `examples/` folder for complete working examples:
250
291
  - `download-model.js`: Download a single model to disk via metadata lookup
251
292
  - `download-blob.js`: Download a blob directly using known blob coordinates
252
293
  - `download-all-models.js`: Download all models in the registry
294
+ - `profile-download.js`: Profile download performance with network/connection/hypercore stats
253
295
 
254
296
  Run examples:
255
297
 
@@ -259,6 +301,7 @@ node examples/example.js
259
301
  node examples/download-model.js
260
302
  node examples/download-blob.js
261
303
  node examples/download-all-models.js
304
+ node examples/profile-download.js "model/path"
262
305
  ```
263
306
 
264
307
  ## Configuration
package/bin/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  const { command, flag, arg, summary, header, footer, description } = require('paparam')
5
5
  const { QVACRegistryClient } = require('../index')
6
+ const { profileDownload } = require('../lib/profiler')
6
7
  const IdEnc = require('hypercore-id-encoding')
7
8
  const path = require('#path')
8
9
 
@@ -191,6 +192,44 @@ const downloadCmd = command('download',
191
192
  }
192
193
  )
193
194
 
195
+ const profileCmd = command('profile',
196
+ summary('Profile download performance for a model'),
197
+ description('Downloads a model to a temp directory while collecting UDX network stats, connection info, and hypercore metrics. Similar to hyperdrive-profiler but for registry blobs.'),
198
+ arg('<path>', 'Model path'),
199
+ arg('[source]', 'Model source filter (e.g. hf, s3)'),
200
+ flag('--interval|-i [seconds]', 'Stats print interval in seconds (default: 5)'),
201
+ flag('--timeout|-t [ms]', 'Stream read timeout in ms (default: 120000)'),
202
+ async function (cmd) {
203
+ const rootFlags = getRootFlags(cmd)
204
+ const registryCoreKey = rootFlags.key || process.env.QVAC_REGISTRY_CORE_KEY
205
+
206
+ if (!registryCoreKey) {
207
+ console.error('Registry core key is required. Set QVAC_REGISTRY_CORE_KEY or use --key.')
208
+ process.exit(1)
209
+ }
210
+
211
+ const interval = cmd.flags.interval ? parseInt(cmd.flags.interval, 10) : 5
212
+ const timeout = cmd.flags.timeout ? parseInt(cmd.flags.timeout, 10) : 120000
213
+
214
+ if (Number.isNaN(interval) || interval <= 0) {
215
+ console.error('--interval must be a positive number')
216
+ process.exit(1)
217
+ }
218
+ if (Number.isNaN(timeout) || timeout <= 0) {
219
+ console.error('--timeout must be a positive number')
220
+ process.exit(1)
221
+ }
222
+
223
+ await profileDownload({
224
+ registryCoreKey,
225
+ modelPath: cmd.args.path,
226
+ source: cmd.args.source || undefined,
227
+ intervalSec: interval,
228
+ timeout
229
+ })
230
+ }
231
+ )
232
+
194
233
  // --- Root command ---
195
234
 
196
235
  const cmd = command('qvac-registry',
@@ -202,7 +241,8 @@ const cmd = command('qvac-registry',
202
241
  flag('--verbose|-v', 'Enable verbose/debug logging'),
203
242
  listCmd,
204
243
  getCmd,
205
- downloadCmd
244
+ downloadCmd,
245
+ profileCmd
206
246
  )
207
247
 
208
248
  cmd.parse()
@@ -0,0 +1,297 @@
1
+ 'use strict'
2
+
3
+ const Corestore = require('corestore')
4
+ const Hyperswarm = require('hyperswarm')
5
+ const Hyperblobs = require('hyperblobs')
6
+ const HypercoreStats = require('hypercore-stats')
7
+ const HyperswarmStats = require('hyperswarm-stats')
8
+ const IdEnc = require('hypercore-id-encoding')
9
+ const byteSize = require('tiny-byte-size')
10
+ const { RegistryDatabase } = require('@qvac/registry-schema')
11
+
12
+ /**
13
+ * Normalize a blob core key into a Buffer regardless of input format.
14
+ * Handles raw Buffer, { data: [...] } objects from HyperDB, and z32/hex strings.
15
+ */
16
+ function decodeCoreKey (key) {
17
+ if (Buffer.isBuffer(key)) return key
18
+ if (typeof key === 'object' && key !== null && key.data) {
19
+ return Buffer.from(key.data)
20
+ }
21
+ return IdEnc.decode(key)
22
+ }
23
+
24
+ /**
25
+ * Collect a snapshot of network, connection, and hypercore stats.
26
+ * All fields are plain values suitable for logging or formatting.
27
+ */
28
+ function collectStats (swarmStats, hypercoreStats, blobCore, elapsedSec) {
29
+ const bytesRx = swarmStats.dhtStats.udxBytesReceived
30
+ const bytesTx = swarmStats.dhtStats.udxBytesTransmitted
31
+
32
+ const peers = []
33
+ for (const peer of blobCore.peers) {
34
+ peers.push({
35
+ key: IdEnc.normalize(peer.remotePublicKey),
36
+ remoteLength: peer.remoteLength,
37
+ remoteContiguous: peer.remoteContiguousLength
38
+ })
39
+ }
40
+
41
+ return {
42
+ elapsedSec,
43
+ network: {
44
+ bytesRx,
45
+ bytesTx,
46
+ rxPerSec: elapsedSec > 0 ? bytesRx / elapsedSec : 0,
47
+ txPerSec: elapsedSec > 0 ? bytesTx / elapsedSec : 0,
48
+ packetsRx: swarmStats.dhtStats.udxPacketsReceived,
49
+ packetsTx: swarmStats.dhtStats.udxPacketsTransmitted,
50
+ packetsDropped: swarmStats.dhtStats.udxPacketsDropped
51
+ },
52
+ connection: {
53
+ firewalled: swarmStats.dhtStats.isFirewalled,
54
+ blobPeers: blobCore.peers.length,
55
+ attempted: swarmStats.connects.client.attempted,
56
+ opened: swarmStats.connects.client.opened,
57
+ closed: swarmStats.connects.client.closed,
58
+ rtos: swarmStats.getRTOCountAcrossAllStreams(),
59
+ fastRecoveries: swarmStats.getFastRecoveriesAcrossAllStreams(),
60
+ retransmits: swarmStats.getRetransmitsAcrossAllStreams()
61
+ },
62
+ hypercore: {
63
+ contiguousLength: blobCore.contiguousLength,
64
+ length: blobCore.length,
65
+ hotswaps: hypercoreStats.totalHotswaps
66
+ },
67
+ peers
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Format a stats snapshot into a human-readable string.
73
+ */
74
+ function formatStats (stats) {
75
+ const n = stats.network
76
+ const c = stats.connection
77
+ const h = stats.hypercore
78
+
79
+ let lines = '--- ' + stats.elapsedSec.toFixed(1) + 's elapsed ---\n'
80
+ lines += 'Network (UDX)\n'
81
+ lines += ' Bytes received: ' + byteSize(n.bytesRx) + ' (' + byteSize(n.rxPerSec) + '/s)\n'
82
+ lines += ' Bytes transmitted: ' + byteSize(n.bytesTx) + ' (' + byteSize(n.txPerSec) + '/s)\n'
83
+ lines += ' Packets rx/tx: ' + n.packetsRx + ' / ' + n.packetsTx + '\n'
84
+ lines += ' Packets dropped: ' + n.packetsDropped + '\n'
85
+ lines += 'Connection\n'
86
+ lines += ' Firewalled: ' + c.firewalled + '\n'
87
+ lines += ' Blob peers: ' + c.blobPeers + '\n'
88
+ lines += ' Attempted: ' + c.attempted + '\n'
89
+ lines += ' Opened: ' + c.opened + '\n'
90
+ lines += ' Closed: ' + c.closed + '\n'
91
+ lines += ' Issues: rto=' + c.rtos + ' fast-recoveries=' + c.fastRecoveries + ' retransmits=' + c.retransmits + '\n'
92
+ lines += 'Hypercore\n'
93
+ lines += ' Blob core: ' + h.contiguousLength + ' / ' + h.length + ' (contiguous / length)\n'
94
+ lines += ' Hotswaps: ' + h.hotswaps + '\n'
95
+
96
+ if (stats.peers.length > 0) {
97
+ lines += ' Peers:\n'
98
+ for (const p of stats.peers) {
99
+ lines += ' ' + p.key + ' remote=' + p.remoteContiguous + '/' + p.remoteLength + '\n'
100
+ }
101
+ }
102
+
103
+ return lines
104
+ }
105
+
106
+ /**
107
+ * Format a final download summary into a human-readable string.
108
+ */
109
+ function formatSummary (opts) {
110
+ let lines = '='.repeat(50) + '\n'
111
+ lines += 'FINAL SUMMARY\n'
112
+ lines += '='.repeat(50) + '\n'
113
+ lines += 'Download\n'
114
+ lines += ' Model: ' + opts.modelPath + '\n'
115
+ lines += ' Size: ' + byteSize(opts.totalBytes) + ' (' + opts.totalBlocks + ' blocks)\n'
116
+ lines += ' Metadata: ' + opts.metadataSec.toFixed(2) + 's\n'
117
+ lines += ' Transfer: ' + opts.transferSec.toFixed(2) + 's\n'
118
+ lines += ' Avg speed: ' + byteSize(opts.avgSpeed) + '/s\n'
119
+ lines += ' Total: ' + opts.totalSec.toFixed(2) + 's\n'
120
+ return lines
121
+ }
122
+
123
+ /**
124
+ * Profile a blob download from the registry, printing periodic stats.
125
+ *
126
+ * @param {object} opts
127
+ * @param {string} opts.registryCoreKey - Registry view core key (z32 or hex)
128
+ * @param {string} opts.modelPath - Model path in the registry
129
+ * @param {string} [opts.source] - Source filter (e.g. "hf", "s3")
130
+ * @param {number} [opts.intervalSec=5] - Stats print interval in seconds
131
+ * @param {number} [opts.timeout=120000] - Stream read timeout in ms
132
+ * @param {function} [opts.onStats] - Called with (formattedString, rawStats) on each interval
133
+ * @param {function} [opts.onLog] - Called with (message) for log output; defaults to console.log
134
+ * @returns {Promise<object>} Summary with timing and stats
135
+ */
136
+ async function profileDownload (opts) {
137
+ // Lazy-loaded: these Node builtins don't exist in Bare runtime,
138
+ // so they must not be required at the top level or unit tests break.
139
+ const os = require('#os')
140
+ const fs = require('#fs')
141
+ const path = require('#path')
142
+ const { performance } = require('perf_hooks')
143
+ const { pipeline } = require('stream/promises')
144
+
145
+ const {
146
+ registryCoreKey,
147
+ modelPath,
148
+ source,
149
+ intervalSec = 5,
150
+ timeout = 120000,
151
+ onStats,
152
+ onLog = console.log
153
+ } = opts
154
+
155
+ if (!registryCoreKey) throw new Error('registryCoreKey is required')
156
+ if (!modelPath) throw new Error('modelPath is required')
157
+
158
+ const tStart = performance.now()
159
+ const tmpdir = path.join(os.tmpdir(), 'qvac-profile-' + Date.now())
160
+ fs.mkdirSync(tmpdir, { recursive: true })
161
+
162
+ const store = new Corestore(tmpdir)
163
+ await store.ready()
164
+
165
+ const swarm = new Hyperswarm()
166
+ const hcStats = await HypercoreStats.fromCorestore(store, { cacheExpiryMs: 1000 })
167
+ const swStats = new HyperswarmStats(swarm)
168
+
169
+ swarm.on('connection', (conn, peerInfo) => {
170
+ const key = peerInfo?.publicKey ? IdEnc.normalize(peerInfo.publicKey) : 'unknown'
171
+ onLog(' [conn] peer ' + key + ' connected')
172
+ store.replicate(conn)
173
+ conn.on('error', (e) => onLog(' [conn] error: ' + e.message))
174
+ conn.on('close', () => onLog(' [conn] peer ' + key + ' disconnected'))
175
+ })
176
+
177
+ const cleanupResources = async () => {
178
+ await swarm.destroy()
179
+ await store.close()
180
+ try { fs.rmSync(tmpdir, { recursive: true, force: true }) } catch {}
181
+ }
182
+
183
+ try {
184
+ const viewKey = IdEnc.decode(registryCoreKey)
185
+ const viewCore = store.get({ key: viewKey })
186
+ await viewCore.ready()
187
+
188
+ onLog('View core key: ' + IdEnc.normalize(viewCore.key))
189
+
190
+ const foundPeers = viewCore.findingPeers()
191
+ swarm.join(viewCore.discoveryKey, { client: true, server: false })
192
+ swarm.flush().then(() => foundPeers())
193
+
194
+ await viewCore.update()
195
+ const metadataSec = (performance.now() - tStart) / 1000
196
+
197
+ const db = new RegistryDatabase(viewCore, { extension: false })
198
+ await db.ready()
199
+
200
+ onLog('Metadata synced in ' + metadataSec.toFixed(2) + 's (' + viewCore.length + ' blocks)')
201
+
202
+ const model = await db.getModel(modelPath, source || undefined)
203
+ if (!model) throw new Error('Model not found: ' + modelPath)
204
+
205
+ const blob = model.blobBinding
206
+ if (!blob || !blob.coreKey) throw new Error('Model has no blob binding')
207
+
208
+ onLog('\nProfiling download: ' + model.path)
209
+ onLog(' engine: ' + model.engine)
210
+ onLog(' source: ' + model.source)
211
+ onLog(' size: ' + byteSize(blob.byteLength) + ' (' + blob.blockLength + ' blocks)')
212
+ onLog('')
213
+
214
+ const coreKeyBuf = decodeCoreKey(blob.coreKey)
215
+ const blobCore = store.get({ key: coreKeyBuf })
216
+ await blobCore.ready()
217
+ const blobs = new Hyperblobs(blobCore)
218
+ await blobs.ready()
219
+
220
+ onLog('Blob core key: ' + IdEnc.normalize(blobCore.key))
221
+ onLog('Blob core discovery: ' + IdEnc.normalize(blobCore.discoveryKey))
222
+
223
+ const foundBlobPeers = blobCore.findingPeers()
224
+ swarm.join(blobCore.discoveryKey, { client: true, server: false })
225
+ swarm.flush().then(() => foundBlobPeers())
226
+
227
+ await blobCore.update()
228
+ onLog('Blob core synced: length=' + blobCore.length)
229
+ onLog('')
230
+
231
+ const outputFile = path.join(tmpdir, 'download.bin')
232
+ const tDownloadStart = performance.now()
233
+
234
+ const emitStats = () => {
235
+ const elapsed = (performance.now() - tDownloadStart) / 1000
236
+ const raw = collectStats(swStats, hcStats, blobCore, elapsed)
237
+ const formatted = formatStats(raw)
238
+ if (onStats) {
239
+ onStats(formatted, raw)
240
+ } else {
241
+ onLog(formatted)
242
+ }
243
+ return raw
244
+ }
245
+
246
+ const statsInterval = setInterval(emitStats, intervalSec * 1000)
247
+
248
+ const rangeDownload = blobCore.download({
249
+ start: blob.blockOffset,
250
+ length: blob.blockLength
251
+ })
252
+
253
+ const readStream = blobs.createReadStream(blob, { wait: true, timeout })
254
+ const writeStream = fs.createWriteStream(outputFile)
255
+
256
+ try {
257
+ await pipeline(readStream, writeStream)
258
+ } finally {
259
+ rangeDownload.destroy()
260
+ clearInterval(statsInterval)
261
+ }
262
+
263
+ const totalSec = (performance.now() - tStart) / 1000
264
+ const transferSec = (performance.now() - tDownloadStart) / 1000
265
+ const avgSpeed = transferSec > 0 ? blob.byteLength / transferSec : 0
266
+
267
+ const finalStats = emitStats()
268
+
269
+ const summary = {
270
+ modelPath: model.path,
271
+ totalBytes: blob.byteLength,
272
+ totalBlocks: blob.blockLength,
273
+ metadataSec,
274
+ transferSec,
275
+ avgSpeed,
276
+ totalSec,
277
+ finalStats
278
+ }
279
+
280
+ onLog(formatSummary(summary))
281
+
282
+ await blobs.close()
283
+ await blobCore.close()
284
+
285
+ return summary
286
+ } finally {
287
+ await cleanupResources()
288
+ }
289
+ }
290
+
291
+ module.exports = {
292
+ decodeCoreKey,
293
+ collectStats,
294
+ formatStats,
295
+ formatSummary,
296
+ profileDownload
297
+ }
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "@qvac/registry-client",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "QVAC Registry client library for read-only queries via Hyperswarm",
5
5
  "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tetherto/qvac.git",
9
+ "directory": "packages/qvac-lib-registry-server/client"
10
+ },
11
+ "bugs": "https://github.com/tetherto/qvac/issues",
12
+ "homepage": "https://github.com/tetherto/qvac/tree/main/packages/qvac-lib-registry-server/client#readme",
6
13
  "type": "commonjs",
7
14
  "main": "index.js",
8
15
  "bin": {
@@ -56,9 +63,12 @@
56
63
  "hyperblobs": "^2.8.0",
57
64
  "hypercore-id-encoding": "^1.3.0",
58
65
  "hyperdb": "^4.16.1",
66
+ "hypercore-stats": "^2.4.0",
59
67
  "hyperswarm": "^4.14.0",
68
+ "hyperswarm-stats": "^1.3.0",
60
69
  "paparam": "^1.10.0",
61
- "ready-resource": "^1.0.1"
70
+ "ready-resource": "^1.0.1",
71
+ "tiny-byte-size": "^1.1.0"
62
72
  },
63
73
  "devDependencies": {
64
74
  "brittle": "^3.4.0",