// VALIDATION: Q2 (#103) — does the pending-indexer transition + finalization settle
// under LOW churn? The passing probe-indexer-quorum.mjs drove aggressive churn (a write
// from every peer every 150ms), which is the classic way to unstick an autobase. The
// open question was whether the 3-indexer grow only worked BECAUSE of that churn.
//
// This probe grows the set to 3 indexers, then does exactly ONE write and advances the
// bases ONLY via base.update() polling — NO heartbeat writes. Autobase auto-acks on its
// ackInterval (1000ms), so consensus should still advance without app-level churn. If
// finalization needs churn, this hangs (watchdog) or step 4 fails.
//
// PASS =
// - the set reaches 3 indexers, AND
// - a single write finalizes (signedLength advances) + replicates under update-only polling.
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-lowchurn-' + 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
// Full churn — used ONLY to build the initial 3-indexer set (the setup, not the thing
// under test). The finalization step below uses updateOnly (no writes).
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()
}
// LOW churn — advance consensus with NO app writes, relying on autobase auto-ack only.
const updateOnlySettle = async (predicate, maxMs = 45000) => {
const deadline = Date.now() + maxMs
while (Date.now() < deadline) {
for (const x of live()) await x.base.update().catch(() => {})
await sleep(500)
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)
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)
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)
// SETUP (full churn allowed): grow to 3 indexers.
stage = 'growB'; await B.requestWriter({ timeoutMs: 40000, startIndexing: true }); await churnSettle(async () => B._isLocalIndexer() === true, 40000)
stage = 'growC'; await C.requestWriter({ timeoutMs: 40000, startIndexing: true }); await churnSettle(async () => C._isLocalIndexer() === true, 40000)
const reached3 = await churnSettle(async () => [A, B, C].every(x => idxCount(x) === 3), 60000)
ok(reached3, '1. set reached 3 indexers (setup)')
// Quiesce: let any in-flight churn drain so the finalization test starts from rest.
stage = 'quiesce'; await updateOnlySettle(async () => false, 4000)
// THE TEST: one write, then NO app churn — only base.update() polling. Autobase's
// 1000ms auto-ack must carry it to finality across the 3-indexer quorum.
const key = 'lowchurn:probe:' + Date.now()
const signedBefore = A.base.signedLength
stage = 'writeA'; await A.set(key, { n: 7, by: 'A' })
stage = 'finalize'; const finalized = await updateOnlySettle(async () => A.base.signedLength > signedBefore, 45000)
console.log(' [diag] signedLength A: %d → %d (update-only, no churn)', signedBefore, A.base.signedLength)
ok(finalized, '2. single write FINALIZES under low churn (update-only): signedLength ' + signedBefore + '→' + A.base.signedLength)
stage = 'replicate'; const gotOnC = await updateOnlySettle(async () => { try { const v = await C.get(key); return v && v.n === 7 } catch { return false } }, 30000)
ok(gotOnC, '3. write REPLICATED to C under low churn')
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) })