| … | … | @@ -0,0 +1,104 @@ |
| 1 | // Persistent DeployServer fileIndex (root-cause fix for expensive cold-boot |
| 2 | // re-scan + fragile tree serving across restarts). |
| 3 | // |
| 4 | // hyper-deploy's DeployServer defaults to a fresh os.tmpdir() corestore on |
| 5 | // every open, so it re-hashes the ENTIRE served dir on every boot and only |
| 6 | // serves trees indexed by the current process. pgh-sidecar now passes a |
| 7 | // PERSISTENT corestore (opts.corestore) so the fileIndex survives restarts. |
| 8 | // |
| 9 | // This proves the mechanism the fix relies on: |
| 10 | // 1. After a first open, trees are registered (getTree returns entries). |
| 11 | // 2. Reopening with the SAME persistent corestore keeps trees registered |
| 12 | // (durable across "restart") AND reuses cached hashes instead of |
| 13 | // re-hashing (scan-cache reports hits, not all-misses). |
| 14 | // Contrast test: an EPHEMERAL (fresh) corestore re-hashes everything (all |
| 15 | // misses) on the second open — the pathology the fix removes. |
| 16 | |
| 17 | import test from 'brittle' |
| 18 | import fs from 'bare-fs' |
| 19 | import path from 'bare-path' |
| 20 | import os from 'bare-os' |
| 21 | import Corestore from 'corestore' |
| 22 | import { DeployServer } from 'hyper-deploy' |
| 23 | |
| 24 | let n = 0 |
| 25 | function tmpDir (tag) { |
| 26 | const d = path.join(os.tmpdir(), 'pgh-deployidx-' + tag + '-' + (n++) + '-' + Math.floor(os.uptime() * 1000)) |
| 27 | fs.mkdirSync(d, { recursive: true }) |
| 28 | return d |
| 29 | } |
| 30 | |
| 31 | // Build a small content-addressed-ish served dir: a couple of "tree" subdirs |
| 32 | // with a few files each, enough that a re-hash is observable as misses. |
| 33 | function makeContentDir () { |
| 34 | const dir = tmpDir('content') |
| 35 | for (const treeName of ['aaaa', 'bbbb']) { |
| 36 | const td = path.join(dir, treeName) |
| 37 | fs.mkdirSync(path.join(td, 'lib'), { recursive: true }) |
| 38 | fs.writeFileSync(path.join(td, 'index.js'), '// ' + treeName + '\nmodule.exports = ' + JSON.stringify(treeName) + '\n') |
| 39 | fs.writeFileSync(path.join(td, 'package.json'), JSON.stringify({ name: treeName, version: '1.0.0' }) + '\n') |
| 40 | fs.writeFileSync(path.join(td, 'lib', 'a.js'), 'exports.a = 1\n'.repeat(50)) |
| 41 | } |
| 42 | return dir |
| 43 | } |
| 44 | |
| 45 | async function openServer (contentDir, storeDir) { |
| 46 | const store = new Corestore(storeDir) |
| 47 | const stats = { hits: 0, misses: 0 } |
| 48 | const srv = new DeployServer(contentDir, { corestore: store, swarm: false, topic: Buffer.alloc(32, 1) }) |
| 49 | srv.on('scan-cache-stats', (s) => { stats.hits = s.hits; stats.misses = s.misses }) |
| 50 | await srv.ready() |
| 51 | return { srv, store, stats } |
| 52 | } |
| 53 | |
| 54 | test('persistent store: trees survive a restart and are not re-hashed', async (t) => { |
| 55 | t.timeout(60000) |
| 56 | const contentDir = makeContentDir() |
| 57 | const storeDir = tmpDir('persist-store') |
| 58 | |
| 59 | // --- First open (cold): indexes + registers trees --- |
| 60 | const a = await openServer(contentDir, storeDir) |
| 61 | t.ok(a.srv.manifest, 'first open produced a manifest') |
| 62 | const rootHash = a.srv.manifest.rootTreeHash |
| 63 | t.ok(rootHash, 'root tree hash was published') |
| 64 | const treeA = await a.srv._fileIndex.getTree(rootHash) |
| 65 | t.ok(treeA && treeA.entries && treeA.entries.length > 0, 'getTree(root) returns entries after first open') |
| 66 | t.is(a.stats.hits, 0, 'first (cold) open: no cache hits — everything hashed') |
| 67 | t.ok(a.stats.misses > 0, 'first open hashed files (misses > 0)') |
| 68 | await a.srv.close() |
| 69 | await a.store.close() // DeployServer doesn't own a passed-in store — release the lock (this is why the sidecar's close() closes it explicitly) |
| 70 | |
| 71 | // --- Reopen with the SAME persistent store (simulates a daemon restart) --- |
| 72 | const b = await openServer(contentDir, storeDir) |
| 73 | const treeAafter = await b.srv._fileIndex.getTree(rootHash) |
| 74 | t.ok(treeAafter && treeAafter.entries && treeAafter.entries.length > 0, |
| 75 | 'getTree(root) STILL returns entries after restart (durable serving)') |
| 76 | t.ok(b.stats.hits > 0, |
| 77 | 'restart with persistent store REUSED cached hashes (hits > 0) — no full re-hash') |
| 78 | await b.srv.close() |
| 79 | await b.store.close() |
| 80 | |
| 81 | t.teardown(async () => { |
| 82 | try { fs.rmSync(contentDir, { recursive: true, force: true }) } catch {} |
| 83 | try { fs.rmSync(storeDir, { recursive: true, force: true }) } catch {} |
| 84 | }) |
| 85 | }) |
| 86 | |
| 87 | test('contrast: ephemeral (fresh) store re-hashes everything on second open', async (t) => { |
| 88 | t.timeout(60000) |
| 89 | const contentDir = makeContentDir() |
| 90 | |
| 91 | const a = await openServer(contentDir, tmpDir('ephem-1')) |
| 92 | t.ok(a.stats.misses > 0, 'first open hashed files') |
| 93 | await a.srv.close() |
| 94 | await a.store.close() |
| 95 | |
| 96 | // Different store dir = the old ephemeral behavior. |
| 97 | const b = await openServer(contentDir, tmpDir('ephem-2')) |
| 98 | t.is(b.stats.hits, 0, 'fresh store: zero cache hits (re-hashes everything) — the pathology the fix removes') |
| 99 | t.ok(b.stats.misses > 0, 'fresh store re-hashed all files') |
| 100 | await b.srv.close() |
| 101 | await b.store.close() |
| 102 | |
| 103 | t.teardown(async () => { try { fs.rmSync(contentDir, { recursive: true, force: true }) } catch {} }) |
| 104 | }) |