OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / probe-rotation-setlocal-vs-rejoin.mjs

174 lines · javascript ref:
// HEAD-TO-HEAD: setLocal rotation vs leave/rejoin — prove that rotating a data-bearing
// player to a FRESH writer via base.setLocal() (in place, no teardown) leaves it a FULLY
// HEALTHY indexer, and compare against the heavier close+reopen ("leave/rejoin").
//
//   ROTATE_METHOD=setlocal (default): freshCore = store.get({keyPair}); base.setLocal(key)
//   ROTATE_METHOD=rejoin            : close the StateBase, reopen a NEW one at a fresh
//                                      storage dir (fresh corestore → fresh auto-derived
//                                      writer, length 0), same roomSeed/selfKey.
//
// Both must yield the SAME outcome: C becomes a healthy indexer. The point is to prove
// setLocal is EQUIVALENT (and lighter — keeps the corestore, no re-replicate from scratch).
//
// HEALTH CHECKS (identical for both methods):
//   1  base indexer set {A,B}=2; C is a data-bearing plain writer (not an indexer)
//   2  A's committed key a:world readable on C BEFORE rotation (baseline: C reads shared state)
//   3  after rotation C is an indexer + clean 3-indexer set
//   4  C still reads A's committed a:world after rotation (reads OTHERS' committed state)
//   5  C's NEW post-rotation write finalizes (signedLength advances) + replicates to B
//   6  GENESIS OFFLINE: C's write still finalizes (B+C quorum) — the durability payoff
//   7  C's pre-rotation own write (c:score) survived on B (nothing destroyed)
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'
import process from 'bare-process'

const METHOD = (process.env.ROTATE_METHOD || 'setlocal').toLowerCase()
const ROOT = path.join(os.tmpdir(), 'sbv3-rotcmp-' + Bare.pid)
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
const hex = (b) => b4a.toString(b, 'hex')

// A and B are BOTH admins (role 1); _amCreator picks the lex-lowest role-1 key as genesis.
// A is opened first + calls projectMembers, so A MUST be that genesis → force kpA lex-lowest
// of the two role-1 keys. (Unsorted = nondeterministic genesis = the flaky setup.)
let kpA = crypto.keyPair(); let kpB = crypto.keyPair()
if (hex(kpB.publicKey) < hex(kpA.publicKey)) { const t = kpA; kpA = kpB; kpB = t }
const kpC = crypto.keyPair()
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, 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 '?' } }
const getK = async (sb, k) => { try { const v = await sb.get(k); return v } catch { return 'ERR' } }

let A, B, C
let bootstrap = null; let roomSeed = null
const optFor = (kp) => ({ roomSeed, encryptionKey: roomSeed, network: true, swarmOpts: { bootstrap }, selfKeyPair: kp, selfKey: kp.publicKey, getMembers: async () => members, promoteRetryMs: 9999999 })
const live = () => [A, B, C].filter(x => x && x.base && !x._closing)
let hb = 0
// Churn EVERY live writable peer (matches probe-indexer-quorum, which is reliably green).
// Only-churning-A was starving the linearizer of B's acks → flaky 1→2 forks. writeC=false
// quiesces C specifically (used around rotation).
const settle = async (predicate, maxMs, writeC = true) => {
  const deadline = Date.now() + maxMs
  while (Date.now() < deadline) {
    for (const x of live()) {
      if (x.base.writable && (x !== C || writeC)) { 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 () {
  console.log('  METHOD: ' + METHOD)
  stage = 'testnet'
  const testnet = await createTestnet(3); bootstrap = testnet.bootstrap; roomSeed = crypto.randomBytes(32)

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

  stage = 'growB'; await B.requestWriter({ timeoutMs: 60000, startIndexing: true })
  const reached2 = await settle(async () => idxCount(A) === 2 && idxCount(B) === 2 && B._isLocalIndexer() === true, 90000)
  // A writes a committed shared key for the read checks.
  await A.set('a:world', { n: 1 })
  ok(reached2 && C._isLocalIndexer() !== true, '1. {A,B}=2 indexers; C not an indexer')

  // C plays (data-bearing), then is granted admin.
  stage = 'C.play'; await C.set('c:score', { pts: 100 })
  await settle(async () => (await C.effectiveWritable()) === true, 40000)
  ok(await settle(async () => { const v = await getK(C, 'a:world'); return v && v.n === 1 }, 45000), '2. C reads A\'s committed a:world BEFORE rotation')
  ok((C.base.local.length || 0) > 0, '2b. C\'s writer is data-bearing (local.length>0)')
  stage = 'grantC'; members[2].role = 1; await A.projectMembers(members)
  await settle(async () => (await C.getProjectedRole(hex(kpC.publicKey))) === 1, 30000)
  await settle(async () => false, 4000, false) // quiesce C

  // ── ROTATE via the selected method ──
  if (METHOD === 'rejoin') {
    stage = 'rejoin'
    try { C._closing = true; await C.close() } catch {}
    C = new StateBaseV3(path.join(ROOT, 'C2-rejoin'), roomKey, optFor(kpC)) // fresh storage → fresh writer
    await C.ready()
    await settle(async () => (C.base.length || 0) > 0, 45000, false)
    console.log('  [diag] rejoined: fresh C local len=%d', C.base.local.length)
    ok((C.base.local.length || 0) === 0, '3a. (rejoin) reopened C has a FRESH length-0 writer')
  } else {
    stage = 'setlocal'
    const freshKp = crypto.keyPair()
    const freshCore = C.store.get({ keyPair: freshKp }); await freshCore.ready()
    const oldKey = hex(C.base.local.key)
    await C.base.setLocal(freshCore.key); await C.base.update()
    console.log('  [diag] setLocal: %s → %s len=%d', oldKey.slice(0, 10), hex(C.base.local.key).slice(0, 10), C.base.local.length)
    ok(hex(C.base.local.key) !== oldKey && (C.base.local.length || 0) === 0, '3a. (setlocal) C rotated to a FRESH length-0 writer, in place')
  }

  // Promote the fresh writer.
  stage = 'promote'; await C.requestWriter({ timeoutMs: 60000, startIndexing: true })
  ok(await settle(async () => C._isLocalIndexer() === true, 60000), '3b. C is a real indexer after rotation')
  ok(await settle(async () => [A, B, C].every(x => idxCount(x) === 3), 40000), '3c. clean 3-indexer set')

  // HEALTH: reads OTHERS' committed state.
  ok(await settle(async () => { const v = await getK(C, 'a:world'); return v && v.n === 1 }, 40000), '4. C reads A\'s committed a:world AFTER rotation (healthy read)')

  // HEALTH: C's new write FINALIZES. Measure on the STABLE indexer B (a freshly-rotated
  // writer's OWN signedLength lags); require B's signed length to advance AND B to read it.
  stage = 'C.write2'; const k2 = 'c:post:' + Date.now(); const sB = B.base.signedLength
  await C.set(k2, { ok: true })
  const finalized = await settle(async () => { const v = await getK(B, k2); return B.base.signedLength > sB && v && v.ok === true }, 60000)
  console.log('  [diag] post-rotation write: B.signedLength %d→%d, B reads k2=%s', sB, B.base.signedLength, JSON.stringify(await getK(B, k2)))
  ok(finalized, '5. C\'s post-rotation write FINALIZES (B.signedLength advances) + readable on B')

  // DURABILITY: genesis offline, the B+C quorum must still finalize (B.signedLength advances).
  stage = 'closeA'; try { A._closing = true; await A.close() } catch {}
  const k3 = 'c:durable:' + Date.now(); const s2 = B.base.signedLength
  await C.set(k3, { by: 'C' })
  const fin2 = await settle(async () => { const v = await getK(B, k3); return B.base.signedLength > s2 && v && v.by === 'C' }, 90000)
  console.log('  [diag] genesis offline: B.signedLength %d→%d, B reads k3=%s', s2, B.base.signedLength, JSON.stringify(await getK(B, k3)))
  ok(fin2, '6. GENESIS OFFLINE: B+C quorum FINALIZES C\'s write (B.signedLength advances) — durability payoff')
  ok((await getK(B, 'c:score')) && true, '7. C\'s pre-rotation c:score still present on B (nothing destroyed): ' + JSON.stringify(await getK(B, 'c:score')))

  // DEFINITIVE finalization proof: a FRESH COLD reader (new storage, never joined) replicates
  // and reads C's durable write. A cold non-writer only ever obtains genuinely-finalized
  // (indexer-signed) state — so this reading k3 proves real finalization, not optimistic apply.
  stage = 'freshReader'
  const R2 = new StateBaseV3(path.join(ROOT, 'R2-cold'), roomKey, optFor(crypto.keyPair()))
  await R2.ready()
  const r2sees = await (async () => {
    const dl = Date.now() + 60000
    while (Date.now() < dl) {
      for (const x of [B, C, R2]) { if (x && x.base) await x.base.update().catch(() => {}) }
      if (B.base.writable) { try { await B.set('__hbB__', hb++) } catch {} }
      const v = await getK(R2, k3)
      if (v && v.by === 'C') return true
      await sleep(200)
    }
    return false
  })()
  console.log('  [diag] cold reader R2 reads k3=%s c:score=%s', JSON.stringify(await getK(R2, k3)), JSON.stringify(await getK(R2, 'c:score')))
  ok(r2sees, '8. FRESH COLD reader replicates + reads C\'s durable write (genuine finalization)')
  try { R2._closing = true; await R2.close() } catch {}

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

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