OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / release-index-serve-order.test.js

148 lines · javascript ref:
// The index must not advertise bytes this daemon cannot yet serve.
//
// `_writeIndex` wrote `_index.json` FIRST and registered the artifacts with the
// overlay afterwards, sequentially and awaited. `_rescan` then appends the
// release-event that wakes every mirror. So the publish order was:
//
//     write _index.json  →  register file 1  →  register file 2  →  …  →  event
//                    ^                                                     ^
//                    the mirror is told about all of these ------------- here
//
// but a mirror reacts within milliseconds, and registering a large artifact
// means chunking and hashing it. Any file not yet registered when the mirror
// asks is answered with "no holder" — the index names bytes nobody can fetch.
//
// Measured on stortron 2026-08-17 for ifsrenderer-linux linux-port-14855c7,
// published 21:53:32, pulled 21:54:
//
//     fetched   dist/ifsrenderer-linux-x86_64.tar.gz (49170279 B)   <- first in the list
//     error: no holder for dist/IFSRenderer-x86_64.AppImage         <- 49 MB, still chunking
//     error: no holder for build.log                                <- 8.5 KB
//     error: no holder for manifest.json                            <- 508 B
//     channel ifsrenderer-linux/experimental NOT pinned to linux-port-14855c7
//
// The file ORDER is the tell: the artifact registered first arrived, everything
// after it did not. Size was irrelevant — a 508-byte manifest failed while a
// 49 MB tarball succeeded. Verified afterwards with hyper-overlay's own
// hashChunk that all four hashes on the origin matched the index exactly, so the
// bytes and the advertised hashes were correct all along; only the timing was
// wrong.
//
// This is a DIFFERENT window from the one `seedOverlayServeMap` addresses. That
// one is boot-time: the overlay's hash→path maps are in-memory, so a restart
// loses them. This one happens at publish time on a daemon that never restarted.
//
// The fix is an ordering constraint, so that is what is asserted: every artifact
// is registered with the overlay BEFORE the index that names it is published.
// The existing "the event is appended AFTER the index is written" test in
// release-event-log.test.js pins the other half, giving the full order:
//
//     register overlay  →  write _index.json  →  append event

import test from 'brittle'
import { mkdtempSync, readFileSync, existsSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import b4a from 'b4a'

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

const INDEX_FILE = '_index.json'

function tmpStorage (p = 'pgv3-rel-serve-order-') {
  return mkdtempSync(join(tmpdir(), p))
}

/**
 * An overlay that records, for each registerFile call, whether the on-disk
 * index ALREADY advertises the path being registered.
 *
 * Reading the index from disk at that instant is what makes this a real
 * ordering assertion rather than a restatement of the implementation: if the
 * index already names the file, a mirror woken now would ask for bytes this
 * overlay has not been told about yet.
 */
function recordingOverlay (dir) {
  const calls = []
  return {
    calls,
    async registerFile (overlayPath, absPath, opts) {
      let advertisedAlready = false
      try {
        const idx = JSON.parse(readFileSync(join(dir, INDEX_FILE), 'utf8'))
        const rel = overlayPath.slice(RELEASE_OVERLAY_PREFIX.length)
        advertisedAlready = (idx.files || []).some((f) => f.path === rel)
      } catch {
        advertisedAlready = false // no index on disk yet — nothing is advertised
      }
      calls.push({ overlayPath, advertisedAlready, contentHash: opts && opts.contentHash })
    }
  }
}

test('artifacts are registered with the overlay BEFORE the index names them', async (t) => {
  const dir = tmpStorage()
  const overlay = recordingOverlay(dir)
  const rm = new ReleaseManager({ releaseDir: dir, overlay })

  await rm.publish({
    repo: 'demo',
    version: 'v1',
    artifacts: [
      { path: 'dist/big.bin', data: b4a.alloc(4096, 1) },
      { path: 'dist/small.bin', data: b4a.from('small') },
      { path: 'build.log', data: b4a.from('log output') }
    ]
  })

  t.ok(overlay.calls.length >= 4,
    'every artifact plus manifest.json was registered (' + overlay.calls.length + ')')

  const premature = overlay.calls.filter((c) => c.advertisedAlready)
  t.is(premature.length, 0,
    'no file was still being registered while the index already advertised it' +
    (premature.length ? ' — ' + premature.map((c) => c.overlayPath).join(', ') : ''))
})

test('the index is complete once written, not filled in behind the mirror', async (t) => {
  const dir = tmpStorage()
  const registered = new Set()
  const overlay = {
    async registerFile (overlayPath) {
      registered.add(overlayPath.slice(RELEASE_OVERLAY_PREFIX.length))
    }
  }
  const rm = new ReleaseManager({ releaseDir: dir, overlay })

  await rm.publish({
    repo: 'demo',
    version: 'v1',
    artifacts: [{ path: 'dist/a.bin', data: b4a.from('A') }, { path: 'dist/b.bin', data: b4a.from('B') }]
  })

  t.ok(existsSync(join(dir, INDEX_FILE)), 'an index was written')
  const idx = JSON.parse(readFileSync(join(dir, INDEX_FILE), 'utf8'))
  const missing = (idx.files || []).filter((f) => !registered.has(f.path)).map((f) => f.path)

  t.alike(missing, [],
    'every path the index advertises is one the overlay was already told to serve')
})

test('a second publish still registers the NEW version before advertising it', async (t) => {
  // The first publish is the easy case: there is no index on disk at all, so any
  // order passes a naive check. The regression this guards is the second one,
  // where a stale index exists and the new entries are appended to it.
  const dir = tmpStorage()
  const overlay = recordingOverlay(dir)
  const rm = new ReleaseManager({ releaseDir: dir, overlay })

  await rm.publish({ repo: 'demo', version: 'v1', artifacts: [{ path: 'a.bin', data: b4a.from('A') }] })
  const afterFirst = overlay.calls.length
  await rm.publish({ repo: 'demo', version: 'v2', artifacts: [{ path: 'a.bin', data: b4a.from('B') }] })

  const second = overlay.calls.slice(afterFirst)
  t.ok(second.length >= 2, 'the second publish registered its own files (' + second.length + ')')
  t.is(second.filter((c) => c.advertisedAlready).length, 0,
    'and none of them were advertised before being registered')
})