OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / retired-repos-mirror.test.js

197 lines · javascript ref:
/**
 * Tombstone propagation to a MIRROR (issue #135 follow-up).
 *
 * retired-repos.js calls the set "DERIVED, NOT STORED ... rebuilt identically
 * on every boot". That is true on the ORIGIN, which folds its own writable
 * RepoEventLog from seq 0 (pear-git.js: `foldRetired(repoEventLog)`).
 *
 * It was NOT true on a mirror. A mirror's own log carries no tombstones — the
 * origin wrote them to ITS log — so the mirror's only source is the fold
 * inside RepoPuller._enqueue, and that rides on a stream opened at
 * `cursor + 1`. The cursor exists to avoid ~80 minutes of redundant git
 * fetches per restart, so it is not going away; the consequence was that a
 * tombstone appended BELOW the cursor was folded once, in memory, and then
 * forgotten on the next restart. The mirror resumed listing and announcing a
 * repo the origin had retired, for ever.
 *
 * Observed in production 2026-09-02: tabertron (origin) held 15 tombstones,
 * stortron (mirror, and the load-balancer's ACTIVE backend) reported none,
 * because it had restarted 2026-08-29 with its cursor already past the
 * 2026-07-30 retire events.
 *
 * These tests use the same real-testnet harness as repo-puller-cursor.test.js
 * so the log-streaming path is exercised end-to-end rather than stubbed.
 */

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 Corestore from 'corestore'
import createTestnet from '@hyperswarm/testnet'
import Hyperswarm from 'hyperswarm'

import { RepoEventLog } from '../lib/services/repo-event-log.js'
import { RepoPuller } from '../lib/services/repo-mirror.js'
import { RetiredRepos, OP_CREATED, OP_UPDATED, OP_RETIRED } from '../lib/services/retired-repos.js'

let n = 0
function tmpDir () {
  return mkdtempSync(join(tmpdir(), 'pgv3-retired-mirror-' + (n++) + '-'))
}

class StubSync {
  constructor () {
    this.calls = []
    this.remotes = new Map()
  }

  addRemote (name, key) { this.remotes.set(name, key) }
  async pull (remote, repoName) { this.calls.push({ remote, repoName }) }
}

async function setupOriginAndMirror (testnet, t) {
  const topic = b4a.alloc(32, 'pgv3-retired-' + (n++))

  const originStore = new Corestore(join(tmpDir(), 'origin'))
  await originStore.ready()
  const originLog = new RepoEventLog({ corestore: originStore })
  await originLog.ready()

  const originSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
  originSwarm.on('connection', (conn) => originStore.replicate(conn))
  originSwarm.join(topic, { server: true, client: false })
  await originSwarm.flush()

  const mirrorStore = new Corestore(join(tmpDir(), 'mirror'))
  await mirrorStore.ready()
  const mirrorLog = new RepoEventLog({ corestore: mirrorStore, key: originLog.keyHex })
  await mirrorLog.ready()

  const mirrorSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
  mirrorSwarm.on('connection', (conn) => mirrorStore.replicate(conn))
  mirrorSwarm.join(topic, { server: false, client: true })
  await mirrorSwarm.flush()

  t.teardown(async () => {
    try { await originSwarm.destroy() } catch {}
    try { await mirrorSwarm.destroy() } catch {}
    try { await originLog.close() } catch {}
    try { await mirrorLog.close() } catch {}
    try { await originStore.close() } catch {}
    try { await mirrorStore.close() } catch {}
  })

  return { originLog, mirrorLog, mirrorStore }
}

async function waitFor (predicate, timeoutMs = 8000) {
  const t0 = Date.now()
  while (Date.now() - t0 < timeoutMs) {
    if (await predicate()) return true
    await new Promise((r) => setTimeout(r, 50))
  }
  return false
}

function readCursor (cursorPath, serverKey) {
  if (!existsSync(cursorPath)) return null
  try { return JSON.parse(readFileSync(cursorPath, 'utf8'))[serverKey] } catch { return null }
}

test('mirror: a tombstone below the cursor survives a restart', async (t) => {
  t.timeout(60000)
  const testnet = await createTestnet(3, t.teardown)
  const { originLog, mirrorLog, mirrorStore } = await setupOriginAndMirror(testnet, t)
  const serverKey = 'a'.repeat(64)
  const cursorPath = join(tmpDir(), 'repo-puller-cursor.json')

  // Phase 1 — origin serves two repos, retires one, then keeps working.
  // That last entry matters: the cursor advances only on a successful pull
  // (a retire returns early), so without it the cursor would stop AT the
  // tombstone and the restart would replay it, hiding the bug.
  await originLog.append({ op: OP_CREATED, name: 'alpha' }) // seq 0
  await originLog.append({ op: OP_CREATED, name: 'beta' })  // seq 1
  await originLog.append({ op: OP_RETIRED, name: 'alpha' }) // seq 2  <- tombstone
  await originLog.append({ op: OP_CREATED, name: 'gamma' }) // seq 3  <- pushes cursor past it

  const retired1 = new RetiredRepos()
  const sync1 = new StubSync()
  const puller1 = new RepoPuller({
    sync: sync1, log: mirrorLog, primaryServerKey: serverKey, cursorPath, retired: retired1
  })
  await puller1.start()

  t.ok(await waitFor(() => retired1.has('alpha')), 'phase 1: the live fold sees the tombstone')
  t.ok(await waitFor(() => readCursor(cursorPath, serverKey) === 3),
    'phase 1: cursor advanced PAST the tombstone (seq 3)')
  await puller1.close()

  // Phase 2 — restart. RetiredRepos is derived state, and on a mirror it is
  // folded from the mirror's OWN log, which carries no tombstones. So a real
  // boot starts with an empty set, exactly as modelled here.
  // A real restart re-opens the core; puller1.close() closed the old session.
  const mirrorLog2 = new RepoEventLog({ corestore: mirrorStore, key: mirrorLog.keyHex })
  await mirrorLog2.ready()
  const retired2 = new RetiredRepos()
  const sync2 = new StubSync()
  const puller2 = new RepoPuller({
    sync: sync2, log: mirrorLog2, primaryServerKey: serverKey, cursorPath, retired: retired2
  })
  t.teardown(async () => { try { await puller2.close() } catch {} })
  await puller2.start()

  // A live entry for an unrelated repo proves the read loop is running, so a
  // failure below is a missing fold and not a stalled stream.
  await originLog.append({ op: OP_UPDATED, name: 'beta' })  // seq 4
  t.ok(await waitFor(() => sync2.calls.some((c) => c.repoName === 'beta')),
    'phase 2: the read loop is live')

  t.ok(retired2.has('alpha'),
    'the tombstone is still known after a restart with a warm cursor')
  t.absent(sync2.calls.some((c) => c.repoName === 'alpha'),
    'and the retired repo is not pulled again')
})

test('mirror: re-folding must not clobber an un-retire that came later', async (t) => {
  // The fix re-reads the log from seq 0 at boot. That must not resurrect a
  // tombstone the origin has since reversed — retired-repos.js promises "an
  // op 0 after an op 2 un-retires the repo and a mistake is reversible", and
  // a naive re-fold ordered after the live stream would break exactly that.
  t.timeout(60000)
  const testnet = await createTestnet(3, t.teardown)
  const { originLog, mirrorLog, mirrorStore } = await setupOriginAndMirror(testnet, t)
  const serverKey = 'c'.repeat(64)
  const cursorPath = join(tmpDir(), 'repo-puller-cursor.json')

  await originLog.append({ op: OP_CREATED, name: 'alpha' }) // seq 0
  await originLog.append({ op: OP_RETIRED, name: 'alpha' }) // seq 1
  await originLog.append({ op: OP_CREATED, name: 'beta' })  // seq 2  <- cursor lands here

  const puller1 = new RepoPuller({
    sync: new StubSync(), log: mirrorLog, primaryServerKey: serverKey, cursorPath, retired: new RetiredRepos()
  })
  await puller1.start()
  t.ok(await waitFor(() => readCursor(cursorPath, serverKey) === 2), 'cursor past the tombstone')
  await puller1.close()

  // A real restart re-opens the core; puller1.close() closed the old session.
  const mirrorLog2 = new RepoEventLog({ corestore: mirrorStore, key: mirrorLog.keyHex })
  await mirrorLog2.ready()
  const retired2 = new RetiredRepos()
  const sync2 = new StubSync()
  const puller2 = new RepoPuller({
    sync: sync2, log: mirrorLog2, primaryServerKey: serverKey, cursorPath, retired: retired2
  })
  t.teardown(async () => { try { await puller2.close() } catch {} })
  await puller2.start()

  // The operator changed their mind on the origin.
  await originLog.append({ op: OP_CREATED, name: 'alpha' }) // seq 3  <- un-retire

  t.ok(await waitFor(() => sync2.calls.some((c) => c.repoName === 'alpha')),
    'the un-retired repo is pulled again')
  t.absent(retired2.has('alpha'), 'and the tombstone is gone')
})