OverviewFilesCommitsIssuesDocsReleasesPeers

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

99 lines · javascript ref:
// VALIDATION: Q7 (#103) — the indexer set is BOUNDED by _maxIndexers. Every admin is
// eligible (keet model), but once the live indexer set is full, further admins stay
// PLAIN WRITERS instead of self-promoting. Without a cap, a room with many admins pushes
// the autobase majority threshold ((n>>>1)+1) so high that a couple of offline indexers
// stall finalization; the cap keeps quorum manageable.
//
// Setup: three admins, _maxIndexers=2. A (genesis) + the first admin to promote fill the
// set; the third admin must NOT become an indexer, yet must still be an admitted writer
// whose writes finalize (via the 2-indexer quorum).
//
// PASS =
//   - indexer set never exceeds 2, AND
//   - the capped-out third admin is still writable and its write replicates.
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-cap-' + 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
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()
}

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)
  // maxIndexers: 2 — the cap under test. promoteRetryMs small so timer-driven attempts fire.
  const opt = (kp) => ({ roomSeed, encryptionKey: roomSeed, network: true, swarmOpts: { bootstrap }, selfKeyPair: kp, selfKey: kp.publicKey, getMembers: async () => members, maxIndexers: 2, promoteRetryMs: 3000 })

  stage = 'A.ready'; A = new StateBaseV3(path.join(ROOT, 'A'), crypto.randomBytes(32), opt(kpA))
  await A.ready(); await A.projectMembers(members)
  ok(A.writable, '1. A = genesis creator + writable')

  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)

  // Deterministically fill the 2-slot set: A (genesis) + B. Explicit so the cap decision
  // for C is unambiguous (A+B are the two indexers; C is the one that must be capped out).
  stage = 'growB'; await B.requestWriter({ timeoutMs: 40000, startIndexing: true }); await churnSettle(async () => B._isLocalIndexer() === true, 40000)
  const reached2 = await churnSettle(async () => idxCount(A) === 2 && idxCount(B) === 2, 40000)
  ok(reached2, '2. indexer set filled to the cap (2): A + B')

  // Give C's auto-promote timer ample time to (wrongly, if the cap failed) grow the set.
  // C is an admin, so WITHOUT the cap it would self-promote to a 3rd indexer.
  stage = 'C.settle'
  await churnSettle(async () => false, 15000) // just churn/advance for 15s
  console.log('  [diag] indexer counts A=%s B=%s C=%s', idxCount(A), idxCount(B), idxCount(C))
  ok(idxCount(A) === 2 && idxCount(B) === 2, '3. indexer set stays at the cap (2) — C did NOT grow it')
  ok(C._isLocalIndexer() !== true, '3b. capped-out admin C is NOT an indexer')

  // C must still be a first-class writer: admitted + its writes finalize via the 2-quorum.
  stage = 'C.write'; await C.set('c:capped', { still: 'writable' })
  const cWritable = await churnSettle(async () => (await C.effectiveWritable()) === true, 40000)
  ok(cWritable, '4. capped-out admin C is still an admitted writer')
  const gotOnA = await churnSettle(async () => { try { const v = await A.get('c:capped'); return v && v.still === 'writable' } catch { return false } }, 30000)
  ok(gotOnA, '4b. C\'s write finalized + replicated to A despite C not indexing')

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