brainy/tests/unit/cold-open-rebuild-gate.test.ts
David Snelling 61c247c923 fix: cold-open no longer re-derives durable indexes — complete the readiness contract for all three providers
A production deployment measured ~48 seconds on EVERY reopen of an
11k-entity brain. Root cause: brainy's rebuild gate decided from in-memory
size()/count, which read 0 for a durable-but-not-resident index, so it
re-read every entity file to rebuild from scratch. At GA we gave only the
GRAPH provider a readiness contract (init() eager cold-load + isReady()
honest signal) so it would never eat that spurious rebuild; the vector and
metadata providers never got it, and brainy never even eager-inited the
vector provider.

Complete the contract symmetrically:

- plugin.ts: VectorIndexProvider gains optional init()+isReady();
  MetadataIndexProvider gains isReady() — mirroring GraphIndexProvider.
  Additive and optional; a provider that exposes nothing keeps today's
  behavior.
- brainy.ts: eager-init every provider that exposes init() (after metadata
  init() so the id-mapper is hydrated first), then decide per leg in
  precedence order — migrating (skip) -> epoch drift (rebuild) -> isReady()
  -> a per-leg empty fallback. The old instant fast-path keyed off
  this.index.size()>0, a dishonest proxy that skipped the metadata/graph
  checks whenever the vector was warm and never fired on a real cold process
  anyway; removed.

The per-leg fallbacks differ because "empty" means different things: the JS
vector's rebuild() IS its load, so size()===0 correctly triggers it; the
id-mapper backs metadata, so totalEntries===0 (past the empty-store return)
is a real load failure; but entities do not imply edges, so a graph
size()===0 is a valid empty state, not a load failure.

- The JS graph now COLD-LOADS its durable LSM instead of re-deriving from a
  full canonical verb scan on every boot (baseStorage._initializeGraphIndex
  loads the persisted SSTables via a new GraphAdjacencyIndex.init(); it
  self-heals from canonical only when the durable state is genuinely missing).
  This removes an O(E)-per-open cost every filesystem consumer paid.
- LSMTree.loadManifest loads its SSTables BEFORE publishing the relationship
  count, and resets to an honest-empty state on load failure — a tree can no
  longer claim persisted relationships while holding none (the silent-empty
  cold-load class the query-time guards exist to prevent).

Verified end-to-end against a built brain: a warm reopen (with edges and
edgeless) reloads only the JS vector; the graph and metadata cold-load with
no rebuild, and queries return correct results. New tests in
cold-open-rebuild-gate.test.ts pin the contract (isReady() defers, self-heal
still fires); migration-deference updated to drive size-based deference
through the vector, the leg where empty->rebuild remains correct.

Pairs with the native provider's isReady()/init() implementation — brainy's
gate defers only to a signal the provider exposes.
2026-07-07 10:39:00 -07:00

213 lines
7.8 KiB
TypeScript

/**
* Cold-open rebuild gate — the readiness contract (8.0.12).
*
* A production deployment measured 48 seconds on EVERY boot because the
* rebuild gate keyed off in-memory size()/count heuristics that are dishonest
* for durable indexes: a disk-native provider legitimately reports 0 resident
* entries while fully durable, and the JS graph loaded nothing at gate time
* (its LSM initialized lazily) — so `rebuildIndexesIfNeeded()` re-derived
* indexes from a full canonical scan on every open.
*
* The contract pinned here:
* - JS reopen: the graph must COLD-LOAD its persisted LSM (no re-derive; the
* old `_initializeGraphIndex` rebuilt from a full verb scan every boot),
* the metadata leg must not rebuild (id-mapper signal is honest), and
* queries stay correct. The JS vector leg still runs `rebuild()` — that IS
* its load path.
* - A vector provider exposing `isReady() === true` is never rebuilt, even
* at `size() === 0`; `isReady() === false` gets its rebuild. `init?()` is
* eagerly awaited before the gate.
*/
import { describe, it, expect, afterEach } from 'vitest'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { Brainy, NounType, VerbType } from '../../src/index.js'
import { GraphAdjacencyIndex } from '../../src/graph/graphAdjacencyIndex.js'
import { MetadataIndexManager } from '../../src/utils/metadataIndex.js'
const tmpDirs: string[] = []
function mkTmp(): string {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-cold-open-'))
tmpDirs.push(d)
return d
}
afterEach(() => {
for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true })
})
const V = () => Array.from({ length: 384 }, () => Math.random())
/** Populate a brain with nouns + edges and close it. */
async function buildBrain(dir: string, n = 12): Promise<string[]> {
const brain: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
plugins: [],
silent: true
})
await brain.init()
const ids: string[] = []
for (let i = 0; i < n; i++) {
ids.push(
await brain.add({
data: `entity ${i}`,
type: NounType.Concept,
subtype: 's',
metadata: { wave: i % 3 },
vector: V()
})
)
}
for (let i = 0; i + 1 < ids.length; i++) {
await brain.relate({ from: ids[i], to: ids[i + 1], type: VerbType.RelatedTo, subtype: 's' })
}
await brain.close()
return ids
}
describe('Cold-open rebuild gate (readiness contract)', () => {
it('JS reopen: graph cold-loads its LSM (no re-derive), metadata skips, queries correct', async () => {
const dir = mkTmp()
const ids = await buildBrain(dir)
// Spy on the two rebuilds that must NOT run on a healthy reopen.
const graphRebuilds: number[] = []
const metadataRebuilds: number[] = []
const origGraphRebuild = GraphAdjacencyIndex.prototype.rebuild
const origMetaRebuild = MetadataIndexManager.prototype.rebuild
GraphAdjacencyIndex.prototype.rebuild = async function (...args: any[]) {
graphRebuilds.push(1)
return origGraphRebuild.apply(this, args as any)
}
MetadataIndexManager.prototype.rebuild = async function (...args: any[]) {
metadataRebuilds.push(1)
return origMetaRebuild.apply(this, args as any)
}
try {
const brain: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
plugins: [],
silent: true
})
await brain.init()
expect(graphRebuilds.length).toBe(0) // persisted LSM loaded — no O(E) re-derive
expect(metadataRebuilds.length).toBe(0) // id-mapper signal honest — no rebuild
expect(await brain.graphIndex.size()).toBeGreaterThan(0) // durable edges COLD-LOADED at boot
// Correctness after the load — the whole point of not rebuilding.
const byWhere = await brain.find({ type: NounType.Concept, where: { wave: 1 } })
expect(byWhere.length).toBe(4) // waves 1,4,7,10 of 12
const connected = await brain.find({ connected: { from: ids[0], depth: 1 } })
expect(connected.length).toBe(1)
await brain.close()
} finally {
GraphAdjacencyIndex.prototype.rebuild = origGraphRebuild
MetadataIndexManager.prototype.rebuild = origMetaRebuild
}
})
it('a vector provider with isReady()===true is never rebuilt, even at size()===0 (init eagerly awaited)', async () => {
const dir = mkTmp()
await buildBrain(dir, 6)
const calls = { init: 0, rebuild: 0 }
const stubVector = {
addItem: async (item: any) => item?.id ?? 'stub-id',
removeItem: async () => true,
search: async () => [] as Array<[string, number]>,
size: () => 0, // disk-native posture: durable, zero resident
clear: () => {},
rebuild: async () => {
calls.rebuild++
},
flush: async () => 0,
getPersistMode: () => 'deferred' as const,
init: async () => {
calls.init++
},
isReady: () => true
}
const brain: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
plugins: [],
silent: true
})
brain.use({ name: 'fake-native-vector', activate: async (ctx: any) => { ctx.registerProvider('vector', () => stubVector); return true } })
await brain.init()
expect(calls.init).toBe(1) // the eager cold-load ran before the gate
expect(calls.rebuild).toBe(0) // isReady()===true replaced the size()===0 heuristic
await brain.close()
})
it('a vector provider with isReady()===false gets its rebuild (honest in both directions)', async () => {
const dir = mkTmp()
await buildBrain(dir, 6)
const calls = { rebuild: 0 }
let ready = false
const stubVector = {
addItem: async (item: any) => item?.id ?? 'stub-id',
removeItem: async () => true,
search: async () => [] as Array<[string, number]>,
size: () => 999, // even a non-zero size must NOT mask a not-ready provider
clear: () => {},
rebuild: async () => {
calls.rebuild++
ready = true // rebuild restores readiness
},
flush: async () => 0,
getPersistMode: () => 'deferred' as const,
isReady: () => ready
}
const brain: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
plugins: [],
silent: true
})
brain.use({ name: 'fake-native-vector', activate: async (ctx: any) => { ctx.registerProvider('vector', () => stubVector); return true } })
await brain.init()
expect(calls.rebuild).toBe(1)
await brain.close()
})
it('self-heal survives: durable graph state deleted → one rebuild from canonical on reopen', async () => {
const dir = mkTmp()
const ids = await buildBrain(dir, 6)
// Simulate lost durable graph state: remove the persisted LSM artifacts
// (metadata channel keys live under _system hash buckets — nuke the graph
// manifests via the storage API instead of guessing paths).
const wipe: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
plugins: [],
silent: true
})
await wipe.init()
// Clear both LSM trees' persisted manifests through the live index, then
// close WITHOUT letting them re-flush a fresh manifest state.
const gi: any = wipe.graphIndex
await gi.lsmTreeVerbsBySource.clear?.()
await gi.lsmTreeVerbsByTarget.clear?.()
await wipe.close()
const brain: any = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
plugins: [],
silent: true
})
await brain.init()
// Canonical verbs still exist → the boot self-heal must have restored the adjacency.
const connected = await brain.find({ connected: { from: ids[0], depth: 1 } })
expect(connected.length).toBe(1)
await brain.close()
})
})