OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / spike / storage-floor-probe.mjs

127 lines · javascript ref:
// Spike 4 (org #36) — is RocksDB's per-store cost a KNOB or a WALL?
//
// spec/mobile-node.md §6 says a two-DID node costs 179 MB (hpp) + 83 MB (favela-web) while its RECORDS
// are 2.8 MB, and flags RocksDB's per-instance floor as "the dominant cost and possibly a wall". This
// measures the floor directly instead of quoting it.
//
// ⛔ MEASURE ALLOCATED BLOCKS, NOT FILE LENGTH. The two differ by ~2x here and the whole finding lives in
// the gap: `du --apparent-size` on hpp's store says 83 MB, `du` says 158 MB. A probe that reported file
// lengths would conclude there is no floor at all.
//
// Run: bare spike/storage-floor-probe.mjs        (needs only corestore + hyperbee, which are deps)
import '../lib/bare-globals.js'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import fs from 'bare-fs'
import os from 'bare-os'
import path from 'bare-path'

const root = path.join(os.tmpdir(), 'storage-floor-' + Date.now())
fs.mkdirSync(root, { recursive: true })

// Allocated bytes = st_blocks * 512. This is what the phone's filesystem actually gives up.
function usage (dir) {
  let apparent = 0; let allocated = 0; let files = 0
  const walk = (d) => {
    for (const e of fs.readdirSync(d)) {
      const p = path.join(d, e)
      const st = fs.statSync(p)
      if (st.isDirectory()) { walk(p); continue }
      files++
      apparent += st.size
      allocated += (st.blocks !== undefined ? st.blocks : Math.ceil(st.size / 512)) * 512
    }
  }
  walk(dir)
  return { apparent, allocated, files }
}

const mb = (n) => (n / 1048576).toFixed(2).padStart(8)

async function build (label, records) {
  const dir = path.join(root, label)
  fs.mkdirSync(dir, { recursive: true })
  const store = new Corestore(dir)
  await store.ready()
  const bee = new Hyperbee(store.get({ name: 'probe' }), { keyEncoding: 'utf-8', valueEncoding: 'json' })
  await bee.ready()
  if (records > 0) {
    const b = bee.batch()
    for (let i = 0; i < records; i++) await b.put(`k${String(i).padStart(6, '0')}`, { i, pad: 'x'.repeat(200) })
    await b.flush()
  }
  await store.close()
  const u = usage(dir)
  // What the CONTENT is actually worth, so the overhead is legible rather than implied.
  const content = records * 220
  console.log(`  ${label.padEnd(14)} records=${String(records).padStart(6)}  content~${mb(content)} MB  apparent=${mb(u.apparent)} MB  ALLOCATED=${mb(u.allocated)} MB  files=${u.files}`)
  return { records, ...u }
}

console.log('spike 4 — RocksDB per-store floor\n')
console.log('  (content~ is the raw bytes written; ALLOCATED is what the filesystem gave up)\n')
const rows = []
rows.push(await build('empty', 0))
rows.push(await build('one', 1))
rows.push(await build('hundred', 100))
rows.push(await build('tenk', 10000))

const floor = rows[0].allocated
console.log(`\n  FLOOR (an empty store):            ${mb(floor)} MB allocated, ${rows[0].files} files`)
console.log(`  a store with ONE record:           ${mb(rows[1].allocated)} MB`)
console.log(`  marginal cost of 10k records:      ${mb(rows[3].allocated - floor)} MB`)

// The question §6 actually asks: does the floor repeat per store, or is it amortised?
console.log('\n  Does the floor repeat PER STORE? (a phone would hold several)')
let total = 0
for (let i = 0; i < 3; i++) { const r = await build(`multi-${i}`, 10); total += r.allocated }
console.log(`  3 near-empty stores:               ${mb(total)} MB  (${mb(total / 3)} MB each)`)

// Where the floor lives — the single biggest apparent->allocated gap in an empty store.
const d = path.join(root, 'empty')
let worst = null
const scan = (dir) => {
  for (const e of fs.readdirSync(dir)) {
    const p = path.join(dir, e); const st = fs.statSync(p)
    if (st.isDirectory()) { scan(p); continue }
    const gap = (st.blocks !== undefined ? st.blocks : 0) * 512 - st.size
    if (!worst || gap > worst.gap) worst = { gap, name: e, size: st.size, alloc: (st.blocks || 0) * 512 }
  }
}
scan(d)
if (worst) {
  console.log(`\n  Biggest single overhead in an EMPTY store:`)
  console.log(`    ${worst.name}  ${(worst.size / 1024).toFixed(1)} KB of content -> ${mb(worst.alloc)} MB allocated`)
  console.log(`    That is RocksDB PREALLOCATING its write-ahead log (fallocate), not data.`)
}

// ⛔ THE ONE THAT MATTERS. The 70 MB seen in hpp's PROD store is not a fresh-store cost — the probes
// above all CLOSE before measuring. Measure a store while it is still OPEN and being written, which is
// the state a phone's node is actually in.
{
  const dir = path.join(root, 'live')
  fs.mkdirSync(dir, { recursive: true })
  const store = new Corestore(dir)
  await store.ready()
  const bee = new Hyperbee(store.get({ name: 'probe' }), { keyEncoding: 'utf-8', valueEncoding: 'json' })
  await bee.ready()
  const b = bee.batch()
  for (let i = 0; i < 20000; i++) await b.put(`k${String(i).padStart(6, '0')}`, { i, pad: 'x'.repeat(200) })
  await b.flush()
  const open = usage(dir)
  await store.close()
  const closed = usage(dir)
  console.log('\n  LIVE vs CLOSED (the state a running node is actually in):')
  console.log(`    while OPEN   apparent=${mb(open.apparent)} MB  ALLOCATED=${mb(open.allocated)} MB`)
  console.log(`    after CLOSE  apparent=${mb(closed.apparent)} MB  ALLOCATED=${mb(closed.allocated)} MB`)
  console.log(`    reclaimed on close: ${mb(open.allocated - closed.allocated)} MB`)
}

console.log('\n  KNOB OR WALL: the preallocation is governed by RocksDB write_buffer_size, which')
console.log('  rocksdb-native does NOT currently expose (lib/column-family.js exposes')
console.log('  maxWriteBufferNumber; lib/state.js exposes walTtlSeconds/walSizeLimitMegabytes,')
console.log('  which control WAL RETENTION, not preallocation). hypercore-storage:534 already')
console.log('  spreads opts through, so exposing it upstream is a small change, not a redesign.')

try { fs.rmSync(root, { recursive: true, force: true }) } catch {}