OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / probe-durability-no-indexer.js

144 lines · javascript ref:
// probe-durability-no-indexer.js
//
// Load-bearing question for the game-state foundation:
//   Does an ADMITTED non-indexer writer's UNSIGNED write survive a restart
//   when NO indexer is online to sign it?
//
// If yes  -> durability != online indexer (oplog is the durable substrate;
//            signing is only eventual finality). Confirms keet's "no state
//            loss" model and kills the "need a permanent indexer" premise.
// If no   -> there is a real durability gap and the design must account for it.
//
// Pure node (autobase 7.28 loads under node). On-disk corestores so we can
// truly close + reopen. Manual replication pipes (no autobase-test-helpers).

import os from 'os'
import path from 'path'
import fs from 'fs'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import Corestore from 'corestore'
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'

const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'dur-probe-'))
const A_DIR = path.join(ROOT, 'A')
const B_DIR = path.join(ROOT, 'B')

let failures = 0
function check (label, cond) {
  console.log((cond ? 'PASS ' : 'FAIL ') + label)
  if (!cond) failures++
}

function baseOpts () {
  return {
    open: (store) => new Hyperbee(store.get('view'), { keyEncoding: 'utf-8', valueEncoding: 'json' }),
    apply: async (nodes, view, host) => {
      for (const node of nodes) {
        const op = node.value
        if (!op || typeof op !== 'object') continue
        if (op.type === 'add-writer') {
          await host.addWriter(b4a.from(op.key, 'hex'), { indexer: !!op.indexer })
          continue
        }
        if (op.type === 'set') await view.put(op.k, op.v)
      }
    },
    close: async (view) => { if (view) await view.close() },
    valueEncoding: 'json',
    optimistic: true,
    ackInterval: 1000,
    ackThreshold: 4
  }
}

function pipe (storeA, storeB) {
  const sa = storeA.replicate(true)
  const sb = storeB.replicate(false)
  sa.on('error', () => {})
  sb.on('error', () => {})
  sa.pipe(sb).pipe(sa)
  return () => { sa.destroy(); sb.destroy() }
}

const sleep = (ms) => new Promise(r => setTimeout(r, ms))

async function getView (base, key) {
  await base.update()
  const node = await base.view.get(key)
  return node ? node.value : null
}

async function main () {
  // ---- A = creator/genesis indexer (pins keyPair) ----
  const kpA = crypto.keyPair()
  const storeA = new Corestore(A_DIR)
  await storeA.ready()
  const A = new Autobase(storeA, null, { ...baseOpts(), keyPair: kpA })
  await A.ready()
  console.log('A.key', A.key.toString('hex').slice(0, 12), 'A.writable', A.writable, 'A.isIndexer', A.isIndexer)

  // ---- B = joiner, no keyPair (auto-derives local writer) ----
  const storeB = new Corestore(B_DIR)
  await storeB.ready()
  let B = new Autobase(storeB, A.key, baseOpts())
  await B.ready()
  console.log('B.local.key', B.local.key.toString('hex').slice(0, 12), 'B.writable(before admit)', B.writable)

  // ---- connect, admit B as a NON-INDEXER writer (A is the only indexer, online) ----
  let cut = pipe(storeA, storeB)
  await sleep(300)
  await A.append({ type: 'add-writer', key: B.local.key.toString('hex'), indexer: false })
  await A.update()
  // let B receive the addWriter + system update
  for (let i = 0; i < 40 && !B.writable; i++) { await A.update(); await B.update(); await sleep(100) }
  check('1. B becomes writable with ONE online indexer (transient, not permanent)', B.writable === true)

  // ---- B writes 'a' while A (the indexer) is online ----
  await B.append({ type: 'set', k: 'a', v: 1 })
  await B.update()
  for (let i = 0; i < 30; i++) { await A.update(); await B.update(); await sleep(80); if ((await getView(A, 'a')) === 1) break }
  check('2. A sees B write while indexer online (a=1)', (await getView(A, 'a')) === 1)
  const signedWithIndexer = B.signedLength

  // ---- A GOES OFFLINE. B (a non-indexer) writes 'b' with NO indexer online ----
  cut()
  await sleep(200)
  await B.append({ type: 'set', k: 'b', v: 2 })
  await B.update()
  check('3. B applies its own write to tentative view with NO indexer online (b=2)', (await getView(B, 'b')) === 2)
  console.log('   B.signedLength before:', signedWithIndexer, 'after offline write:', B.signedLength, '(freeze expected — no quorum)')

  // ---- RESTART B: close + reopen from disk (still no indexer online) ----
  await B.close()
  await storeB.close()
  const storeB2 = new Corestore(B_DIR)
  await storeB2.ready()
  B = new Autobase(storeB2, A.key, baseOpts())
  await B.ready()
  await B.update()
  const aAfter = await getView(B, 'a')
  const bAfter = await getView(B, 'b')
  console.log('   after restart: B.writable', B.writable, 'a=', aAfter, 'b=', bAfter)
  check('4a. ADMITTED writer survives restart (still writable, no indexer online)', B.writable === true)
  check('4b. SIGNED write survives restart (a=1)', aAfter === 1)
  check('4c. ★ UNSIGNED write survives restart with NO indexer online (b=2)', bAfter === 2)

  // ---- A returns: b should propagate + eventually sign (eventual finality) ----
  cut = pipe(storeA, storeB2)
  for (let i = 0; i < 40; i++) { await A.update(); await B.update(); await sleep(100); if ((await getView(A, 'b')) === 2) break }
  check('5. After indexer returns, offline write propagates + finalizes (A sees b=2)', (await getView(A, 'b')) === 2)
  console.log('   A.signedLength after rejoin:', A.signedLength)

  cut()
  await A.close(); await storeA.close()
  await B.close(); await storeB2.close()
  try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}

  console.log('\n' + (failures === 0 ? 'ALL PASS' : failures + ' FAILURE(S)'))
  process.exit(failures === 0 ? 0 : 1)
}

main().catch((e) => { console.error('THREW:', e); process.exit(2) })