fix(8.0): metadata cold-read guard — no more silent [] on cold find({where})

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. Brainy
had a cold-read guard for GRAPH reads (verifyGraphAdjacencyLive → self-heal or a
loud GraphIndexNotReadyError) but no equivalent for metadata `where`.

Add verifyMetadataLive() — the field-index counterpart, one-shot per brain on the
first filtered find(): probe a known persisted entity's plain field value; if the
index serves it, live (the only cost on a warm brain — one O(1) probe). If not,
the postings didn't load: 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,
migrating provider) resolve to live — never a false rebuild.

The 8.0 open-core JS index already cold-loads correctly (verified: cold where 3/3,
cold related 2/2) — this guards the NATIVE path, where the durable cold-load cure
is cortex-side (same shape as the graph 2.7.8 cure). 4 unit tests (warm no-rebuild,
self-heal, loud-fail, no-filter-no-probe). Gates: typecheck 0, build 0, test:unit
1757/1757.
This commit is contained in:
David Snelling 2026-07-02 10:13:07 -07:00
parent ab53fa0893
commit 79e8709c35
4 changed files with 252 additions and 2 deletions

View file

@ -169,7 +169,7 @@ import {
import { GenerationStore } from './db/generationStore.js' import { GenerationStore } from './db/generationStore.js'
import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js' import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js'
import { GenerationConflictError } from './db/errors.js' import { GenerationConflictError } from './db/errors.js'
import { BrainyError, GraphIndexNotReadyError, MigrationInProgressError } from './errors/brainyError.js' import { BrainyError, GraphIndexNotReadyError, MetadataIndexNotReadyError, MigrationInProgressError } from './errors/brainyError.js'
import { MemoryStorage } from './storage/adapters/memoryStorage.js' import { MemoryStorage } from './storage/adapters/memoryStorage.js'
import type { import type {
CompactHistoryOptions, CompactHistoryOptions,
@ -421,6 +421,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
private _graphAdjacencyVerified = false private _graphAdjacencyVerified = false
/** Re-entrancy guard: a verify (rebuild → reads) is in flight. */ /** Re-entrancy guard: a verify (rebuild → reads) is in flight. */
private _graphAdjacencyVerifying = false private _graphAdjacencyVerifying = false
/** Metadata field-index cold-read guard: verified-serving this session (one-shot). */
private _metadataVerified = false
/** Re-entrancy guard for {@link verifyMetadataLive}. */
private _metadataVerifying = false
/** /**
* Coordinated migration LOCK (#18): dedup guards so the "upgrading, blocking" * Coordinated migration LOCK (#18): dedup guards so the "upgrading, blocking"
* and "upgrade complete, resumed" lines each log once per migration window, * and "upgrade complete, resumed" lines each log once per migration window,
@ -2928,6 +2932,122 @@ export class Brainy<T = any> implements BrainyInterface<T> {
} }
} }
/**
* @description 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 `[]` the exact
* failure a downstream deployment reported (cold reads blanking filtered pages
* after every restart). This one-shot guard, run on the first FILTERED `find()`,
* closes that: it takes a KNOWN persisted entity + one of its plain field values
* and asks the index to resolve it. If the index returns the known id the field
* postings are live (the common case, and the ONLY cost on a warm brain one
* O(1) probe). If it does not, the postings did not load: brainy rebuilds the
* index from the canonical records and re-probes; if it STILL cannot serve the
* known value it throws a loud {@link MetadataIndexNotReadyError} rather than
* let a silent `[]` stand. Inconclusive cases (empty store, no plain field to
* probe, a shared store surfacing a foreign entity) 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 verifyMetadataLive(): Promise<'live' | 'rebuilt'> {
if (this._metadataVerified) return 'live'
// Migration LOCK (#18): a migrating provider owns its in-place rebuild — do
// not race it. Defensive; the data-plane lock already gates callers upstream.
if (this.providerIsMigrating(this.metadataIndex)) 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) {
// Empty store, or nothing with a plain user field to probe — inconclusive.
this._metadataVerified = true
return 'live'
}
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
}
}
/**
* @description 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
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/** /**
@ -5045,6 +5165,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
const hasFilterCriteria = params.where || params.type || params.subtype || params.service const hasFilterCriteria = params.where || params.type || params.subtype || params.service
const hasGraphCriteria = params.connected 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) // Handle metadata-only queries (no vector search needed)
if (!hasVectorSearchCriteria && !hasGraphCriteria && hasFilterCriteria) { if (!hasVectorSearchCriteria && !hasGraphCriteria && hasFilterCriteria) {
// Build filter for metadata index // Build filter for metadata index

View file

@ -12,6 +12,7 @@ export type BrainyErrorType =
| 'VALIDATION' | 'VALIDATION'
| 'FIELD_NOT_INDEXED' | 'FIELD_NOT_INDEXED'
| 'GRAPH_INDEX_NOT_READY' | 'GRAPH_INDEX_NOT_READY'
| 'METADATA_INDEX_NOT_READY'
| 'MIGRATION_IN_PROGRESS' | 'MIGRATION_IN_PROGRESS'
/** /**
@ -255,6 +256,29 @@ 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)
}
}
}
/** /**
* Thrown when a data-plane read or write is issued against a brain that is * Thrown when a data-plane read or write is issued against a brain that is
* running its one-time, automatic 7.x 8.0 on-disk upgrade the coordinated * running its one-time, automatic 7.x 8.0 on-disk upgrade the coordinated

View file

@ -139,7 +139,7 @@ export { EntityNotFoundError, RelationNotFoundError } from './errors/notFound.js
// Base error + typed migration-lock error — thrown by any data-plane call while a // Base error + typed migration-lock error — thrown by any data-plane call while a
// brain runs its one-time 7.x→8.0 upgrade; catch to answer HTTP 503 + Retry-After. // brain runs its one-time 7.x→8.0 upgrade; catch to answer HTTP 503 + Retry-After.
export { BrainyError, MigrationInProgressError, GraphIndexNotReadyError } from './errors/brainyError.js' export { BrainyError, MigrationInProgressError, GraphIndexNotReadyError, MetadataIndexNotReadyError } from './errors/brainyError.js'
export type { BrainyErrorType } from './errors/brainyError.js' export type { BrainyErrorType } from './errors/brainyError.js'
// ============= 8.0 Db API — generational MVCC ============= // ============= 8.0 Db API — generational MVCC =============

View file

@ -0,0 +1,97 @@
/**
* Metadata cold-read guard (verifyMetadataLive) a downstream deployment
* reported cold `find({ where })` returning a silent `[]` on a freshly-opened
* brain (a native metadata index that reports data but has not loaded its field
* postings). This guard, the field-index counterpart of verifyGraphAdjacencyLive,
* probes a known persisted value on the first filtered find(): if the index does
* not serve it, brainy rebuilds and re-probes, and raises a loud
* MetadataIndexNotReadyError only if the rebuild still can't serve never a
* silent empty result that misrepresents existing data.
*
* The 8.0 JS index cold-loads correctly, so we simulate the cold native failure
* mode by intercepting the provider's getIdsForFilter/rebuild.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { Brainy, NounType, MetadataIndexNotReadyError } from '../../src/index.js'
const V = () => Array.from({ length: 384 }, (_, i) => Math.sin(i * 0.1) + 0.001)
describe('Metadata cold-read guard (#venue silent-[])', () => {
let brain: any
beforeEach(async () => {
brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
await brain.init()
await brain.add({ vector: V(), type: NounType.Concept, metadata: { status: 'active' } })
await brain.add({ vector: V(), type: NounType.Concept, metadata: { status: 'archived' } })
await brain.flush()
})
it('warm brain: filtered find is correct and the guard does not rebuild', async () => {
const mi = brain.metadataIndex
let rebuilds = 0
const origRebuild = mi.rebuild.bind(mi)
mi.rebuild = async () => {
rebuilds++
return origRebuild()
}
const res = await brain.find({ where: { status: 'active' }, limit: 100 })
expect(res.length).toBe(1)
expect(rebuilds).toBe(0) // served live — no rebuild
expect(brain._metadataVerified).toBe(true) // one-shot latched
mi.rebuild = origRebuild
})
it('cold index: verifyMetadataLive self-heals via rebuild — find({where}) is correct, NOT silent []', async () => {
const mi = brain.metadataIndex
const origGetIds = mi.getIdsForFilter.bind(mi)
const origRebuild = mi.rebuild.bind(mi)
let cold = true
brain._metadataVerified = false // re-arm the one-shot for this scenario
mi.getIdsForFilter = async (...a: any[]) => (cold ? [] : origGetIds(...a))
mi.rebuild = async () => {
await origRebuild()
cold = false // the rebuild warms the postings
}
try {
const res = await brain.find({ where: { status: 'active' }, limit: 100 })
expect(res.length).toBe(1) // self-healed — the known entity is returned
} finally {
mi.getIdsForFilter = origGetIds
mi.rebuild = origRebuild
}
})
it('unrecoverably cold index: find({where}) throws MetadataIndexNotReadyError — never a silent []', async () => {
const mi = brain.metadataIndex
const origGetIds = mi.getIdsForFilter.bind(mi)
const origRebuild = mi.rebuild.bind(mi)
brain._metadataVerified = false
mi.getIdsForFilter = async () => [] // always cold; rebuild can't fix it
mi.rebuild = async () => {}
try {
await expect(brain.find({ where: { status: 'active' }, limit: 100 })).rejects.toBeInstanceOf(
MetadataIndexNotReadyError
)
} finally {
mi.getIdsForFilter = origGetIds
mi.rebuild = origRebuild
}
})
it('a query with no filter does not trigger the metadata probe', async () => {
const mi = brain.metadataIndex
let probes = 0
const origGetIds = mi.getIdsForFilter.bind(mi)
mi.getIdsForFilter = async (...a: any[]) => {
probes++
return origGetIds(...a)
}
brain._metadataVerified = false
// A pure vector query (no where/type) must not run verifyMetadataLive's probe.
await brain.find({ vector: V(), limit: 5 })
expect(brain._metadataVerified).toBe(false) // guard never ran
mi.getIdsForFilter = origGetIds
void probes
})
})