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:
parent
ab53fa0893
commit
79e8709c35
4 changed files with 252 additions and 2 deletions
131
src/brainy.ts
131
src/brainy.ts
|
|
@ -169,7 +169,7 @@ import {
|
|||
import { GenerationStore } from './db/generationStore.js'
|
||||
import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.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 type {
|
||||
CompactHistoryOptions,
|
||||
|
|
@ -421,6 +421,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
private _graphAdjacencyVerified = false
|
||||
/** Re-entrancy guard: a verify (rebuild → reads) is in flight. */
|
||||
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"
|
||||
* 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 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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type BrainyErrorType =
|
|||
| 'VALIDATION'
|
||||
| 'FIELD_NOT_INDEXED'
|
||||
| 'GRAPH_INDEX_NOT_READY'
|
||||
| 'METADATA_INDEX_NOT_READY'
|
||||
| '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
|
||||
* running its one-time, automatic 7.x → 8.0 on-disk upgrade — the coordinated
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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'
|
||||
|
||||
// ============= 8.0 Db API — generational MVCC =============
|
||||
|
|
|
|||
97
tests/unit/metadata-cold-read-guard.test.ts
Normal file
97
tests/unit/metadata-cold-read-guard.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue