OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / scaling.mjs

196 lines · javascript ref:
// Writer-set scaling under autobee — does a many-writer log cost every reader?
//
// The question driving it: a "thread" where N people like a post is an N-writer log. Under autobase
// a reader needs every writer's core to compute the view, which is the wall the whole feed design
// was built to dodge. autobee has fast-forward over oplog heads, so the cost MAY be different.
// This measures it instead of arguing about it.
//
// Topology: one creator (genesis indexer) + N writers, each its own Corestore (own primaryKey → own
// local writer key). Every writer is piped to the creator only — a star, which is the realistic
// shape (you connect to whoever is around, not to all N).
//
// Then a FRESH reader opens the base by key, replicates from the creator ALONE, and we measure what
// it costs it to reach the full view.

import fs from '#node:fs'
import os from '#node:os'
import process from '#node:process'
import { join } from '#node:path'
import b4a from 'b4a'
import Corestore from 'corestore'
import Autobee from 'autobee'

const enc = (v) => b4a.from(JSON.stringify(v), 'utf8')
const dec = (b) => JSON.parse(b4a.toString(b, 'utf8'))
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'scale-'))

function duBytes (dir) {
  let total = 0
  const walk = (d) => {
    let ents
    try { ents = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
    for (const e of ents) {
      const p = join(d, e.name)
      if (e.isDirectory()) walk(p)
      else { try { total += fs.statSync(p).size } catch {} }
    }
  }
  walk(dir)
  return total
}

async function apply (nodes, view, host) {
  const w = view.write()
  for (const node of nodes) {
    let op
    try { op = dec(node.value) } catch { continue }
    if (op.t === 'add-writer') { try { host.addWriter(b4a.from(op.key, 'hex'), { isIndexer: false }) } catch {} ; continue }
    if (op.t === 'like') w.tryPut(b4a.from('like/' + op.by, 'utf8'), enc({ by: op.by }))
  }
  await w.flush()
}

const open = (bee) => bee

function pipePair (a, b) {
  const s1 = a.replicate(true)
  const s2 = b.replicate(false)
  s1.pipe(s2).pipe(s1)
  return () => { try { s1.destroy() } catch {}; try { s2.destroy() } catch {} }
}

async function until (fn, ms = 60000, poll = 20) {
  const end = Date.now() + ms
  while (Date.now() < end) {
    if (await fn()) return true
    await new Promise((r) => setTimeout(r, poll))
  }
  return false
}

async function countLikes (db) {
  let n = 0
  for await (const node of db.view.createReadStream({ gte: b4a.from('like/', 'utf8'), lt: b4a.from('like0', 'utf8') })) n++
  return n
}

async function run (N) {
  const t0 = Date.now()
  const creatorDir = tmp()
  // ★ mostRecentTrusted is the STAMP side (writers.js:112 — flushLocal passes its result into
  // w.flush(views, trusted), writing the vouched head into OUR OWN oplog). Joiners read those stamps
  // off the writers they wake on. So it belongs on the peer that SERVES, not on the one joining —
  // configuring it only on the reader (as I first did) stamps nothing and leaves discovery empty.
  const vouch = (auto) => async () => {
    const h = auto.local
    return h && h.length ? { key: h.key, length: h.length } : null
  }
  let creator = null
  creator = new Autobee(new Corestore(creatorDir), null, {
    apply,
    open,
    isTrusted: () => true,
    mostRecentTrusted: () => vouch(creator)()
  })
  await creator.ready()

  const writers = []
  const unpipes = []
  for (let i = 0; i < N; i++) {
    const dir = tmp()
    const w = new Autobee(new Corestore(dir), creator.key, { apply, open })
    await w.ready()
    writers.push({ w, dir })
    unpipes.push(pipePair(creator, w))
  }

  // Admit them all, then have each append one "like".
  const tAdmit = Date.now()
  for (const { w } of writers) await creator.append(enc({ t: 'add-writer', key: b4a.toString(w.local.key, 'hex') }))
  await creator.update()
  const admitted = await until(async () => {
    for (const { w } of writers) { await w.update(); if (!w.writable) return false }
    return true
  }, 120000)
  const admitMs = Date.now() - tAdmit
  if (!admitted) return { N, error: 'not all writers became writable' }

  const tWrite = Date.now()
  for (let i = 0; i < N; i++) await writers[i].w.append(enc({ t: 'like', by: String(i) }))
  await until(async () => { await creator.update(); return (await countLikes(creator)) === N }, 120000)
  const writeMs = Date.now() - tWrite

  // ★ flush() — "wait until all known writers have been fully indexed". The README says each flush
  // STAMPS the head you vouch for into your own oplog, and peers read those stamps. Without it there
  // may be no head for a joiner to fast-forward to, which would make fastForwards:0 a usage error
  // rather than a defect.
  const tFlush = Date.now()
  await creator.flush()
  const flushMs = Date.now() - tFlush

  const creatorBytes = duBytes(creatorDir)
  const creatorCores = creator.store ? creator.store.cores.size : -1

  // ⛔ Tear the writers DOWN before measuring the reader. The first version of this measured a
  // reader competing with N live autobee instances on one event loop and called the result
  // "reader catch-up" — that number was the harness, not the system.
  for (const u of unpipes) u()
  for (const { w } of writers) await w.close()

  // A FRESH reader, connected to the creator ALONE, with nothing else running.
  // Run it twice: fast-forward as configured by default, and explicitly disabled, so the mechanism
  // is OBSERVED rather than assumed. stats.fastForwards says which path actually ran.
  const measureReader = async (ffOpt, poll = 20) => {
    const dir = tmp()
    const store = new Corestore(dir)
    const t = Date.now()
    // The configurations a maintainer would ask about before accepting a bug report.
    // bootHead is an OPLOG head — "{key, length} of a writer's core", per the README — taken from
    // the creator's own local writer, which is the only thing a joiner could plausibly be pointed at.
    const bootHead = { key: creator.local.key, length: creator.local.length }
    const opts = ffOpt === false ? { apply, open, fastForward: false }
      : ffOpt === 'boot' ? { apply, open, fastForward: { boot: { head: bootHead } } }
        : ffOpt === 'trusted' ? { apply, open, isTrusted: () => true }
          : { apply, open }
    const r = new Autobee(store, creator.key, opts)
    await r.ready()
    const un = pipePair(creator, r)
    const ok = await until(async () => { await r.update(); return (await countLikes(r)) === N }, 120000, poll)
    const ms = Date.now() - t
    const out = {
      ms,
      kb: Math.round(duBytes(dir) / 1024),
      cores: store.cores ? store.cores.size : -1,
      ff: r.stats.fastForwards,
      applies: r.stats.applies,
      drains: r.stats.drains,
      ok
    }
    un()
    await r.close()
    return out
  }

  // A/B the FLAG and the HARNESS independently. If the slow column tracks the poll interval rather
  // than the flag, the cost is mine.
  const ffOn = await measureReader(undefined, 20)
  const ffBoot = await measureReader('boot', 20)
  const ffTrusted = await measureReader('trusted', 20)
  const ffOff = await measureReader(false, 20)

  await creator.close()

  return { N, flushMs, admitMs, writeMs, creatorKB: Math.round(creatorBytes / 1024), creatorCores, ffOn, ffBoot, ffTrusted, ffOff, totalMs: Date.now() - t0 }
}

const sizes = (process.argv[2] || '5,25,100').split(',').map(Number)
console.log('N\t| default: ms ffs\t| boot-head: ms ffs\t| discovery(server stamps): ms ffs\t| ff=off: ms ffs')
for (const N of sizes) {
  const r = await run(N)
  if (r.error) { console.log(N + '\tERROR ' + r.error); continue }
  const f = (x) => [x.ms, x.ff, x.applies].join('  ')
  const g = (x) => [x.ms, x.ff].join('  ')
  console.log([r.N, '| ' + g(r.ffOn), '| ' + g(r.ffBoot), '| ' + g(r.ffTrusted), '| ' + g(r.ffOff)].join('\t'))
}
process.exit(0)