OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / cross-machine-autobase.js

207 lines · javascript ref:
// Cross-machine autobase replication test, driven by tabertron + stortron.
//
// Goal: prove the canonical autobase v7.27.3 multi-writer pattern works
// over real Hyperswarm DHT replication. One peer is primary (creates the
// autobase, owns the only indexer), the other is mirror (joins via the
// bootstrap key, added by primary as a non-indexer writer). Both append
// ops, both see each other's writes through the linearized view.
//
// Usage (3-step flow because we exchange keys via shell, not RPC):
//
//   # Step 1 — on tabertron (PRIMARY):
//   node test/cross-machine-autobase.js init-primary --storage /tmp/abe2e-tb
//   # Outputs: BOOTSTRAP_KEY=<hex>, PRIMARY_LOCAL=<hex>
//
//   # Step 2 — on stortron (MIRROR), with the BOOTSTRAP_KEY from step 1:
//   node test/cross-machine-autobase.js init-mirror --storage /tmp/abe2e-st --bootstrap <hex>
//   # Outputs: MIRROR_LOCAL=<hex>
//
//   # Step 3 — back on tabertron, add the mirror as writer:
//   node test/cross-machine-autobase.js add-writer --storage /tmp/abe2e-tb --writer <MIRROR_LOCAL hex>
//
//   # Step 4 — run both peers in parallel (different terminals):
//   # tabertron: node test/cross-machine-autobase.js run --storage /tmp/abe2e-tb --role primary
//   # stortron:  node test/cross-machine-autobase.js run --storage /tmp/abe2e-st --role mirror
//
//   # Each "run" appends 5 ops timestamped with the role, then waits up to 60s
//   # for the OTHER role's 5 ops to appear in the view, then exits with a
//   # PASS/FAIL summary.

import Autobase from 'autobase'
import Corestore from 'corestore'
import Hyperswarm from 'hyperswarm'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import fs from 'fs'

const args = parseArgs(process.argv.slice(2))
const cmd = args._[0]

async function main () {
  if (cmd === 'init-primary') return initPrimary()
  if (cmd === 'init-mirror') return initMirror()
  if (cmd === 'add-writer') return addWriter()
  if (cmd === 'run') return runPeer()
  console.error('usage: cross-machine-autobase.js {init-primary|init-mirror|add-writer|run} [opts]')
  process.exit(2)
}

function parseArgs (argv) {
  const out = { _: [] }
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i]
    if (a.startsWith('--')) {
      const key = a.slice(2)
      const val = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true
      out[key] = val
    } else out._.push(a)
  }
  return out
}

// Apply: handles two op types — register-writer (host.addWriter) + msg (view.append)
async function apply (nodes, view, host) {
  for (const node of nodes) {
    const v = node.value
    if (v && v.type === 'register-writer') {
      try {
        await host.addWriter(b4a.from(v.writerKey, 'hex'), { indexer: !!v.indexer })
      } catch (err) {
        console.error('[apply] addWriter failed:', err.message)
      }
    } else {
      await view.append(v)
    }
  }
}

async function openBase (storage, bootstrapKey = null) {
  const store = new Corestore(storage)
  await store.ready()
  const base = new Autobase(store, bootstrapKey, {
    apply,
    open: (s) => s.get('view', { valueEncoding: 'json' }),
    valueEncoding: 'json',
    ackInterval: 1000 // production-canonical auto-ack so signedLength advances
  })
  await base.ready()
  return { store, base }
}

async function initPrimary () {
  if (!args.storage) throw new Error('--storage required')
  if (fs.existsSync(args.storage)) {
    console.error(`refusing to clobber existing storage at ${args.storage}`)
    process.exit(1)
  }
  const { store, base } = await openBase(args.storage)
  console.log(`BOOTSTRAP_KEY=${b4a.toString(base.key, 'hex')}`)
  console.log(`PRIMARY_LOCAL=${b4a.toString(base.local.key, 'hex')}`)
  await base.close()
  await store.close()
}

async function initMirror () {
  if (!args.storage) throw new Error('--storage required')
  if (!args.bootstrap) throw new Error('--bootstrap <hex> required')
  if (fs.existsSync(args.storage)) {
    console.error(`refusing to clobber existing storage at ${args.storage}`)
    process.exit(1)
  }
  const { store, base } = await openBase(args.storage, b4a.from(args.bootstrap, 'hex'))
  console.log(`MIRROR_LOCAL=${b4a.toString(base.local.key, 'hex')}`)
  await base.close()
  await store.close()
}

async function addWriter () {
  if (!args.storage) throw new Error('--storage required')
  if (!args.writer) throw new Error('--writer <hex> required')
  const { store, base } = await openBase(args.storage)
  await base.append({ type: 'register-writer', writerKey: args.writer, indexer: false })
  await base.update()
  console.log(`appended addWriter op for ${args.writer}`)
  await base.close()
  await store.close()
}

async function runPeer () {
  if (!args.storage) throw new Error('--storage required')
  if (!args.role) throw new Error('--role primary|mirror required')

  const role = args.role
  const { store, base } = await openBase(args.storage)
  console.log(`[${role}] booted`)
  console.log(`[${role}] autobase key = ${b4a.toString(base.key, 'hex').slice(0, 16)}…`)
  console.log(`[${role}] local key    = ${b4a.toString(base.local.key, 'hex').slice(0, 16)}…`)
  console.log(`[${role}] writable     = ${base.writable}`)

  // Hyperswarm replication on the autobase's discoveryKey.
  const swarm = new Hyperswarm()
  swarm.on('connection', (conn, info) => {
    const remoteHex = b4a.toString(info.publicKey || Buffer.alloc(32), 'hex').slice(0, 12)
    console.log(`[${role}] swarm conn from ${remoteHex}`)
    store.replicate(conn)
  })
  const topic = base.discoveryKey
  console.log(`[${role}] joining topic ${b4a.toString(topic, 'hex').slice(0, 16)}…`)
  const discovery = swarm.join(topic, { client: true, server: true })
  await discovery.flushed()
  console.log(`[${role}] swarm flushed (announced + queried)`)

  // Append 5 ops tagged with role.
  // Mirror may need to wait until it's been added as writer.
  if (!base.writable) {
    console.log(`[${role}] waiting up to 60s for writability…`)
    await waitFor(() => base.writable, 60000, 500)
    if (!base.writable) {
      console.error(`[${role}] never became writable — primary didn't add us`)
      process.exit(1)
    }
  }
  console.log(`[${role}] writable; appending 5 ops`)
  for (let i = 0; i < 5; i++) {
    await base.append({ from: role, seq: i, ts: Date.now() })
  }
  await base.update()

  // Wait for the OTHER role's 5 ops to appear in the view.
  const otherRole = role === 'primary' ? 'mirror' : 'primary'
  console.log(`[${role}] waiting up to 60s to see 5 ops from ${otherRole}…`)
  let seenOther = 0
  await waitFor(async () => {
    seenOther = 0
    const len = base.view ? base.view.length : 0
    for (let i = 0; i < len; i++) {
      const v = await base.view.get(i)
      if (v && v.from === otherRole) seenOther++
    }
    return seenOther >= 5
  }, 60000, 1000)

  console.log(`[${role}] saw ${seenOther} ops from ${otherRole}`)
  console.log(`[${role}] view length      = ${base.view.length}`)
  console.log(`[${role}] signed length    = ${base.signedLength}`)
  console.log(`[${role}] indexerCount     = ${base.linearizer.indexers.length}`)
  console.log(`[${role}] activeWriters    = ${base.activeWriters.size}`)

  if (seenOther >= 5) console.log(`[${role}] PASS`)
  else console.log(`[${role}] FAIL — only saw ${seenOther}/5`)

  await swarm.destroy()
  await base.close()
  await store.close()
  process.exit(seenOther >= 5 ? 0 : 1)
}

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

main().catch(err => { console.error(err); process.exit(1) })