OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-deploy commits / fc41b25

deploy: fast manifest-diff boot path — O(dir delta) not O(all files) (0.4.9)
Cold boot re-walked + re-stat'd the ENTIRE content-addressed store every start (pgh-sidecar: ~33k files / 41GB → ~190s), even though the persistent fileIndex already had the content hashes. The residual cost was the WALK + per-file hyperbee point-reads, not hashing. New `_scanBoot()`: when the persisted index carries the current scan schema, `_scanFromManifest()` reconstructs the manifest from ONE sequential fileIndex scan (listFiles) and reconciles only the hash-dir delta on disk (one readdir): index NEW dirs via the existing `_scanIncremental`, prune GC'd dirs, reuse the rest with zero walk/stat. `dependencies/<hash>/` dirs are content-addressed + immutable, so an unchanged dir never needs re-walking → boot is O(dir delta). Falls back to a full `_scan()` on any inconsistency (correctness over speed). Supporting changes: - Persist `linkname` on file entries so symlinks are reconstructable on the fast path without lstat (the FileIndex schema already had the field). - Symlink-blob dir durability follows the store: a caller-supplied (persistent) corestore — e.g. the sidecar — keeps blobs in a durable sibling dir so symlink content serves across restarts; an ephemeral own-store still uses a tmp dir cleaned on close (fixes the regression in symlink-serve's close-cleanup test). - SCAN_SCHEMA_VERSION gates the transition: first boot after upgrade full-scans (repopulates linkname) then fast-paths. Test: test/manifest-boot.test.js (full→fast identical manifest incl. symlink, new-dir pickup, GC prune) 3/3, 20/20. Regressions green: incremental-rescan, tree-publish, symlink-serve, e2e-release-sync. Tracked: hyper-fleeky-dev #105 / [[followup_sidecar-startup-index]]. NOT yet deployed — fleet deploy is separate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fc41b25ed12e3444ad9e20067c7e242cb6ac1a2b
by plankatron <fleeky@gmail.com> · 2026-07-02T16:55:11.000Z

Changed files (3) +291 -20

index.js +155 -19
……@@ -73,6 +73,12 @@ function hashFile (filePath) {
7373 return hashChunk(fs.readFileSync(filePath))
7474}
7575
76// Bumped when the persisted index gains a field the fast manifest-diff boot
77// path relies on. On a version mismatch (or absence) the boot does ONE full
78// _scan() to repopulate + re-stamp, then fast-paths thereafter. v1 added
79// `linkname` on file entries (so symlinks are reconstructable without lstat).
80const SCAN_SCHEMA_VERSION = 1
81
7682// ── DeployServer ─────────────────────────────────────────────
7783
7884export class DeployServer extends ReadyResource {
……@@ -100,6 +106,16 @@ export class DeployServer extends ReadyResource {
100106 this.manifest = null
101107 this.stats = { totalConnections: 0, filesServed: 0, bytesServed: 0 }
102108
109 // Symlink-target blob dir. Durability follows the corestore: a
110 // caller-supplied (persistent) store — e.g. the sidecar — keeps blobs in a
111 // durable sibling dir so the fast manifest-diff boot path can serve symlink
112 // content across restarts WITHOUT re-walking to re-materialize them. An OWN
113 // (ephemeral tmp) store gets no persistent fast-path benefit, so it uses a
114 // tmp dir that _close() cleans up (no litter). content-addressed either way.
115 this._symlinkBlobsDir = opts.symlinkBlobsDir || (this._ownStore
116 ? path.join(os.tmpdir(), 'hyper-deploy-symlinks-' + crypto.randomBytes(8).toString('hex'))
117 : path.join(path.dirname(this.dir), '.hyper-deploy-symlink-blobs'))
118
103119 this.ready().catch(safetyCatch)
104120 }
105121
……@@ -135,8 +151,12 @@ export class DeployServer extends ReadyResource {
135151 )
136152 }
137153
138 // Index directory
139 await this._scan()
154 // Index directory. Prefer the fast manifest-diff boot path (reconstruct the
155 // manifest from the persistent fileIndex + walk only NEW hash-dirs); fall
156 // back to a full rescan whenever the persisted schema is absent/older or
157 // anything is inconsistent — correctness over speed.
158 fs.mkdirSync(this._symlinkBlobsDir, { recursive: true })
159 await this._scanBoot()
140160
141161 // Start swarm
142162 if (!this.swarm) this.swarm = new Hyperswarm()
……@@ -164,9 +184,13 @@ export class DeployServer extends ReadyResource {
164184 }
165185 if (this._fileIndex) await this._fileIndex.close()
166186 if (this._ownStore && this._corestore) await this._corestore.close()
167 if (this._symlinkBlobsDir) {
187 // Ephemeral (own tmp corestore → no persistent fast-path benefit): clean up
188 // the tmp symlink-blob dir on close (no litter). Durable (caller-supplied
189 // corestore, e.g. the sidecar): KEEP the blobs so the fast manifest-diff
190 // boot path can serve symlink content across restarts — they are GC'd
191 // alongside the tree store, not on process exit.
192 if (this._ownStore && this._symlinkBlobsDir) {
168193 try { fs.rmSync(this._symlinkBlobsDir, { recursive: true, force: true }) } catch {}
169 this._symlinkBlobsDir = null
170194 }
171195 }
172196
……@@ -223,10 +247,8 @@ export class DeployServer extends ReadyResource {
223247 * @returns {{ addedFiles: number, addedBytes: number }}
224248 */
225249 async _scanIncremental (subPaths) {
226 if (!this._symlinkBlobsDir) {
227 this._symlinkBlobsDir = path.join(os.tmpdir(), 'hyper-deploy-symlinks-' + crypto.randomBytes(8).toString('hex'))
228 fs.mkdirSync(this._symlinkBlobsDir, { recursive: true })
229 }
250 // Durable symlink-blob dir (set in the constructor, ensured in _open).
251 fs.mkdirSync(this._symlinkBlobsDir, { recursive: true })
230252 if (!this.manifest) {
231253 // First-ever scan or after a full reset — fall back to legacy full scan,
232254 // since incremental can't synthesize the manifest skeleton from nothing.
……@@ -269,7 +291,8 @@ export class DeployServer extends ReadyResource {
269291 await this._fileIndex.putFile(f.path, {
270292 contentHash,
271293 size: f.size,
272 mtime
294 mtime,
295 linkname: f.isSymlink ? f.linkTarget : null
273296 })
274297
275298 if (f.size >= CHUNK_THRESHOLD) {
……@@ -332,19 +355,129 @@ export class DeployServer extends ReadyResource {
332355 return { addedFiles, addedBytes }
333356 }
334357
358 /**
359 * Boot indexer. Fast manifest-diff path when the persisted index carries the
360 * current scan schema; otherwise a full _scan() that repopulates + re-stamps
361 * the schema. ANY failure in the fast path falls back to a full _scan().
362 */
363 async _scanBoot () {
364 let schema = null
365 try { schema = await this._fileIndex.getIndexMeta('scanSchema') } catch {}
366 if (schema && schema.version === SCAN_SCHEMA_VERSION) {
367 try {
368 await this._scanFromManifest()
369 return
370 } catch (err) {
371 this.emit('manifest-scan-fallback', err)
372 // fall through to a full, always-correct rescan
373 }
374 }
375 await this._scan()
376 try { await this._fileIndex.putIndexMeta('scanSchema', { version: SCAN_SCHEMA_VERSION }) } catch {}
377 }
378
379 /**
380 * Fast boot: reconstruct the manifest from the persistent fileIndex (ONE
381 * sequential hyperbee scan) and reconcile only the hash-dir delta on disk.
382 * The served `<dir>/<hash>/` dirs are content-addressed + immutable, so an
383 * unchanged dir is never re-walked/re-stat'd. Throws on any inconsistency so
384 * _scanBoot can fall back to a full scan (correctness over speed).
385 */
386 async _scanFromManifest () {
387 const persisted = await this._fileIndex.listFiles('')
388 if (persisted.length === 0) throw new Error('empty index — full scan required')
389
390 // Current top-level hash-dirs on disk (one readdir, NOT a recursive walk).
391 const currentDirs = new Set()
392 for (const name of fs.readdirSync(this.dir)) {
393 let st
394 try { st = fs.statSync(path.join(this.dir, name)) } catch { continue }
395 if (st.isDirectory()) currentDirs.add(name)
396 }
397
398 // Group persisted entries by first path segment (= hash-dir name).
399 const filesByDir = new Map()
400 for (const e of persisted) {
401 const slash = e.path.indexOf('/')
402 const seg = slash === -1 ? e.path : e.path.slice(0, slash)
403 if (!filesByDir.has(seg)) filesByDir.set(seg, [])
404 filesByDir.get(seg).push(e)
405 }
406
407 // Prune GC'd dirs: indexed dirs no longer on disk (bidirectional diff).
408 const goneDirs = []
409 for (const dir of filesByDir.keys()) if (!currentDirs.has(dir)) goneDirs.push(dir)
410 let pruned = 0
411 for (const dir of goneDirs) {
412 for (const e of filesByDir.get(dir)) { await this._fileIndex.delFile(e.path); pruned++ }
413 filesByDir.delete(dir)
414 }
415
416 // Reconstruct the manifest from surviving (unchanged) dirs — no walk/stat.
417 // Regular files resolve their diskPath by pure path join (zero fs calls);
418 // only symlinks touch the fs (existsSync + self-heal of the durable blob).
419 this.manifest = { files: [], totalSize: 0, rootTreeHash: null }
420 for (const entries of filesByDir.values()) {
421 for (const e of entries) {
422 this.manifest.files.push({
423 path: e.path,
424 size: e.size,
425 diskPath: this._diskPathFor(e),
426 hash: e.contentHash || null
427 })
428 this.manifest.totalSize += e.size
429 }
430 }
431 const reused = this.manifest.files.length
432
433 // New dirs (on disk, absent from the index) → incremental index (files +
434 // trees + symlink blobs), appending to this.manifest.
435 const newDirs = []
436 for (const dir of currentDirs) if (!filesByDir.has(dir)) newDirs.push(dir)
437 if (newDirs.length) await this._scanIncremental(newDirs)
438
439 this.emit('scan-cache-stats', {
440 hits: reused,
441 misses: this.manifest.files.length - reused,
442 hashedBytes: 0,
443 totalFiles: this.manifest.files.length,
444 prunedFiles: pruned,
445 newDirs: newDirs.length,
446 fastPath: true
447 })
448 // manifest.rootTreeHash is left null on the fast path — vestigial for a
449 // multi-tree store (clients fetch specific tree hashes, which stay
450 // individually persisted). The full-scan fallback still computes it.
451 this.emit('indexed', this.manifest.files.length, this.manifest.totalSize)
452 }
453
454 /**
455 * diskPath for a persisted file entry. Symlink entries (non-null linkname)
456 * resolve to the durable content-addressed blob, self-healed from the
457 * persisted linkname if missing (no tree re-read). Regular files resolve to
458 * their in-tree path (stable — dirs are immutable), with no fs call.
459 */
460 _diskPathFor (entry) {
461 if (entry.linkname != null && entry.contentHash) {
462 const blob = path.join(this._symlinkBlobsDir, entry.contentHash)
463 if (!fs.existsSync(blob)) {
464 try { fs.writeFileSync(blob, Buffer.from(entry.linkname, 'utf8')) } catch {}
465 }
466 return blob
467 }
468 return path.join(this.dir, entry.path)
469 }
470
335471 async _scan () {
336472 const files = walkDir(this.dir)
337473 let totalSize = 0
338474
339 // Scratch dir for symlink-target blobs. Each unique link-target
340 // string gets materialized to <scratch>/<contentHash> so that the
341 // existing diskPath-based content-request flow can serve it via
342 // fs.readFileSync. Single source of bytes; the protocol layer does
343 // not need to know about symlinks.
344 if (!this._symlinkBlobsDir) {
345 this._symlinkBlobsDir = path.join(os.tmpdir(), 'hyper-deploy-symlinks-' + crypto.randomBytes(8).toString('hex'))
346 fs.mkdirSync(this._symlinkBlobsDir, { recursive: true })
347 }
475 // Durable dir for symlink-target blobs (set in the constructor, ensured in
476 // _open). Each unique link-target string is materialized to
477 // <symlinkBlobsDir>/<contentHash> so the diskPath-based content-request flow
478 // can serve it via fs.readFileSync. Single source of bytes; the protocol
479 // layer needn't know about symlinks.
480 fs.mkdirSync(this._symlinkBlobsDir, { recursive: true })
348481
349482 // Mtime cache stats — measured at debug/observability level. When
350483 // the fileIndex already has a matching (size, mtime) for a path,
……@@ -395,7 +528,10 @@ export class DeployServer extends ReadyResource {
395528 await this._fileIndex.putFile(f.path, {
396529 contentHash,
397530 size: f.size,
398 mtime
531 mtime,
532 // Persist the symlink target so the fast boot path can rebuild the
533 // diskPath (durable blob) without an lstat. null for regular files.
534 linkname: f.isSymlink ? f.linkTarget : null
399535 })
400536 }
401537
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-deploy",
3 "version": "0.4.8",
3 "version": "0.4.9",
44 "description": "P2P release distribution — serve build artifacts to peers over Hyperswarm",
55 "type": "module",
66 "main": "index.js",
test/manifest-boot.test.js +135 -0
……@@ -0,0 +1,135 @@
1// Fast manifest-diff boot path: reconstruct the manifest from the persistent
2// fileIndex + reconcile only the hash-dir delta, instead of re-walking every
3// file. Verifies: first boot full-scans + stamps schema; warm boot fast-paths
4// with an identical manifest (incl. symlinks served from the durable blob);
5// a NEW hash-dir is picked up; a removed (GC'd) hash-dir is pruned.
6
7import test from 'brittle'
8import fs from 'bare-fs'
9import path from 'bare-path'
10import os from 'bare-os'
11import Corestore from 'corestore'
12import { DeployServer } from '../index.js'
13
14function tmp (label) {
15 return fs.mkdtempSync(path.join(os.tmpdir(), 'hd-mboot-' + label + '-'))
16}
17
18function writeDir (depDir, hashName, files, symlinks = {}) {
19 const d = path.join(depDir, hashName)
20 fs.mkdirSync(d, { recursive: true })
21 for (const [rel, content] of Object.entries(files)) {
22 const full = path.join(d, rel)
23 fs.mkdirSync(path.dirname(full), { recursive: true })
24 fs.writeFileSync(full, content)
25 }
26 for (const [rel, target] of Object.entries(symlinks)) {
27 fs.symlinkSync(target, path.join(d, rel))
28 }
29}
30
31function stubSwarm () {
32 return {
33 on () {},
34 join () { return { flushed: async () => {}, destroy: async () => {} } },
35 async destroy () {}
36 }
37}
38
39// One "boot": fresh Corestore on the SAME dir (simulates a restart), stub swarm.
40async function boot (depDir, storeDir, symlinkDir) {
41 const store = new Corestore(storeDir)
42 const events = []
43 const server = new DeployServer(depDir, {
44 corestore: store,
45 swarm: stubSwarm(),
46 enableTrees: true,
47 symlinkBlobsDir: symlinkDir
48 })
49 server.on('scan-cache-stats', (s) => events.push(s))
50 await server.ready()
51 const manifest = {
52 files: server.manifest.files.map((f) => ({ path: f.path, diskPath: f.diskPath, hash: f.hash })),
53 count: server.manifest.files.length
54 }
55 await server.close()
56 await store.close()
57 return { manifest, stats: events[events.length - 1] || {} }
58}
59
60function byPath (manifest) {
61 const m = new Map()
62 for (const f of manifest.files) m.set(f.path, f)
63 return m
64}
65
66test('first boot full-scans, warm boot fast-paths with identical manifest (+ symlink)', async (t) => {
67 const depDir = tmp('dep')
68 const storeDir = tmp('store')
69 const symlinkDir = tmp('sym')
70 t.teardown(() => { for (const d of [depDir, storeDir, symlinkDir]) try { fs.rmSync(d, { recursive: true, force: true }) } catch {} })
71
72 writeDir(depDir, 'aaaa1111', { 'index.js': 'module.exports = 1\n', 'pkg/x.txt': 'hello' }, { 'lnk': './index.js' })
73 writeDir(depDir, 'bbbb2222', { 'main.js': 'console.log(2)\n' })
74
75 const b1 = await boot(depDir, storeDir, symlinkDir)
76 t.absent(b1.stats.fastPath, 'first boot is a full scan (no schema yet)')
77 t.ok(b1.manifest.count >= 4, 'full scan indexed all files (incl. symlink)')
78
79 const b2 = await boot(depDir, storeDir, symlinkDir)
80 t.ok(b2.stats.fastPath, 'warm boot took the fast manifest-diff path')
81 t.is(b2.stats.newDirs, 0, 'no new dirs')
82 t.is(b2.stats.prunedFiles, 0, 'nothing pruned')
83 t.is(b2.manifest.count, b1.manifest.count, 'fast-path manifest has the same file count')
84
85 // Same set of paths, and same content-hash per path.
86 const m1 = byPath(b1.manifest)
87 const m2 = byPath(b2.manifest)
88 t.is(m2.size, m1.size, 'same path set')
89 let hashesMatch = true
90 for (const [p, f1] of m1) { if (!m2.has(p) || m2.get(p).hash !== f1.hash) hashesMatch = false }
91 t.ok(hashesMatch, 'every path resolves to the same content hash on the fast path')
92
93 // Symlink served from the DURABLE blob dir, with correct target bytes.
94 const link = m2.get('aaaa1111/lnk')
95 t.ok(link, 'symlink entry present in fast-path manifest')
96 t.ok(link.diskPath.startsWith(symlinkDir), 'symlink diskPath points at the durable blob dir')
97 t.is(fs.readFileSync(link.diskPath, 'utf8'), './index.js', 'symlink blob serves the correct target string')
98
99 // Regular file diskPath still points into the tree and serves real content.
100 const reg = m2.get('aaaa1111/index.js')
101 t.is(fs.readFileSync(reg.diskPath, 'utf8'), 'module.exports = 1\n', 'regular file served correctly on fast path')
102})
103
104test('fast path picks up a NEW hash-dir', async (t) => {
105 const depDir = tmp('dep2'); const storeDir = tmp('store2'); const symlinkDir = tmp('sym2')
106 t.teardown(() => { for (const d of [depDir, storeDir, symlinkDir]) try { fs.rmSync(d, { recursive: true, force: true }) } catch {} })
107
108 writeDir(depDir, 'aaaa1111', { 'a.js': 'a' })
109 await boot(depDir, storeDir, symlinkDir) // full scan + stamp
110
111 writeDir(depDir, 'cccc3333', { 'c.js': 'c', 'sub/d.js': 'd' }) // NEW dir
112 const b = await boot(depDir, storeDir, symlinkDir)
113 t.ok(b.stats.fastPath, 'fast path')
114 t.is(b.stats.newDirs, 1, 'one new dir indexed incrementally')
115 const m = byPath(b.manifest)
116 t.ok(m.has('cccc3333/c.js') && m.has('cccc3333/sub/d.js'), 'new dir files present in manifest')
117 t.ok(m.has('aaaa1111/a.js'), 'old dir still present')
118})
119
120test('fast path prunes a removed (GC\'d) hash-dir', async (t) => {
121 const depDir = tmp('dep3'); const storeDir = tmp('store3'); const symlinkDir = tmp('sym3')
122 t.teardown(() => { for (const d of [depDir, storeDir, symlinkDir]) try { fs.rmSync(d, { recursive: true, force: true }) } catch {} })
123
124 writeDir(depDir, 'aaaa1111', { 'a.js': 'a' })
125 writeDir(depDir, 'bbbb2222', { 'b.js': 'b', 'b2.js': 'bb' })
126 await boot(depDir, storeDir, symlinkDir) // full scan + stamp
127
128 fs.rmSync(path.join(depDir, 'bbbb2222'), { recursive: true, force: true }) // simulate GC
129 const b = await boot(depDir, storeDir, symlinkDir)
130 t.ok(b.stats.fastPath, 'fast path')
131 t.is(b.stats.prunedFiles, 2, 'the removed dir\'s two files were pruned from the index')
132 const m = byPath(b.manifest)
133 t.absent(m.has('bbbb2222/b.js'), 'removed dir no longer in manifest')
134 t.ok(m.has('aaaa1111/a.js'), 'surviving dir still served')
135})