OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / overlay-reindex.test.mjs

124 lines · javascript ref:
// OFFLINE PROOF (bare) — a node must still serve its record bytes BY OID after a RESTART.
//
// hyper-overlay's serve path resolves contentHash -> disk path through an IN-MEMORY map
// (overlay.js `_contentHashPaths`, populated only by registerFile, handed to protocol.js by
// reference). Nothing rebuilds it at boot, and the persisted FileIndex holds { contentHash, size,
// mtime } WITHOUT the disk path — so the overlay cannot repair itself. Before OverlayRepoStore.ready()
// reindexed, a restarted node could serve ONLY records it had written since boot, even though every
// file was still on disk ("registering a file makes it servable", overlay.js:15).
//
// That silently breaks cross-node byte resolution, and byte resolution GATES SIGNING (the forward-only
// sign gate skips while a converged pointer's bytes are unresolved). Measured on the live fleet: wj62's
// app.bsky.feed.post/3mr6v6r2ofs22 was unresolvable for 75+ hours while the peer holding it had the
// exact bytes on disk — verified byte-for-byte against the pointer's oid — reporting only
// "no connected peer served this oid".
//
// This proves cause AND effect in one run: after a simulated restart B's fetch FAILS, then succeeds
// immediately after reindexOverlay() — nothing else changes.
//
// Cross-node uses a direct SecretStream pair (the testnet DHT does not connect under this bare),
// matching test/overlay-blob-store.test.mjs.
//
// Run: bare test/overlay-reindex.test.mjs
import '../lib/bare-globals.js'
import process from 'bare-process'
import fs from 'bare-fs'
import os from 'bare-os'
import path from 'bare-path'
import crypto from 'hypercore-crypto'
import Protomux from 'protomux'
import SecretStream from '@hyperswarm/secret-stream'
import { HyperOverlay } from 'hyper-overlay'
import { OverlayRepoStore } from 'atproto-store/lib/overlay-repo-store.js'
import { openCorestore } from 'atproto-store/lib/corestore.js'

let failed = 0
const dirs = []
const ok = (c, m) => { if (c) console.log('  ok   -', m); else { console.error('  FAIL -', m); failed++ } }
const tmp = (l) => { const d = path.join(os.tmpdir(), l + '-' + crypto.randomBytes(6).toString('hex')); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const NS = 'reindex-ov'
const POST = 'app.bsky.feed.post'
const record = { $type: POST, text: 'served by oid after a restart', createdAt: '2026-07-25T00:00:00.000Z', langs: ['en'] }

// Try to fetch `oid` on node B, bounded. Returns the bytes or null.
async function tryFetch (ovB, oid, label, tries) {
  const dest = path.join(tmp('fetch-' + label), 'out.bin')
  for (let i = 0; i < tries; i++) {
    const r = await ovB.fetchFile(oid, { destPath: dest }).catch(() => null)
    if (r) { try { return fs.readFileSync(dest) } catch { return null } }
    await sleep(400)
  }
  return null
}

const repoRoot = tmp('reindex-records'); dirs.push(repoRoot)
const storeDirA = tmp('reindex-A'); dirs.push(storeDirA)

// ── A writes one record, which registers it (put's side effect) → we learn its oid ──
let oid = null
{
  const storeA = openCorestore(storeDirA)
  const ovA = new HyperOverlay(storeA, { namespace: NS, destDir: tmp('A-dest') })
  await ovA.ready()
  const repoA = await new OverlayRepoStore(repoRoot, { overlay: ovA, did: 'did:plc:reindex' }).ready()
  const put = await repoA.put(POST, 'r1', record)
  oid = put.oid
  ok(!!oid, 'put registered the record file and returned an overlay oid')
  ok(fs.existsSync(path.join(repoRoot, 'records', POST, 'r1.dagcbor')), 'record bytes are a real file on disk')
  await repoA.close(); await ovA.close(); await storeA.close()
}

// ── A RESTARTS: same corestore + same files on disk, brand-new overlay instance ──
const storeA2 = openCorestore(storeDirA)
const ovA2 = new HyperOverlay(storeA2, { namespace: NS, destDir: tmp('A2-dest') })
await ovA2.ready()
// Constructed WITHOUT ready(), so no reindex yet — this is exactly the pre-fix boot state.
const repoA2 = new OverlayRepoStore(repoRoot, { overlay: ovA2, did: 'did:plc:reindex' })
ok((await repoA2.get(POST, 'r1')) != null, 'after restart the record still reads LOCALLY from disk')

// ── B connects and tries to fetch by oid ──
const storeB = openCorestore(tmp('reindex-B'))
const ovB = new HyperOverlay(storeB, { namespace: NS, destDir: tmp('B-dest') })
await ovB.ready()
const sa = new SecretStream(true); const sb = new SecretStream(false)
sa.rawStream.pipe(sb.rawStream).pipe(sa.rawStream)
ovA2.attachProtocol(Protomux.from(sa))
ovB.attachProtocol(Protomux.from(sb))
await sleep(600)

// CONTROL: the file is on disk and in the persisted index, but unregistered in this process.
const before = await tryFetch(ovB, oid, 'before', 4)
ok(before === null, 'BEFORE reindex: B CANNOT fetch by oid — the restarted node serves nothing it did not write this boot')

// ── THE FIX: ready() seeds the overlay path maps from the PERSISTED index (no hashing, no file reads) ──
await repoA2.ready()

const after = await tryFetch(ovB, oid, 'after', 20)
ok(after !== null, 'AFTER ready() seeds from the index: B fetches the bytes by oid — serving restored, nothing re-hashed')
if (after) {
  const local = fs.readFileSync(path.join(repoRoot, 'records', POST, 'r1.dagcbor'))
  ok(after.length === local.length, 'fetched bytes are the same length as the on-disk record')
}

// Release A2's corestore FIRST — a second Corestore on the same dir cannot take the RocksDB lock.
try { sa.destroy(); sb.destroy() } catch {}
await ovA2.close(); await storeA2.close(); await ovB.close(); await storeB.close()

// ready() must do this automatically — a consumer should never have to remember.
{
  const storeA3 = openCorestore(storeDirA)
  const ovA3 = new HyperOverlay(storeA3, { namespace: NS, destDir: tmp('A3-dest') })
  await ovA3.ready()
  const repoA3 = await new OverlayRepoStore(repoRoot, { overlay: ovA3, did: 'did:plc:reindex' }).ready()
  // The heavier explicit repair still works, for a store whose INDEX is missing/stale rather than
  // just its in-memory maps. Not a boot step — it re-hashes every file.
  const s = await repoA3.reindexOverlay()
  ok(s.registered === 1 && s.failed === 0, 'reindexOverlay remains available as the explicit repair (idempotent)')
  await repoA3.close(); await ovA3.close(); await storeA3.close()
}

for (const d of dirs) { try { fs.rmSync(d, { recursive: true, force: true }) } catch {} }
console.log(failed ? `\nOVERLAY-REINDEX: ${failed} FAILED` : '\nOVERLAY-REINDEX OK: a restarted node re-registers its record files, so peers can resolve bytes by oid again')
process.exit(failed ? 1 : 0)