OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-vdrive / test / two-seeder-gc.mjs

142 lines · javascript ref:
// Two-seeder GC routing — the claim left undemonstrated by test/gc-bitfield.mjs.
//
// A (virtual) publishes. B (stock) mirrors it fully. Then A's bytes are GC'd from
// the content store. A client connected to BOTH asks for the file.
//   case "lie":    A advertises blocks it cannot produce  -> does the client recover from B?
//   case "paired": A also clears the range, so it stops advertising -> B serves cleanly.
//
//   node test/two-seeder-gc.mjs <workDir>
import { createRequire } from 'module'
const require = createRequire(import.meta.url)

const fs = require('fs')
const crypto = require('hypercore-crypto')
const Corestore = require('corestore')
const Hyperdrive = require('hyperdrive')
const { ContentStore, build } = require('../lib/storage.cjs')
const { chunk } = await import('../../hyper-overlay/lib/chunker.js')

const ROOT = process.argv[2]
const NAME = '/app.bin'
const srcDir = ROOT + '/src'
fs.mkdirSync(srcDir, { recursive: true })

const payload = crypto.randomBytes(8 * 1024 * 1024)
const file = srcDir + '/payload.bin'
fs.writeFileSync(file, payload)
const chunks = chunk(payload)

function write (ws, buf) {
  return new Promise((resolve) => {
    if (ws.write(buf)) return resolve()
    ws.once('drain', resolve)
  })
}

function pipe (s1, s2) {
  s1.pipe(s2).pipe(s1)
  return () => {
    s1.destroy()
    s2.destroy()
  }
}

async function scenario (tag, { clearAfterGc }) {
  const base = `${ROOT}/${tag}`
  const content = new ContentStore()
  for (const c of chunks) content.add(c.hash, file, c.offset, c.length)
  const Storage = build(content)

  // A — virtual seeder
  const aStore = new Corestore(new Storage(base + '/a'), { primaryKey: Buffer.alloc(32, 7), unsafe: true })
  const aDrive = new Hyperdrive(aStore)
  await aDrive.ready()
  const ws = aDrive.createWriteStream(NAME, { dedup: true })
  for (const c of chunks) await write(ws, c.data)
  await new Promise((resolve, reject) => {
    ws.once('error', reject)
    ws.once('close', resolve)
    ws.end()
  })
  await aDrive.getBlobs()
  const driveKey = aDrive.key

  // B — stock mirror, pulls everything from A
  const bStore = new Corestore(base + '/b', { primaryKey: Buffer.alloc(32, 8), unsafe: true })
  const bDrive = new Hyperdrive(bStore, driveKey)
  await bDrive.ready()
  const unpipeAB = pipe(aStore.replicate(true), bStore.replicate(false))
  await bDrive.core.update({ wait: true })
  const mirrored = await bDrive.get(NAME)
  const bHasAll = mirrored !== null && mirrored.equals(payload)
  await bDrive.getBlobs()
  unpipeAB()

  // count who actually serves
  let servedByA = 0
  let servedByB = 0
  aDrive.blobs.core.on('upload', () => servedByA++)
  bDrive.blobs.core.on('upload', () => servedByB++)

  // the overlay GCs A's bytes
  const entry = await aDrive.entry(NAME)
  const map = await aDrive.blobs.getBlockMap(entry.value.blob)
  const blocks = map.blocks.map((b) => b.index)
  for (const c of chunks) content.locations.delete(c.hash)
  if (clearAfterGc) await aDrive.blobs.core.clear(blocks[0], blocks[blocks.length - 1] + 1)
  const aAdvertises = await aDrive.blobs.core.has(blocks[0])

  // C — fresh client, connected to BOTH
  const cStore = new Corestore(base + '/c', { primaryKey: Buffer.alloc(32, 9), unsafe: true })
  const cDrive = new Hyperdrive(cStore, driveKey)
  await cDrive.ready()
  const unpipeCA = pipe(aStore.replicate(true), cStore.replicate(false))
  const unpipeCB = pipe(bStore.replicate(true), cStore.replicate(false))
  await cDrive.core.update({ wait: true })

  const t0 = Date.now()
  let outcome
  try {
    const got = await cDrive.get(NAME, { timeout: 15000 })
    outcome = got === null ? 'null' : got.equals(payload) ? 'ok' : 'CORRUPT'
  } catch (err) {
    outcome = 'throw:' + (err.code || err.message)
  }
  const elapsedMs = Date.now() - t0

  unpipeCA()
  unpipeCB()
  for (const d of [cDrive, bDrive, aDrive]) await d.close().catch(() => {})
  for (const s of [cStore, bStore, aStore]) await s.close().catch(() => {})
  content.close()

  return { tag, bHasAll, aAdvertisesAfterGc: aAdvertises, outcome, elapsedMs, servedByA, servedByB }
}

const lie = await scenario('lie', { clearAfterGc: false })
console.log(JSON.stringify(lie))
const paired = await scenario('paired', { clearAfterGc: true })
console.log(JSON.stringify(paired))

// NB: do NOT read the elapsed difference as signal — across repeated runs the two
// cases overlap (lie 117-135ms, paired 104-133ms). What matters is that BOTH
// recover, and that the second seeder serves essentially all of it.
console.log(
  JSON.stringify(
    {
      bothRecovered: lie.outcome === 'ok' && paired.outcome === 'ok',
      servedBySecondSeeder: {
        lie: lie.servedByB + '/' + (lie.servedByA + lie.servedByB),
        paired: paired.servedByB + '/' + (paired.servedByA + paired.servedByB)
      },
      ms: { lie: lie.elapsedMs, paired: paired.elapsedMs },
      verdict:
        lie.outcome === 'ok' && paired.outcome === 'ok'
          ? 'the bitfield lie is survivable when another source exists'
          : 'FAIL'
    },
    null,
    2
  )
)