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
|
|
@ -48,6 +48,7 @@ import { MmapVectorBackend } from './hnsw/mmapVectorBackend.js'
|
|||
import { ConnectionsCodec } from './hnsw/connectionsCodec.js'
|
||||
import { TransactionManager } from './transaction/TransactionManager.js'
|
||||
import { RevisionConflictError } from './transaction/RevisionConflictError.js'
|
||||
import { GraphIndexNotReadyError } from './errors/brainyError.js'
|
||||
import {
|
||||
ValidationConfig,
|
||||
validateAddParams,
|
||||
|
|
@ -191,6 +192,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
private _versions?: VersioningAPI
|
||||
private _vfs?: VirtualFileSystem
|
||||
private _vfsInitialized = false // Track VFS init completion separately
|
||||
private _graphAdjacencyVerified = false // 7.33.2: one-time graph-adjacency cold-load consistency check
|
||||
private _hub?: IntegrationHub // Integration Hub for external tools
|
||||
private _pendingMigrationRunner?: MigrationRunner // Deferred migration runner for large datasets
|
||||
private _aggregationIndex?: AggregationIndex // Incremental aggregation engine
|
||||
|
|
@ -2449,6 +2451,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
paramsOrId?: string | GetRelationsParams
|
||||
): Promise<Relation<T>[]> {
|
||||
await this.ensureInitialized()
|
||||
await this.ensureGraphAdjacencyConsistent()
|
||||
|
||||
// Handle string ID shorthand: getRelations(id) -> getRelations({ from: id })
|
||||
const params = typeof paramsOrId === 'string'
|
||||
|
|
@ -8122,6 +8125,61 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
* verbType: VerbType.RELATES_TO
|
||||
* })
|
||||
*/
|
||||
/**
|
||||
* @description 7.33.2 — guard against a graph index that loaded its relationship
|
||||
* COUNT (manifest) on a cold open but NOT its source→target adjacency, so graph
|
||||
* traversals (`find({ connected })`, `neighbors`, `related`) would silently return
|
||||
* `[]` despite persisted edges. Observed with a native graph provider whose
|
||||
* cold-open SSTable load is swallowed on some storage adapters. Runs ONCE per brain
|
||||
* (cached): if the index claims edges exist but a known persisted edge's source
|
||||
* resolves to no neighbors, force a rebuild from storage; if even that can't read
|
||||
* the edge, throw {@link GraphIndexNotReadyError} rather than serving `[]` as truth.
|
||||
* O(1) (one verb read + one neighbor probe) and a no-op once the adjacency is live.
|
||||
*/
|
||||
private async ensureGraphAdjacencyConsistent(): Promise<void> {
|
||||
if (this._graphAdjacencyVerified) return
|
||||
// Set up-front so a re-entrant call (rebuild → reads) cannot loop.
|
||||
this._graphAdjacencyVerified = true
|
||||
try {
|
||||
const claimed = await this.graphIndex.size()
|
||||
if (!claimed || claimed <= 0) return // no edges claimed — nothing to verify
|
||||
|
||||
const sample = await this.storage.getVerbs({ pagination: { limit: 1 } })
|
||||
const verb = sample.items?.[0]
|
||||
if (!verb || !verb.sourceId) return // no edges in storage — stale count, harmless
|
||||
|
||||
const probe = await this.graphIndex.getNeighbors(verb.sourceId, { limit: 1 })
|
||||
if (probe.length > 0) return // adjacency is live — the common case
|
||||
|
||||
// INCONSISTENT: the index reports edges but a persisted edge's source has none →
|
||||
// the adjacency did not load on open. Rebuild it from storage.
|
||||
if (!this.config.silent) {
|
||||
console.warn(
|
||||
`[Brainy] Graph adjacency reports ${claimed} relationship(s) but a persisted edge ` +
|
||||
`resolves to none — the persisted adjacency did not load on open. Rebuilding from storage…`
|
||||
)
|
||||
}
|
||||
await this.graphIndex.rebuild()
|
||||
|
||||
const reprobe = await this.graphIndex.getNeighbors(verb.sourceId, { limit: 1 })
|
||||
if (reprobe.length === 0) {
|
||||
throw new GraphIndexNotReadyError(
|
||||
`Graph adjacency index reports ${claimed} relationship(s) but returns no edges even ` +
|
||||
`after a rebuild — the persisted adjacency could not be loaded. find({ connected }), ` +
|
||||
`neighbors() and related() cannot be served reliably for this brain.`
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof GraphIndexNotReadyError) throw err
|
||||
// A transient probe/rebuild failure must not break the actual query NOR be
|
||||
// masked as "no data". Allow a re-check on the next graph read and fall through.
|
||||
this._graphAdjacencyVerified = false
|
||||
if (!this.config.silent) {
|
||||
console.warn(`[Brainy] Graph adjacency consistency check skipped (transient): ${err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async neighbors(
|
||||
entityId: string,
|
||||
options?: {
|
||||
|
|
@ -8132,6 +8190,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
): Promise<string[]> {
|
||||
await this.ensureInitialized()
|
||||
await this.ensureGraphAdjacencyConsistent()
|
||||
|
||||
const direction = options?.direction || 'both'
|
||||
const limit = options?.limit
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export type BrainyErrorType =
|
|||
| 'RETRY_EXHAUSTED'
|
||||
| 'VALIDATION'
|
||||
| 'FIELD_NOT_INDEXED'
|
||||
| 'GRAPH_INDEX_NOT_READY'
|
||||
|
||||
/**
|
||||
* Custom error class for Brainy operations
|
||||
|
|
@ -223,3 +224,27 @@ export class BrainyError extends Error {
|
|||
return BrainyError.storage(error.message, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the graph adjacency index reports that relationships exist (its
|
||||
* persisted manifest/count loaded) but the source→target adjacency itself did
|
||||
* NOT load — so graph traversals (`find({ connected })`, `neighbors()`,
|
||||
* `related()`) would otherwise return an EMPTY array indistinguishable from
|
||||
* "no edges". Brainy detects this on the first graph read (one verb + one
|
||||
* neighbor probe), attempts a rebuild from storage, and raises this LOUD,
|
||||
* catchable error only if even that cannot read a known persisted edge —
|
||||
* replacing silent data-invisibility with a clear failure.
|
||||
*
|
||||
* Observed with a native graph provider whose cold-open adjacency load is
|
||||
* swallowed on certain storage adapters; the fix is upstream in the provider,
|
||||
* but Brainy refuses to serve `[]` as if it were truth.
|
||||
*/
|
||||
export class GraphIndexNotReadyError extends BrainyError {
|
||||
constructor(message: string, originalError?: Error) {
|
||||
super(message, 'GRAPH_INDEX_NOT_READY', false, originalError)
|
||||
this.name = 'GraphIndexNotReadyError'
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, GraphIndexNotReadyError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue