OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / probe-inplace-promote-min.mjs

97 lines · javascript ref:
// MINIMAL REPRO (deep-debugging): isolate whether an EXISTING plain writer (one that
// already appended data) can be promoted to indexer IN PLACE via start-indexing —
// controlling for whether the promotee keeps WRITING during the promotion.
//
// Two nodes only (fast): A = genesis admin (sole indexer, quorum 1); C = admin. C opens,
// writes data (→ admitted plain writer, indexer:false), then start-indexes to become the
// 2nd indexer. This is the 1→2 in-place case — the smallest form of the mid-run failure.
//
// Toggle CHURN_C=1 to have C keep writing during the promotion window; default (unset) =
// C QUIESCES before promoting. Prediction under the "concurrent-writes-block-the-fork"
// hypothesis: quiescent → PASS, churning → FAIL. If quiescent ALSO fails, in-place
// promotion of a data-bearing writer is fundamentally unsupported (fix must avoid the
// plain-writer-first state).
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 CHURN_C = !!process.env.CHURN_C
const ROOT = path.join(os.tmpdir(), 'sbv3-inplace-min-' + Bare.pid)
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
const hex = (b) => b4a.toString(b, 'hex')

const kps = [crypto.keyPair(), crypto.keyPair()].sort((x, y) => hex(x.publicKey) < hex(y.publicKey) ? -1 : 1)
const [kpA, kpC] = kps
const members = [
  { key: hex(kpA.publicKey), role: 1 }, // genesis admin
  { key: hex(kpC.publicKey), role: 1 }  // admin (promoted in place after writing)
]

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, C
const live = () => [A, C].filter(x => x && x.base && !x._closing)
let hb = 0
// churnWho: which nodes append heartbeats. A always churns (drives consensus); C only if CHURN_C.
const settle = async (predicate, maxMs, churnC) => {
  const deadline = Date.now() + maxMs
  while (Date.now() < deadline) {
    if (A.base.writable) { try { await A.set('__hbA__', hb++) } catch {} }
    if (churnC && C.base.writable) { try { await C.set('__hbC__', 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) }, 150000)

async function main () {
  console.log('  MODE: C ' + (CHURN_C ? 'KEEPS WRITING' : 'QUIESCES') + ' during promotion')
  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: 999999 })

  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 && idxCount(A) === 1, '1. A = genesis, sole indexer')

  stage = 'C.ready'; C = new StateBaseV3(path.join(ROOT, 'C'), A._roomKey, opt(kpC)); await C.ready()
  await settle(async () => (C.base.length || 0) > 0, 45000, true)

  // C writes real data → admitted as a PLAIN writer (indexer:false). This is the state
  // that differs from a fresh indexer add.
  stage = 'C.write'; await C.set('c:data', { hello: 'world' })
  const cAdmitted = await settle(async () => (await C.effectiveWritable()) === true, 40000, true)
  ok(cAdmitted, '2. C admitted as a plain writer after writing data')
  ok(C._isLocalIndexer() !== true, '2b. C is not yet an indexer')

  // Optional quiesce: give consensus a moment to drain C's writes before promoting.
  if (!CHURN_C) { stage = 'quiesce'; await settle(async () => false, 4000, false) }

  // THE PROMOTION: existing plain writer C → indexer, in place.
  stage = 'promote'; await C.requestWriter({ timeoutMs: 60000, startIndexing: true })
  const cIndexed = await settle(async () => C._isLocalIndexer() === true, 60000, CHURN_C)
  console.log('  [diag] indexer counts A=%s C=%s ; C.writable=%s', idxCount(A), idxCount(C), C.base.writable)
  ok(cIndexed, '3. existing plain writer C PROMOTED to indexer in place (set 1→2)')
  ok(idxCount(A) === 2, '3b. A also sees the 2-indexer set')

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