// `gc` must rebuild the index ONCE, not once per deleted version.
//
// deleteVersion ends with _rescan(), which calls _writeIndex() (walking every
// artifact in the release root and re-registering each with the overlay) and
// 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 in prod 2026-08-29: collecting favela-android on stortron (1117
// indexed files) pinned ~45% of a core for minutes and starved HTTP -- the
// public forge flapped 000/200 for the whole run, roughly 90s per version, and
// the operator killed it. The bytes being unlinked were ~150 MB; the cost was
// almost entirely the repeated reindex.
//
// ORDERING IS LOAD-BEARING and must survive the batching. The invariant, pinned
// by release-index-serve-order.test.js and release-event-log.test.js, is:
//
// register overlay -> write _index.json -> append event
//
// A mirror reacts to the event within milliseconds. If gc emitted per-version
// events during the loop and reindexed only at the end, a mirror woken by an
// early event would read an index still advertising the deleted version and ask
// for bytes nobody holds -- reintroducing "no holder" (see deleted-releases.js).
// So the batched order must be: delete all bytes -> reindex ONCE -> emit events.
import test from 'brittle'
import { mkdtempSync, existsSync, readFileSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import b4a from 'b4a'
import { ReleaseManager } from '../lib/release-manager.js'
const INDEX_FILE = '_index.json'
function tmpStorage () {
return mkdtempSync(join(tmpdir(), 'pgv3-rel-gc-batch-'))
}
/** Counts rescans, and records the index state at each event append. */
function harness (dir) {
const rescans = []
const events = []
return {
rescans,
events,
deploy: {
async rescan () { rescans.push(Date.now()) }
},
eventLog: {
writable: true,
async append (e) {
// Snapshot what the on-disk index advertises AT THIS INSTANT. A mirror
// woken by this event would read exactly this.
let advertised = []
try {
const idx = JSON.parse(readFileSync(join(dir, INDEX_FILE), 'utf8'))
advertised = [...new Set((idx.files || []).map((f) => f.version))]
} catch {}
events.push({ ...e, advertisedVersions: advertised })
}
}
}
}
async function seed (rm, repo, versions) {
for (const v of versions) {
await rm.publish({
repo,
version: v,
artifacts: [{ path: 'dist/app.bin', data: b4a.from('payload-' + v) }]
})
}
}
test('gc rebuilds the index ONCE regardless of how many versions it collects', async (t) => {
const dir = tmpStorage()
const h = harness(dir)
const rm = new ReleaseManager({ releaseDir: dir, deploy: h.deploy, eventLog: h.eventLog })
await seed(rm, 'demo', ['v1', 'v2', 'v3', 'v4', 'v5', 'v6', 'v7'])
// Ignore the rescans that publishing itself performed.
h.rescans.length = 0
h.events.length = 0
const res = await rm.gc({ repo: 'demo', keepN: 2 })
t.is(res.deleted.length, 5, 'five stale versions collected')
t.is(h.rescans.length, 1,
'ONE reindex for the whole sweep, not one per version (was ' + h.rescans.length + ')')
})
test('gc still emits one tombstone per collected version', async (t) => {
const dir = tmpStorage()
const h = harness(dir)
const rm = new ReleaseManager({ releaseDir: dir, deploy: h.deploy, eventLog: h.eventLog })
await seed(rm, 'demo', ['v1', 'v2', 'v3', 'v4'])
h.rescans.length = 0
h.events.length = 0
await rm.gc({ repo: 'demo', keepN: 1 })
t.is(h.events.length, 3, 'a tombstone per deleted version — batching the reindex must not batch these')
const versions = h.events.map((e) => e.version).sort()
t.alike(versions, ['v1', 'v2', 'v3'], 'the three oldest')
t.ok(h.events.every((e) => e.op === 3), 'all OP_DELETED')
})
test('every tombstone is emitted AFTER the index stops advertising the dead versions', async (t) => {
// The invariant that makes batching safe: no mirror may be woken while the
// index still names a version whose bytes are gone.
const dir = tmpStorage()
const h = harness(dir)
const rm = new ReleaseManager({ releaseDir: dir, deploy: h.deploy, eventLog: h.eventLog })
await seed(rm, 'demo', ['v1', 'v2', 'v3', 'v4'])
h.rescans.length = 0
h.events.length = 0
await rm.gc({ repo: 'demo', keepN: 1 })
for (const e of h.events) {
t.absent(e.advertisedVersions.includes(e.version),
'when ' + e.version + ' was announced deleted, the index no longer advertised it')
}
t.absent(existsSync(join(dir, 'demo', 'versions', 'v1')), 'bytes actually gone')
t.ok(existsSync(join(dir, 'demo', 'versions', 'v4')), 'the kept version survives')
})
test('a single deleteVersion still reindexes on its own', async (t) => {
// gc batches; the public single-shot API must not regress.
const dir = tmpStorage()
const h = harness(dir)
const rm = new ReleaseManager({ releaseDir: dir, deploy: h.deploy, eventLog: h.eventLog })
await seed(rm, 'demo', ['v1', 'v2'])
h.rescans.length = 0
h.events.length = 0
await rm.deleteVersion('demo', 'v1')
t.is(h.rescans.length, 1, 'one reindex')
t.is(h.events.length, 1, 'one tombstone')
t.absent(h.events[0].advertisedVersions.includes('v1'), 'index updated before the event')
})
test('gc with nothing to collect does no work at all', async (t) => {
const dir = tmpStorage()
const h = harness(dir)
const rm = new ReleaseManager({ releaseDir: dir, deploy: h.deploy, eventLog: h.eventLog })
await seed(rm, 'demo', ['v1', 'v2'])
h.rescans.length = 0
h.events.length = 0
const res = await rm.gc({ repo: 'demo', keepN: 5 })
t.is(res.deleted.length, 0, 'nothing collected')
t.is(h.rescans.length, 0, 'and no reindex — an empty sweep must be free')
t.is(h.events.length, 0, 'no events')
})