From 79e8709c351c3ecf787acbc6dbc04ce1b9ab0144 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Thu, 2 Jul 2026 10:13:07 -0700 Subject: [PATCH] =?UTF-8?q?fix(8.0):=20metadata=20cold-read=20guard=20?= =?UTF-8?q?=E2=80=94=20no=20more=20silent=20[]=20on=20cold=20find({where})?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/brainy.ts | 131 +++++++++++++++++++- src/errors/brainyError.ts | 24 ++++ src/index.ts | 2 +- tests/unit/metadata-cold-read-guard.test.ts | 97 +++++++++++++++ 4 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 tests/unit/metadata-cold-read-guard.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index e3630e56..e65bc884 100644 --- a/src/brainy.ts +++ b/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 implements BrainyInterface { 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 implements BrainyInterface { } } + /** + * @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 }) + 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 => { + 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 } + ): { 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 implements BrainyInterface { 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 diff --git a/src/errors/brainyError.ts b/src/errors/brainyError.ts index 6394aa3a..2b366cb6 100644 --- a/src/errors/brainyError.ts +++ b/src/errors/brainyError.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index 06422faa..c1389dfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 ============= diff --git a/tests/unit/metadata-cold-read-guard.test.ts b/tests/unit/metadata-cold-read-guard.test.ts new file mode 100644 index 00000000..40d37de6 --- /dev/null +++ b/tests/unit/metadata-cold-read-guard.test.ts @@ -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 + }) +})