OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / release-overlay-repair.test.js

134 lines · javascript ref:
// The index and the overlay can disagree, and nothing used to reconcile them.
//
// There are THREE ways an artifact ends up advertised-but-unfetchable, and they
// are distinct. The first two already have fixes:
//
//   1. ORDER   — _index.json written before registration, so a mirror woken by
//                the release-event asks before the bytes are registered.
//                Fixed by registering first. See release-index-serve-order.
//   2. RESTART — the overlay's hash→path maps are in-memory, so a reboot forgets
//                how to serve files it still holds. Fixed by seedOverlayServeMap.
//   3. FAILURE — registerFile THREW, and the call sat in a bare `catch {}` while
//                _index.json was written unconditionally afterwards. This one.
//
// (3) is permanent in a way the others are not. _writeIndex only registers
// NEW-or-CHANGED entries, and a published artifact's hash never changes, so the
// failed registration is never retried. seedOverlayServeMap cannot help either:
// it seeds from the OVERLAY's index, which is precisely where the entry is
// missing. The mirror asks forever and is told "no holder"; the origin logs
// nothing, because the failure was swallowed.
//
// Measured 2026-08-25, hyper-tps v0.919.0: tabertron held a 471 MB
// hypertps-win64.zip on disk and advertised its exact hash in _index.json, while
// stortron retried
//     no holder for hyper-tps/versions/v0.919.0/dist/hypertps-win64.zip (07af6b4a31a0)
// indefinitely — and build.log and manifest.json from the SAME publish
// replicated fine, same peer, same pass. Per-content, not per-peer.
//
// Run: bare test/release-overlay-repair.test.js

import test from 'brittle'
import { mkdtemp, mkdir, writeFile } from '#node:fs/promises'
import { tmpdir } from '#node:os'
import { join } from '#node:path'

import { ReleaseManager, RELEASE_OVERLAY_PREFIX as PREFIX } from '../lib/release-manager.js'


// An overlay that knows about SOME paths and records what it is asked to add.
function stubOverlay (knownPaths = []) {
  const known = new Set(knownPaths)
  const registered = []
  return {
    known,
    registered,
    async seedFilePathsFromIndex (resolve) {
      let n = 0
      for (const p of known) { if (resolve(p)) n++ }
      return n
    },
    async registerFile (overlayPath, diskPath, meta = {}) {
      registered.push({ overlayPath, diskPath, prepare: meta.prepare })
      known.add(overlayPath)
      return { contentHash: meta.contentHash, size: meta.size }
    }
  }
}

async function fixture (files) {
  const dir = await mkdtemp(join(tmpdir(), 'rel-repair-'))
  for (const f of files) {
    await mkdir(join(dir, f.path, '..'), { recursive: true }).catch(() => {})
    await writeFile(join(dir, f.path), f.body || 'x')
  }
  await writeFile(join(dir, '_index.json'),
    JSON.stringify({ schema: 1, files: files.map(f => ({ path: f.path, hash: f.hash, size: 1 })), channels: {} }))
  return dir
}

test('an artifact the overlay never registered is repaired at seed time', async (t) => {
  const files = [
    { path: 'app/versions/v1/manifest.json', hash: 'aaa1' },
    { path: 'app/versions/v1/build.log', hash: 'bbb2' },
    { path: 'app/versions/v1/dist/big.zip', hash: 'ccc3' } // the one that "failed"
  ]
  const dir = await fixture(files)
  // The overlay knows the two small files and NOT the zip — exactly the live
  // shape: same publish, same pass, one hash missing.
  const overlay = stubOverlay([PREFIX + files[0].path, PREFIX + files[1].path])
  const rm = new ReleaseManager({ releaseDir: dir, overlay })

  await rm.seedOverlayServeMap()

  const repaired = overlay.registered.map(r => r.overlayPath)
  t.is(repaired.length, 1, 'only the missing one is registered')
  t.is(repaired[0], PREFIX + 'app/versions/v1/dist/big.zip')
  t.is(overlay.registered[0].prepare, false,
    'repaired WITHOUT an eager chunk-map build — no file read, no re-hash')
})

test('a fully-registered index repairs nothing', async (t) => {
  const files = [
    { path: 'app/versions/v1/manifest.json', hash: 'aaa1' },
    { path: 'app/versions/v1/build.log', hash: 'bbb2' }
  ]
  const dir = await fixture(files)
  const overlay = stubOverlay(files.map(f => PREFIX + f.path))
  const rm = new ReleaseManager({ releaseDir: dir, overlay })

  await rm.seedOverlayServeMap()
  t.is(overlay.registered.length, 0, 'the common case costs nothing')
})

test('an index entry whose file is gone is skipped, not an error', async (t) => {
  // gc collects a version; its files vanish while _index.json still names them
  // until the next write. registerFile returns null rather than throwing.
  const dir = await fixture([{ path: 'app/versions/v1/build.log', hash: 'bbb2' }])
  await writeFile(join(dir, '_index.json'), JSON.stringify({
    schema: 1,
    files: [
      { path: 'app/versions/v1/build.log', hash: 'bbb2', size: 1 },
      { path: 'app/versions/collected/build.log', hash: 'ddd4', size: 1 }
    ],
    channels: {}
  }))
  const overlay = {
    ...stubOverlay([]),
    async registerFile (overlayPath, diskPath, meta) {
      // Mirror the real behaviour: a missing source is null, not a throw.
      const { existsSync } = await import('#node:fs')
      if (!existsSync(diskPath)) return null
      return { contentHash: meta.contentHash }
    }
  }
  overlay.seedFilePathsFromIndex = async () => 0
  const rm = new ReleaseManager({ releaseDir: dir, overlay })
  await t.execution(rm.seedOverlayServeMap(), 'a collected version does not break the repair')
})

test('no overlay at all is a no-op', async (t) => {
  const dir = await fixture([{ path: 'app/versions/v1/build.log', hash: 'bbb2' }])
  const rm = new ReleaseManager({ releaseDir: dir })
  t.is(await rm.seedOverlayServeMap(), 0)
})