OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / probe-indexer-lowchurn.mjs

108 lines · javascript ref:
// VALIDATION: Q2 (#103) — does the pending-indexer transition + finalization settle
// under LOW churn? The passing probe-indexer-quorum.mjs drove aggressive churn (a write
// from every peer every 150ms), which is the classic way to unstick an autobase. The
// open question was whether the 3-indexer grow only worked BECAUSE of that churn.
//
// This probe grows the set to 3 indexers, then does exactly ONE write and advances the
// bases ONLY via base.update() polling — NO heartbeat writes. Autobase auto-acks on its
// ackInterval (1000ms), so consensus should still advance without app-level churn. If
// finalization needs churn, this hangs (watchdog) or step 4 fails.
//
// PASS =
//   - the set reaches 3 indexers, AND
//   - a single write finalizes (signedLength advances) + replicates under update-only polling.
import { StateBaseV3 } from '../lib/state-base-v3.js'
import createTestnet from 'hyperdht/testnet'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'

const ROOT = path.join(os.tmpdir(), 'sbv3-lowchurn-' + Bare.pid)
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
const hex = (b) => b4a.toString(b, 'hex')

const kps = [crypto.keyPair(), crypto.keyPair(), crypto.keyPair()].sort((x, y) => hex(x.publicKey) < hex(y.publicKey) ? -1 : 1)
const [kpA, kpB, kpC] = kps
const members = [
  { key: hex(kpA.publicKey), role: 1 },
  { key: hex(kpB.publicKey), role: 1 },
  { key: hex(kpC.publicKey), role: 1 }
]

let pass = 0; let fail = 0
const ok = (c, m) => { if (c) { pass++; console.log('  ok  ' + m) } else { fail++; console.log('  FAIL ' + m) } }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const idxCount = (sb) => { try { return sb.base.linearizer.indexers.length } catch { return '?' } }

let A, B, C
const live = () => [A, B, C].filter(x => x && x.base && !x._closing)
let hb = 0
// Full churn — used ONLY to build the initial 3-indexer set (the setup, not the thing
// under test). The finalization step below uses updateOnly (no writes).
const churnSettle = async (predicate, maxMs = 60000) => {
  const deadline = Date.now() + maxMs
  while (Date.now() < deadline) {
    for (const x of live()) { if (x.base.writable) { try { await x.set('__hb__:' + hex(x._selfKey).slice(0, 6), hb++) } catch {} } }
    for (const x of live()) await x.base.update().catch(() => {})
    await sleep(150)
    if (await predicate()) return true
  }
  return await predicate()
}
// LOW churn — advance consensus with NO app writes, relying on autobase auto-ack only.
const updateOnlySettle = async (predicate, maxMs = 45000) => {
  const deadline = Date.now() + maxMs
  while (Date.now() < deadline) {
    for (const x of live()) await x.base.update().catch(() => {})
    await sleep(500)
    if (await predicate()) return true
  }
  return await predicate()
}

let stage = 'init'
const watchdog = setTimeout(() => { console.log('  [WATCHDOG] stuck at stage=' + stage + ' — exiting to flush'); Bare.exit(3) }, 180000)

async function main () {
  stage = 'testnet'
  const testnet = await createTestnet(3)
  const bootstrap = testnet.bootstrap
  const roomSeed = crypto.randomBytes(32)
  const opt = (kp) => ({ roomSeed, encryptionKey: roomSeed, network: true, swarmOpts: { bootstrap }, selfKeyPair: kp, selfKey: kp.publicKey, getMembers: async () => members, promoteRetryMs: 3000 })

  stage = 'A.ready'; A = new StateBaseV3(path.join(ROOT, 'A'), crypto.randomBytes(32), opt(kpA))
  await A.ready(); await A.projectMembers(members)
  stage = 'B.ready'; B = new StateBaseV3(path.join(ROOT, 'B'), A._roomKey, opt(kpB)); await B.ready()
  stage = 'C.ready'; C = new StateBaseV3(path.join(ROOT, 'C'), A._roomKey, opt(kpC)); await C.ready()
  await churnSettle(async () => (B.base.length || 0) > 0 && (C.base.length || 0) > 0, 45000)

  // SETUP (full churn allowed): grow to 3 indexers.
  stage = 'growB'; await B.requestWriter({ timeoutMs: 40000, startIndexing: true }); await churnSettle(async () => B._isLocalIndexer() === true, 40000)
  stage = 'growC'; await C.requestWriter({ timeoutMs: 40000, startIndexing: true }); await churnSettle(async () => C._isLocalIndexer() === true, 40000)
  const reached3 = await churnSettle(async () => [A, B, C].every(x => idxCount(x) === 3), 60000)
  ok(reached3, '1. set reached 3 indexers (setup)')

  // Quiesce: let any in-flight churn drain so the finalization test starts from rest.
  stage = 'quiesce'; await updateOnlySettle(async () => false, 4000)

  // THE TEST: one write, then NO app churn — only base.update() polling. Autobase's
  // 1000ms auto-ack must carry it to finality across the 3-indexer quorum.
  const key = 'lowchurn:probe:' + Date.now()
  const signedBefore = A.base.signedLength
  stage = 'writeA'; await A.set(key, { n: 7, by: 'A' })
  stage = 'finalize'; const finalized = await updateOnlySettle(async () => A.base.signedLength > signedBefore, 45000)
  console.log('  [diag] signedLength A: %d → %d (update-only, no churn)', signedBefore, A.base.signedLength)
  ok(finalized, '2. single write FINALIZES under low churn (update-only): signedLength ' + signedBefore + '→' + A.base.signedLength)

  stage = 'replicate'; const gotOnC = await updateOnlySettle(async () => { try { const v = await C.get(key); return v && v.n === 7 } catch { return false } }, 30000)
  ok(gotOnC, '3. write REPLICATED to C under low churn')

  clearTimeout(watchdog)
  console.log('\n  RESULT: ' + pass + ' passed, ' + fail + ' failed')
  Bare.exit(fail === 0 ? 0 : 1)
}

main().catch((e) => { console.log('  FATAL ' + (e && e.stack || e)); Bare.exit(2) })