OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / probe-indexer-promote-midrun.mjs

130 lines · javascript ref:
// VALIDATION: the keet-faithful model for a member GRANTED admin at runtime, and the
// autobase constraint that shapes it (#103 open-question 1 + the in-place-promotion
// investigation).
//
// KEY FINDING: autobase 7.28.1 can only elevate a writer to indexer while it is FRESH
// (base.local.length === 0). A writer that already appended data cannot be promoted in
// place — the manifest fork keeps rolling back (apply-state _rollbackViews; see
// memory/project_statebase-inplace-indexer-promotion). keet has the same constraint: it
// mints indexers only from FRESH devices/joins (addDevice → addWriter(deviceKey,
// {indexer:isIndexing})); its change-member-role op sets the admin CAPABILITY only, never
// addWriter. So "admin" (a permission) and "indexer" (an autobase co-signer) are decoupled.
//
// Scenario: A (genesis admin) + B (admin) form the indexer set; C opens as a plain MEMBER
// (role 3), WRITES game data (→ data-bearing plain writer), then is GRANTED admin (role 1)
// and the roster is re-projected. CORRECT behavior:
//   - C is NOT an indexer while role 3 (non-admins never self-promote), AND
//   - after the grant, C gains the admin CAPABILITY (role 1 in __members__) but — because
//     its writer already has data — does NOT become an indexer (and _maybePromote does NOT
//     futilely churn the set), AND
//   - C stays a healthy admitted WRITER whose writes still persist + replicate via the
//     existing indexer quorum (durability is provided by the fresh/anchor indexers).
// (Separately, the P3 re-fire — timer + projectMembers hook — DOES promote an admin whose
// role becomes visible while its writer is still fresh; that is exercised by
// probe-indexer-quorum.mjs and the fresh-open path, not here.)
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-promote-midrun-' + Bare.pid)
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
const hex = (b) => b4a.toString(b, 'hex')

// Three keypairs; lex-lowest = genesis creator (A). A and B start admin (role 1);
// C starts as a plain member (role 3) and is promoted mid-run.
const kps = [crypto.keyPair(), crypto.keyPair(), crypto.keyPair()].sort((x, y) => hex(x.publicKey) < hex(y.publicKey) ? -1 : 1)
const [kpA, kpB, kpC] = kps
// Live, MUTABLE roster — mutating an entry's role simulates a runtime grant-admin;
// getMembers returns this same reference so every peer's projection sees the change.
const members = [
  { key: hex(kpA.publicKey), role: 1 }, // genesis creator (admin)
  { key: hex(kpB.publicKey), role: 1 }, // admin
  { key: hex(kpC.publicKey), role: 3 }  // plain member — promoted later
]

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)
  // promoteRetryMs small so the P3 re-fire timer is exercised quickly + deterministically.
  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)
  ok(A.writable, '1. A (lex-lowest admin) = 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)

  // Grow the initial admin indexer set to {A,B} (C is only a member so far).
  stage = 'growB'; await B.requestWriter({ timeoutMs: 40000, startIndexing: true }); await churnSettle(async () => B._isLocalIndexer() === true, 40000)
  ok(B._isLocalIndexer() === true, '2. B (admin) self-promoted to indexer')

  // C writes as a plain member so it becomes an admitted (indexer:false) writer.
  // NOTE: base.writable lags admission — an indexer admits our optimistic block into
  // the system writer set before base.writable's localWriter activation, so the
  // authoritative "admitted" signal is effectiveWritable() (#99), not base.writable.
  stage = 'C.write'; await C.set('c:hello', { ok: true })
  const cAdmitted = await churnSettle(async () => (await C.effectiveWritable()) === true, 40000)
  ok(cAdmitted, '3. C admitted as a plain writer (member)')
  ok(C._isLocalIndexer() !== true, '3b. C is NOT an indexer while role 3 (non-admin never self-promotes)')

  // ── The P3 event: C is granted admin at runtime; re-project the roster ──
  stage = 'promoteC'
  members[2].role = 1 // grant-admin
  // Re-project from an existing writer (A) so the updated __members__ replicates to C.
  await A.projectMembers(members)
  await churnSettle(async () => (await C.getProjectedRole(hex(kpC.publicKey))) === 1, 30000)
  ok((await C.getProjectedRole(hex(kpC.publicKey))) === 1, '3c. C sees its new admin role in the projected __members__')

  // CORRECT behavior: C already has data → NOT promotable to indexer in place. Give the
  // re-fire timer several ticks and prove it does NOT futilely churn the indexer set.
  stage = 'C.postpromote'
  await churnSettle(async () => false, 14000)
  console.log('  [diag] indexer counts A=%s B=%s C=%s', idxCount(A), idxCount(B), idxCount(C))
  ok(C._isLocalIndexer() !== true, '4. data-bearing C does NOT become an indexer (autobase length-0 constraint) — correct, not a bug')
  ok(idxCount(A) === 2 && idxCount(B) === 2, '4b. indexer set stays at 2 — no futile promotion churn')
  ok((await C.effectiveWritable()) === true, '4c. C keeps admin capability + remains a healthy admitted WRITER')

  // Durability preserved: a NEW write from C persists + replicates via the existing quorum.
  stage = 'C.write2'
  const k2 = 'c:after-grant:' + Date.now()
  await C.set(k2, { ok: true })
  const gotOnA = await churnSettle(async () => { try { const v = await A.get(k2); return v && v.ok === true } catch { return false } }, 40000)
  ok(gotOnA, '5. C\'s writes still persist + replicate to A (durability via the existing indexers)')

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