OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / 36a4cae

0.1.242: gc reindexes ONCE per sweep, not once per deleted version
deleteVersion ends with _rescan(), and _rescan calls _writeIndex() -- which walks every artifact in the release root and re-registers each with the overlay -- then _deploy.rescan(). gc called deleteVersion in a loop, so collecting N versions rebuilt the whole index N times: O(N x M) where it should be O(N + M), all synchronous on the daemon's event loop. Measured 2026-08-29: collecting favela-android on stortron (1117 indexed files) held ~45% of a core for minutes and starved HTTP -- the public forge flapped 000/200 for the whole run at roughly 90s per version, until it was killed part-way. The ~150 MB of unlinks was never the cost; the repeated reindex was. gc now unlinks every candidate, calls _reindex() ONCE, then emits the tombstones. _rescan is split into _reindex() + _emit() and keeps its exact former behaviour by delegating to both, so deleteVersion -- the public single-shot API -- is unchanged. ORDERING IS PRESERVED, and that is why the emit does not move into the unlink loop. A mirror reacts to a tombstone within milliseconds, so announcing while the index still advertised a deleted version would send it after bytes nobody holds -- the "no holder" failure services/deleted-releases.js exists to stop. The invariant stays: register overlay -> write _index.json -> append event. Tests assert the scope mechanically rather than by timing: one reindex for a 5-version sweep, one tombstone per version, every tombstone emitted only after the index stopped advertising that version, deleteVersion still reindexing on its own, and an empty sweep costing nothing. Pre-existing and unrelated: test/release-overlay-repair.test.js fails 3/6, verified by reverting lib/overlay.js to pre-0.3.13 and re-running -- identical failure. 8/9 release suites pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
36a4cae6da12664ccc2d471d4e7ebf96b09ef632
by plankatron <fleeky@gmail.com> · 2026-08-29T19:07:36.000Z

Changed files (3) +221 -23

lib/release-manager.js +57 -22
……@@ -279,10 +279,14 @@ export class ReleaseManager {
279279 }
280280
281281 // ── Delete / GC ────────────────────────────────────────────
282 /** Unlink a version's bytes. No reindex, no event -- callers own those. */
283 async _removeVersionDir (repo, version) {
284 await rm(this._versionDir(repo, version), { recursive: true, force: true })
285 }
286
282287 async deleteVersion (repo, version) {
283288 if (!repo || !version) throw new Error('deleteVersion requires { repo, version }')
284 const dir = this._versionDir(repo, version)
285 await rm(dir, { recursive: true, force: true })
289 await this._removeVersionDir(repo, version)
286290 await this._rescan({ op: OP_DELETED, repo, version })
287291 return { repo, version, deleted: true }
288292 }
……@@ -314,12 +318,33 @@ export class ReleaseManager {
314318
315319 const deleted = []
316320 if (!dryRun) {
321 // Unlink EVERY candidate first, then reindex ONCE, then announce.
322 //
323 // Calling deleteVersion per candidate reindexed per candidate, and
324 // _writeIndex walks every artifact in the release root and re-registers
325 // each with the overlay -- so collecting N versions rebuilt the whole
326 // index N times, synchronously, on the daemon's event loop. Collecting
327 // favela-android on stortron (1117 indexed files) held ~45% of a core for
328 // minutes and flapped the public forge 000/200 at roughly 90s per version
329 // until the operator killed it (2026-08-29). The ~150 MB of unlinks was
330 // never the cost; the repeated reindex was.
317331 for (const c of candidates) {
318332 try {
319 await this.deleteVersion(c.repo, c.version)
333 await this._removeVersionDir(c.repo, c.version)
320334 deleted.push(c)
321335 } catch {}
322336 }
337 if (deleted.length) {
338 // ORDER IS LOAD-BEARING: reindex BEFORE any event. A mirror reacts to a
339 // tombstone within milliseconds, so announcing while the index still
340 // advertised the dead version would send it after bytes nobody holds --
341 // the "no holder" failure in services/deleted-releases.js. Pinned by
342 // release-gc-batched-rescan.test.js and release-index-serve-order.test.js.
343 await this._reindex()
344 for (const c of deleted) {
345 await this._emit({ op: OP_DELETED, repo: c.repo, version: c.version })
346 }
347 }
323348 }
324349
325350 return { candidates, deleted, dryRun }
……@@ -587,31 +612,41 @@ export class ReleaseManager {
587612 return repaired
588613 }
589614
590 async _rescan (event = null) {
591 // Index BEFORE rescan: the deploy server indexes the directory during
592 // rescan, so a _index.json written afterwards would not be servable until
593 // the next unrelated mutation.
615 /** Rebuild _index.json (which re-registers artifacts) and rescan the deploy dir. */
616 async _reindex () {
594617 try { await this._writeIndex() } catch {}
595618 if (this._deploy && typeof this._deploy.rescan === 'function') {
596619 try { await this._deploy.rescan() } catch {}
597620 }
598 // Announce the mutation to peers. This is the single point where the
599 // release index changes, which is exactly what a mirror needs to observe —
600 // it is what replaced the ReleasePuller's 15-minute polling timer.
601 //
602 // AFTER _writeIndex, so a mirror that reacts instantly reads an index that
603 // already describes the new state; and non-fatal, because a publish must
604 // never fail on account of a notification. A dropped entry costs latency
605 // only: the mirror re-reads the authoritative index on its next wake-up.
606 if (event && this._eventLog && this._eventLog.writable) {
607 try {
608 await this._eventLog.append(event)
609 } catch (err) {
610 // eslint-disable-next-line no-console
611 console.error('[release-manager] event append failed:', err && err.message)
612 }
621 }
622
623 /** Announce one mutation. Non-fatal: a publish must never fail on a notify. */
624 async _emit (event) {
625 if (!event || !this._eventLog || !this._eventLog.writable) return
626 try {
627 await this._eventLog.append(event)
628 } catch (err) {
629 // eslint-disable-next-line no-console
630 console.error('[release-manager] event append failed:', err && err.message)
613631 }
614632 }
633
634 /**
635 * One mutation: reindex, then announce it.
636 *
637 * Index BEFORE the deploy rescan (the deploy server indexes the directory
638 * during rescan, so an _index.json written afterwards would not be servable
639 * until the next unrelated mutation), and the event LAST -- so a mirror that
640 * reacts instantly reads an index already describing the new state.
641 *
642 * gc() deliberately does NOT use this: it unlinks every candidate, calls
643 * _reindex() once, and then _emit()s each tombstone, which preserves the same
644 * ordering for a fraction of the cost. See the comment in gc().
645 */
646 async _rescan (event = null) {
647 await this._reindex()
648 await this._emit(event)
649 }
615650}
616651
617652// Reject path-traversal + anything that'd escape the release dir
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.241",
3 "version": "0.1.242",
44 "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.",
55 "type": "module",
66 "main": "index.js",
test/release-gc-batched-rescan.test.js +163 -0
……@@ -0,0 +1,163 @@
1// `gc` must rebuild the index ONCE, not once per deleted version.
2//
3// deleteVersion ends with _rescan(), which calls _writeIndex() (walking every
4// artifact in the release root and re-registering each with the overlay) and
5// then _deploy.rescan(). gc called deleteVersion in a loop, so collecting N
6// versions rebuilt the whole index N times: O(N x M) where it should be
7// O(N + M), all synchronous on the daemon's event loop.
8//
9// Measured in prod 2026-08-29: collecting favela-android on stortron (1117
10// indexed files) pinned ~45% of a core for minutes and starved HTTP -- the
11// public forge flapped 000/200 for the whole run, roughly 90s per version, and
12// the operator killed it. The bytes being unlinked were ~150 MB; the cost was
13// almost entirely the repeated reindex.
14//
15// ORDERING IS LOAD-BEARING and must survive the batching. The invariant, pinned
16// by release-index-serve-order.test.js and release-event-log.test.js, is:
17//
18// register overlay -> write _index.json -> append event
19//
20// A mirror reacts to the event within milliseconds. If gc emitted per-version
21// events during the loop and reindexed only at the end, a mirror woken by an
22// early event would read an index still advertising the deleted version and ask
23// for bytes nobody holds -- reintroducing "no holder" (see deleted-releases.js).
24// So the batched order must be: delete all bytes -> reindex ONCE -> emit events.
25
26import test from 'brittle'
27import { mkdtempSync, existsSync, readFileSync } from '#node:fs'
28import { join } from '#node:path'
29import { tmpdir } from '#node:os'
30import b4a from 'b4a'
31
32import { ReleaseManager } from '../lib/release-manager.js'
33
34const INDEX_FILE = '_index.json'
35
36function tmpStorage () {
37 return mkdtempSync(join(tmpdir(), 'pgv3-rel-gc-batch-'))
38}
39
40/** Counts rescans, and records the index state at each event append. */
41function harness (dir) {
42 const rescans = []
43 const events = []
44 return {
45 rescans,
46 events,
47 deploy: {
48 async rescan () { rescans.push(Date.now()) }
49 },
50 eventLog: {
51 writable: true,
52 async append (e) {
53 // Snapshot what the on-disk index advertises AT THIS INSTANT. A mirror
54 // woken by this event would read exactly this.
55 let advertised = []
56 try {
57 const idx = JSON.parse(readFileSync(join(dir, INDEX_FILE), 'utf8'))
58 advertised = [...new Set((idx.files || []).map((f) => f.version))]
59 } catch {}
60 events.push({ ...e, advertisedVersions: advertised })
61 }
62 }
63 }
64}
65
66async function seed (rm, repo, versions) {
67 for (const v of versions) {
68 await rm.publish({
69 repo,
70 version: v,
71 artifacts: [{ path: 'dist/app.bin', data: b4a.from('payload-' + v) }]
72 })
73 }
74}
75
76test('gc rebuilds the index ONCE regardless of how many versions it collects', async (t) => {
77 const dir = tmpStorage()
78 const h = harness(dir)
79 const rm = new ReleaseManager({ releaseDir: dir, deploy: h.deploy, eventLog: h.eventLog })
80
81 await seed(rm, 'demo', ['v1', 'v2', 'v3', 'v4', 'v5', 'v6', 'v7'])
82
83 // Ignore the rescans that publishing itself performed.
84 h.rescans.length = 0
85 h.events.length = 0
86
87 const res = await rm.gc({ repo: 'demo', keepN: 2 })
88
89 t.is(res.deleted.length, 5, 'five stale versions collected')
90 t.is(h.rescans.length, 1,
91 'ONE reindex for the whole sweep, not one per version (was ' + h.rescans.length + ')')
92})
93
94test('gc still emits one tombstone per collected version', async (t) => {
95 const dir = tmpStorage()
96 const h = harness(dir)
97 const rm = new ReleaseManager({ releaseDir: dir, deploy: h.deploy, eventLog: h.eventLog })
98
99 await seed(rm, 'demo', ['v1', 'v2', 'v3', 'v4'])
100 h.rescans.length = 0
101 h.events.length = 0
102
103 await rm.gc({ repo: 'demo', keepN: 1 })
104
105 t.is(h.events.length, 3, 'a tombstone per deleted version — batching the reindex must not batch these')
106 const versions = h.events.map((e) => e.version).sort()
107 t.alike(versions, ['v1', 'v2', 'v3'], 'the three oldest')
108 t.ok(h.events.every((e) => e.op === 3), 'all OP_DELETED')
109})
110
111test('every tombstone is emitted AFTER the index stops advertising the dead versions', async (t) => {
112 // The invariant that makes batching safe: no mirror may be woken while the
113 // index still names a version whose bytes are gone.
114 const dir = tmpStorage()
115 const h = harness(dir)
116 const rm = new ReleaseManager({ releaseDir: dir, deploy: h.deploy, eventLog: h.eventLog })
117
118 await seed(rm, 'demo', ['v1', 'v2', 'v3', 'v4'])
119 h.rescans.length = 0
120 h.events.length = 0
121
122 await rm.gc({ repo: 'demo', keepN: 1 })
123
124 for (const e of h.events) {
125 t.absent(e.advertisedVersions.includes(e.version),
126 'when ' + e.version + ' was announced deleted, the index no longer advertised it')
127 }
128 t.absent(existsSync(join(dir, 'demo', 'versions', 'v1')), 'bytes actually gone')
129 t.ok(existsSync(join(dir, 'demo', 'versions', 'v4')), 'the kept version survives')
130})
131
132test('a single deleteVersion still reindexes on its own', async (t) => {
133 // gc batches; the public single-shot API must not regress.
134 const dir = tmpStorage()
135 const h = harness(dir)
136 const rm = new ReleaseManager({ releaseDir: dir, deploy: h.deploy, eventLog: h.eventLog })
137
138 await seed(rm, 'demo', ['v1', 'v2'])
139 h.rescans.length = 0
140 h.events.length = 0
141
142 await rm.deleteVersion('demo', 'v1')
143
144 t.is(h.rescans.length, 1, 'one reindex')
145 t.is(h.events.length, 1, 'one tombstone')
146 t.absent(h.events[0].advertisedVersions.includes('v1'), 'index updated before the event')
147})
148
149test('gc with nothing to collect does no work at all', async (t) => {
150 const dir = tmpStorage()
151 const h = harness(dir)
152 const rm = new ReleaseManager({ releaseDir: dir, deploy: h.deploy, eventLog: h.eventLog })
153
154 await seed(rm, 'demo', ['v1', 'v2'])
155 h.rescans.length = 0
156 h.events.length = 0
157
158 const res = await rm.gc({ repo: 'demo', keepN: 5 })
159
160 t.is(res.deleted.length, 0, 'nothing collected')
161 t.is(h.rescans.length, 0, 'and no reindex — an empty sweep must be free')
162 t.is(h.events.length, 0, 'no events')
163})