OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-sidecar commits / e1457d6

0.2.38: persistent DeployServer fileIndex (fix cold-boot re-scan + fragile tree serving)
hyper-deploy's DeployServer defaults its fileIndex corestore to a fresh os.tmpdir()/hyper-deploy-srv-<Date.now()> on every open (index.js:113) — a reasonable "derived cache, cheap to rebuild" choice for a small built-release dir, but pathological here: the sidecar serves the ENTIRE content-addressed tree store (~34k files / 40+ GB). The ephemeral index forced: 1. A full re-hash of the whole store on EVERY boot (the multi-minute cold boot; observed `0 cache hits, 25637 misses, 38 GB hashed`). 2. Published trees only servable for the lifetime of the process that indexed them — so a tree could be "not found on any peer" after a restart (the pear-git-v3 0.1.127 deploy blocker: hyper-deploy 0.4.8's tree 01b32d13 wouldn't replicate to stortron). Fix: pass a PERSISTENT Corestore (<storeRoot>/deploy-index) to DeployServer via opts.corestore, and close it ourselves in close() (DeployServer only owns the store when it creates the tmp one — _ownStore=false when we pass one). Pure optimization, same consistency guarantee: _scan() still rebuilds the manifest from disk each boot (served set stays disk-accurate) and re-putTrees every tree; the persisted bee is only an mtime→hash cache, safe because the store's tree dirs are content-addressed and immutable. Result: boots stat 34k files and hash only what changed (fast), and every published tree stays durably registered across restarts. Proof: test/deploy-index-persistence.test.js - persistent store: getTree(root) still returns entries after a restart AND the restart reuses cached hashes (hits > 0, no full re-hash) - contrast: a fresh/ephemeral store re-hashes everything (0 hits) — the pathology this removes Note: pre-existing env-dependent failure in sidecar.test.js ("raw keypair on disk") is unrelated (identity-source assumes no ~/.hyperident vault); flagged for the scan/cold-boot post-mortem. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
e1457d6bd07c9d7ae8b459e9073bb0924710f500
by plankatron <fleeky@gmail.com> · 2026-07-02T01:06:15.000Z

Changed files (3) +131 -2

lib/index.js +26 -1
……@@ -130,6 +130,7 @@ export class Sidecar {
130130 this._nameRegistry = null // NameRegistry instance
131131 this._nameRegistrySwarm = null
132132 this._deployServer = null // hyper-deploy DeployServer (advertises trees)
133 this._deployIndexStore = null // persistent Corestore for DeployServer's fileIndex (see _open)
133134 this._registrySwarm = null // Hyperswarm advertising the release-registry
134135 this._releaseReplicas = [] // [{ keyHex, store, registry, swarm }] — warm replicas
135136 this._nameReplicas = [] // [{ keyHex, store, registry, swarm }] — read-only NameRegistry replicas
……@@ -243,7 +244,25 @@ export class Sidecar {
243244
244245 if (this.enableSeed) {
245246 const topic = this._identity.publicKey
246 this._deployServer = new DeployServer(this._treeStore.dependenciesDir, { topic })
247 // Persistent fileIndex store. hyper-deploy's DeployServer defaults to a
248 // fresh `os.tmpdir()/hyper-deploy-srv-<Date.now()>` corestore on every
249 // open (index.js:113), which is fine for a small built-release dir but
250 // pathological here: we serve the ENTIRE content-addressed tree store
251 // (~34k files / 40+ GB). An ephemeral index forces a full re-hash of the
252 // whole store on every boot (the multi-minute cold boot, `0 cache hits`)
253 // AND means published trees are only servable for the lifetime of the
254 // process that indexed them — the root cause of trees intermittently
255 // "not found on any peer" after a restart. A persistent store is a pure
256 // optimization: _scan() still rebuilds the manifest from disk each boot
257 // (so the served set stays disk-accurate), but reuses cached content
258 // hashes when size+mtime match — safe because the store's tree dirs are
259 // content-addressed and immutable. Trees also stay durably registered
260 // across restarts. We own this store (DeployServer only owns the store
261 // when it creates the tmp one), so we close it in close().
262 const deployIndexDir = path.join(this.storeRoot, 'deploy-index')
263 fs.mkdirSync(deployIndexDir, { recursive: true })
264 this._deployIndexStore = new Corestore(deployIndexDir)
265 this._deployServer = new DeployServer(this._treeStore.dependenciesDir, { topic, corestore: this._deployIndexStore })
247266 // Attach error handler BEFORE ready() — peers disconnecting mid-stream
248267 // emit 'error' with ECONNRESET; without a listener the default
249268 // EventEmitter behaviour crashes the process.
……@@ -508,6 +527,12 @@ export class Sidecar {
508527 try { await this._deployServer.close() } catch {}
509528 this._deployServer = null
510529 }
530 // We own the DeployServer's fileIndex store (passed via opts.corestore, so
531 // DeployServer's _ownStore is false and it won't close it). Close it here.
532 if (this._deployIndexStore) {
533 try { await this._deployIndexStore.close() } catch {}
534 this._deployIndexStore = null
535 }
511536 try { if (this._overlayMirrorTimer) clearInterval(this._overlayMirrorTimer) } catch {}
512537 this._overlayMirrorTimer = null
513538 if (this._overlaySwarm) {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh-sidecar",
3 "version": "0.2.37",
3 "version": "0.2.38",
44 "description": "Small optional user-level daemon for pgh — shared identity vault, OTA polling, device linking, bootstrap seed, NameRegistry host",
55 "type": "module",
66 "main": "lib/index.js",
test/deploy-index-persistence.test.js +104 -0
……@@ -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
17import test from 'brittle'
18import fs from 'bare-fs'
19import path from 'bare-path'
20import os from 'bare-os'
21import Corestore from 'corestore'
22import { DeployServer } from 'hyper-deploy'
23
24let n = 0
25function 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.
33function 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
45async 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
54test('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
87test('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})