pinokiod 8.0.37 → 8.0.39
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/kernel/vault/hash_worker.js +6 -1
- package/kernel/vault/index.js +238 -69
- package/kernel/vault/registry.js +2 -2
- package/kernel/vault/sweeper.js +208 -47
- package/package.json +1 -1
- package/server/index.js +29 -0
- package/server/views/vault.ejs +479 -198
- package/test/vault-engine.test.js +96 -9
- package/test/vault-sweep.test.js +103 -0
- package/test/vault-ui.test.js +108 -0
package/kernel/vault/sweeper.js
CHANGED
|
@@ -12,6 +12,25 @@ const fastq = require('fastq')
|
|
|
12
12
|
|
|
13
13
|
const SHA256_RE = /^[0-9a-f]{64}$/
|
|
14
14
|
const TMP_SUFFIX = ".pinokio-dedup-tmp"
|
|
15
|
+
const DIR_CONCURRENCY = 8
|
|
16
|
+
const STAT_CONCURRENCY = 32
|
|
17
|
+
|
|
18
|
+
const fileSnapshot = (st) => ({
|
|
19
|
+
dev: st.dev,
|
|
20
|
+
ino: st.ino,
|
|
21
|
+
size: st.size,
|
|
22
|
+
mtime: st.mtimeMs,
|
|
23
|
+
ctime: st.ctimeMs
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const sameSnapshot = (snapshot, st) => !!(
|
|
27
|
+
snapshot && st &&
|
|
28
|
+
snapshot.dev === st.dev &&
|
|
29
|
+
snapshot.ino === st.ino &&
|
|
30
|
+
snapshot.size === st.size &&
|
|
31
|
+
snapshot.mtime === st.mtimeMs &&
|
|
32
|
+
snapshot.ctime === st.ctimeMs
|
|
33
|
+
)
|
|
15
34
|
|
|
16
35
|
class Sweeper {
|
|
17
36
|
constructor(vault) {
|
|
@@ -21,33 +40,82 @@ class Sweeper {
|
|
|
21
40
|
this.currentHash = null
|
|
22
41
|
this.hashQueue = fastq.promise(this, this._hashJob, 1)
|
|
23
42
|
this.stats = { hashes: 0, ingested: 0 }
|
|
43
|
+
this.statConcurrency = vault.statConcurrency || STAT_CONCURRENCY
|
|
44
|
+
this.dirConcurrency = vault.dirConcurrency || DIR_CONCURRENCY
|
|
45
|
+
this.metadataBaseCache = new Map()
|
|
46
|
+
this.hashJobsByIno = new Map()
|
|
24
47
|
}
|
|
25
48
|
idleState() {
|
|
26
49
|
return {
|
|
27
50
|
active: false, dirs: 0, files: 0, bytes_total: 0,
|
|
28
51
|
home_bytes_total: 0, source_bytes: {},
|
|
29
|
-
candidates: 0, hashed: 0,
|
|
52
|
+
candidates: 0, hashed: 0, hash_total: 0, hash_bytes: 0, queued: 0,
|
|
53
|
+
inode_reuses: 0, unstable_hashes: 0,
|
|
54
|
+
source_ids: [], estimated_files: null, estimated_walk_weight: null,
|
|
55
|
+
started: null, duration_ms: null,
|
|
56
|
+
walk_duration_ms: null, hash_wait_duration_ms: null,
|
|
57
|
+
hash_duration_ms: 0
|
|
30
58
|
}
|
|
31
59
|
}
|
|
32
60
|
// The one entry point: the Pinokio home plus explicit linked imports.
|
|
33
61
|
async scan() {
|
|
34
62
|
if (this.state.active) return { already_running: true }
|
|
35
63
|
this.state = Object.assign(this.idleState(), { active: true, started: Date.now() })
|
|
64
|
+
this.metadataBaseCache.clear()
|
|
65
|
+
this.hashJobsByIno.clear()
|
|
36
66
|
try {
|
|
37
67
|
await this.vault.refreshSources()
|
|
38
|
-
|
|
68
|
+
const scanRoots = this.vault.scanRoots()
|
|
69
|
+
this.state.source_ids = scanRoots.map((source) => source.source_id).sort()
|
|
70
|
+
const previous = this.vault.registry.lastScan
|
|
71
|
+
const previousSourceIds = previous
|
|
72
|
+
? (Array.isArray(previous.source_ids) ? previous.source_ids : Object.keys(previous.source_bytes || {})).slice().sort()
|
|
73
|
+
: []
|
|
74
|
+
const sameSources = previousSourceIds.length === this.state.source_ids.length &&
|
|
75
|
+
previousSourceIds.every((sourceId, index) => sourceId === this.state.source_ids[index])
|
|
76
|
+
if (previous && previous.files > 0 && sameSources) {
|
|
77
|
+
this.state.estimated_files = previous.files
|
|
78
|
+
const measuredWeight = previous.duration_ms > 0 && previous.walk_duration_ms >= 0
|
|
79
|
+
? previous.walk_duration_ms / previous.duration_ms
|
|
80
|
+
: 0.8
|
|
81
|
+
this.state.estimated_walk_weight = Math.max(0.15, Math.min(0.98, measuredWeight))
|
|
82
|
+
}
|
|
83
|
+
const walkStarted = Date.now()
|
|
84
|
+
for (const source of scanRoots) {
|
|
85
|
+
if (!Object.prototype.hasOwnProperty.call(this.state.source_bytes, source.source_id)) {
|
|
86
|
+
this.state.source_bytes[source.source_id] = 0
|
|
87
|
+
}
|
|
39
88
|
await this.walk(source.root, source.source_id === "pinokio" ? null : source.source_id)
|
|
40
89
|
}
|
|
90
|
+
this.state.walk_duration_ms = Date.now() - walkStarted
|
|
91
|
+
const hashWaitStarted = Date.now()
|
|
41
92
|
await this.settle()
|
|
93
|
+
this.state.hash_wait_duration_ms = Date.now() - hashWaitStarted
|
|
94
|
+
this.state.duration_ms = Date.now() - this.state.started
|
|
42
95
|
this.vault.registry.setLastScan({
|
|
43
96
|
ts: Date.now(),
|
|
44
97
|
dirs: this.state.dirs,
|
|
45
98
|
files: this.state.files,
|
|
46
99
|
bytes_total: this.state.bytes_total,
|
|
47
100
|
home_bytes_total: this.state.home_bytes_total,
|
|
48
|
-
source_bytes: Object.assign({}, this.state.source_bytes)
|
|
101
|
+
source_bytes: Object.assign({}, this.state.source_bytes),
|
|
102
|
+
source_ids: this.state.source_ids.slice(),
|
|
103
|
+
candidates: this.state.candidates,
|
|
104
|
+
hashed: this.state.hashed,
|
|
105
|
+
hash_total: this.state.hash_total,
|
|
106
|
+
hash_bytes: this.state.hash_bytes,
|
|
107
|
+
inode_reuses: this.state.inode_reuses,
|
|
108
|
+
unstable_hashes: this.state.unstable_hashes,
|
|
109
|
+
walk_duration_ms: this.state.walk_duration_ms,
|
|
110
|
+
hash_wait_duration_ms: this.state.hash_wait_duration_ms,
|
|
111
|
+
hash_duration_ms: this.state.hash_duration_ms,
|
|
112
|
+
duration_ms: this.state.duration_ms
|
|
49
113
|
})
|
|
50
114
|
} finally {
|
|
115
|
+
// A failed walk must not leave background hash work or per-scan inode
|
|
116
|
+
// state alive when the next manual scan starts.
|
|
117
|
+
await this.settle().catch(() => {})
|
|
118
|
+
this.hashJobsByIno.clear()
|
|
51
119
|
this.state.active = false
|
|
52
120
|
}
|
|
53
121
|
return {
|
|
@@ -62,40 +130,80 @@ class Sweeper {
|
|
|
62
130
|
const vaultRoot = this.vault.root
|
|
63
131
|
const stack = [root]
|
|
64
132
|
while (stack.length) {
|
|
65
|
-
const
|
|
66
|
-
this.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
133
|
+
const dirs = []
|
|
134
|
+
while (dirs.length < this.dirConcurrency && stack.length) {
|
|
135
|
+
dirs.push(stack.pop())
|
|
136
|
+
}
|
|
137
|
+
const directoryResults = await Promise.all(dirs.map(async (dir) => {
|
|
138
|
+
this.state.dirs += 1
|
|
139
|
+
try {
|
|
140
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
141
|
+
return { dir, entries }
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return { dir, entries: null }
|
|
144
|
+
}
|
|
145
|
+
}))
|
|
146
|
+
const files = []
|
|
147
|
+
const hfDirectories = []
|
|
148
|
+
for (const { dir, entries } of directoryResults) {
|
|
149
|
+
if (!entries) continue
|
|
150
|
+
// HF hub blobs dir: filenames ARE sha256 for LFS files — no hashing.
|
|
151
|
+
if (path.basename(dir) === "blobs" && /^(models|datasets|spaces)--/.test(path.basename(path.dirname(dir)))) {
|
|
152
|
+
hfDirectories.push({ dir, entries })
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
if (entry.isSymbolicLink()) continue
|
|
157
|
+
const full = path.resolve(dir, entry.name)
|
|
158
|
+
if (entry.isDirectory()) {
|
|
159
|
+
if (full === vaultRoot) continue
|
|
160
|
+
stack.push(full)
|
|
161
|
+
} else if (entry.isFile()) {
|
|
162
|
+
files.push(full)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
72
165
|
}
|
|
73
|
-
//
|
|
74
|
-
|
|
166
|
+
// One metadata pool is shared by all files in this directory batch, so
|
|
167
|
+
// trees made of many small directories still use the bounded workers.
|
|
168
|
+
await this.considerFiles(files, preferredSourceId)
|
|
169
|
+
for (const { dir, entries } of hfDirectories) {
|
|
75
170
|
await this.ingestHfBlobs(dir, entries, preferredSourceId)
|
|
76
|
-
continue
|
|
77
171
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async considerFiles(filePaths, preferredSourceId = null) {
|
|
175
|
+
const stats = new Array(filePaths.length)
|
|
176
|
+
let cursor = 0
|
|
177
|
+
const workers = Array.from({ length: Math.min(this.statConcurrency, filePaths.length) }, async () => {
|
|
178
|
+
while (cursor < filePaths.length) {
|
|
179
|
+
const index = cursor++
|
|
180
|
+
try {
|
|
181
|
+
stats[index] = await fs.promises.stat(filePaths[index])
|
|
182
|
+
} catch (error) {
|
|
183
|
+
stats[index] = null
|
|
86
184
|
}
|
|
87
185
|
}
|
|
186
|
+
})
|
|
187
|
+
await Promise.all(workers)
|
|
188
|
+
// Preserve deterministic classification order while parallelizing only
|
|
189
|
+
// the expensive filesystem metadata reads.
|
|
190
|
+
for (let index = 0; index < filePaths.length; index++) {
|
|
191
|
+
if (stats[index]) await this.considerStat(filePaths[index], stats[index], preferredSourceId)
|
|
88
192
|
}
|
|
89
193
|
}
|
|
90
194
|
async considerFile(filePath, preferredSourceId = null) {
|
|
91
195
|
if (filePath.endsWith(TMP_SUFFIX)) return
|
|
92
|
-
const registry = this.vault.registry
|
|
93
196
|
let st
|
|
94
197
|
try {
|
|
95
198
|
st = await fs.promises.stat(filePath)
|
|
96
199
|
} catch (e) {
|
|
97
200
|
return
|
|
98
201
|
}
|
|
202
|
+
await this.considerStat(filePath, st, preferredSourceId)
|
|
203
|
+
}
|
|
204
|
+
async considerStat(filePath, st, preferredSourceId = null) {
|
|
205
|
+
if (filePath.endsWith(TMP_SUFFIX)) return
|
|
206
|
+
const registry = this.vault.registry
|
|
99
207
|
this.state.files += 1
|
|
100
208
|
this.state.bytes_total += st.size
|
|
101
209
|
const sourceKey = preferredSourceId || "pinokio"
|
|
@@ -130,19 +238,63 @@ class Sweeper {
|
|
|
130
238
|
await this.classify(filePath, st, harvested, sourceId)
|
|
131
239
|
return
|
|
132
240
|
}
|
|
241
|
+
// Only coalesce an inode which the filesystem itself reports as having
|
|
242
|
+
// multiple names. This avoids trusting placeholder inode values on
|
|
243
|
+
// filesystems which do not expose usable identity metadata.
|
|
244
|
+
const reusableInode = st.nlink > 1 && st.ino !== undefined && st.ino !== 0
|
|
245
|
+
const inodeKey = reusableInode ? registry.inoKey(st.dev, st.ino) : null
|
|
246
|
+
const existingJob = inodeKey ? this.hashJobsByIno.get(inodeKey) : null
|
|
247
|
+
const name = { filePath, source_id: sourceId, expected: fileSnapshot(st) }
|
|
248
|
+
if (existingJob) {
|
|
249
|
+
existingJob.names.push(name)
|
|
250
|
+
this.state.inode_reuses += 1
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
const job = { inodeKey, names: [name] }
|
|
254
|
+
if (inodeKey) this.hashJobsByIno.set(inodeKey, job)
|
|
255
|
+
this.state.hash_total += 1
|
|
133
256
|
this.state.queued += 1
|
|
134
|
-
this.hashQueue.push(
|
|
257
|
+
this.hashQueue.push(job).catch(() => {})
|
|
135
258
|
}
|
|
136
|
-
async _hashJob(
|
|
137
|
-
|
|
259
|
+
async _hashJob(job) {
|
|
260
|
+
const primary = job.names[0]
|
|
261
|
+
const started = Date.now()
|
|
262
|
+
this.currentHash = { path: primary.filePath }
|
|
138
263
|
try {
|
|
139
|
-
const { hash } = await this.vault.hashFile(filePath)
|
|
264
|
+
const { hash, size } = await this.vault.hashFile(primary.filePath)
|
|
140
265
|
this.stats.hashes += 1
|
|
141
266
|
this.state.hashed += 1
|
|
142
|
-
|
|
143
|
-
await
|
|
267
|
+
this.state.hash_bytes += size || 0
|
|
268
|
+
const primaryStat = await fs.promises.stat(primary.filePath)
|
|
269
|
+
// Never publish a digest if the path changed while its bytes were read.
|
|
270
|
+
// The next manual scan can retry a continuously-mutating file safely.
|
|
271
|
+
if (size !== primaryStat.size || !sameSnapshot(primary.expected, primaryStat)) {
|
|
272
|
+
this.state.unstable_hashes += 1
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
let index = 0
|
|
276
|
+
while (index < job.names.length) {
|
|
277
|
+
const name = job.names[index++]
|
|
278
|
+
let st
|
|
279
|
+
try {
|
|
280
|
+
st = name === primary ? primaryStat : await fs.promises.stat(name.filePath)
|
|
281
|
+
} catch (e) {
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
// Same (device, inode) is physical identity, not a content guess.
|
|
285
|
+
// Size and mtime also prevent applying a completed digest after an
|
|
286
|
+
// in-place mutation. ctime is omitted here because adoption itself
|
|
287
|
+
// adds a name and therefore legitimately changes inode metadata.
|
|
288
|
+
if (st.dev !== primaryStat.dev || st.ino !== primaryStat.ino ||
|
|
289
|
+
st.size !== primaryStat.size || st.mtimeMs !== primaryStat.mtimeMs) continue
|
|
290
|
+
await this.classify(name.filePath, st, hash, name.source_id)
|
|
291
|
+
}
|
|
144
292
|
} catch (e) {
|
|
145
293
|
} finally {
|
|
294
|
+
this.state.hash_duration_ms += Date.now() - started
|
|
295
|
+
if (job.inodeKey && this.hashJobsByIno.get(job.inodeKey) === job) {
|
|
296
|
+
this.hashJobsByIno.delete(job.inodeKey)
|
|
297
|
+
}
|
|
146
298
|
this.currentHash = null
|
|
147
299
|
this.state.queued = Math.max(0, this.state.queued - 1)
|
|
148
300
|
}
|
|
@@ -224,35 +376,43 @@ class Sweeper {
|
|
|
224
376
|
// via the recorded timestamp against mtime (huggingface_hub's own rule).
|
|
225
377
|
async harvestLocalDirEtag(filePath, st) {
|
|
226
378
|
try {
|
|
227
|
-
|
|
379
|
+
const start = path.dirname(filePath)
|
|
380
|
+
let dir = start
|
|
228
381
|
const stop = path.resolve(this.kernel.homedir)
|
|
229
|
-
const
|
|
382
|
+
const visited = []
|
|
383
|
+
let base = null
|
|
230
384
|
for (let depth = 0; depth < 12; depth++) {
|
|
385
|
+
if (this.metadataBaseCache.has(dir)) {
|
|
386
|
+
base = this.metadataBaseCache.get(dir)
|
|
387
|
+
break
|
|
388
|
+
}
|
|
389
|
+
visited.push(dir)
|
|
231
390
|
const metaRoot = path.resolve(dir, ".cache", "huggingface")
|
|
232
391
|
let exists = false
|
|
233
392
|
try {
|
|
234
393
|
exists = (await fs.promises.stat(metaRoot)).isDirectory()
|
|
235
394
|
} catch (e) {}
|
|
236
395
|
if (exists) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
try {
|
|
240
|
-
raw = await fs.promises.readFile(metaPath, "utf8")
|
|
241
|
-
} catch (e) {
|
|
242
|
-
return null
|
|
243
|
-
}
|
|
244
|
-
const lines = raw.split("\n")
|
|
245
|
-
const etag = String(lines[1] || "").replace(/"/g, "").trim().toLowerCase()
|
|
246
|
-
const ts = parseFloat(lines[2] || "0") * 1000
|
|
247
|
-
if (SHA256_RE.test(etag) && st.mtimeMs <= ts + 1000) {
|
|
248
|
-
return etag
|
|
249
|
-
}
|
|
250
|
-
return null
|
|
396
|
+
base = dir
|
|
397
|
+
break
|
|
251
398
|
}
|
|
252
|
-
if (dir === stop || path.dirname(dir) === dir)
|
|
253
|
-
rel.unshift(path.basename(dir))
|
|
399
|
+
if (dir === stop || path.dirname(dir) === dir) break
|
|
254
400
|
dir = path.dirname(dir)
|
|
255
401
|
}
|
|
402
|
+
for (const visitedDir of visited) this.metadataBaseCache.set(visitedDir, base)
|
|
403
|
+
if (!base) return null
|
|
404
|
+
const relative = path.relative(base, filePath)
|
|
405
|
+
const metaPath = path.resolve(base, ".cache", "huggingface", relative + ".metadata")
|
|
406
|
+
let raw
|
|
407
|
+
try {
|
|
408
|
+
raw = await fs.promises.readFile(metaPath, "utf8")
|
|
409
|
+
} catch (e) {
|
|
410
|
+
return null
|
|
411
|
+
}
|
|
412
|
+
const lines = raw.split("\n")
|
|
413
|
+
const etag = String(lines[1] || "").replace(/"/g, "").trim().toLowerCase()
|
|
414
|
+
const ts = parseFloat(lines[2] || "0") * 1000
|
|
415
|
+
if (SHA256_RE.test(etag) && st.mtimeMs <= ts + 1000) return etag
|
|
256
416
|
} catch (e) {}
|
|
257
417
|
return null
|
|
258
418
|
}
|
|
@@ -293,8 +453,9 @@ class Sweeper {
|
|
|
293
453
|
summary.bytes_saved += result.bytes_saved || 0
|
|
294
454
|
} else if (result.status === "locked") {
|
|
295
455
|
summary.locked += 1
|
|
456
|
+
} else if (result.status === "unavailable" || result.status === "copy-mode") {
|
|
457
|
+
summary.unavailable += 1
|
|
296
458
|
} else {
|
|
297
|
-
registry.duplicates.delete(filePath)
|
|
298
459
|
summary.failed += 1
|
|
299
460
|
}
|
|
300
461
|
} catch (e) {
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -15740,9 +15740,20 @@ class Server {
|
|
|
15740
15740
|
res.json({ enabled: false })
|
|
15741
15741
|
return
|
|
15742
15742
|
}
|
|
15743
|
+
if (req.query && req.query.progress === "1") {
|
|
15744
|
+
res.json(vault.progressStatus())
|
|
15745
|
+
return
|
|
15746
|
+
}
|
|
15743
15747
|
res.json(await vault.status())
|
|
15744
15748
|
}))
|
|
15745
15749
|
this.app.get("/vault", ex(async (req, res) => {
|
|
15750
|
+
const { install_required, requirements_pending } = await this.kernel.bin.check({
|
|
15751
|
+
bin: this.kernel.bin.preset("dev"),
|
|
15752
|
+
})
|
|
15753
|
+
if (requirements_pending || install_required) {
|
|
15754
|
+
res.redirect(`/setup/dev?callback=${encodeURIComponent(req.originalUrl)}`)
|
|
15755
|
+
return
|
|
15756
|
+
}
|
|
15746
15757
|
res.render("vault", { theme: this.theme, agent: req.agent })
|
|
15747
15758
|
}))
|
|
15748
15759
|
this.app.post("/vault/action", ex(async (req, res) => {
|
|
@@ -15754,6 +15765,19 @@ class Server {
|
|
|
15754
15765
|
const body = req.body || {}
|
|
15755
15766
|
try {
|
|
15756
15767
|
switch (body.action) {
|
|
15768
|
+
case "add_source": {
|
|
15769
|
+
const result = await vault.addExternalSource(body.path)
|
|
15770
|
+
res.json({
|
|
15771
|
+
created: result.created,
|
|
15772
|
+
source: {
|
|
15773
|
+
id: result.source.id,
|
|
15774
|
+
label: result.source.label,
|
|
15775
|
+
target_path: result.source.root,
|
|
15776
|
+
shareable: !!result.source.shareable
|
|
15777
|
+
}
|
|
15778
|
+
})
|
|
15779
|
+
return
|
|
15780
|
+
}
|
|
15757
15781
|
case "deduplicate": {
|
|
15758
15782
|
const scopeId = typeof body.scope_id === "string" ? body.scope_id : null
|
|
15759
15783
|
if (scopeId) {
|
|
@@ -15803,7 +15827,12 @@ class Server {
|
|
|
15803
15827
|
vault.sweeper.scan().catch(() => {})
|
|
15804
15828
|
res.json({ started: true })
|
|
15805
15829
|
return
|
|
15830
|
+
case "repair":
|
|
15806
15831
|
case "rebuild":
|
|
15832
|
+
if (vault.sweeper && vault.sweeper.state.active) {
|
|
15833
|
+
res.json({ error: "Wait for the current scan to finish before repairing the index." })
|
|
15834
|
+
return
|
|
15835
|
+
}
|
|
15807
15836
|
await vault.rebuild()
|
|
15808
15837
|
res.json({ done: true })
|
|
15809
15838
|
return
|