OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-deploy commits / a4a4b14

0.4.12: build the overlay lookup maps once per manifest, not once per connection
serveOnConnection rebuilt contentHashPaths + filePaths from scratch on EVERY inbound connection. Measured on tabertron's tree store (39,484 files across 2,977 trees -- counted, not the "100k+" the old comment claimed): 3.8 MB of garbage and ~79,000 map insertions per connection, for a result identical every time until the manifest changes. It was also largely redundant. _scanIncremental already pushes new entries into the live maps, deliberately, so peers connected BEFORE a rescan can fetch new bytes without reconnecting. The full rebuild only ever had to happen on the first connection and after a manifest-replacing scan. Cached on manifest IDENTITY, which is what the two mutation styles require: _scan / _scanBoot assign a NEW manifest object -> identity differs, rebuild _scanIncremental push()es into the EXISTING array -> identity unchanged, and it maintains these same Map objects itself, so reuse is correct rather than merely cheaper A dirty-flag or version counter would have been wrong for the incremental case for exactly that reason -- it would have thrown away maps the incremental path had just updated. This is a CPU/GC tidy-up, not a memory fix. It does not explain the ~4.4 GB steady-state on the publishing node, which remains flat and unexplained; the actual leak was the _paired one fixed in 0.4.11. Test is red-first on the reuse assertions and green throughout on the rebuild-after-rescan assertions, so it guards both directions. 23/23 with the two pre-existing broken files excluded (symlink-serve.test.js node-style imports, e2e-release-sync.test.js "finalize failed"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a4a4b14cc4d34f4609c4e459acbbcb6f51b2d12c
by plankatron <fleeky@gmail.com> · 2026-09-02T14:59:58.000Z

Changed files (3) +139 -15

index.js +53 -14
……@@ -104,6 +104,10 @@ export class DeployServer extends ReadyResource {
104104 this._overlayProtocol = null
105105
106106 this.manifest = null
107 // Overlay lookup maps + the manifest identity they were built from.
108 // See _overlayLookupMaps.
109 this._lookupMaps = null
110 this._lookupMapsFor = null
107111 this.stats = { totalConnections: 0, filesServed: 0, bytesServed: 0 }
108112
109113 // Symlink-target blob dir. Durability follows the corestore: a
……@@ -618,24 +622,59 @@ export class DeployServer extends ReadyResource {
618622 * @param {Duplex} conn — an open connection (secret-stream / duplex)
619623 * @returns {boolean} true if the content channel was attached
620624 */
625 /**
626 * content-hash → disk-path and path → disk-path lookups for the overlay,
627 * built once per manifest rather than once per connection.
628 *
629 * These were rebuilt from scratch inside serveOnConnection on EVERY inbound
630 * connection. On tabertron's tree store (39,484 files / 2,977 trees) that
631 * measured 3.8 MB of garbage and ~79,000 map insertions per connection, for
632 * a result that is identical every time until the manifest changes.
633 *
634 * The cache key is manifest IDENTITY, which is exactly right for the two
635 * ways manifest.files changes:
636 *
637 * _scan / _scanBoot assign a NEW manifest object -> identity differs,
638 * so the next caller rebuilds.
639 * _scanIncremental push()es into the EXISTING array -> identity is
640 * unchanged, and it maintains these same Map objects
641 * itself with its own .set() calls, so reuse is not
642 * merely an optimization here: rebuilding would be
643 * redundant work that throws away nothing.
644 *
645 * A version counter would be wrong for the same reason — the incremental
646 * path deliberately keeps the live maps current so peers connected BEFORE a
647 * rescan can fetch new bytes without reconnecting.
648 */
649 _overlayLookupMaps () {
650 if (this._lookupMaps && this._lookupMapsFor === this.manifest) {
651 return this._lookupMaps
652 }
653
654 const contentHashPaths = new Map()
655 const filePaths = new Map()
656 for (const f of (this.manifest && this.manifest.files) || []) {
657 if (f.hash) {
658 // f.diskPath points to the symlink-target blob for symlink
659 // entries and to the actual source file for regular files.
660 // Falls back to dir-relative path for back-compat with any
661 // older manifest shapes that lack f.diskPath.
662 const diskPath = f.diskPath || path.join(this.dir, f.path)
663 contentHashPaths.set(f.hash, diskPath)
664 filePaths.set(f.path, diskPath)
665 }
666 }
667
668 this._lookupMaps = { contentHashPaths, filePaths }
669 this._lookupMapsFor = this.manifest
670 return this._lookupMaps
671 }
672
621673 serveOnConnection (conn) {
622674 if (!this._overlayProtocol) return false
623675 try {
624676 const mux = Protomux.from(conn)
625 // Build content-hash → disk-path map so content-request works.
626 const contentHashPaths = new Map()
627 const filePaths = new Map()
628 for (const f of (this.manifest && this.manifest.files) || []) {
629 if (f.hash) {
630 // f.diskPath points to the symlink-target blob for symlink
631 // entries and to the actual source file for regular files.
632 // Falls back to dir-relative path for back-compat with any
633 // older manifest shapes that lack f.diskPath.
634 const diskPath = f.diskPath || path.join(this.dir, f.path)
635 contentHashPaths.set(f.hash, diskPath)
636 filePaths.set(f.path, diskPath)
637 }
638 }
677 const { contentHashPaths, filePaths } = this._overlayLookupMaps()
639678 this._overlayProtocol._contentHashPaths = contentHashPaths
640679 this._overlayProtocol._filePaths = filePaths
641680 this._overlayProtocol.attach(mux)
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-deploy",
3 "version": "0.4.11",
3 "version": "0.4.12",
44 "description": "P2P release distribution — serve build artifacts to peers over Hyperswarm",
55 "type": "module",
66 "main": "index.js",
test/overlay-map-cache.test.js +85 -0
……@@ -0,0 +1,85 @@
1/**
2 * serveOnConnection must not rebuild the overlay lookup maps per connection.
3 *
4 * It built contentHashPaths + filePaths from scratch on EVERY inbound
5 * connection. Measured on tabertron's tree store (39,484 files / 2,977 trees)
6 * that is 3.8 MB of garbage and ~79,000 map insertions per connection, for a
7 * result identical every time until the manifest changes.
8 *
9 * It is also largely redundant: _scanIncremental already pushes new entries
10 * into the live maps so peers connected BEFORE a rescan can find new bytes
11 * without reconnecting. The full rebuild only has to happen when the manifest
12 * object is REPLACED (_scan / _scanBoot), not when it is appended to.
13 *
14 * Hence the cache key is manifest IDENTITY:
15 * - _scan / _scanBoot assign a new manifest object -> rebuild
16 * - _scanIncremental pushes into the existing array -> reuse, and its own
17 * .set() calls keep the cached maps current
18 */
19
20import test from 'brittle'
21import fs from 'bare-fs'
22import path from 'bare-path'
23import os from 'bare-os'
24import crypto from 'hypercore-crypto'
25import SecretStream from '@hyperswarm/secret-stream'
26
27import { DeployServer } from '../index.js'
28
29function mktemp (prefix) {
30 const dir = path.join(os.tmpdir(), 'hyper-deploy-mapcache-' + prefix + '-' + crypto.randomBytes(6).toString('hex'))
31 fs.mkdirSync(dir, { recursive: true })
32 return dir
33}
34
35function pair () {
36 const a = new SecretStream(true)
37 const b = new SecretStream(false)
38 a.rawStream.pipe(b.rawStream).pipe(a.rawStream)
39 return { a, b }
40}
41
42test('overlay lookup maps are reused across connections, rebuilt on rescan', async (t) => {
43 t.timeout(30000)
44
45 const dir = mktemp('cache')
46 fs.writeFileSync(path.join(dir, 'a.txt'), 'hello')
47
48 const server = new DeployServer(dir)
49 await server.ready()
50 t.teardown(async () => {
51 try { await server.close() } catch {}
52 try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
53 })
54
55 const proto = server._overlayProtocol
56 t.ok(proto, 'overlay protocol enabled')
57
58 const c1 = pair()
59 server._onConnection(c1.a)
60 const maps1 = { hashes: proto._contentHashPaths, paths: proto._filePaths }
61 t.ok(maps1.hashes instanceof Map, 'first connection populates the maps')
62 t.ok(maps1.hashes.size > 0, 'maps are non-empty')
63
64 const c2 = pair()
65 server._onConnection(c2.a)
66
67 t.is(proto._contentHashPaths, maps1.hashes,
68 'second connection REUSES the same contentHashPaths instance — no rebuild')
69 t.is(proto._filePaths, maps1.paths,
70 'second connection REUSES the same filePaths instance — no rebuild')
71
72 // A full rescan replaces the manifest object, so the cache must drop.
73 fs.writeFileSync(path.join(dir, 'b.txt'), 'second file')
74 await server.rescan()
75
76 const c3 = pair()
77 server._onConnection(c3.a)
78
79 t.not(proto._contentHashPaths, maps1.hashes,
80 'a full rescan (new manifest object) forces a rebuild')
81 t.is(proto._filePaths.size, server.manifest.files.length,
82 'rebuilt maps cover every manifest entry')
83
84 for (const c of [c1, c2, c3]) { c.a.destroy(); c.b.destroy() }
85})