fix: graph adjacency cold-load consistency guard — no more silent [] on connected
A native graph provider can load its relationship COUNT (manifest) on a cold open
but fail to load the source→target adjacency itself (observed on mmap-filesystem:
the SSTable-segment load is swallowed). The result is find({ connected }),
neighbors(), and related() silently returning [] despite persisted edges — and
staying empty after warm-up. Because it is [] not an error, callers cannot tell
real data is missing.
Brainy now runs a one-time consistency check on the first graph read: if the index
reports relationships exist but a known persisted edge's source resolves to no
neighbors, force a rebuild from storage (which re-scans + repopulates the adjacency);
if even that cannot read the edge, throw the new GraphIndexNotReadyError instead of
serving [] as truth. O(1) probe (one verb + one neighbor lookup), cached per brain,
and a no-op once the adjacency is live — so it self-heals the cold-open case and is
loud when it genuinely can't.
Wired into neighbors() + getRelations() (the graph-read primitives behind
find({ connected })). The underlying cold-open load is a native-provider concern
(addressed upstream); this makes the failure self-healing + loud rather than silent.
Tests: tests/unit/brainy/graph-adjacency-cold-load.test.ts (live → no-op; broken →
rebuild; unfixable → throws; size 0 / no edges → no-op; transient failure → re-checks
without breaking the query). Full unit gate green (1531/1531).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
811c7da89e
commit
1694f68419
4 changed files with 240 additions and 0 deletions
130
tests/unit/brainy/graph-adjacency-cold-load.test.ts
Normal file
130
tests/unit/brainy/graph-adjacency-cold-load.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* @module tests/unit/brainy/graph-adjacency-cold-load
|
||||
* @description 7.33.2 — the graph-adjacency cold-load consistency guard. A native
|
||||
* graph provider can load its relationship COUNT (manifest) on a cold open but fail
|
||||
* to load the source→target adjacency (observed with cor 2.7.5's NativeGraphAdjacencyIndex
|
||||
* on mmap-filesystem: the SSTable-segment load is swallowed). The result is
|
||||
* `find({ connected })` / `neighbors()` / `related()` silently returning `[]` despite
|
||||
* persisted edges. Brainy now probes one known persisted edge on the first graph read;
|
||||
* if the index claims edges but the source resolves to none, it forces a rebuild from
|
||||
* storage, and only if even that fails does it throw a loud, catchable error instead of
|
||||
* serving `[]` as truth. These exercise the guard against a stubbed graph index.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { Brainy } from '../../../src/index.js'
|
||||
import { GraphIndexNotReadyError } from '../../../src/errors/brainyError.js'
|
||||
import { createTestConfig } from '../../helpers/test-factory.js'
|
||||
|
||||
/** Replace the brain's graph index with a stub that models a (possibly broken) cold load. */
|
||||
function stubGraphIndex(
|
||||
brain: any,
|
||||
opts: { size: number; neighborsBeforeRebuild: string[]; neighborsAfterRebuild?: string[] }
|
||||
) {
|
||||
let rebuilt = false
|
||||
const calls = { rebuild: 0, getNeighbors: 0 }
|
||||
brain.graphIndex = {
|
||||
size: () => opts.size,
|
||||
getNeighbors: async () => {
|
||||
calls.getNeighbors++
|
||||
return rebuilt ? opts.neighborsAfterRebuild ?? [] : opts.neighborsBeforeRebuild
|
||||
},
|
||||
rebuild: async () => {
|
||||
calls.rebuild++
|
||||
rebuilt = true
|
||||
},
|
||||
flush: async () => {},
|
||||
close: async () => {}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
async function freshBrain(): Promise<any> {
|
||||
const brain: any = new Brainy(createTestConfig())
|
||||
await brain.init()
|
||||
brain._graphAdjacencyVerified = false
|
||||
return brain
|
||||
}
|
||||
|
||||
describe('7.33.2 graph-adjacency cold-load consistency guard', () => {
|
||||
it('is a no-op when the adjacency is live (a persisted edge resolves to neighbors)', async () => {
|
||||
const brain = await freshBrain()
|
||||
const calls = stubGraphIndex(brain, { size: 12, neighborsBeforeRebuild: ['n1'] })
|
||||
brain.storage.getVerbs = async () => ({ items: [{ id: 'v1', sourceId: 's1', targetId: 't1' }], hasMore: false })
|
||||
|
||||
await brain.ensureGraphAdjacencyConsistent()
|
||||
expect(calls.rebuild).toBe(0)
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('force-rebuilds when the index reports edges but the source probes empty', async () => {
|
||||
const brain = await freshBrain()
|
||||
const calls = stubGraphIndex(brain, { size: 12, neighborsBeforeRebuild: [], neighborsAfterRebuild: ['n1'] })
|
||||
brain.storage.getVerbs = async () => ({ items: [{ id: 'v1', sourceId: 's1', targetId: 't1' }], hasMore: false })
|
||||
|
||||
await brain.ensureGraphAdjacencyConsistent()
|
||||
expect(calls.rebuild).toBe(1) // detected the empty adjacency + rebuilt it
|
||||
|
||||
// Cached: a second call does not re-probe or re-rebuild.
|
||||
await brain.ensureGraphAdjacencyConsistent()
|
||||
expect(calls.rebuild).toBe(1)
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('throws GraphIndexNotReadyError when even a rebuild cannot load the adjacency', async () => {
|
||||
const brain = await freshBrain()
|
||||
stubGraphIndex(brain, { size: 12, neighborsBeforeRebuild: [], neighborsAfterRebuild: [] }) // never loads
|
||||
brain.storage.getVerbs = async () => ({ items: [{ id: 'v1', sourceId: 's1', targetId: 't1' }], hasMore: false })
|
||||
|
||||
await expect(brain.ensureGraphAdjacencyConsistent()).rejects.toBeInstanceOf(GraphIndexNotReadyError)
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('no-op when no edges are claimed (size 0) — never even probes storage', async () => {
|
||||
const brain = await freshBrain()
|
||||
const calls = stubGraphIndex(brain, { size: 0, neighborsBeforeRebuild: [] })
|
||||
let probedStorage = false
|
||||
brain.storage.getVerbs = async () => {
|
||||
probedStorage = true
|
||||
return { items: [], hasMore: false }
|
||||
}
|
||||
|
||||
await brain.ensureGraphAdjacencyConsistent()
|
||||
expect(calls.rebuild).toBe(0)
|
||||
expect(probedStorage).toBe(false)
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('no-op when storage has no edges (stale count) — does not rebuild or throw', async () => {
|
||||
const brain = await freshBrain()
|
||||
const calls = stubGraphIndex(brain, { size: 12, neighborsBeforeRebuild: [] })
|
||||
brain.storage.getVerbs = async () => ({ items: [], hasMore: false })
|
||||
|
||||
await brain.ensureGraphAdjacencyConsistent()
|
||||
expect(calls.rebuild).toBe(0)
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('does not loop or break the query on a transient probe failure (re-checks next time)', async () => {
|
||||
const brain = await freshBrain()
|
||||
const calls = stubGraphIndex(brain, { size: 12, neighborsBeforeRebuild: [], neighborsAfterRebuild: ['n1'] })
|
||||
let firstCall = true
|
||||
brain.storage.getVerbs = async () => {
|
||||
if (firstCall) {
|
||||
firstCall = false
|
||||
throw new Error('transient storage hiccup')
|
||||
}
|
||||
return { items: [{ id: 'v1', sourceId: 's1', targetId: 't1' }], hasMore: false }
|
||||
}
|
||||
|
||||
// First call: probe throws transiently → swallowed (no GraphIndexNotReadyError), flag reset.
|
||||
await brain.ensureGraphAdjacencyConsistent()
|
||||
expect(calls.rebuild).toBe(0)
|
||||
expect(brain._graphAdjacencyVerified).toBe(false) // re-check allowed
|
||||
|
||||
// Second call: storage works → detects the empty adjacency + rebuilds.
|
||||
await brain.ensureGraphAdjacencyConsistent()
|
||||
expect(calls.rebuild).toBe(1)
|
||||
await brain.close()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue