fix: honest index readiness — no silently-empty queries on a cold index
Closes the "dishonest readiness proxy" anti-pattern (Pattern A): size()>0 /
isInitialized were treated as "this index serves queries", but a cold native
index can load its COUNT before its SERVING structure, so a query returned a
silent [] indistinguishable from "no such data". A shared assessIndexReadiness()
now reads only the provider's honest isReady() signal (never size()), applied at
every site:
- Vector: a one-shot verifyVectorLive() guard on the semantic/proximity search
path (a pure semantic find({query}) has no filter, so nothing guarded it). It
prefers isReady(), else a known-vector self-match probe; self-heals via rebuild
or throws the new VectorIndexNotReadyError instead of a silent [].
- Graph: getVerbsBySource/ByTarget skip the fast path when the provider reports
not-ready (falling to the canonical shard scan), plus a one-shot probe that
self-heals a no-isReady provider whose adjacency did not cold-load.
- getIndexStatus(): folds in per-index honest `ready` (making `populated`
honest) + rebuildFailed/rebuildError/degradedIds, so a readiness probe never
200s a brain that is still warming up or degraded.
Unblocked by the native providers now reporting serving-truth (graph via
SSTable-residency readiness, vector via durableBaseLoadFailed). New export:
VectorIndexNotReadyError. getIndexStatus gains additive fields. No breaking API.
13 new tests; existing readiness guards green.
This commit is contained in:
parent
36d4e80ba2
commit
d0f69c731f
9 changed files with 654 additions and 9 deletions
215
src/brainy.ts
215
src/brainy.ts
|
|
@ -175,7 +175,8 @@ import {
|
|||
} from './events/changeFeed.js'
|
||||
import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js'
|
||||
import { GenerationConflictError, StoreInconsistentError } from './db/errors.js'
|
||||
import { BrainyError, GraphIndexNotReadyError, MetadataIndexNotReadyError, MigrationInProgressError } from './errors/brainyError.js'
|
||||
import { BrainyError, GraphIndexNotReadyError, MetadataIndexNotReadyError, MigrationInProgressError, VectorIndexNotReadyError } from './errors/brainyError.js'
|
||||
import { assessIndexReadiness } from './utils/indexReadiness.js'
|
||||
import { MemoryStorage } from './storage/adapters/memoryStorage.js'
|
||||
import type {
|
||||
CompactHistoryOptions,
|
||||
|
|
@ -500,6 +501,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
private _metadataVerified = false
|
||||
/** Re-entrancy guard for {@link verifyMetadataLive}. */
|
||||
private _metadataVerifying = false
|
||||
/** Vector-index cold-read guard: verified-serving this session (one-shot). */
|
||||
private _vectorVerified = false
|
||||
/** Re-entrancy guard for {@link verifyVectorLive}. */
|
||||
private _vectorVerifying = false
|
||||
/**
|
||||
* Coordinated migration LOCK (#18): dedup guards so the "upgrading, blocking"
|
||||
* and "upgrade complete, resumed" lines each log once per migration window,
|
||||
|
|
@ -3523,6 +3528,142 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @description The vector-index counterpart of {@link verifyGraphAdjacencyLive}
|
||||
* / {@link verifyMetadataLive}. On a cold open a native vector provider can
|
||||
* report a non-zero `size()` (its persisted COUNT loaded) yet not have loaded
|
||||
* its serving structure (the mmap/DiskANN graph) — so a pure semantic
|
||||
* `find({ query })` silently returns `[]`. A pure semantic query has
|
||||
* `hasFilterCriteria === false`, so the metadata guard never fires; this guard
|
||||
* closes that gap. Run one-shot on the first vector/proximity search:
|
||||
* - **Preferred (honest signal):** the provider exposes `isReady()`. `false`
|
||||
* → rebuild from storage, re-check; if still `false`, throw
|
||||
* {@link VectorIndexNotReadyError} rather than serving `[]`.
|
||||
* - **Fallback (no `isReady()`):** a KNOWN persisted vector (sampled +
|
||||
* hydrated) is searched against the index; if it does not self-match, the
|
||||
* serving structure did not load — rebuild + re-probe, else throw.
|
||||
* Inconclusive cases (empty store, no probeable vector, `size()===0` — where
|
||||
* the JS baseline's cold load is `ensureIndexesLoaded`'s job) are treated as
|
||||
* live: never a false rebuild. A migrating provider is skipped (it owns its
|
||||
* locked rebuild).
|
||||
* @returns `'live'` when the index serves, `'rebuilt'` when a rebuild restored it.
|
||||
*/
|
||||
private async verifyVectorLive(): Promise<'live' | 'rebuilt'> {
|
||||
if (this._vectorVerified) return 'live'
|
||||
// Migration LOCK (#18): a migrating provider owns its in-place rebuild.
|
||||
if (this.providerIsMigrating(this.index)) return 'live'
|
||||
// Re-entrancy: rebuild() can trigger reads that call back into this guard.
|
||||
if (this._vectorVerifying) return 'live'
|
||||
this._vectorVerifying = true
|
||||
try {
|
||||
// ── Strategy 1: honest isReady() signal (native provider) ──────────────
|
||||
const readiness = assessIndexReadiness(this.index)
|
||||
if (readiness !== 'unknown') {
|
||||
if (readiness === 'ready') {
|
||||
this._vectorVerified = true
|
||||
return 'live'
|
||||
}
|
||||
// Not ready: the serving structure did not load on open. Rebuild.
|
||||
if (!this.config.silent) {
|
||||
console.warn(
|
||||
`[Brainy] Vector index reports not-ready (isReady() === false) — the persisted ` +
|
||||
`vector index did not load on open. Rebuilding from storage…`
|
||||
)
|
||||
}
|
||||
await this.index.rebuild()
|
||||
if (assessIndexReadiness(this.index) === 'ready') {
|
||||
this._vectorVerified = true
|
||||
return 'rebuilt'
|
||||
}
|
||||
throw new VectorIndexNotReadyError(
|
||||
`Vector index reports not-ready even after a rebuild — semantic find({ query }) and ` +
|
||||
`proximity search cannot be served reliably for this brain (a silent empty result ` +
|
||||
`would misrepresent existing data).`
|
||||
)
|
||||
}
|
||||
|
||||
// ── Strategy 2: known-vector probe (providers without isReady()) ───────
|
||||
const claimed = this.index.size()
|
||||
if (!claimed || claimed <= 0) return 'live' // JS cold path is ensureIndexesLoaded's job
|
||||
|
||||
const probe = await this.pickVectorProbe()
|
||||
if (!probe) {
|
||||
// Empty store, or nothing with a probeable vector — inconclusive.
|
||||
this._vectorVerified = true
|
||||
return 'live'
|
||||
}
|
||||
const p = probe
|
||||
|
||||
const probeServes = async (): Promise<boolean> => {
|
||||
// The failure mode we guard is the SILENT EMPTY result: a cold index that
|
||||
// loaded its COUNT but not its serving structure returns `[]` for a
|
||||
// known-present vector, while a warm index returns at least one hit. We
|
||||
// check for a NON-EMPTY result, NOT an exact self-match — HNSW is
|
||||
// approximate and `get()` may return a re-hydrated/normalized vector, so
|
||||
// demanding the exact self as top-1 would false-positive on a perfectly
|
||||
// healthy index (and wrongly rebuild → throw).
|
||||
const hits = await this.index.search(p.vector, 1)
|
||||
return hits.length > 0
|
||||
}
|
||||
void p.id // probe keyed on the vector; id retained for diagnostics only
|
||||
|
||||
if (await probeServes()) {
|
||||
this._vectorVerified = true
|
||||
return 'live' // serving structure is live — the common case
|
||||
}
|
||||
|
||||
if (!this.config.silent) {
|
||||
console.warn(
|
||||
`[Brainy] Vector index reports ${claimed} vector(s) but a known persisted vector ` +
|
||||
`returns no results — the serving structure did not load on open. Rebuilding…`
|
||||
)
|
||||
}
|
||||
await this.index.rebuild()
|
||||
|
||||
if (await probeServes()) {
|
||||
this._vectorVerified = true
|
||||
return 'rebuilt'
|
||||
}
|
||||
throw new VectorIndexNotReadyError(
|
||||
`Vector index reports ${claimed} vector(s) but a known persisted vector returns no ` +
|
||||
`results even after a rebuild — semantic find({ query }) cannot be served reliably ` +
|
||||
`for this brain (a silent empty result would misrepresent existing data).`
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof VectorIndexNotReadyError) throw err
|
||||
// A transient probe/rebuild failure must not break the query NOR mask as
|
||||
// "no data". Allow a re-check on the next vector read and fall through.
|
||||
this._vectorVerified = false
|
||||
if (!this.config.silent) {
|
||||
console.warn(`[Brainy] Vector consistency check skipped (transient): ${err}`)
|
||||
}
|
||||
return 'live'
|
||||
} finally {
|
||||
this._vectorVerifying = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Sample a KNOWN persisted noun and hydrate its vector, to probe
|
||||
* the vector index with. `get()` omits vectors by default, so this passes
|
||||
* `{ includeVectors: true }`. Samples a few (a system-only / vectorless entity
|
||||
* must not make every open inconclusive). Returns `null` when nothing has a
|
||||
* probeable vector.
|
||||
*/
|
||||
private async pickVectorProbe(): Promise<{ id: string; vector: number[] } | null> {
|
||||
const sample = await this.storage.getNouns({ pagination: { limit: 5, offset: 0 } })
|
||||
for (const noun of sample.items ?? []) {
|
||||
const id = (noun as { id?: string }).id
|
||||
if (!id) continue
|
||||
const full = await this.get(id, { includeVectors: true })
|
||||
const vector = (full as { vector?: number[] } | null)?.vector
|
||||
if (Array.isArray(vector) && vector.length > 0) {
|
||||
return { id, vector }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
|
@ -10175,17 +10316,32 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
migrating: boolean
|
||||
/** Structured progress while {@link migrating}; absent otherwise. */
|
||||
migration?: MigrationProgress
|
||||
/** `true` when a non-fatal index rebuild failed at init() (degraded — queries
|
||||
* may be incomplete). Folds in the same `_indexRebuildFailed` signal that
|
||||
* {@link validateIndexConsistency} / {@link checkHealth} already expose. */
|
||||
rebuildFailed: boolean
|
||||
/** The rebuild failure message when {@link rebuildFailed}; absent otherwise. */
|
||||
rebuildError?: string
|
||||
/** Count of records committed via adopt-forward failed-rollback recovery whose
|
||||
* derived index may be incomplete until repairIndex() (the 8.2.6 degraded
|
||||
* set). Non-zero = degraded — a readiness probe should not report 200-ready. */
|
||||
degradedIds: number
|
||||
hnswIndex: {
|
||||
size: number
|
||||
/** Honest "serving" — the provider's `isReady()` when exposed, else `size>0`. */
|
||||
populated: boolean
|
||||
/** The provider's honest `isReady()` signal; absent when unexposed. */
|
||||
ready?: boolean
|
||||
}
|
||||
metadataIndex: {
|
||||
entries: number
|
||||
populated: boolean
|
||||
ready?: boolean
|
||||
}
|
||||
graphIndex: {
|
||||
relationships: number
|
||||
populated: boolean
|
||||
ready?: boolean
|
||||
}
|
||||
storage: {
|
||||
totalEntities: number
|
||||
|
|
@ -10200,6 +10356,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
lazyRebuildCompleted: this.lazyRebuildCompleted,
|
||||
disableAutoRebuild: this.config.disableAutoRebuild || false,
|
||||
migrating: false,
|
||||
rebuildFailed: this._indexRebuildFailed != null,
|
||||
...(this._indexRebuildFailed ? { rebuildError: this._indexRebuildFailed.message } : {}),
|
||||
degradedIds: this._indexDegradedIds.size,
|
||||
hnswIndex: { size: 0, populated: false },
|
||||
metadataIndex: { entries: 0, populated: false },
|
||||
graphIndex: { relationships: 0, populated: false },
|
||||
|
|
@ -10211,6 +10370,20 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const hnswSize = this.index.size()
|
||||
const graphSize = await this.graphIndex.size()
|
||||
|
||||
// Honest readiness: when a provider exposes isReady(), it is the truth of
|
||||
// whether the index actually SERVES (a native index can report a non-zero
|
||||
// size/count yet not have loaded its serving structure). `populated` reflects
|
||||
// that honest signal when present, falling back to size>0 only for providers
|
||||
// that do not expose isReady(). Mirrors validateIndexConsistency/checkHealth,
|
||||
// which already fold in the same signals.
|
||||
const readyOf = (p: unknown): boolean | undefined => {
|
||||
const r = assessIndexReadiness(p)
|
||||
return r === 'unknown' ? undefined : r === 'ready'
|
||||
}
|
||||
const hnswReady = readyOf(this.index)
|
||||
const metadataReady = readyOf(this.metadataIndex)
|
||||
const graphReady = readyOf(this.graphIndex)
|
||||
|
||||
// Check storage entity count
|
||||
let storageEntityCount = 0
|
||||
try {
|
||||
|
|
@ -10224,18 +10397,29 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
initialized: this.initialized,
|
||||
lazyRebuildCompleted: this.lazyRebuildCompleted,
|
||||
disableAutoRebuild: this.config.disableAutoRebuild || false,
|
||||
// A non-fatal index-rebuild failure recorded at init(), or adopt-forward
|
||||
// degraded ids, are degraded states (queries may be incomplete) — surface
|
||||
// them here alongside the same signals validateIndexConsistency()/
|
||||
// checkHealth() already expose, so a readiness probe never reports 200-ready
|
||||
// over a known-degraded index.
|
||||
rebuildFailed: this._indexRebuildFailed != null,
|
||||
...(this._indexRebuildFailed ? { rebuildError: this._indexRebuildFailed.message } : {}),
|
||||
degradedIds: this._indexDegradedIds.size,
|
||||
...this.migrationSnapshot(),
|
||||
hnswIndex: {
|
||||
size: hnswSize,
|
||||
populated: hnswSize > 0
|
||||
populated: hnswReady ?? hnswSize > 0,
|
||||
...(hnswReady !== undefined ? { ready: hnswReady } : {})
|
||||
},
|
||||
metadataIndex: {
|
||||
entries: metadataStats.totalEntries,
|
||||
populated: metadataStats.totalEntries > 0
|
||||
populated: metadataReady ?? metadataStats.totalEntries > 0,
|
||||
...(metadataReady !== undefined ? { ready: metadataReady } : {})
|
||||
},
|
||||
graphIndex: {
|
||||
relationships: graphSize,
|
||||
populated: graphSize > 0
|
||||
populated: graphReady ?? graphSize > 0,
|
||||
...(graphReady !== undefined ? { ready: graphReady } : {})
|
||||
},
|
||||
storage: {
|
||||
totalEntities: storageEntityCount
|
||||
|
|
@ -12863,6 +13047,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
candidateIds?: string[],
|
||||
allowedIds?: OpaqueIdSet
|
||||
): Promise<Result<T>[]> {
|
||||
// Vector cold-read guard: before trusting a semantic/vector result, verify the
|
||||
// vector index actually SERVES a known persisted vector (one-shot per brain).
|
||||
// A pure semantic find({ query }) has no filter, so verifyMetadataLive never
|
||||
// fires — a cold native index that loaded its COUNT but not its serving
|
||||
// structure would return a silent []. This self-heals (rebuild) or throws
|
||||
// VectorIndexNotReadyError instead.
|
||||
await this.verifyVectorLive()
|
||||
|
||||
const vector = params.vector || (await this.embed(params.query!))
|
||||
const limit = params.limit || 10
|
||||
|
||||
|
|
@ -12903,6 +13095,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
private async executeProximitySearch(params: FindParams<T>): Promise<Result<T>[]> {
|
||||
if (!params.near) return []
|
||||
|
||||
// Vector cold-read guard (see executeVectorSearch): proximity search also
|
||||
// hits this.index.search — verify it serves before trusting an empty result.
|
||||
await this.verifyVectorLive()
|
||||
|
||||
// Teaching error: without an anchor id the constraint is meaningless, and
|
||||
// letting it fall through produces an opaque storage-layer sharding error.
|
||||
if (!params.near.id) {
|
||||
|
|
@ -14157,8 +14353,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
return
|
||||
}
|
||||
|
||||
// If indexes already populated, mark as complete and skip
|
||||
if (this.index.size() > 0) {
|
||||
// If indexes already populated AND honestly serving, mark complete and skip.
|
||||
// Honest gate: when the provider exposes isReady(), that REPLACES the size()>0
|
||||
// proxy (a native index can report a non-zero size while its serving structure
|
||||
// is not loaded — the silent-empty cold-load class). A not-ready provider falls
|
||||
// through so the rebuild path can load it; verifyVectorLive() is the query-time
|
||||
// backstop either way. Providers without isReady() keep the size() heuristic
|
||||
// (the JS index's size()>0 genuinely means loaded).
|
||||
const vectorReadiness = assessIndexReadiness(this.index)
|
||||
if (vectorReadiness === 'ready' || (vectorReadiness === 'unknown' && this.index.size() > 0)) {
|
||||
this.lazyRebuildCompleted = true
|
||||
return
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue