// 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)