fix(8.0): gate native graph analytics on the provider readiness flag

The optional native graph-acceleration provider exposes `isInitialized`,
which is false during its cold-start / rebuild window (engine + cursor not
yet loaded). The accessor cached the resolved provider INSTANCE but never
re-checked readiness, so `brain.graph.*` could dispatch to a not-ready
provider and throw instead of answering.

Resolve and cache the instance once, but check `isInitialized` LIVE on every
call: route to the pure-TS analytics path while the provider is not ready,
and re-engage the native path automatically the moment it flips true. This
gates every native route at once — subgraph, export, rank, communities,
path, and the query→expand fusion. Adds a test asserting the provider is
never called (and the TS fallback returns real answers) while not ready.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Snelling 2026-06-24 15:18:50 -07:00
parent bf0afe8563
commit d321cf5f33
2 changed files with 65 additions and 19 deletions

View file

@ -3299,14 +3299,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
/** Resolve the optional native graph-acceleration provider (feature-detected). */ /** Resolve the optional native graph-acceleration provider (feature-detected). */
private graphAccelerationProvider(): GraphAccelerationProvider | undefined { private graphAccelerationProvider(): GraphAccelerationProvider | undefined {
if (this._graphAccelProvider !== undefined) { // Resolve + cache the provider INSTANCE once. Accept EITHER a ready instance OR
return this._graphAccelProvider ?? undefined // a `(storage) => provider` factory (the convention the graphIndex/metadataIndex/
} // vector providers use) — a registered factory would otherwise fail the
// Resolve the optional 'graphAcceleration' provider once and cache it. Accept // isGraphAccelerationProvider duck-test and the native path would silently never engage.
// EITHER a ready instance OR a `(storage) => provider` factory (the convention if (this._graphAccelProvider === undefined) {
// the graphIndex/metadataIndex/vector providers use) — a registered factory
// would otherwise fail the isGraphAccelerationProvider duck-test and the native
// path would silently never engage.
const raw = this.pluginRegistry.getProvider<unknown>('graphAcceleration') const raw = this.pluginRegistry.getProvider<unknown>('graphAcceleration')
let resolved: unknown = raw let resolved: unknown = raw
if (typeof raw === 'function' && !isGraphAccelerationProvider(raw)) { if (typeof raw === 'function' && !isGraphAccelerationProvider(raw)) {
@ -3317,7 +3314,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
} }
} }
this._graphAccelProvider = isGraphAccelerationProvider(resolved) ? resolved : null this._graphAccelProvider = isGraphAccelerationProvider(resolved) ? resolved : null
return this._graphAccelProvider ?? undefined }
const provider = this._graphAccelProvider ?? undefined
// Readiness gate — checked LIVE each call, never cached: the native engine sets
// `isInitialized` false until its engine + cursor have loaded (the cold-start /
// rebuild window). Until then, route to the pure-TS path rather than calling a
// not-ready provider (which throws) — and re-engage the native path automatically
// the moment `isInitialized` flips true. Gates EVERY `brain.graph.*` route
// (subgraph/export/rank/communities/path + the query→expand fusion) at once.
return provider && provider.isInitialized ? provider : undefined
} }
/** /**

View file

@ -18,7 +18,7 @@ import { NounType, VerbType } from '../../../src/types/graphTypes.js'
import { createTestConfig } from '../../helpers/test-factory.js' import { createTestConfig } from '../../helpers/test-factory.js'
/** A stateful mock GraphAccelerationProvider; the test sets the columnar payloads. */ /** A stateful mock GraphAccelerationProvider; the test sets the columnar payloads. */
function makeMockAccel() { function makeMockAccel(opts: { isInitialized?: boolean } = {}) {
const calls = { traverse: 0, cursorOpen: 0, cursorNext: 0, cursorClose: 0, rank: 0, communities: 0, path: 0 } const calls = { traverse: 0, cursorOpen: 0, cursorNext: 0, cursorClose: 0, rank: 0, communities: 0, path: 0 }
const state: { const state: {
calls: typeof calls calls: typeof calls
@ -44,7 +44,7 @@ function makeMockAccel() {
communityCount: 0 communityCount: 0
} }
const provider = { const provider = {
isInitialized: true, isInitialized: opts.isInitialized ?? true,
traverse: async (seeds: any) => { traverse: async (seeds: any) => {
calls.traverse++ calls.traverse++
state.lastTraverseSeeds = seeds state.lastTraverseSeeds = seeds
@ -290,4 +290,45 @@ describe('brain.graph.* native routing + columnar hydration (native seam)', () =
expect(Array.isArray(mock.state.lastTraverseSeeds)).toBe(true) // bigint[], not a Buffer expect(Array.isArray(mock.state.lastTraverseSeeds)).toBe(true) // bigint[], not a Buffer
expect(typeof (mock.state.lastTraverseSeeds as unknown[])[0]).toBe('bigint') expect(typeof (mock.state.lastTraverseSeeds as unknown[])[0]).toBe('bigint')
}) })
// Readiness gate (cor boundary-audit item #1): the native engine reports
// isInitialized=false during its cold-start/rebuild window. Brainy must route to the
// pure-TS path then — NOT call the not-ready provider (which would throw) — across the
// whole brain.graph.* surface, and re-engage the native path once it flips true.
it('routes to the TS fallback (never calls the provider) while accel.isInitialized is false', async () => {
const notReady = makeMockAccel({ isInitialized: false })
const b = new Brainy(createTestConfig())
b.use({
name: 'mock-graph-accel-not-ready',
activate: async (ctx: any) => {
ctx.registerProvider('graphAcceleration', notReady.provider)
return true
}
} as any)
await b.init()
const p = await b.add({ type: NounType.Person, subtype: 'employee', data: 'P' })
const q = await b.add({ type: NounType.Person, subtype: 'employee', data: 'Q' })
await b.relate({ from: p, to: q, type: VerbType.RelatedTo })
// Every brain.graph.* route + the query→expand fusion must NOT throw and must serve
// from the TS fallback while the engine is cold.
const sub = await b.graph.subgraph(p, { depth: 1 })
const ranked = await b.graph.rank()
const comm = await b.graph.communities()
const route = await b.graph.path(p, q)
const fused = await b.graph.subgraph({ type: NounType.Person }, { depth: 1 })
// The provider was never touched — all served by the JS fallback.
expect(notReady.state.calls.traverse).toBe(0)
expect(notReady.state.calls.rank).toBe(0)
expect(notReady.state.calls.communities).toBe(0)
expect(notReady.state.calls.path).toBe(0)
// And the TS fallback returned real answers (not throws / empties from a cold engine).
expect(sub.nodes.some((n) => n.id === p)).toBe(true)
expect(ranked.length).toBeGreaterThan(0)
expect(comm.count).toBeGreaterThan(0)
expect(route?.nodes).toEqual([p, q])
expect(fused.nodes.some((n) => n.id === p)).toBe(true)
await b.close()
})
}) })