OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / self-heal-separate-corestore.test.js

160 lines · javascript ref:
// pear-git #3 Increment 2b: SEPARATE-corestore self-heal for a broken
// standalone tracker.
//
// Background (Increment 2, self-heal-resync.test.js): a per-repo tracker whose
// autobase manifest is migrated out from under a standalone GENESIS peer
// crash-loops on boot — the peer's own WRITABLE genesis writer core advances its
// stale authored state across the migration, hits a view key it can't resolve
// locally, and `apply-state.js` derefs a null view `ref` in `_startTrace`
// (gated by `node.writer.core.writable`). Increment 2 reopened the tracker under
// a fresh corestore NAMESPACE — but a namespace is NOT a clean slate: the same
// store still resolves the writable genesis core BY KEY, so it re-crashes.
//
// Increment 2b reopens the tracker against a genuinely SEPARATE Corestore (its
// own storage path) that replicates from peers. This test proves the mechanism
// that makes that work, without needing to synthesize the exact prod SIGABRT
// (which depends on a precise migration/partial-replication interleaving):
//
//   1. HEAL — a fresh separate store opened with the tracker bootstrapKey,
//      replicating from a healthy MIGRATED peer, converges like a mirror: the
//      migrated view materializes and every issue is readable.
//   2. CRASH GATE — the genesis writer core is WRITABLE in the origin store (the
//      `node.writer.core.writable` gate that crashes), and STILL writable through
//      a fresh namespace on that store (so Increment 2 can't escape it), but is a
//      READ-ONLY replica in a separate store (so Increment 2b heals like a mirror).
import test from 'brittle'
import Corestore from 'corestore'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { HyperIssues } from 'hyper-issues'
import { replicate, replicateAndSync } from 'autobase-test-helpers'

function tmp () { return mkdtempSync(join(tmpdir(), 'pgv3-heal-')) }
const viewKey = (t) => t.base.system.views.map(v => v.key.toString('hex')).join(',')

async function settle (bases, rounds = 30) {
  for (let i = 0; i < rounds; i++) {
    for (const b of bases) { try { await b.ack() } catch {} }
    await replicateAndSync(bases)
  }
}

// Build a healthy tracker on peers B + C whose manifest has migrated (indexer
// set changed -> new entropy-derived view key) well past genesis, with content.
async function buildMigratedTracker (dir) {
  const storeA = new Corestore(join(dir, 'A')); await storeA.ready()
  const A = new HyperIssues(storeA, { namespace: 'ns' }); await A.ready()
  const key = A.keyHex
  await A.create({ title: 'issue-1', body: 'genesis', author: 'A' })

  const storeB = new Corestore(join(dir, 'B')); await storeB.ready()
  const B = new HyperIssues(storeB, { namespace: 'ns', bootstrapKey: key }); await B.ready()
  const storeC = new Corestore(join(dir, 'C')); await storeC.ready()
  const C = new HyperIssues(storeC, { namespace: 'ns', bootstrapKey: key }); await C.ready()
  await replicateAndSync([A.base, B.base, C.base])

  // migration: add B + C as indexers (entropy view key changes)
  await A.addWriter('B', { writerKey: B.localKeyHex, role: 'owner' })
  await A.addWriter('C', { writerKey: C.localKeyHex, role: 'owner' })
  await settle([A.base, B.base, C.base])
  // grow the view so it is materially past genesis
  for (let i = 0; i < 40; i++) await B.create({ title: 'issue-B' + i, body: 'x'.repeat(40), author: 'B' })
  await settle([A.base, B.base, C.base])

  const genesisLocalKey = A.base.local.key
  return {
    key,
    genesisLocalKey,
    migratedView: viewKey(B),
    all: await B.list({ status: 'all' }),
    peers: [B, C],
    stores: { A: storeA, B: storeB, C: storeC },
    trackers: { A, B, C }
  }
}

test('Increment 2b HEAL — a separate corestore replicates a migrated tracker like a mirror', async (t) => {
  const dir = tmp()
  const g = await buildMigratedTracker(dir)
  t.ok(g.all.length >= 41, `healthy migrated tracker has content (${g.all.length} issues)`)

  // Open the tracker in a FRESH, SEPARATE corestore (its own path) with the same
  // bootstrapKey — exactly what Increment 2b does for a broken tracker.
  const healStore = new Corestore(join(dir, 'HEAL')); await healStore.ready()
  const H = new HyperIssues(healStore, { namespace: 'ns', bootstrapKey: g.key })
  await H.ready()

  // Replicate from the healthy peers and poll until the issue DATA is readable
  // (reaching the view KEY is not enough — the view blocks must download too).
  const done = replicate([H.base, g.peers[0].base, g.peers[1].base])
  let healed = []
  let converged = false
  for (let i = 0; i < 100; i++) {
    try { await H.base.update() } catch {}
    await new Promise((r) => setTimeout(r, 100))
    if (viewKey(H) === g.migratedView) {
      try { healed = await H.list({ status: 'all' }) } catch { healed = [] }
      if (healed.length === g.all.length) { converged = true; break }
    }
  }
  await done()

  t.ok(converged, 'separate store converged on the migrated view + readable issue data')
  t.is(viewKey(H), g.migratedView, 'separate store reached the migrated view key')
  t.is(healed.length, g.all.length, 'separate store materialized every issue (heals like a mirror)')
  t.absent(H.base.writable, 'separate store is a READ-ONLY replica — no stale writable local writer to advance')

  t.teardown(async () => {
    try { await H.close(); await healStore.close() } catch {}
    for (const name of ['A', 'B', 'C']) {
      try { await g.trackers[name].close() } catch {}
      try { await g.stores[name].close() } catch {}
    }
  })
})

test('Increment 2b CRASH GATE — genesis core writable in origin store (incl. fresh namespace), read-only in a separate store', async (t) => {
  const dir = tmp()
  const g = await buildMigratedTracker(dir)

  // The crash is gated by `node.writer.core.writable` (apply-state.js:1256): only
  // a WRITABLE local writer reaches _startTrace and derefs the null view ref.
  const originStore = g.stores.A

  // (a) In the origin store the genesis writer core is WRITABLE — the crash gate.
  const c1 = originStore.get({ key: g.genesisLocalKey })
  await c1.ready()
  t.is(c1.writable, true, 'genesis writer core is WRITABLE in the origin store (the crash gate)')
  await c1.close()

  // (b) A fresh NAMESPACE on the SAME store STILL resolves that core by key and it
  //     is STILL writable — Increment 2 (fresh namespace) cannot escape the gate.
  const nsStore = originStore.namespace('ns#r1')
  const c2 = nsStore.get({ key: g.genesisLocalKey })
  await c2.ready()
  t.is(c2.writable, true, 'fresh-namespace view of the SAME store keeps the genesis core WRITABLE (Increment 2 cannot escape the crash gate)')
  await c2.close()

  // (c) In a genuinely SEPARATE store the genesis core, once replicated, is a
  //     READ-ONLY replica — no writable local writer to advance, so Increment 2b
  //     takes the fast-forward/mirror path and heals.
  const sepStore = new Corestore(join(dir, 'SEP')); await sepStore.ready()
  const S = new HyperIssues(sepStore, { namespace: 'ns', bootstrapKey: g.key }); await S.ready()
  const done = replicate([S.base, g.peers[0].base, g.peers[1].base])
  for (let i = 0; i < 60; i++) { try { await S.base.update() } catch {} await new Promise((r) => setTimeout(r, 100)); if (viewKey(S) === g.migratedView) break }
  await done()
  const c3 = sepStore.get({ key: g.genesisLocalKey })
  await c3.ready()
  t.is(c3.writable, false, 'separate store holds the genesis core READ-ONLY (writable=false) — heals like a mirror')
  await c3.close()

  t.teardown(async () => {
    try { await S.close(); await sepStore.close() } catch {}
    for (const name of ['A', 'B', 'C']) {
      try { await g.trackers[name].close() } catch {}
      try { await g.stores[name].close() } catch {}
    }
  })
})