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
|
|
@ -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() -----------------------------------
|
||||
|
|
|
|||
Reference in a new issue