A production deployment's warm report showed metadata: 'unavailable' under a native metadata provider. brain.warm()'s metadata leg only duck-typed the built-in JS manager's hydrateAll() method, which a native provider has no reason to implement. - MetadataIndexProvider (src/plugin.ts) gains an optional warm?(): Promise<void> hook, mirroring the existing vector and graph provider hooks. brain.warm() now checks the active provider's own warm() FIRST, falls back to the JS manager's hydrateAll() when absent, and reports 'unavailable' only when neither exists -- never init() as a stand-in, since a native provider's init() may be a cheap verify rather than a real warm. - Tests (tests/unit/brainy/warm.test.ts): a live provider instance shaped to have warm() reports 'warmed' and the hook called with no hydrateAll fallback; shaped to have neither hook reports 'unavailable' (pins the honest branch); the unmodified built-in JS manager still reports 'warmed' via hydrateAll(), unchanged. Additive scope agreed mid-flight with the native-provider team: a maintenance-debt observability seam so an operator sees a grind coming instead of discovering it as a CPU storm. - New optional maintenanceDebt?(): Promise<ProviderMaintenanceDebt> hook on all three provider contracts (vector, metadata, graph -- the same three warm?() lives on). ProviderMaintenanceDebt is fields-all-optional: a provider reports only what it truly measures (pendingBytes, pendingItems, lastPassCompletedAt, lastPassOutcome, converging), never an estimate dressed as fact. - New public brain.maintenanceDebt(): a pure passthrough -- for each surface it calls only the active provider's own hook and reports the payload verbatim, or 'unavailable' when absent. No thresholds, no polling, no JS-side estimation; the provider owns the numbers, the operator owns the policy. - ProviderMaintenanceDebt, MaintenanceDebtReport, and MaintenanceDebtOutcome are exported from the package root. - Tests (tests/unit/brainy/maintenance-debt.test.ts): hook present reports 'reported' with the exact payload passed through; hook absent reports 'unavailable' on every surface; mixed surfaces resolve independently of each other. RELEASES.md gains the 8.10.1 entry covering both fixes above and this feature, including the no-hot-retry contract from the prior commit.
398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
/**
|
|
* @module tests/unit/brainy/warm
|
|
* @description Coverage for `brain.warm()` / `warmOnOpen` / the provider
|
|
* `warm?()` contract (the cold-restart readiness fix): first operations after
|
|
* a cold restart should run at steady-state cost instead of paying
|
|
* demand-load latency on the critical path.
|
|
*
|
|
* Uses a real, in-process fake plugin provider implementing the actual
|
|
* `VectorIndexProvider` contract from `src/plugin.ts` — the real seam a
|
|
* native provider (e.g. a disk-native accelerator) plugs into. The real
|
|
* built-in JS metadata index and graph adjacency index run against real
|
|
* (filesystem or in-memory) storage with pre-existing data, so their
|
|
* hydration paths are exercised for real, not mocked.
|
|
*/
|
|
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 } from '../../../src/brainy.js'
|
|
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
|
import type { VectorIndexProvider } from '../../../src/plugin.js'
|
|
import type { VectorDocument, Vector } from '../../../src/coreTypes.js'
|
|
import { MetadataIndexManager } from '../../../src/utils/metadataIndex.js'
|
|
import { GraphAdjacencyIndex } from '../../../src/graph/graphAdjacencyIndex.js'
|
|
|
|
const tmpDirs: string[] = []
|
|
function mkTmp(): string {
|
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-warm-'))
|
|
tmpDirs.push(d)
|
|
return d
|
|
}
|
|
afterEach(() => {
|
|
for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true })
|
|
})
|
|
|
|
// Brainy's ValidationConfig fixes vectors at exactly 384 dimensions
|
|
// (src/utils/paramValidation.ts) — match it so `add()` doesn't reject test data.
|
|
const DIM = 384
|
|
const V = (seed = 1): number[] => Array.from({ length: DIM }, (_, i) => Math.sin(seed + i))
|
|
|
|
/**
|
|
* A real (not mocked) VectorIndexProvider implementation, backed by a plain
|
|
* Map, that optionally implements `warm()` — the exact seam
|
|
* `AddToVectorIndexOperation` / `brain.warm()` call through.
|
|
*/
|
|
class FakeVectorProvider implements VectorIndexProvider {
|
|
readonly name = 'fake-vector-provider'
|
|
readonly items = new Map<string, Vector>()
|
|
warmCalls = 0
|
|
searchCalls: Array<{ k?: number }> = []
|
|
// Only present on the instance when the constructor is told to — mirrors a
|
|
// real provider that may or may not implement the optional hook. Assigned
|
|
// in the constructor BODY (not as a field initializer): under native ES
|
|
// class fields, field initializers run before constructor-body statements
|
|
// — including the parameter-property assignment — so referencing a
|
|
// parameter property from a field initializer would see it as still
|
|
// `undefined`.
|
|
warm?: () => Promise<void>
|
|
|
|
constructor(hasWarm: boolean) {
|
|
if (hasWarm) {
|
|
this.warm = async (): Promise<void> => {
|
|
this.warmCalls++
|
|
}
|
|
}
|
|
}
|
|
|
|
async addItem(item: VectorDocument): Promise<string> {
|
|
this.items.set(item.id, item.vector)
|
|
return item.id
|
|
}
|
|
async removeItem(id: string): Promise<boolean> {
|
|
return this.items.delete(id)
|
|
}
|
|
async search(_queryVector: Vector, k?: number): Promise<Array<[string, number]>> {
|
|
this.searchCalls.push({ k })
|
|
return [...this.items.keys()].slice(0, k ?? 10).map((id) => [id, 0])
|
|
}
|
|
size(): number {
|
|
return this.items.size
|
|
}
|
|
clear(): void {
|
|
this.items.clear()
|
|
}
|
|
async rebuild(): Promise<void> {}
|
|
async flush(): Promise<number> {
|
|
return 0
|
|
}
|
|
getPersistMode(): 'immediate' | 'deferred' {
|
|
return 'deferred'
|
|
}
|
|
}
|
|
|
|
/** Registers `provider` under the `'vector'` plugin key, before `init()`. */
|
|
function useFakeVectorProvider(brain: Brainy<any>, provider: FakeVectorProvider): void {
|
|
brain.use({
|
|
name: 'fake-vector-provider',
|
|
activate: async (ctx: any) => {
|
|
ctx.registerProvider('vector', () => provider)
|
|
return true
|
|
}
|
|
})
|
|
}
|
|
|
|
describe('brain.warm()', () => {
|
|
it('(a) calls the vector provider\'s warm() when present and reports "warmed"', async () => {
|
|
const brain = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'memory' },
|
|
silent: true
|
|
})
|
|
const provider = new FakeVectorProvider(true)
|
|
useFakeVectorProvider(brain, provider)
|
|
await brain.init()
|
|
await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) })
|
|
|
|
const report = await brain.warm()
|
|
|
|
expect(provider.warmCalls).toBe(1)
|
|
expect(provider.searchCalls.length).toBe(0) // warm() ran — no probe fallback
|
|
expect(report.vector.outcome).toBe('warmed')
|
|
await brain.close()
|
|
})
|
|
|
|
it('(b) falls back to a probe search() when warm() is absent and reports "probed", never "warmed"', async () => {
|
|
const brain = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'memory' },
|
|
silent: true
|
|
})
|
|
const provider = new FakeVectorProvider(false)
|
|
useFakeVectorProvider(brain, provider)
|
|
await brain.init()
|
|
await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) })
|
|
await brain.add({ data: 'b', type: NounType.Thing, vector: V(2) })
|
|
|
|
const report = await brain.warm()
|
|
|
|
expect(provider.warmCalls).toBe(0) // no warm() on this provider
|
|
expect(provider.searchCalls.length).toBe(1) // the probe ran
|
|
expect(provider.searchCalls[0].k).toBe(Math.min(100, provider.size())) // k = min(100, size)
|
|
expect(report.vector.outcome).toBe('probed')
|
|
expect(report.vector.outcome).not.toBe('warmed') // never conflated
|
|
await brain.close()
|
|
})
|
|
|
|
it('vector: reports "unavailable" when there is nothing to probe (empty index, unknown dimension)', async () => {
|
|
const brain = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'memory' },
|
|
silent: true
|
|
})
|
|
// A disk-native provider MAY honestly report 0 resident entries while
|
|
// durable data exists on disk (the same posture documented on
|
|
// `VectorIndexProvider.isReady` — "an mmap/disk-native index may
|
|
// legitimately report 0 resident entries"). warm()'s probe fallback
|
|
// reads `size()`, so this is the real trigger for "nothing to probe":
|
|
// never a k=0 search, an honest skip.
|
|
const provider = new FakeVectorProvider(false)
|
|
provider.size = () => 0
|
|
useFakeVectorProvider(brain, provider)
|
|
await brain.init() // the VFS root bootstrap write sets `dimensions`, but size() still reports 0
|
|
|
|
const report = await brain.warm()
|
|
|
|
expect(provider.searchCalls.length).toBe(0) // nothing probed
|
|
expect(report.vector.outcome).toBe('unavailable')
|
|
await brain.close()
|
|
})
|
|
|
|
it('(c) metadata + graph hydration paths actually execute against filesystem storage with pre-existing data', async () => {
|
|
const dir = mkTmp()
|
|
|
|
// Build a brain with real data (including a field NOT in the metadata
|
|
// index's own common-fields warm subset — 'wave' — so hydrateAll()'s
|
|
// FULL hydration is distinguishable from init()'s partial warmCache()),
|
|
// and real graph edges, then close it (persisting everything).
|
|
const seed = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'filesystem', path: dir },
|
|
silent: true
|
|
})
|
|
await seed.init()
|
|
const ids: string[] = []
|
|
for (let i = 0; i < 6; i++) {
|
|
ids.push(
|
|
await seed.add({
|
|
data: `entity ${i}`,
|
|
type: NounType.Thing,
|
|
metadata: { wave: i % 3 },
|
|
vector: V(i + 1)
|
|
})
|
|
)
|
|
}
|
|
for (let i = 0; i + 1 < ids.length; i++) {
|
|
await seed.relate({ from: ids[i], to: ids[i + 1], type: VerbType.RelatedTo })
|
|
}
|
|
await seed.close()
|
|
|
|
// Cold-reopen a FRESH instance and spy on the real hydration seams before
|
|
// calling warm(), so we assert they actually ran (not just that the
|
|
// report claims they did).
|
|
const metaHydrateCalls: number[] = []
|
|
const loadedFields: string[] = []
|
|
const graphInitCalls: number[] = []
|
|
|
|
const origHydrateAll = MetadataIndexManager.prototype.hydrateAll
|
|
const origLoadSparseIndex = (MetadataIndexManager.prototype as any).loadSparseIndex
|
|
const origGraphInit = GraphAdjacencyIndex.prototype.init
|
|
|
|
MetadataIndexManager.prototype.hydrateAll = async function (...args: any[]) {
|
|
metaHydrateCalls.push(1)
|
|
return origHydrateAll.apply(this, args as any)
|
|
}
|
|
;(MetadataIndexManager.prototype as any).loadSparseIndex = async function (
|
|
field: string,
|
|
...args: any[]
|
|
) {
|
|
loadedFields.push(field)
|
|
return origLoadSparseIndex.apply(this, [field, ...args] as any)
|
|
}
|
|
GraphAdjacencyIndex.prototype.init = async function (...args: any[]) {
|
|
graphInitCalls.push(1)
|
|
return origGraphInit.apply(this, args as any)
|
|
}
|
|
|
|
try {
|
|
const brain = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'filesystem', path: dir },
|
|
silent: true
|
|
})
|
|
await brain.init()
|
|
|
|
const report = await brain.warm()
|
|
|
|
expect(metaHydrateCalls.length).toBe(1) // hydrateAll() actually ran
|
|
expect(loadedFields).toContain('wave') // a NON-common field was loaded — full hydration, not the heuristic subset
|
|
expect(report.metadata.outcome).toBe('warmed')
|
|
|
|
expect(graphInitCalls.length).toBeGreaterThanOrEqual(1) // graph's full-load seam ran (once at brain init, once via warm())
|
|
expect(report.graph.outcome).toBe('warmed')
|
|
|
|
// Correctness survives: the hydrated data still answers queries.
|
|
const byWhere = await brain.find({ type: NounType.Thing, where: { wave: 1 } })
|
|
expect(byWhere.length).toBe(2) // waves 1,4 of 0..5
|
|
|
|
await brain.close()
|
|
} finally {
|
|
MetadataIndexManager.prototype.hydrateAll = origHydrateAll
|
|
;(MetadataIndexManager.prototype as any).loadSparseIndex = origLoadSparseIndex
|
|
GraphAdjacencyIndex.prototype.init = origGraphInit
|
|
}
|
|
})
|
|
|
|
it('(d) warmOnOpen: true runs warm() during init() — observable via the fake provider', async () => {
|
|
const brain = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'memory' },
|
|
warmOnOpen: true,
|
|
silent: true
|
|
})
|
|
const provider = new FakeVectorProvider(true)
|
|
useFakeVectorProvider(brain, provider)
|
|
|
|
// No explicit brain.warm() call — warmOnOpen must have run it as part of init().
|
|
await brain.init()
|
|
|
|
expect(provider.warmCalls).toBe(1)
|
|
await brain.close()
|
|
})
|
|
|
|
it('warmOnOpen defaults to false — init() does NOT run warm() unless opted in', async () => {
|
|
const brain = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'memory' },
|
|
silent: true
|
|
})
|
|
const provider = new FakeVectorProvider(true)
|
|
useFakeVectorProvider(brain, provider)
|
|
|
|
await brain.init()
|
|
|
|
expect(provider.warmCalls).toBe(0)
|
|
await brain.close()
|
|
})
|
|
|
|
it('(e) WarmReport shape: outcome literal + durationMs per surface, plus totalDurationMs', async () => {
|
|
const brain = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'memory' },
|
|
silent: true
|
|
})
|
|
const provider = new FakeVectorProvider(true)
|
|
useFakeVectorProvider(brain, provider)
|
|
await brain.init()
|
|
await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) })
|
|
|
|
const report = await brain.warm()
|
|
|
|
for (const surface of ['vector', 'metadata', 'graph'] as const) {
|
|
expect(['warmed', 'probed', 'unavailable']).toContain(report[surface].outcome)
|
|
expect(typeof report[surface].durationMs).toBe('number')
|
|
expect(report[surface].durationMs).toBeGreaterThanOrEqual(0)
|
|
}
|
|
expect(typeof report.totalDurationMs).toBe('number')
|
|
expect(report.totalDurationMs).toBeGreaterThanOrEqual(0)
|
|
await brain.close()
|
|
})
|
|
|
|
// --- Metadata leg routes through the ACTIVE provider (8.10.1) -----------
|
|
//
|
|
// `MetadataIndexProvider` is a ~50-method interface (src/plugin.ts) — far
|
|
// too large to hand-write a compliant fake class the way `FakeVectorProvider`
|
|
// fakes the ~8-method `VectorIndexProvider` above. Test (c) already
|
|
// establishes this file's pattern for the metadata leg: exercise the REAL
|
|
// `MetadataIndexManager` instance and shape just the probe points brain.ts
|
|
// reads (`typeof provider.warm === 'function'` /
|
|
// `typeof provider.hydrateAll === 'function'`) directly on that instance.
|
|
// Shadowing an own property on the live object stands in for "a different
|
|
// provider implementation" without needing a hand-rolled full fake — the
|
|
// rest of the real manager (used by add()/init() above) is untouched.
|
|
describe('metadata leg — warm() routes through the active provider', () => {
|
|
it('(f) calls the ACTIVE metadata provider\'s warm() when present and reports "warmed", never falling back to hydrateAll', async () => {
|
|
const brain = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'memory' },
|
|
silent: true
|
|
})
|
|
await brain.init()
|
|
await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) })
|
|
|
|
const metadataIndex = (brain as any).metadataIndex
|
|
let warmCalls = 0
|
|
let hydrateAllCalls = 0
|
|
const origHydrateAll = metadataIndex.hydrateAll.bind(metadataIndex)
|
|
metadataIndex.hydrateAll = async (...args: unknown[]) => {
|
|
hydrateAllCalls++
|
|
return origHydrateAll(...args)
|
|
}
|
|
// Simulates a native metadata provider declaring the optional `warm()`
|
|
// hook added to `MetadataIndexProvider` (src/plugin.ts) in 8.10.1.
|
|
metadataIndex.warm = async () => {
|
|
warmCalls++
|
|
}
|
|
|
|
const report = await brain.warm()
|
|
|
|
expect(warmCalls).toBe(1)
|
|
expect(hydrateAllCalls).toBe(0) // warm() ran — no hydrateAll fallback
|
|
expect(report.metadata.outcome).toBe('warmed')
|
|
await brain.close()
|
|
})
|
|
|
|
it('reports "unavailable" when the active metadata provider implements neither warm() nor hydrateAll() (the honest branch a native provider without either hook must hit)', async () => {
|
|
const brain = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'memory' },
|
|
silent: true
|
|
})
|
|
await brain.init()
|
|
await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) })
|
|
|
|
const metadataIndex = (brain as any).metadataIndex
|
|
// Shadow away BOTH optional hooks — models a genuinely native provider
|
|
// that (unlike the built-in JS manager) offers neither seam. This must
|
|
// never fall back to calling init() as a stand-in for warmth.
|
|
metadataIndex.warm = undefined
|
|
metadataIndex.hydrateAll = undefined
|
|
|
|
const report = await brain.warm()
|
|
|
|
expect(report.metadata.outcome).toBe('unavailable')
|
|
await brain.close()
|
|
})
|
|
|
|
it('the built-in JS manager (no warm()) still reports "warmed" via its existing hydrateAll() duck-type — unchanged by the new provider hook', async () => {
|
|
const brain = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'memory' },
|
|
silent: true
|
|
})
|
|
await brain.init()
|
|
await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) })
|
|
|
|
// No patching at all — the default built-in MetadataIndexManager has
|
|
// hydrateAll() but no warm(), exactly as it did before this change.
|
|
const metadataIndex = (brain as any).metadataIndex
|
|
expect(typeof metadataIndex.warm).not.toBe('function')
|
|
expect(typeof metadataIndex.hydrateAll).toBe('function')
|
|
|
|
const report = await brain.warm()
|
|
|
|
expect(report.metadata.outcome).toBe('warmed')
|
|
await brain.close()
|
|
})
|
|
})
|
|
})
|