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 { 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 =============
|
||||||
|
|
|
||||||
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