OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / probe-writer-rotation-to-indexer.mjs

139 lines · javascript ref:
// CONCEPT VALIDATION: promote a DATA-BEARING peer to indexer via WRITER ROTATION
// ("leave/rejoin" = give the peer a FRESH length-0 writer, then swear THAT in).
//
// Autobase 7.28.1 only elevates a FRESH (local.length===0) writer to indexer; a writer that
// already appended data cannot be promoted in place (the manifest fork keeps rolling back —
// see memory/project_statebase-inplace-indexer-promotion). keet's own join task rotates the
// local writer to a fresh keypair via `base.setLocal()` (pairing/keys.js:69,88). This probe
// proves the same move works in our StateBaseV3 to satisfy the durability requirement:
// "we need indexers to maintain gamestate and cannot depend on any one being online."
//
// Flow (mirrors your two cases — a writer/mod that played, then promoted to admin+indexer):
//   A (genesis) + B = 2 fresh indexers. C joins as role-3 MEMBER, WRITES game data
//   (→ data-bearing plain writer). C is GRANTED admin (role 1).
//   CONTROL: in-place start-indexing does NOT make C an indexer (data-bearing).
//   ROTATE : C.store.get({keyPair: fresh}) + C.base.setLocal(freshCore.key) → fresh length-0
//            local writer; then start-indexing → C becomes a real indexer (set → 3).
//   PROVE  : C's OLD data still reads (durability preserved), new writes persist, AND with
//            the GENESIS (A) offline the rotated peer's quorum (B+C) still finalizes.
//
// PASS =
//   1-2  setup: A+B indexers; C data-bearing plain writer, granted admin
//   3    CONTROL: in-place promotion leaves C a non-indexer (set stays 2)
//   4    after ROTATION + start-indexing, C IS an indexer (set → 3 on all peers)
//   5    C's pre-rotation data is still readable (nothing destroyed)
//   6    a post-rotation write from C finalizes with the GENESIS OFFLINE (durable quorum)
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-rotate-' + 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 }, // genesis admin
  { key: hex(kpB.publicKey), role: 1 }, // admin
  { key: hex(kpC.publicKey), role: 3 }  // member — plays, then granted admin, then rotated
]

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, churnC = true) => {
  const deadline = Date.now() + maxMs
  while (Date.now() < deadline) {
    for (const x of live()) { if (x.base.writable && (x !== C || churnC)) { 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) }, 260000)

async function main () {
  stage = 'testnet'
  const testnet = await createTestnet(3)
  const bootstrap = testnet.bootstrap
  const roomSeed = crypto.randomBytes(32)
  // promoteRetryMs huge: drive promotion explicitly so the assertions are deterministic
  // (the auto-timer would also correctly SKIP C once it is data-bearing, via the fresh-guard).
  const opt = (kp) => ({ roomSeed, encryptionKey: roomSeed, network: true, swarmOpts: { bootstrap }, selfKeyPair: kp, selfKey: kp.publicKey, getMembers: async () => members, promoteRetryMs: 9999999 })

  stage = 'A'; A = new StateBaseV3(path.join(ROOT, 'A'), crypto.randomBytes(32), opt(kpA))
  await A.ready(); await A.projectMembers(members)
  stage = 'B'; B = new StateBaseV3(path.join(ROOT, 'B'), A._roomKey, opt(kpB)); await B.ready()
  stage = 'C'; 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)

  // Build the base indexer set {A,B}. (C is a role-3 member, not an indexer.)
  stage = 'growB'; await B.requestWriter({ timeoutMs: 40000, startIndexing: true }); await churnSettle(async () => B._isLocalIndexer() === true, 40000)
  ok(await churnSettle(async () => idxCount(A) === 2 && idxCount(B) === 2, 40000), '1. base indexer set {A,B} = 2')

  // C plays: writes game data → admitted as a data-bearing PLAIN writer.
  stage = 'C.play'; await C.set('c:score', { pts: 100 })
  ok(await churnSettle(async () => (await C.effectiveWritable()) === true, 40000), '2. C is a data-bearing plain writer (played)')
  ok(C._isLocalIndexer() !== true && (C.base.local.length || 0) > 0, '2b. C not an indexer; its writer has data (local.length>0)')

  // Grant C admin.
  stage = 'grantC'; members[2].role = 1; await A.projectMembers(members)
  await churnSettle(async () => (await C.getProjectedRole(hex(kpC.publicKey))) === 1, 30000)

  // CONTROL: in-place start-indexing must NOT promote a data-bearing writer.
  stage = 'inplace'; await C.requestWriter({ timeoutMs: 30000, startIndexing: true })
  await churnSettle(async () => false, 8000)
  ok(C._isLocalIndexer() !== true && idxCount(A) === 2, '3. CONTROL: in-place promotion leaves C a non-indexer (set stays 2)')

  // ── ROTATE: give C a FRESH length-0 writer (keet pairing/keys.js:69,88) ──
  stage = 'rotate'
  const freshKp = crypto.keyPair()
  const freshCore = C.store.get({ keyPair: freshKp })
  await freshCore.ready()
  const oldLocalKey = hex(C.base.local.key)
  await C.base.setLocal(freshCore.key)
  await C.base.update()
  console.log('  [diag] rotated C local: %s → %s (fresh len=%d)', oldLocalKey.slice(0, 12), hex(C.base.local.key).slice(0, 12), C.base.local.length)
  ok(hex(C.base.local.key) !== oldLocalKey && (C.base.local.length || 0) === 0, '3b. C rotated to a FRESH length-0 local writer')

  // Now promote the FRESH writer — the supported (length-0) path.
  stage = 'promote'; await C.requestWriter({ timeoutMs: 60000, startIndexing: true })
  const cIndexed = await churnSettle(async () => C._isLocalIndexer() === true, 60000)
  console.log('  [diag] indexer counts A=%s B=%s C=%s', idxCount(A), idxCount(B), idxCount(C))
  ok(cIndexed, '4. after ROTATION, C is a real indexer')
  ok(await churnSettle(async () => [A, B, C].every(x => idxCount(x) === 3), 40000), '4b. indexer set reaches 3 on all peers')

  // Durability check A: C's PRE-rotation data survived the rotation (nothing destroyed).
  stage = 'olddata'; const oldVal = await C.get('c:score')
  ok(oldVal && oldVal.pts === 100, '5. C\'s pre-rotation data still readable after rotation: ' + JSON.stringify(oldVal))

  // Durability check B: with the GENESIS (A) offline, the rotated peer's quorum finalizes.
  stage = 'closeA'; try { A._closing = true; await A.close() } catch {}
  const key = 'rotated:durable:' + Date.now()
  const signedBefore = C.base.signedLength
  stage = 'writeC'; await C.set(key, { by: 'rotated-C' })
  const finalized = await churnSettle(async () => C.base.signedLength > signedBefore, 90000)
  console.log('  [diag] signedLength C: %d → %d (genesis A offline)', signedBefore, C.base.signedLength)
  const gotOnB = await churnSettle(async () => { try { const v = await B.get(key); return v && v.by === 'rotated-C' } catch { return false } }, 30000)
  ok(finalized && gotOnB, '6. GENESIS OFFLINE: rotated C\'s write FINALIZES + replicates to B (durable quorum 2-of-3)')

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