feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door
The read gate stops consulting the unnamed isReady() boolean: every provider
may expose healthReport() (sync, O(1), composed from exact ledgers —
HealthReport with a monotonic generation, per-invariant source
ledger|deep|unledgered, missing {count, sample}), and one readiness
authority (assessProviderHealth) derives the verdict. Unledgered families
are UNKNOWN — never healthy, never broken; a report that throws is a loud
not-ready, never a shrug. Reads at the four index choke points refuse with
the typed NotReady errors, narrated once per (provider, generation) — a
read NEVER starts a store walk:
- the first-read lazy build retires (open builds instead, regardless of
size — the ≥10k deferral and the "lazy loading on first query" branch go;
disableAutoRebuild is re-meant honestly in its docs);
- the verify*Live read-path rebuild triggers retire (refuse-or-serve);
- the read-time consistency probe that could launch a dark rebuild from an
ordinary find() retires;
- repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the
one explicit door: rebuilds the named leg unconditionally and reports
rebuilt per family; bare repairIndex() stays report-driven.
test(lifecycle): the biography lane — a store's whole life, refereed
tests/lifecycle/: an independent shadow model referees every read after
every chapter (founding, a working day, clean restart, crash, repair,
second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and
are marked .fails as a release-blocking finding (the kill-matrix
convention): after a crash + adopt reopen the metadata index computes its
'catchup' watermark verdict and nothing consumes it — find() serves the
pre-crash index while canonical and counts recover. The catchup wiring is
the cure; a passing .fails will force the marker's removal. The lane runs
in the integration gate (config + coverage guard).
This commit is contained in:
parent
a8b5ca0c8f
commit
f8f64780b1
19 changed files with 2160 additions and 652 deletions
|
|
@ -1,38 +1,49 @@
|
|||
/**
|
||||
* @module tests/unit/brainy/lazy-notready-honor
|
||||
* @description THE SILENT-EMPTY TRAP pin (found during a fleet adoption,
|
||||
* SELF-ENGINE-PAIR-STANDARD): under `disableAutoRebuild: true`, the lazy
|
||||
* SELF-ENGINE-PAIR-STANDARD): under `disableAutoRebuild: true`, the OLD lazy
|
||||
* first-query path (`ensureIndexesLoaded`) assessed ONLY the vector index's
|
||||
* readiness — a native METADATA provider reporting not-ready (its strand
|
||||
* report) never blocked the completion latch, so the promised lazy rebuild
|
||||
* never fired and every `find()` silently returned `[]` on a populated
|
||||
* store (measured: 52 entities durable-but-unqueryable, first query
|
||||
* 0ms/0 rows). The law: a not-ready report from ANY provider falls through
|
||||
* to the rebuild — never a silent empty.
|
||||
* never fired and every `find()` silently returned `[]` on a populated store
|
||||
* (measured: 52 entities durable-but-unqueryable, first query 0ms/0 rows).
|
||||
*
|
||||
* RE-POINTED to the health-gate law (a read never builds; a rebuild runs
|
||||
* entirely at open): `ensureIndexesLoaded()` is now a pure CHECK. A not-ready
|
||||
* report from ANY provider — metadata, vector, or graph — makes it THROW the
|
||||
* matching typed `*NotReadyError` rather than silently letting the read
|
||||
* proceed, and it NEVER calls `rebuildIndexesIfNeeded` (that is entirely
|
||||
* open()'s job now — see the second describe block below). The spirit is
|
||||
* unchanged: a not-ready report from any single provider can never be
|
||||
* shadowed into a silent empty result.
|
||||
*
|
||||
* White-box provider-double pattern per tests/unit/brainy/migration-deference.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import { Brainy } from '../../../src/index.js'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Brainy, MetadataIndexNotReadyError } from '../../../src/index.js'
|
||||
import { NounType } from '../../../src/types/graphTypes.js'
|
||||
import { createTestConfig } from '../../helpers/test-factory.js'
|
||||
|
||||
interface BrainInternals {
|
||||
index: { size(): number }
|
||||
metadataIndex: { isReady?: () => boolean }
|
||||
lazyRebuildCompleted: boolean
|
||||
ensureIndexesLoaded(): Promise<void>
|
||||
ensureIndexesLoaded(): void
|
||||
rebuildIndexesIfNeeded(force?: boolean): Promise<void>
|
||||
}
|
||||
|
||||
const brains: Brainy[] = []
|
||||
const dirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const b of brains.splice(0)) await b.close().catch(() => {})
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function warmLazyBrain(): Promise<{ brain: Brainy; internals: BrainInternals }> {
|
||||
async function warmBrain(): Promise<{ brain: Brainy; internals: BrainInternals }> {
|
||||
const brain = new Brainy(createTestConfig({ disableAutoRebuild: true }))
|
||||
await brain.init()
|
||||
brains.push(brain)
|
||||
|
|
@ -40,36 +51,59 @@ async function warmLazyBrain(): Promise<{ brain: Brainy; internals: BrainInterna
|
|||
await brain.add({ data: `row ${i}`, type: NounType.Document, metadata: { i } })
|
||||
}
|
||||
const internals = brain as unknown as BrainInternals
|
||||
internals.lazyRebuildCompleted = false // simulate the cold first query
|
||||
return { brain, internals }
|
||||
}
|
||||
|
||||
describe('lazy path honors EVERY provider’s not-ready report', () => {
|
||||
it('a not-ready METADATA provider blocks the completion latch and fires the rebuild', async () => {
|
||||
const { internals } = await warmLazyBrain()
|
||||
describe('the read gate honors EVERY provider’s not-ready report', () => {
|
||||
it('a not-ready METADATA provider refuses loudly — it never lets a read proceed, and it never rebuilds', async () => {
|
||||
const { internals } = await warmBrain()
|
||||
|
||||
// The trap's shape: vector side looks fine (populated), metadata
|
||||
// provider says NOT ready — the old gate latched complete here.
|
||||
;(internals.metadataIndex as { isReady?: () => boolean }).isReady = () => false
|
||||
const rebuildSpy = vi
|
||||
.spyOn(internals, 'rebuildIndexesIfNeeded')
|
||||
.mockResolvedValue(undefined)
|
||||
// provider says NOT ready — the OLD gate silently latched complete here.
|
||||
// The new gate refuses loudly instead; a read never triggers a rebuild.
|
||||
internals.metadataIndex.isReady = () => false
|
||||
const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined)
|
||||
|
||||
await internals.ensureIndexesLoaded()
|
||||
|
||||
expect(rebuildSpy, 'not-ready metadata provider must fire the lazy rebuild').toHaveBeenCalledWith(true)
|
||||
expect(() => internals.ensureIndexesLoaded()).toThrow(MetadataIndexNotReadyError)
|
||||
expect(rebuildSpy, 'a read NEVER triggers a rebuild — building is entirely open()\'s job now').not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('control: all providers ready/unknown+populated → latch completes, no rebuild', async () => {
|
||||
const { internals } = await warmLazyBrain()
|
||||
;(internals.metadataIndex as { isReady?: () => boolean }).isReady = () => true
|
||||
const rebuildSpy = vi
|
||||
.spyOn(internals, 'rebuildIndexesIfNeeded')
|
||||
.mockResolvedValue(undefined)
|
||||
|
||||
await internals.ensureIndexesLoaded()
|
||||
it('control: all providers ready/unknown+populated → the gate lets the read through, no rebuild', async () => {
|
||||
const { internals } = await warmBrain()
|
||||
internals.metadataIndex.isReady = () => true
|
||||
const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined)
|
||||
|
||||
expect(() => internals.ensureIndexesLoaded()).not.toThrow()
|
||||
expect(rebuildSpy).not.toHaveBeenCalled()
|
||||
expect(internals.lazyRebuildCompleted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the open-time build honors the same law: a needed rebuild runs at open, never deferred to a read', () => {
|
||||
it('disableAutoRebuild:true does not defer a needed rebuild past open() on a reopened, populated store', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'brainy-lazy-notready-honor-'))
|
||||
dirs.push(dir)
|
||||
|
||||
const writer = new Brainy(createTestConfig({ disableAutoRebuild: true, storage: { type: 'filesystem', path: dir } }))
|
||||
await writer.init()
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await writer.add({ data: `row ${i}`, type: NounType.Document, metadata: { i } })
|
||||
}
|
||||
await writer.flush()
|
||||
await writer.close()
|
||||
|
||||
// Fresh instance over the same store: its derived indexes start empty in
|
||||
// memory, so open()'s rebuildIndexesIfNeeded MUST fire (and complete)
|
||||
// before init() returns — even though disableAutoRebuild is true, there
|
||||
// is no first-query lazy path left to defer to.
|
||||
const reader = new Brainy(createTestConfig({ disableAutoRebuild: true, storage: { type: 'filesystem', path: dir } }))
|
||||
const internals = reader as unknown as { rebuildIndexesIfNeeded(force?: boolean): Promise<void> }
|
||||
const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded')
|
||||
|
||||
await reader.init()
|
||||
brains.push(reader)
|
||||
|
||||
expect(rebuildSpy).toHaveBeenCalledTimes(1)
|
||||
const rows = await reader.find({ where: { i: 1 } })
|
||||
expect(rows.length).toBe(1)
|
||||
}, 30000)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
/**
|
||||
* @module tests/unit/brainy/metadata-provider-contract
|
||||
* @description Brainy-side wiring of the two metadata-provider contract additions
|
||||
* confirmed with cor for the lockstep:
|
||||
* @description Brainy-side wiring of the metadata-provider contract.
|
||||
*
|
||||
* 1. `probeConsistency()` — an OPTIONAL O(1) cold-open consistency sampler. On the
|
||||
* first read, brainy calls it once; on `false` it self-heals via
|
||||
* `detectAndRepairCorruption()` (the metadata counterpart of the graph cold-load
|
||||
* guard). The native provider implements it; the JS index omits it (no-op).
|
||||
* 2. `getIdsForFilter(filter, opts?)` — brainy passes a page bound on the UNSORTED
|
||||
* `find({ type, where, limit })` path so a native provider can early-stop. The JS
|
||||
* index ignores `opts`.
|
||||
* `getIdsForFilter(filter, opts?)` — brainy passes a page bound on the UNSORTED
|
||||
* `find({ type, where, limit })` path so a native provider can early-stop. The JS
|
||||
* index ignores `opts`.
|
||||
*
|
||||
* RETIRED (health-gate law): `probeConsistency()` / `ensureMetadataConsistencyProbed()`
|
||||
* — a read-time consistency probe that launches `detectAndRepairCorruption()` on
|
||||
* `false` was exactly the read-triggered dark rebuild the law forbids (a read must
|
||||
* never start a store walk or a rebuild). The probe's diagnostic value lives on in
|
||||
* `validateIndexConsistency()` / `repairIndex()`, which remain explicit, operator-invoked
|
||||
* calls. The pin below confirms the retirement: `probeConsistency()` is never called by
|
||||
* a read, even when a provider exposes it.
|
||||
*
|
||||
* These are unit tests of brainy's CALL behaviour (the real end-to-end honoring is
|
||||
* exercised by cor's combined matrix); they inject probe/spy hooks onto the live JS
|
||||
|
|
@ -19,7 +22,7 @@ import { describe, it, expect, beforeEach } from 'vitest'
|
|||
import { Brainy } from '../../../src/brainy'
|
||||
import { NounType } from '../../../src/types/graphTypes'
|
||||
|
||||
describe('metadata-provider contract wiring (probeConsistency + getIdsForFilter opts)', () => {
|
||||
describe('metadata-provider contract wiring (getIdsForFilter opts)', () => {
|
||||
let brain: Brainy<any>
|
||||
let mi: any
|
||||
|
||||
|
|
@ -29,47 +32,23 @@ describe('metadata-provider contract wiring (probeConsistency + getIdsForFilter
|
|||
await brain.add({ data: 'a', type: NounType.Thing, metadata: { kind: 'x' } })
|
||||
await brain.add({ data: 'b', type: NounType.Thing, metadata: { kind: 'y' } })
|
||||
mi = (brain as any).metadataIndex
|
||||
;(brain as any)._metadataConsistencyProbed = false // reset the one-shot guard
|
||||
})
|
||||
|
||||
it('calls probeConsistency once on cold open and self-heals via detectAndRepairCorruption on false', async () => {
|
||||
it('RETIRED: a read never calls probeConsistency() / self-heals via detectAndRepairCorruption — that is the read-triggered dark rebuild the health-gate law forbids', async () => {
|
||||
let probes = 0
|
||||
let repairs = 0
|
||||
mi.probeConsistency = async () => { probes++; return false } // corrupt → must repair
|
||||
mi.probeConsistency = async () => { probes++; return false } // would-be corrupt signal
|
||||
const origRepair = mi.detectAndRepairCorruption.bind(mi)
|
||||
mi.detectAndRepairCorruption = async () => { repairs++; return origRepair() }
|
||||
|
||||
await brain.find({ where: { kind: 'x' } })
|
||||
expect(probes).toBe(1)
|
||||
expect(repairs).toBe(1)
|
||||
|
||||
// Second read must NOT re-probe (once per brain).
|
||||
await brain.find({ where: { kind: 'y' } })
|
||||
expect(probes).toBe(1)
|
||||
expect(repairs).toBe(1)
|
||||
})
|
||||
|
||||
it('does NOT repair when the probe reports healthy', async () => {
|
||||
let repairs = 0
|
||||
mi.probeConsistency = async () => true // clean
|
||||
const origRepair = mi.detectAndRepairCorruption.bind(mi)
|
||||
mi.detectAndRepairCorruption = async () => { repairs++; return origRepair() }
|
||||
expect(probes).toBe(0) // no read-time probe exists anymore
|
||||
expect(repairs).toBe(0) // and therefore no read-triggered self-heal either
|
||||
|
||||
await brain.find({ where: { kind: 'x' } })
|
||||
expect(repairs).toBe(0)
|
||||
})
|
||||
|
||||
it('a probe failure never breaks the read (best-effort, retried next time)', async () => {
|
||||
let probes = 0
|
||||
mi.probeConsistency = async () => { probes++; throw new Error('probe boom') }
|
||||
|
||||
// The read still succeeds despite the throwing probe.
|
||||
const rows = await brain.find({ where: { kind: 'x' } })
|
||||
expect(rows.length).toBe(1)
|
||||
expect(probes).toBe(1)
|
||||
// Guard reset on failure → the next read retries the probe.
|
||||
await brain.find({ where: { kind: 'y' } })
|
||||
expect(probes).toBe(2)
|
||||
delete mi.probeConsistency
|
||||
mi.detectAndRepairCorruption = origRepair
|
||||
})
|
||||
|
||||
it('passes a page bound to getIdsForFilter on the unsorted find path (offset 0, brainy re-windows)', async () => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import { Brainy } from '../../../src/index.js'
|
||||
import { Brainy, VectorIndexNotReadyError } from '../../../src/index.js'
|
||||
import { NounType } from '../../../src/types/graphTypes.js'
|
||||
import { createTestConfig } from '../../helpers/test-factory.js'
|
||||
import { BaseStorage } from '../../../src/storage/baseStorage.js'
|
||||
|
|
@ -43,9 +43,8 @@ interface BrainInternals {
|
|||
metadataIndex: { rebuild(...a: unknown[]): Promise<unknown> }
|
||||
graphIndex: { size(): number; rebuild(...a: unknown[]): Promise<unknown> }
|
||||
_indexEpochStale: boolean
|
||||
lazyRebuildCompleted: boolean
|
||||
rebuildIndexesIfNeeded(force?: boolean): Promise<void>
|
||||
ensureIndexesLoaded(): Promise<void>
|
||||
ensureIndexesLoaded(): void
|
||||
storage: { readRawObject(p: string): Promise<unknown> }
|
||||
}
|
||||
|
||||
|
|
@ -181,40 +180,40 @@ describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / b
|
|||
expect(idxSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// --- Hook 1: large-path first-query lazy force-rebuild deference ----------
|
||||
// --- Hook 1: read-gate deference (RE-POINTED — the health-gate law retired
|
||||
// the first-query lazy force-rebuild entirely: ensureIndexesLoaded() is now
|
||||
// a pure CHECK that never calls rebuildIndexesIfNeeded, migrating or not.
|
||||
// What survives from the original law is the DEFERENCE itself: a migrating
|
||||
// provider's report is never judged by the gate — it neither throws nor
|
||||
// rebuilds — while the exact same not-ready report on a NON-migrating
|
||||
// provider throws the typed error instead of ever rebuilding.) ------------
|
||||
|
||||
it('lazy first-query force-rebuild is SKIPPED when the vector provider isMigrating()', async () => {
|
||||
// disableAutoRebuild routes first queries through ensureIndexesLoaded() (the
|
||||
// large-brain lazy path that would otherwise force a blocking rebuild).
|
||||
it('the read gate defers to a migrating vector provider — a not-ready report neither throws nor rebuilds', async () => {
|
||||
const brain = await makeWarmBrain(2, { disableAutoRebuild: true })
|
||||
const internals = internalsOf(brain)
|
||||
|
||||
const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined)
|
||||
// Simulate a cold/empty live vector index (cor is mid-swap, serving canonical).
|
||||
vi.spyOn(internals.index, 'size').mockReturnValue(0)
|
||||
internals.lazyRebuildCompleted = false
|
||||
// Simulate a not-ready live vector index (cor is mid-swap, serving canonical).
|
||||
;(internals.index as unknown as { isReady?: () => boolean }).isReady = () => false
|
||||
setMigrating(internals.index, true)
|
||||
|
||||
await internals.ensureIndexesLoaded()
|
||||
|
||||
// A query during cor's background swap must not trigger brainy's blocking rebuild.
|
||||
expect(() => internals.ensureIndexesLoaded()).not.toThrow()
|
||||
// A query during cor's background swap must not trigger brainy's own
|
||||
// rebuild — reads never rebuild in any case, migrating or not.
|
||||
expect(rebuildSpy).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it('lazy first-query force-rebuild STILL fires when the vector provider is not migrating (control)', async () => {
|
||||
it('the read gate THROWS for the same not-ready vector provider once migration clears (control)', async () => {
|
||||
const brain = await makeWarmBrain(2, { disableAutoRebuild: true })
|
||||
const internals = internalsOf(brain)
|
||||
|
||||
const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined)
|
||||
vi.spyOn(internals.index, 'size').mockReturnValue(0)
|
||||
internals.lazyRebuildCompleted = false
|
||||
;(internals.index as unknown as { isReady?: () => boolean }).isReady = () => false
|
||||
// No isMigrating → not deferring.
|
||||
|
||||
await internals.ensureIndexesLoaded()
|
||||
|
||||
// Without deference, the cold empty index drives the lazy force-rebuild.
|
||||
expect(rebuildSpy).toHaveBeenCalledTimes(1)
|
||||
expect(rebuildSpy).toHaveBeenCalledWith(true)
|
||||
expect(() => internals.ensureIndexesLoaded()).toThrow(VectorIndexNotReadyError)
|
||||
// Still never rebuilds — the gate refuses loudly instead.
|
||||
expect(rebuildSpy).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
// --- Hook 2: public stampBrainFormat() -----------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue