On a cold process start of a large brain (above the eager index-rebuild
threshold), a native graph adjacency can report size()>0 — its membership set
reloaded — while the source->target edges did NOT load, so find({connected}),
neighbors() and related() returned [] for edges that are persisted on disk. A
database returning empty for data that exists, based purely on warm/cold state,
is a correctness bug; it hit a production deployment after every deploy.
Refactor the cold-load guard into verifyGraphAdjacencyLive(), which gates its
heal/throw decision on a GLOBAL known-edge sample (a persisted verb's source,
which by definition has an outgoing edge) rather than the queried anchor: a
stale adjacency is rebuilt from storage, an unrecoverable one throws
GraphIndexNotReadyError instead of serving [], and a genuinely edgeless anchor
still returns [] with no spurious rebuild. executeGraphSearch re-verifies before
trusting an empty connected result and re-collects after a heal. Adds a 5-case
integration test (self-heal / loud-throw / edgeless-no-false-positive / healthy
/ re-collect) plus updated unit coverage.
170 lines
7.7 KiB
TypeScript
170 lines
7.7 KiB
TypeScript
/**
|
|
* @module tests/integration/cold-graph-connected
|
|
* @description BRAINY-COLD-GRAPH-CONNECTED — regression coverage for the silent-empty
|
|
* graph-traversal bug. On the FIRST `find({ connected })` after a cold process start of a
|
|
* LARGE brain (≥10k nouns, which skips the eager index rebuild), a native graph adjacency can
|
|
* reload its relationship COUNT (so `size() > 0`) but NOT its source→target edges — so
|
|
* `getNeighbors()` returns `[]` for EVERY source and brainy would serve that `[]` as if the
|
|
* anchor were genuinely edgeless.
|
|
*
|
|
* The fix verifies any empty traversal against a GLOBAL known-edge sample (a real persisted
|
|
* verb's `sourceId`, which by definition HAS an outgoing edge) rather than the queried anchor:
|
|
* - a cold-unloaded adjacency is rebuilt from storage and the traversal re-runs ('rebuilt');
|
|
* - if even a rebuild cannot serve a known edge, it throws {@link GraphIndexNotReadyError}
|
|
* instead of returning `[]`;
|
|
* - a genuinely edgeless anchor (while OTHER edges exist) verifies 'live' and the empty
|
|
* result stands — no spurious rebuild, no throw.
|
|
*
|
|
* These exercise REAL `find({ connected })` against an in-memory brain whose graph index is
|
|
* instrumented to simulate the empty-then-(maybe-)rebuilt cold load — only `getNeighbors`/
|
|
* `rebuild` are wrapped; the underlying real adjacency (built by `relate()`) is unmasked once
|
|
* a rebuild "heals" it.
|
|
*/
|
|
|
|
import { describe, it, expect, afterEach } from 'vitest'
|
|
import { Brainy } from '../../src/index.js'
|
|
import { NounType, VerbType } from '../../src/types/graphTypes.js'
|
|
import { GraphIndexNotReadyError } from '../../src/errors/brainyError.js'
|
|
import { createTestConfig } from '../helpers/test-factory.js'
|
|
|
|
/**
|
|
* Build a real in-memory brain. Always seeds an UNRELATED edge (`E -> F`) so a GLOBAL known-edge
|
|
* sample exists for the verify probe even when the queried anchor is genuinely edgeless. When
|
|
* `anchorEdges` is set, the anchor links out to three target nouns via `Knows`. Ids are brainy-
|
|
* generated (7.x requires UUID-format ids) and returned for assertions.
|
|
*/
|
|
async function buildBrain(
|
|
opts: { anchorEdges: boolean }
|
|
): Promise<{ brain: any; anchorId: string; targetIds: string[] }> {
|
|
const brain: any = new Brainy(createTestConfig({ silent: true }))
|
|
await brain.init()
|
|
|
|
// Unrelated edge — guarantees storage.getVerbs() always yields a known-edge source.
|
|
const eId = await brain.add({ data: 'node E', type: NounType.Person })
|
|
const fId = await brain.add({ data: 'node F', type: NounType.Person })
|
|
await brain.relate({ from: eId, to: fId, type: VerbType.Knows })
|
|
|
|
const anchorId = await brain.add({ data: 'anchor', type: NounType.Person })
|
|
|
|
const targetIds: string[] = []
|
|
if (opts.anchorEdges) {
|
|
for (const label of ['B', 'C', 'D']) {
|
|
const tId = await brain.add({ data: `node ${label}`, type: NounType.Person })
|
|
await brain.relate({ from: anchorId, to: tId, type: VerbType.Knows })
|
|
targetIds.push(tId)
|
|
}
|
|
}
|
|
|
|
// Fresh cold-start state: nothing verified yet.
|
|
brain._graphAdjacencyVerified = false
|
|
return { brain, anchorId, targetIds }
|
|
}
|
|
|
|
/**
|
|
* Wrap the brain's real graph index so `getNeighbors` returns `[]` while `broken` (modelling the
|
|
* cold-unloaded adjacency) and delegates to the REAL index once a rebuild flips `broken` off.
|
|
* `rebuild` is counted; it heals only when `healsOnRebuild` is set.
|
|
*/
|
|
function instrumentGraphIndex(
|
|
brain: any,
|
|
opts: { broken: boolean; healsOnRebuild: boolean }
|
|
): { rebuildCalls: number } {
|
|
const gi = brain.graphIndex
|
|
const origGetNeighbors = gi.getNeighbors.bind(gi)
|
|
const state = { broken: opts.broken, rebuildCalls: 0 }
|
|
|
|
gi.getNeighbors = async (id: string, optionsOrDirection?: any): Promise<string[]> =>
|
|
state.broken ? [] : origGetNeighbors(id, optionsOrDirection)
|
|
|
|
gi.rebuild = async (): Promise<void> => {
|
|
state.rebuildCalls++
|
|
if (opts.healsOnRebuild) state.broken = false // unmask the real (already-populated) adjacency
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
describe('BRAINY-COLD-GRAPH-CONNECTED — find({ connected }) never serves a silent []', () => {
|
|
let brains: any[] = []
|
|
afterEach(async () => {
|
|
for (const b of brains) {
|
|
try {
|
|
await b.close()
|
|
} catch {
|
|
/* best-effort cleanup */
|
|
}
|
|
}
|
|
brains = []
|
|
})
|
|
|
|
it('(a) self-heals: cold-unloaded adjacency that rebuild() repopulates → correct N edges', async () => {
|
|
const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true })
|
|
brains.push(brain)
|
|
const state = instrumentGraphIndex(brain, { broken: true, healsOnRebuild: true })
|
|
|
|
const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 })
|
|
|
|
expect(state.rebuildCalls).toBeGreaterThanOrEqual(1) // detected the empty adjacency + healed it
|
|
const ids = results.map((r: any) => r.id).sort()
|
|
expect(ids).toEqual(targetIds.sort()) // B, C, D — the real edges, served after the heal
|
|
})
|
|
|
|
it('(b) loud throw: cold-unloaded adjacency that rebuild() cannot repopulate → GraphIndexNotReadyError', async () => {
|
|
const { brain, anchorId } = await buildBrain({ anchorEdges: true })
|
|
brains.push(brain)
|
|
instrumentGraphIndex(brain, { broken: true, healsOnRebuild: false }) // rebuild never heals
|
|
|
|
await expect(
|
|
brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 })
|
|
).rejects.toBeInstanceOf(GraphIndexNotReadyError) // NOT a silent []
|
|
})
|
|
|
|
it('(c) edgeless anchor (other edges exist): returns [] with NO rebuild and NO throw', async () => {
|
|
// The anchor has no edges, but E -> F does — so the GLOBAL sample resolves and the empty stands.
|
|
const { brain, anchorId } = await buildBrain({ anchorEdges: false })
|
|
brains.push(brain)
|
|
const state = instrumentGraphIndex(brain, { broken: false, healsOnRebuild: false })
|
|
|
|
const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 })
|
|
|
|
expect(results).toEqual([]) // genuinely edgeless — empty is the truth
|
|
expect(state.rebuildCalls).toBe(0) // no spurious rebuild on a healthy adjacency
|
|
})
|
|
|
|
it('(d) healthy: edges present, adjacency live → correct results, no rebuild', async () => {
|
|
const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true })
|
|
brains.push(brain)
|
|
const state = instrumentGraphIndex(brain, { broken: false, healsOnRebuild: false })
|
|
|
|
const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 })
|
|
|
|
expect(state.rebuildCalls).toBe(0)
|
|
const ids = results.map((r: any) => r.id).sort()
|
|
expect(ids).toEqual(targetIds.sort())
|
|
})
|
|
|
|
it('(e) executeGraphSearch re-collect path: neighbors() verify hits a transient first, the empty-result guard then heals + re-collects', async () => {
|
|
// Force the FIRST verify (inside neighbors()) to fail transiently so it does NOT heal there;
|
|
// the empty connectedIds set then drives executeGraphSearch's own verify, which rebuilds and
|
|
// re-runs the from/to collection. This specifically exercises the 'rebuilt' → re-collect branch.
|
|
const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true })
|
|
brains.push(brain)
|
|
const state = instrumentGraphIndex(brain, { broken: true, healsOnRebuild: true })
|
|
|
|
const origGetVerbs = brain.storage.getVerbs.bind(brain.storage)
|
|
let firstVerbsCall = true
|
|
brain.storage.getVerbs = async (params?: any) => {
|
|
if (firstVerbsCall) {
|
|
firstVerbsCall = false
|
|
throw new Error('transient storage hiccup')
|
|
}
|
|
return origGetVerbs(params)
|
|
}
|
|
|
|
const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 })
|
|
|
|
expect(state.rebuildCalls).toBeGreaterThanOrEqual(1)
|
|
const ids = results.map((r: any) => r.id).sort()
|
|
expect(ids).toEqual(targetIds.sort()) // re-collected after the heal
|
|
})
|
|
})
|