pinokiod 8.0.37 → 8.0.38
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/index.js +238 -69
- package/kernel/vault/registry.js +2 -2
- package/kernel/vault/sweeper.js +70 -25
- package/package.json +1 -1
- package/server/index.js +22 -0
- package/server/views/vault.ejs +404 -196
- package/test/vault-engine.test.js +96 -9
- package/test/vault-sweep.test.js +53 -0
- package/test/vault-ui.test.js +85 -0
package/kernel/vault/index.js
CHANGED
|
@@ -14,17 +14,40 @@ const Sweeper = require('./sweeper')
|
|
|
14
14
|
|
|
15
15
|
const SIZE_THRESHOLD = 100 * 1024 * 1024
|
|
16
16
|
const TMP_SUFFIX = ".pinokio-dedup-tmp"
|
|
17
|
-
const PRUNE_DIRS = new Set(["node_modules", ".git", "env", "venv", "__pycache__", "logs"])
|
|
18
17
|
const NO_LINK_CODES = new Set(["EXDEV", "ENOTSUP", "EPERM", "ENOSYS"])
|
|
19
18
|
const LOCK_CODES = new Set(["EBUSY", "EPERM", "EACCES"])
|
|
19
|
+
const STAT_CONCURRENCY = 32
|
|
20
20
|
|
|
21
21
|
const isPathWithin = (root, target) => {
|
|
22
22
|
const rel = path.relative(path.resolve(root), path.resolve(target))
|
|
23
23
|
return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel))
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const samePath = (left, right) => {
|
|
27
|
+
const a = path.resolve(left)
|
|
28
|
+
const b = path.resolve(right)
|
|
29
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
const sourceId = (kind, name) => `${kind}:${encodeURIComponent(name)}`
|
|
27
33
|
|
|
34
|
+
const statMany = async (paths, concurrency = STAT_CONCURRENCY) => {
|
|
35
|
+
const results = new Array(paths.length)
|
|
36
|
+
let cursor = 0
|
|
37
|
+
const workers = Array.from({ length: Math.min(concurrency, paths.length) }, async () => {
|
|
38
|
+
while (cursor < paths.length) {
|
|
39
|
+
const index = cursor++
|
|
40
|
+
try {
|
|
41
|
+
results[index] = await fs.promises.stat(paths[index])
|
|
42
|
+
} catch (error) {
|
|
43
|
+
results[index] = null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
await Promise.all(workers)
|
|
48
|
+
return results
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
class Vault {
|
|
29
52
|
constructor(kernel) {
|
|
30
53
|
this.kernel = kernel
|
|
@@ -36,6 +59,10 @@ class Vault {
|
|
|
36
59
|
this.worker = null
|
|
37
60
|
this.workerJobs = new Map()
|
|
38
61
|
this.workerSeq = 0
|
|
62
|
+
this.workerStarts = 0
|
|
63
|
+
this.workerIdleTimer = null
|
|
64
|
+
this.workerIdleMs = 750
|
|
65
|
+
this.statConcurrency = STAT_CONCURRENCY
|
|
39
66
|
this.sizeThreshold = SIZE_THRESHOLD
|
|
40
67
|
this._sources = []
|
|
41
68
|
}
|
|
@@ -115,6 +142,60 @@ class Vault {
|
|
|
115
142
|
sources() {
|
|
116
143
|
return this._sources
|
|
117
144
|
}
|
|
145
|
+
async addExternalSource(folderPath) {
|
|
146
|
+
if (typeof folderPath !== "string" || !path.isAbsolute(folderPath.trim())) {
|
|
147
|
+
throw new Error("Choose a valid folder.")
|
|
148
|
+
}
|
|
149
|
+
const requested = folderPath.trim()
|
|
150
|
+
let canonical
|
|
151
|
+
let stats
|
|
152
|
+
try {
|
|
153
|
+
canonical = path.resolve(await fs.promises.realpath(requested))
|
|
154
|
+
stats = await fs.promises.stat(canonical)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
throw new Error("That folder is no longer available.")
|
|
157
|
+
}
|
|
158
|
+
if (!stats.isDirectory()) throw new Error("Choose a folder, not a file.")
|
|
159
|
+
|
|
160
|
+
const home = path.resolve(this.kernel.homedir)
|
|
161
|
+
let canonicalHome = home
|
|
162
|
+
try { canonicalHome = path.resolve(await fs.promises.realpath(home)) } catch (error) {}
|
|
163
|
+
if (isPathWithin(canonicalHome, canonical) || isPathWithin(canonical, canonicalHome)) {
|
|
164
|
+
throw new Error("That folder is already inside Pinokio and is included in scans.")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await this.refreshSources()
|
|
168
|
+
const existing = this._sources.find((source) => source.kind === "external" && source.root && samePath(source.root, canonical))
|
|
169
|
+
if (existing) {
|
|
170
|
+
return { created: false, source: existing }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const apiRoot = path.resolve(home, "api")
|
|
174
|
+
await fs.promises.mkdir(apiRoot, { recursive: true })
|
|
175
|
+
const baseLabel = path.basename(canonical) || "external-folder"
|
|
176
|
+
let mountPath = null
|
|
177
|
+
for (let index = 1; index < 10000; index++) {
|
|
178
|
+
const label = index === 1 ? baseLabel : `${baseLabel}-${index}`
|
|
179
|
+
const candidate = path.resolve(apiRoot, label)
|
|
180
|
+
try {
|
|
181
|
+
await fs.promises.symlink(canonical, candidate, this.kernel.platform === "win32" ? "junction" : "dir")
|
|
182
|
+
mountPath = candidate
|
|
183
|
+
break
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (error && error.code === "EEXIST") continue
|
|
186
|
+
if (error && (error.code === "EACCES" || error.code === "EPERM")) {
|
|
187
|
+
throw new Error("Pinokio does not have permission to add that folder.")
|
|
188
|
+
}
|
|
189
|
+
throw new Error("Pinokio could not add that folder.")
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!mountPath) throw new Error("Pinokio could not create a unique name for that folder.")
|
|
193
|
+
|
|
194
|
+
await this.refreshSources()
|
|
195
|
+
const source = this._sources.find((item) => item.kind === "external" && item.mount_path && samePath(item.mount_path, mountPath))
|
|
196
|
+
if (!source) throw new Error("The folder was added but could not be loaded. Restart Pinokio and try again.")
|
|
197
|
+
return { created: true, source }
|
|
198
|
+
}
|
|
118
199
|
sourceForPath(filePath, preferredId) {
|
|
119
200
|
const sources = this._sources || []
|
|
120
201
|
if (preferredId) {
|
|
@@ -181,7 +262,7 @@ class Vault {
|
|
|
181
262
|
await this.refreshSources()
|
|
182
263
|
const loaded = await this.registry.load()
|
|
183
264
|
if (loaded.corrupt) {
|
|
184
|
-
await this.rebuild()
|
|
265
|
+
await this.rebuild(undefined, { preserveState: false })
|
|
185
266
|
}
|
|
186
267
|
this.sweeper = new Sweeper(this)
|
|
187
268
|
this.initialized = true
|
|
@@ -213,9 +294,14 @@ class Vault {
|
|
|
213
294
|
return mode
|
|
214
295
|
}
|
|
215
296
|
async hashFile(filePath) {
|
|
297
|
+
if (this.workerIdleTimer) {
|
|
298
|
+
clearTimeout(this.workerIdleTimer)
|
|
299
|
+
this.workerIdleTimer = null
|
|
300
|
+
}
|
|
216
301
|
if (!this.worker) {
|
|
217
302
|
const worker = new Worker(path.resolve(__dirname, "hash_worker.js"))
|
|
218
303
|
this.worker = worker
|
|
304
|
+
this.workerStarts += 1
|
|
219
305
|
worker.unref()
|
|
220
306
|
worker.on("message", ({ id, hash, size, error }) => {
|
|
221
307
|
const job = this.workerJobs.get(id)
|
|
@@ -223,22 +309,31 @@ class Vault {
|
|
|
223
309
|
this.workerJobs.delete(id)
|
|
224
310
|
if (error) job.reject(new Error(error))
|
|
225
311
|
else job.resolve({ hash, size })
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
// the next batch costs ~30ms and hashing is idle-priority anyway.
|
|
312
|
+
// Keep the worker warm across the scan queue. Starting one worker per
|
|
313
|
+
// file costs ~30ms and adds up quickly on a first scan.
|
|
229
314
|
if (this.workerJobs.size === 0 && this.worker === worker) {
|
|
230
|
-
this.
|
|
231
|
-
|
|
315
|
+
this.workerIdleTimer = setTimeout(() => {
|
|
316
|
+
this.workerIdleTimer = null
|
|
317
|
+
if (this.workerJobs.size === 0 && this.worker === worker) {
|
|
318
|
+
this.worker = null
|
|
319
|
+
worker.terminate().catch(() => {})
|
|
320
|
+
}
|
|
321
|
+
}, this.workerIdleMs)
|
|
322
|
+
if (this.workerIdleTimer.unref) this.workerIdleTimer.unref()
|
|
232
323
|
}
|
|
233
324
|
})
|
|
234
325
|
worker.on("error", (error) => {
|
|
235
326
|
if (this.worker !== worker) return
|
|
327
|
+
if (this.workerIdleTimer) clearTimeout(this.workerIdleTimer)
|
|
328
|
+
this.workerIdleTimer = null
|
|
236
329
|
for (const job of this.workerJobs.values()) job.reject(error)
|
|
237
330
|
this.workerJobs.clear()
|
|
238
331
|
this.worker = null
|
|
239
332
|
})
|
|
240
333
|
worker.on("exit", (code) => {
|
|
241
334
|
if (this.worker !== worker) return
|
|
335
|
+
if (this.workerIdleTimer) clearTimeout(this.workerIdleTimer)
|
|
336
|
+
this.workerIdleTimer = null
|
|
242
337
|
for (const job of this.workerJobs.values()) {
|
|
243
338
|
job.reject(new Error(`hash worker exited with code ${code}`))
|
|
244
339
|
}
|
|
@@ -314,7 +409,6 @@ class Vault {
|
|
|
314
409
|
if (storeStat.size !== targetStat.size) {
|
|
315
410
|
return { status: "size-mismatch" }
|
|
316
411
|
}
|
|
317
|
-
const volumeMode = this.volumeModes.get(targetStat.dev)
|
|
318
412
|
const tmp = targetPath + TMP_SUFFIX
|
|
319
413
|
try {
|
|
320
414
|
try {
|
|
@@ -330,13 +424,12 @@ class Vault {
|
|
|
330
424
|
await fs.promises.rename(tmp, targetPath)
|
|
331
425
|
} catch (e) {
|
|
332
426
|
await fs.promises.unlink(tmp).catch(() => {})
|
|
333
|
-
if (NO_LINK_CODES.has(e.code) && volumeMode !== "link") {
|
|
334
|
-
this.registry.addLink(targetPath, { hash, app: meta.app || null, source_id: meta.source_id || null, dev: targetStat.dev, ino: targetStat.ino, mode: "copy" })
|
|
335
|
-
return { status: "copy-mode" }
|
|
336
|
-
}
|
|
337
427
|
if (LOCK_CODES.has(e.code)) {
|
|
338
428
|
return { status: "locked" }
|
|
339
429
|
}
|
|
430
|
+
if (NO_LINK_CODES.has(e.code)) {
|
|
431
|
+
return { status: "unavailable", code: e.code }
|
|
432
|
+
}
|
|
340
433
|
throw e
|
|
341
434
|
}
|
|
342
435
|
const st = await fs.promises.stat(targetPath)
|
|
@@ -413,11 +506,22 @@ class Vault {
|
|
|
413
506
|
}
|
|
414
507
|
this.registry.schedulePersist()
|
|
415
508
|
}
|
|
416
|
-
// Rebuild from disk
|
|
417
|
-
// (dev, ino) matching re-associates app paths.
|
|
418
|
-
|
|
509
|
+
// Rebuild derived state from disk: store filenames are hashes, stat gives
|
|
510
|
+
// nlink, and (dev, ino) matching re-associates app paths. The replacement
|
|
511
|
+
// maps are assembled off to the side and swapped in only after the walk so
|
|
512
|
+
// a long repair never persists a half-reset registry.
|
|
513
|
+
async rebuild(roots, options = {}) {
|
|
419
514
|
if (!this.enabled) return
|
|
420
|
-
this.registry
|
|
515
|
+
const registry = this.registry
|
|
516
|
+
const preserveState = options.preserveState !== false
|
|
517
|
+
const preserved = preserveState ? {
|
|
518
|
+
excluded: new Map(registry.excluded),
|
|
519
|
+
totals: Object.assign({}, registry.totals),
|
|
520
|
+
lastScan: registry.lastScan ? Object.assign({}, registry.lastScan) : null,
|
|
521
|
+
duplicates: new Map(registry.duplicates)
|
|
522
|
+
} : null
|
|
523
|
+
const rebuiltBlobs = new Map()
|
|
524
|
+
const rebuiltLinks = new Map()
|
|
421
525
|
const storeInoToHash = new Map()
|
|
422
526
|
let shards = []
|
|
423
527
|
try {
|
|
@@ -432,8 +536,14 @@ class Vault {
|
|
|
432
536
|
if (!/^[0-9a-f]{64}$/.test(name)) continue
|
|
433
537
|
try {
|
|
434
538
|
const st = await fs.promises.stat(path.resolve(this.blobRoot, shard, name))
|
|
435
|
-
|
|
436
|
-
|
|
539
|
+
const previous = registry.blobs.get(name)
|
|
540
|
+
rebuiltBlobs.set(name, {
|
|
541
|
+
size: st.size,
|
|
542
|
+
first_seen: previous && previous.first_seen ? previous.first_seen : Date.now(),
|
|
543
|
+
source_urls: previous && Array.isArray(previous.source_urls) ? [...previous.source_urls] : [],
|
|
544
|
+
verified_at: Date.now(),
|
|
545
|
+
orphan: st.nlink === 1
|
|
546
|
+
})
|
|
437
547
|
storeInoToHash.set(`${st.dev}:${st.ino}`, name)
|
|
438
548
|
} catch (e) {}
|
|
439
549
|
}
|
|
@@ -443,34 +553,73 @@ class Vault {
|
|
|
443
553
|
? roots.map((root) => typeof root === "string" ? { root, source_id: null } : root)
|
|
444
554
|
: this.scanRoots()
|
|
445
555
|
for (const entry of walkRoots) {
|
|
446
|
-
await this.walkForInoMatch(
|
|
556
|
+
await this.walkForInoMatch(
|
|
557
|
+
entry.root,
|
|
558
|
+
storeInoToHash,
|
|
559
|
+
entry.source_id === "pinokio" ? null : entry.source_id,
|
|
560
|
+
rebuiltLinks
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
const rebuiltDuplicates = new Map()
|
|
564
|
+
if (preserved) {
|
|
565
|
+
for (const [duplicatePath, duplicate] of preserved.duplicates) {
|
|
566
|
+
if (preserved.excluded.has(duplicatePath) || rebuiltLinks.has(duplicatePath) || !rebuiltBlobs.has(duplicate.hash)) continue
|
|
567
|
+
try {
|
|
568
|
+
const st = await fs.promises.stat(duplicatePath)
|
|
569
|
+
if (st.isFile() && (!duplicate.size || duplicate.size === st.size)) {
|
|
570
|
+
rebuiltDuplicates.set(duplicatePath, Object.assign({}, duplicate, { size: st.size }))
|
|
571
|
+
}
|
|
572
|
+
} catch (error) {}
|
|
573
|
+
}
|
|
447
574
|
}
|
|
448
|
-
|
|
575
|
+
const rebuiltByIno = new Map()
|
|
576
|
+
for (const entry of rebuiltLinks.values()) {
|
|
577
|
+
rebuiltByIno.set(registry.inoKey(entry.dev, entry.ino), entry.hash)
|
|
578
|
+
}
|
|
579
|
+
registry.blobs = rebuiltBlobs
|
|
580
|
+
registry.links = rebuiltLinks
|
|
581
|
+
registry.scanIndex = new Map()
|
|
582
|
+
registry.duplicates = rebuiltDuplicates
|
|
583
|
+
registry.excluded = preserved ? preserved.excluded : new Map()
|
|
584
|
+
registry.lastScan = preserved ? preserved.lastScan : null
|
|
585
|
+
registry.totals = preserved ? preserved.totals : { lifetime_bytes_saved: 0 }
|
|
586
|
+
registry.byIno = rebuiltByIno
|
|
587
|
+
await registry.flush()
|
|
449
588
|
}
|
|
450
|
-
async walkForInoMatch(
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
589
|
+
async walkForInoMatch(root, storeInoToHash, preferredSourceId = null, outputLinks = null) {
|
|
590
|
+
const stack = [root]
|
|
591
|
+
const vaultRoot = path.resolve(this.root)
|
|
592
|
+
while (stack.length) {
|
|
593
|
+
const dir = stack.pop()
|
|
594
|
+
let entries = []
|
|
595
|
+
try {
|
|
596
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
597
|
+
} catch (error) { continue }
|
|
598
|
+
const files = []
|
|
599
|
+
for (const entry of entries) {
|
|
600
|
+
const full = path.resolve(dir, entry.name)
|
|
601
|
+
if (entry.isSymbolicLink()) continue
|
|
602
|
+
if (entry.isDirectory()) {
|
|
603
|
+
if (full === vaultRoot) continue
|
|
604
|
+
stack.push(full)
|
|
605
|
+
}
|
|
606
|
+
else if (entry.isFile()) files.push(full)
|
|
607
|
+
}
|
|
608
|
+
const stats = await statMany(files, this.statConcurrency)
|
|
609
|
+
for (let index = 0; index < files.length; index++) {
|
|
610
|
+
const st = stats[index]
|
|
611
|
+
if (!st || st.size < this.sizeThreshold) continue
|
|
612
|
+
const hash = storeInoToHash.get(`${st.dev}:${st.ino}`)
|
|
613
|
+
if (hash) {
|
|
614
|
+
const source = this.sourceForPath(files[index], preferredSourceId)
|
|
615
|
+
const link = {
|
|
616
|
+
hash, app: source && source.kind === "app" ? source.app : null,
|
|
617
|
+
source_id: source ? source.id : null, dev: st.dev, ino: st.ino,
|
|
618
|
+
mode: "link", created: Date.now()
|
|
472
619
|
}
|
|
473
|
-
|
|
620
|
+
if (outputLinks) outputLinks.set(files[index], link)
|
|
621
|
+
else this.registry.addLink(files[index], link)
|
|
622
|
+
}
|
|
474
623
|
}
|
|
475
624
|
}
|
|
476
625
|
}
|
|
@@ -484,19 +633,21 @@ class Vault {
|
|
|
484
633
|
if (!this.enabled || !this.registry) return { enabled: false }
|
|
485
634
|
const registry = this.registry
|
|
486
635
|
const blobs = []
|
|
636
|
+
const blobByHash = new Map()
|
|
487
637
|
const storeStats = new Map()
|
|
638
|
+
const namesByHash = new Map()
|
|
639
|
+
for (const [linkPath, entry] of registry.links) {
|
|
640
|
+
if (!namesByHash.has(entry.hash)) namesByHash.set(entry.hash, [])
|
|
641
|
+
const location = this.locationForPath(linkPath, entry.source_id)
|
|
642
|
+
namesByHash.get(entry.hash).push(Object.assign({
|
|
643
|
+
path: linkPath, app: entry.app || null, mode: entry.mode
|
|
644
|
+
}, location))
|
|
645
|
+
}
|
|
488
646
|
let bytesOnDisk = 0
|
|
489
647
|
let wouldBe = 0
|
|
490
648
|
for (const [hash, blob] of registry.blobs) {
|
|
491
|
-
const names = []
|
|
492
|
-
const apps = new Set()
|
|
493
|
-
for (const [linkPath, entry] of registry.links) {
|
|
494
|
-
if (entry.hash === hash) {
|
|
495
|
-
const location = this.locationForPath(linkPath, entry.source_id)
|
|
496
|
-
names.push(Object.assign({ path: linkPath, app: entry.app || null, mode: entry.mode }, location))
|
|
497
|
-
if (entry.app) apps.add(entry.app)
|
|
498
|
-
}
|
|
499
|
-
}
|
|
649
|
+
const names = namesByHash.get(hash) || []
|
|
650
|
+
const apps = new Set(names.map((name) => name.app).filter(Boolean))
|
|
500
651
|
let nlink = null
|
|
501
652
|
let storeStat = null
|
|
502
653
|
try {
|
|
@@ -504,12 +655,16 @@ class Vault {
|
|
|
504
655
|
nlink = storeStat.nlink
|
|
505
656
|
} catch (e) {}
|
|
506
657
|
storeStats.set(hash, storeStat)
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
658
|
+
const size = blob.size || 0
|
|
659
|
+
const physicalCopies = Math.max(1, (storeStat ? 1 : 0) + names.filter((name) => name.mode === "copy").length)
|
|
660
|
+
bytesOnDisk += size * physicalCopies
|
|
661
|
+
wouldBe += size * Math.max(physicalCopies, names.length)
|
|
662
|
+
const publicBlob = {
|
|
510
663
|
hash, size: blob.size || 0, orphan: !!blob.orphan, nlink,
|
|
511
664
|
names, apps: [...apps], source_urls: blob.source_urls || []
|
|
512
|
-
}
|
|
665
|
+
}
|
|
666
|
+
blobs.push(publicBlob)
|
|
667
|
+
blobByHash.set(hash, publicBlob)
|
|
513
668
|
}
|
|
514
669
|
const duplicates = []
|
|
515
670
|
for (const [p, entry] of registry.duplicates) {
|
|
@@ -518,20 +673,21 @@ class Vault {
|
|
|
518
673
|
const index = registry.scanIndex.get(p)
|
|
519
674
|
const storeStat = storeStats.get(entry.hash)
|
|
520
675
|
const shareable = !!(source && source.shareable && storeStat && (!index || index.dev === undefined || index.dev === storeStat.dev))
|
|
521
|
-
const blob =
|
|
676
|
+
const blob = blobByHash.get(entry.hash)
|
|
522
677
|
duplicates.push(Object.assign({
|
|
523
678
|
path: p, hash: entry.hash, size: entry.size || 0, app: entry.app || null,
|
|
524
679
|
shareable, matches: blob ? blob.names : []
|
|
525
680
|
}, location))
|
|
526
681
|
}
|
|
527
682
|
const events = (await registry.readEvents()).slice(-100).reverse()
|
|
528
|
-
const
|
|
529
|
-
const scan = sweeper ? Object.assign({}, sweeper.state, {
|
|
530
|
-
current_file: sweeper.currentHash ? path.basename(sweeper.currentHash.path) : null
|
|
531
|
-
}) : null
|
|
683
|
+
const scan = this.scanStatus()
|
|
532
684
|
const excluded = []
|
|
533
685
|
for (const [p, meta] of registry.excluded) {
|
|
534
|
-
|
|
686
|
+
let size = Number(meta.size) || 0
|
|
687
|
+
if (!size) {
|
|
688
|
+
try { size = (await fs.promises.stat(p)).size } catch (error) {}
|
|
689
|
+
}
|
|
690
|
+
excluded.push(Object.assign({ path: p, ts: meta.ts || null, size }, this.locationForPath(p, meta.source_id)))
|
|
535
691
|
}
|
|
536
692
|
const publicSources = this._sources.map((source) => ({
|
|
537
693
|
id: source.id, kind: source.kind, label: source.label, root: source.root,
|
|
@@ -555,6 +711,19 @@ class Vault {
|
|
|
555
711
|
sources: publicSources, blobs, duplicates, excluded, events
|
|
556
712
|
}
|
|
557
713
|
}
|
|
714
|
+
scanStatus() {
|
|
715
|
+
const sweeper = this.sweeper
|
|
716
|
+
return sweeper ? Object.assign({}, sweeper.state, {
|
|
717
|
+
current_file: sweeper.currentHash ? path.basename(sweeper.currentHash.path) : null
|
|
718
|
+
}) : null
|
|
719
|
+
}
|
|
720
|
+
progressStatus() {
|
|
721
|
+
return {
|
|
722
|
+
enabled: !!this.enabled,
|
|
723
|
+
scan: this.scanStatus(),
|
|
724
|
+
last_scan: this.registry ? this.registry.lastScan : null
|
|
725
|
+
}
|
|
726
|
+
}
|
|
558
727
|
// detach: make one name an independent copy again ("go back"), and pin it
|
|
559
728
|
// so future scans neither re-list nor re-share it. Works on linked names
|
|
560
729
|
// (copies bytes out) and on pending duplicates (just ignores them).
|
|
@@ -562,22 +731,22 @@ class Vault {
|
|
|
562
731
|
if (!this.enabled) return { status: "disabled" }
|
|
563
732
|
const entry = this.registry.links.get(filePath)
|
|
564
733
|
if (entry) {
|
|
565
|
-
const storePath = this.storePathFor(entry.hash)
|
|
566
734
|
const tmp = filePath + TMP_SUFFIX
|
|
567
735
|
await fs.promises.copyFile(filePath, tmp)
|
|
568
736
|
await fs.promises.rename(tmp, filePath)
|
|
737
|
+
const st = await fs.promises.stat(filePath)
|
|
569
738
|
this.registry.removeLink(filePath)
|
|
570
|
-
this.registry.excluded.set(filePath, { ts: Date.now(), source_id: entry.source_id || null })
|
|
739
|
+
this.registry.excluded.set(filePath, { ts: Date.now(), source_id: entry.source_id || null, size: st.size })
|
|
571
740
|
this.registry.schedulePersist()
|
|
572
|
-
await this.registry.appendEvent({ kind: "detach", hash: entry.hash, path: filePath, app: entry.app || null, source_id: entry.source_id || null })
|
|
741
|
+
await this.registry.appendEvent({ kind: "detach", hash: entry.hash, path: filePath, app: entry.app || null, source_id: entry.source_id || null, size: st.size })
|
|
573
742
|
return { status: "detached" }
|
|
574
743
|
}
|
|
575
744
|
if (this.registry.duplicates.has(filePath)) {
|
|
576
745
|
const dup = this.registry.duplicates.get(filePath)
|
|
577
746
|
this.registry.duplicates.delete(filePath)
|
|
578
|
-
this.registry.excluded.set(filePath, { ts: Date.now(), source_id: dup.source_id || null })
|
|
747
|
+
this.registry.excluded.set(filePath, { ts: Date.now(), source_id: dup.source_id || null, size: dup.size || 0 })
|
|
579
748
|
this.registry.schedulePersist()
|
|
580
|
-
await this.registry.appendEvent({ kind: "detach", hash: dup.hash, path: filePath, app: dup.app || null, source_id: dup.source_id || null })
|
|
749
|
+
await this.registry.appendEvent({ kind: "detach", hash: dup.hash, path: filePath, app: dup.app || null, source_id: dup.source_id || null, size: dup.size || 0 })
|
|
581
750
|
return { status: "ignored" }
|
|
582
751
|
}
|
|
583
752
|
return { status: "not-found" }
|
|
@@ -620,7 +789,8 @@ class Vault {
|
|
|
620
789
|
this.registry.totals.lifetime_bytes_saved - (event.bytes_saved || 0))
|
|
621
790
|
await this.registry.appendEvent({
|
|
622
791
|
kind: "undo", hash: event.hash, path: event.path,
|
|
623
|
-
source_id: entry.source_id || event.source_id || null, batch_id: batchId
|
|
792
|
+
source_id: entry.source_id || event.source_id || null, batch_id: batchId,
|
|
793
|
+
bytes_saved: event.bytes_saved || storeStat.size
|
|
624
794
|
})
|
|
625
795
|
summary.undone += 1
|
|
626
796
|
summary.bytes += storeStat.size
|
|
@@ -646,6 +816,5 @@ class Vault {
|
|
|
646
816
|
|
|
647
817
|
Vault.SIZE_THRESHOLD = SIZE_THRESHOLD
|
|
648
818
|
Vault.TMP_SUFFIX = TMP_SUFFIX
|
|
649
|
-
Vault.PRUNE_DIRS = PRUNE_DIRS
|
|
650
819
|
|
|
651
820
|
module.exports = Vault
|
package/kernel/vault/registry.js
CHANGED
|
@@ -19,8 +19,8 @@ class Registry {
|
|
|
19
19
|
this.links = new Map() // path -> { hash, app, source_id, dev, ino, created, mode }
|
|
20
20
|
this.scanIndex = new Map() // path -> { size, mtime, dev, ino, hash, source_id }
|
|
21
21
|
this.duplicates = new Map() // path -> { hash, size, app, source_id, discovered } pending user conversion
|
|
22
|
-
this.excluded = new Map() // path -> { ts, source_id } user chose "keep independent"
|
|
23
|
-
this.lastScan = null //
|
|
22
|
+
this.excluded = new Map() // path -> { ts, source_id, size } user chose "keep independent"
|
|
23
|
+
this.lastScan = null // scan totals, duration, and hash work
|
|
24
24
|
this.totals = { lifetime_bytes_saved: 0 }
|
|
25
25
|
this.byIno = new Map() // "dev:ino" -> hash
|
|
26
26
|
}
|
package/kernel/vault/sweeper.js
CHANGED
|
@@ -12,6 +12,7 @@ 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 STAT_CONCURRENCY = 32
|
|
15
16
|
|
|
16
17
|
class Sweeper {
|
|
17
18
|
constructor(vault) {
|
|
@@ -21,31 +22,40 @@ class Sweeper {
|
|
|
21
22
|
this.currentHash = null
|
|
22
23
|
this.hashQueue = fastq.promise(this, this._hashJob, 1)
|
|
23
24
|
this.stats = { hashes: 0, ingested: 0 }
|
|
25
|
+
this.statConcurrency = vault.statConcurrency || STAT_CONCURRENCY
|
|
26
|
+
this.metadataBaseCache = new Map()
|
|
24
27
|
}
|
|
25
28
|
idleState() {
|
|
26
29
|
return {
|
|
27
30
|
active: false, dirs: 0, files: 0, bytes_total: 0,
|
|
28
31
|
home_bytes_total: 0, source_bytes: {},
|
|
29
|
-
candidates: 0, hashed: 0,
|
|
32
|
+
candidates: 0, hashed: 0, hash_bytes: 0, queued: 0,
|
|
33
|
+
started: null, duration_ms: null
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
// The one entry point: the Pinokio home plus explicit linked imports.
|
|
33
37
|
async scan() {
|
|
34
38
|
if (this.state.active) return { already_running: true }
|
|
35
39
|
this.state = Object.assign(this.idleState(), { active: true, started: Date.now() })
|
|
40
|
+
this.metadataBaseCache.clear()
|
|
36
41
|
try {
|
|
37
42
|
await this.vault.refreshSources()
|
|
38
43
|
for (const source of this.vault.scanRoots()) {
|
|
39
44
|
await this.walk(source.root, source.source_id === "pinokio" ? null : source.source_id)
|
|
40
45
|
}
|
|
41
46
|
await this.settle()
|
|
47
|
+
this.state.duration_ms = Date.now() - this.state.started
|
|
42
48
|
this.vault.registry.setLastScan({
|
|
43
49
|
ts: Date.now(),
|
|
44
50
|
dirs: this.state.dirs,
|
|
45
51
|
files: this.state.files,
|
|
46
52
|
bytes_total: this.state.bytes_total,
|
|
47
53
|
home_bytes_total: this.state.home_bytes_total,
|
|
48
|
-
source_bytes: Object.assign({}, this.state.source_bytes)
|
|
54
|
+
source_bytes: Object.assign({}, this.state.source_bytes),
|
|
55
|
+
candidates: this.state.candidates,
|
|
56
|
+
hashed: this.state.hashed,
|
|
57
|
+
hash_bytes: this.state.hash_bytes,
|
|
58
|
+
duration_ms: this.state.duration_ms
|
|
49
59
|
})
|
|
50
60
|
} finally {
|
|
51
61
|
this.state.active = false
|
|
@@ -81,21 +91,46 @@ class Sweeper {
|
|
|
81
91
|
if (entry.isDirectory()) {
|
|
82
92
|
if (full === vaultRoot) continue
|
|
83
93
|
stack.push(full)
|
|
84
|
-
} else if (entry.isFile()) {
|
|
85
|
-
await this.considerFile(full, preferredSourceId)
|
|
86
94
|
}
|
|
87
95
|
}
|
|
96
|
+
const files = entries.filter((entry) => entry.isFile() && !entry.isSymbolicLink())
|
|
97
|
+
.map((entry) => path.resolve(dir, entry.name))
|
|
98
|
+
await this.considerFiles(files, preferredSourceId)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async considerFiles(filePaths, preferredSourceId = null) {
|
|
102
|
+
const stats = new Array(filePaths.length)
|
|
103
|
+
let cursor = 0
|
|
104
|
+
const workers = Array.from({ length: Math.min(this.statConcurrency, filePaths.length) }, async () => {
|
|
105
|
+
while (cursor < filePaths.length) {
|
|
106
|
+
const index = cursor++
|
|
107
|
+
try {
|
|
108
|
+
stats[index] = await fs.promises.stat(filePaths[index])
|
|
109
|
+
} catch (error) {
|
|
110
|
+
stats[index] = null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
await Promise.all(workers)
|
|
115
|
+
// Preserve deterministic classification order while parallelizing only
|
|
116
|
+
// the expensive filesystem metadata reads.
|
|
117
|
+
for (let index = 0; index < filePaths.length; index++) {
|
|
118
|
+
if (stats[index]) await this.considerStat(filePaths[index], stats[index], preferredSourceId)
|
|
88
119
|
}
|
|
89
120
|
}
|
|
90
121
|
async considerFile(filePath, preferredSourceId = null) {
|
|
91
122
|
if (filePath.endsWith(TMP_SUFFIX)) return
|
|
92
|
-
const registry = this.vault.registry
|
|
93
123
|
let st
|
|
94
124
|
try {
|
|
95
125
|
st = await fs.promises.stat(filePath)
|
|
96
126
|
} catch (e) {
|
|
97
127
|
return
|
|
98
128
|
}
|
|
129
|
+
await this.considerStat(filePath, st, preferredSourceId)
|
|
130
|
+
}
|
|
131
|
+
async considerStat(filePath, st, preferredSourceId = null) {
|
|
132
|
+
if (filePath.endsWith(TMP_SUFFIX)) return
|
|
133
|
+
const registry = this.vault.registry
|
|
99
134
|
this.state.files += 1
|
|
100
135
|
this.state.bytes_total += st.size
|
|
101
136
|
const sourceKey = preferredSourceId || "pinokio"
|
|
@@ -136,9 +171,10 @@ class Sweeper {
|
|
|
136
171
|
async _hashJob({ filePath, source_id }) {
|
|
137
172
|
this.currentHash = { path: filePath }
|
|
138
173
|
try {
|
|
139
|
-
const { hash } = await this.vault.hashFile(filePath)
|
|
174
|
+
const { hash, size } = await this.vault.hashFile(filePath)
|
|
140
175
|
this.stats.hashes += 1
|
|
141
176
|
this.state.hashed += 1
|
|
177
|
+
this.state.hash_bytes += size || 0
|
|
142
178
|
const st = await fs.promises.stat(filePath)
|
|
143
179
|
await this.classify(filePath, st, hash, source_id)
|
|
144
180
|
} catch (e) {
|
|
@@ -224,35 +260,43 @@ class Sweeper {
|
|
|
224
260
|
// via the recorded timestamp against mtime (huggingface_hub's own rule).
|
|
225
261
|
async harvestLocalDirEtag(filePath, st) {
|
|
226
262
|
try {
|
|
227
|
-
|
|
263
|
+
const start = path.dirname(filePath)
|
|
264
|
+
let dir = start
|
|
228
265
|
const stop = path.resolve(this.kernel.homedir)
|
|
229
|
-
const
|
|
266
|
+
const visited = []
|
|
267
|
+
let base = null
|
|
230
268
|
for (let depth = 0; depth < 12; depth++) {
|
|
269
|
+
if (this.metadataBaseCache.has(dir)) {
|
|
270
|
+
base = this.metadataBaseCache.get(dir)
|
|
271
|
+
break
|
|
272
|
+
}
|
|
273
|
+
visited.push(dir)
|
|
231
274
|
const metaRoot = path.resolve(dir, ".cache", "huggingface")
|
|
232
275
|
let exists = false
|
|
233
276
|
try {
|
|
234
277
|
exists = (await fs.promises.stat(metaRoot)).isDirectory()
|
|
235
278
|
} catch (e) {}
|
|
236
279
|
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
|
|
280
|
+
base = dir
|
|
281
|
+
break
|
|
251
282
|
}
|
|
252
|
-
if (dir === stop || path.dirname(dir) === dir)
|
|
253
|
-
rel.unshift(path.basename(dir))
|
|
283
|
+
if (dir === stop || path.dirname(dir) === dir) break
|
|
254
284
|
dir = path.dirname(dir)
|
|
255
285
|
}
|
|
286
|
+
for (const visitedDir of visited) this.metadataBaseCache.set(visitedDir, base)
|
|
287
|
+
if (!base) return null
|
|
288
|
+
const relative = path.relative(base, filePath)
|
|
289
|
+
const metaPath = path.resolve(base, ".cache", "huggingface", relative + ".metadata")
|
|
290
|
+
let raw
|
|
291
|
+
try {
|
|
292
|
+
raw = await fs.promises.readFile(metaPath, "utf8")
|
|
293
|
+
} catch (e) {
|
|
294
|
+
return null
|
|
295
|
+
}
|
|
296
|
+
const lines = raw.split("\n")
|
|
297
|
+
const etag = String(lines[1] || "").replace(/"/g, "").trim().toLowerCase()
|
|
298
|
+
const ts = parseFloat(lines[2] || "0") * 1000
|
|
299
|
+
if (SHA256_RE.test(etag) && st.mtimeMs <= ts + 1000) return etag
|
|
256
300
|
} catch (e) {}
|
|
257
301
|
return null
|
|
258
302
|
}
|
|
@@ -293,8 +337,9 @@ class Sweeper {
|
|
|
293
337
|
summary.bytes_saved += result.bytes_saved || 0
|
|
294
338
|
} else if (result.status === "locked") {
|
|
295
339
|
summary.locked += 1
|
|
340
|
+
} else if (result.status === "unavailable" || result.status === "copy-mode") {
|
|
341
|
+
summary.unavailable += 1
|
|
296
342
|
} else {
|
|
297
|
-
registry.duplicates.delete(filePath)
|
|
298
343
|
summary.failed += 1
|
|
299
344
|
}
|
|
300
345
|
} catch (e) {
|