OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / release-gc-batched-rescan.test.js

164 lines · javascript ref:
// `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')
})