fix: metadata cold-read guard — no more silent [] on cold find({where}) (7.33.5)
Companion to 7.33.4's graph cold-read guard, now for the metadata field index. A
downstream deployment reported find({where}) returning a silent [] on a
freshly-opened brain (a native metadata provider that reports data but hasn't
loaded its field postings), blanking filtered pages after every restart.
verifyMetadataLive() — one-shot per brain on the first filtered find(): probe a
known persisted field value; if served, live (one O(1) probe on a warm brain). If
not, rebuild from canonical + re-probe; if still unserved, throw the new exported
MetadataIndexNotReadyError rather than let a silent [] pass. Inconclusive cases
(empty store, no plain field, shared-store foreign entity) resolve to live, no
false rebuild. Also exports BrainyError + GraphIndexNotReadyError (were internal).
The JS index cold-loads correctly; this guards the native path (durable cure is
cortex-side, same shape as the graph 2.7.8 cure). 4 unit tests. tsc 0, guard 4/4,
full unit 1538 pass (+1 pre-existing flaky VFS perf test, green in isolation).
This commit is contained in:
parent
2be3d0f88b
commit
9dc4c5e62b
5 changed files with 268 additions and 1 deletions
123
src/brainy.ts
123
src/brainy.ts
|
|
@ -48,7 +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 { GraphIndexNotReadyError, MetadataIndexNotReadyError } from './errors/brainyError.js'
|
||||
import {
|
||||
ValidationConfig,
|
||||
validateAddParams,
|
||||
|
|
@ -194,6 +194,8 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
private _vfsInitialized = false // Track VFS init completion separately
|
||||
private _graphAdjacencyVerified = false // graph-adjacency cold-load consistency: verified-live this session
|
||||
private _graphAdjacencyVerifying = false // re-entrancy guard: a verify (rebuild→reads) is in flight
|
||||
private _metadataVerified = false // metadata field-index cold-read guard: verified-serving this session
|
||||
private _metadataVerifying = false // re-entrancy guard for verifyMetadataLive
|
||||
private _hub?: IntegrationHub // Integration Hub for external tools
|
||||
private _pendingMigrationRunner?: MigrationRunner // Deferred migration runner for large datasets
|
||||
private _aggregationIndex?: AggregationIndex // Incremental aggregation engine
|
||||
|
|
@ -3224,6 +3226,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const hasFilterCriteria = params.where || params.type || params.subtype || params.service
|
||||
const hasGraphCriteria = params.connected
|
||||
|
||||
// Metadata cold-read guard: before trusting a filter result, verify the
|
||||
// field index actually serves a known persisted value (one-shot per brain).
|
||||
// A cold native index that has not loaded its `where` postings self-heals
|
||||
// here (rebuild) or throws MetadataIndexNotReadyError — never a silent [].
|
||||
// The graph counterpart (verifyGraphAdjacencyLive) covers `connected`.
|
||||
if (hasFilterCriteria) {
|
||||
await this.verifyMetadataLive()
|
||||
}
|
||||
|
||||
// Handle metadata-only queries (no vector search needed)
|
||||
if (!hasVectorSearchCriteria && !hasGraphCriteria && hasFilterCriteria) {
|
||||
// Build filter for metadata index
|
||||
|
|
@ -8258,6 +8269,116 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The metadata field-index counterpart of {@link verifyGraphAdjacencyLive}.
|
||||
* On a cold open a native metadata provider can report data yet not serve its
|
||||
* `where` postings, so `find({ where })` silently returns `[]` — a downstream
|
||||
* deployment reported cold filtered reads blanking pages after every restart.
|
||||
* This one-shot guard, run on the first FILTERED `find()`, takes a KNOWN
|
||||
* persisted entity + one of its plain field values and asks the index to
|
||||
* resolve it. If the known id comes back the postings are live (the common
|
||||
* case, and the only cost on a warm brain — one O(1) probe). If not, they did
|
||||
* not load: brainy rebuilds from the canonical records and re-probes, and
|
||||
* raises a loud {@link MetadataIndexNotReadyError} only if the rebuild still
|
||||
* cannot serve — never a silent `[]` over existing data. Inconclusive cases
|
||||
* (empty store, no plain field to probe, a shared store surfacing a foreign
|
||||
* entity) resolve to live — never a false rebuild.
|
||||
*/
|
||||
private async verifyMetadataLive(): Promise<'live' | 'rebuilt'> {
|
||||
if (this._metadataVerified) return 'live'
|
||||
// Re-entrancy: rebuild() can trigger reads that call back into this guard.
|
||||
if (this._metadataVerifying) return 'live'
|
||||
this._metadataVerifying = true
|
||||
try {
|
||||
// A KNOWN persisted entity + one plain field to probe. Sample a few so a
|
||||
// system-only entity (e.g. the VFS root) doesn't make every open inconclusive.
|
||||
const sample = await this.storage.getNouns({ pagination: { limit: 5, offset: 0 } })
|
||||
let probe: { field: string; value: string | number | boolean; id: string } | null = null
|
||||
for (const noun of sample.items ?? []) {
|
||||
probe = this.pickMetadataProbe(noun as { id?: string; metadata?: Record<string, unknown> })
|
||||
if (probe) break
|
||||
}
|
||||
if (!probe) {
|
||||
this._metadataVerified = true
|
||||
return 'live' // empty store, or nothing with a plain user field to probe
|
||||
}
|
||||
const p = probe
|
||||
|
||||
const probeServes = async (): Promise<boolean> => {
|
||||
try {
|
||||
const ids = await this.metadataIndex.getIdsForFilter({ [p.field]: p.value })
|
||||
return ids.includes(p.id)
|
||||
} catch {
|
||||
// FIELD_NOT_INDEXED for a field a persisted entity actually holds is
|
||||
// itself the cold/broken signal — treat as not-serving (→ rebuild).
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (await probeServes()) {
|
||||
this._metadataVerified = true
|
||||
return 'live' // field postings are live — the common case
|
||||
}
|
||||
|
||||
if (!this.config.silent) {
|
||||
console.warn(
|
||||
`[Brainy] Metadata field index returns no match for a known persisted value of ` +
|
||||
`'${p.field}' — the field postings did not load on open. Rebuilding from storage…`
|
||||
)
|
||||
}
|
||||
await this.metadataIndex.rebuild()
|
||||
|
||||
if (await probeServes()) {
|
||||
this._metadataVerified = true
|
||||
return 'rebuilt'
|
||||
}
|
||||
throw new MetadataIndexNotReadyError(
|
||||
`Metadata field index cannot serve a known persisted value of '${p.field}' even after ` +
|
||||
`a rebuild — find({ where }) and other filtered reads cannot be served reliably for ` +
|
||||
`this brain (a silent empty result would misrepresent existing data).`
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof MetadataIndexNotReadyError) throw err
|
||||
// A transient probe/rebuild failure must not break the query NOR mask as
|
||||
// "no data". Allow a re-check on the next filtered read and fall through.
|
||||
this._metadataVerified = false
|
||||
if (!this.config.silent) {
|
||||
console.warn(`[Brainy] Metadata consistency check skipped (transient): ${err}`)
|
||||
}
|
||||
return 'live'
|
||||
} finally {
|
||||
this._metadataVerifying = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose one plain (scalar, user-written) field from an entity's metadata to
|
||||
* probe the field index with — skipping internal / system fields (`__words__`
|
||||
* text hash, VFS markers, reserved `visibility`/`subtype`/`service`, the `noun`
|
||||
* type alias, timestamps) that use different index paths, and any non-scalar
|
||||
* value. Returns `null` when the entity has no probeable field.
|
||||
*/
|
||||
private pickMetadataProbe(
|
||||
noun: { id?: string; metadata?: Record<string, unknown> }
|
||||
): { field: string; value: string | number | boolean; id: string } | null {
|
||||
const id = noun?.id
|
||||
const metadata = noun?.metadata
|
||||
if (!id || !metadata || typeof metadata !== 'object') return null
|
||||
const skip = new Set([
|
||||
'noun', 'type', 'subtype', 'service', 'visibility', 'id', 'vector',
|
||||
'isVFSEntity', 'vfsType', 'vfsPath', 'vfsName', 'createdAt', 'updatedAt'
|
||||
])
|
||||
for (const [field, value] of Object.entries(metadata)) {
|
||||
if (field.startsWith('_') || skip.has(field)) continue
|
||||
if (value === null || value === undefined) continue
|
||||
const t = typeof value
|
||||
if (t === 'string' || t === 'number' || t === 'boolean') {
|
||||
return { field, value: value as string | number | boolean, id }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async neighbors(
|
||||
entityId: string,
|
||||
options?: {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type BrainyErrorType =
|
|||
| 'VALIDATION'
|
||||
| 'FIELD_NOT_INDEXED'
|
||||
| 'GRAPH_INDEX_NOT_READY'
|
||||
| 'METADATA_INDEX_NOT_READY'
|
||||
|
||||
/**
|
||||
* Custom error class for Brainy operations
|
||||
|
|
@ -248,3 +249,26 @@ export class GraphIndexNotReadyError extends BrainyError {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the metadata field index reports data but cannot serve a KNOWN
|
||||
* persisted field value even after a rebuild — i.e. the `where` / filter
|
||||
* postings did not load on a cold open and could not be restored. The
|
||||
* field-index counterpart of {@link GraphIndexNotReadyError}: it replaces the
|
||||
* silent-empty failure mode (a cold `find({ where })` returning `[]`
|
||||
* indistinguishable from "no such data") with a loud, catchable error, so a
|
||||
* consumer never renders "nothing found" over data that is simply not-yet-warm.
|
||||
*
|
||||
* Detected once per brain by a known-value serving probe on the first filtered
|
||||
* `find()`; brainy self-heals (rebuilds the index from the canonical records)
|
||||
* first and only raises this if the rebuild still cannot serve the known value.
|
||||
*/
|
||||
export class MetadataIndexNotReadyError extends BrainyError {
|
||||
constructor(message: string, originalError?: Error) {
|
||||
super(message, 'METADATA_INDEX_NOT_READY', false, originalError)
|
||||
this.name = 'MetadataIndexNotReadyError'
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, MetadataIndexNotReadyError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,6 +139,11 @@ export type { Migration, MigrationState, MigrationPreview, MigrationResult, Migr
|
|||
// Export optimistic-concurrency types (7.31.0)
|
||||
export { RevisionConflictError } from './transaction/RevisionConflictError.js'
|
||||
|
||||
// Cold-read guards (7.33.4 graph / 7.33.5 metadata): catch these instead of
|
||||
// silently rendering an empty cold-index result. `error.type` also works.
|
||||
export { BrainyError, GraphIndexNotReadyError, MetadataIndexNotReadyError } from './errors/brainyError.js'
|
||||
export type { BrainyErrorType } from './errors/brainyError.js'
|
||||
|
||||
// Export embedding functionality
|
||||
import {
|
||||
UniversalSentenceEncoder,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue