fix: surface a degraded derived index on reads instead of serving it silently

Two known-degraded states were recorded but never consulted by the read paths, so
a partial result looked authoritative:

- commitSingleOp returns `degraded` ids on an adopt-forward failed-rollback
  recovery (the canonical record is durable but its derived-index entry may be
  incomplete). persistSingleOp dropped that list on the floor — no health flag,
  no read signal. It now records them in a queryable degraded set.
- A non-fatal index-rebuild failure at init (_indexRebuildFailed) was folded into
  checkHealth()/validateIndexConsistency() but no read consulted it.

find() and get() now emit ONE loud warning per degraded window (reads still
return — canonical is the source of truth — but the caller is told results may be
partial and to run repairIndex()). Both degraded sources fold into the two health
surfaces, and repairIndex() reconciles from canonical and clears them.
This commit is contained in:
David Snelling 2026-07-13 09:49:38 -07:00
parent 7feba49d94
commit ba958d97b5
2 changed files with 184 additions and 3 deletions

View file

@ -0,0 +1,79 @@
/**
* @module tests/unit/brainy/degraded-reads-surfaced
* @description Finding 10: a known-degraded derived index must be SURFACED, not
* silently served as authoritative. Two degraded sources:
* (a) a non-fatal index rebuild failure at init() (`_indexRebuildFailed`), and
* (b) an adopt-forward failed-rollback recovery (`commitSingleOp`'s `degraded`
* ids, previously dropped on the floor by persistSingleOp).
* Both now fold into checkHealth()/validateIndexConsistency() and emit ONE loud
* read-path warning per degraded window; repairIndex() reconciles + clears them.
*
* The private fields are the observable contract of the fix, so the test drives
* them directly (a real failed rollback is exercised elsewhere).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../../src/brainy.js'
import { NounType } from '../../../src/types/graphTypes.js'
import { prodLog } from '../../../src/utils/logger.js'
const UUID = (suffix: string): string => `00000000-0000-4000-8000-0000000000${suffix}`
describe('Finding 10 — degraded derived-index state is surfaced on reads', () => {
beforeEach(() => {
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
})
afterEach(() => vi.restoreAllMocks())
it('checkHealth() reports adopt-forward degraded ids as unhealthy', async () => {
const brain = new Brainy({ storage: { type: 'memory' }, dimensions: 384, requireSubtype: false })
await brain.init()
;(brain as any)._indexDegradedIds.add(UUID('de'))
const health = await brain.checkHealth()
expect(health.healthy).toBe(false)
expect(health.recommendation).toMatch(/repairIndex\(\)/)
})
it('find()/get() warn loudly while degraded, ONCE, then repairIndex() clears it', async () => {
const warn = vi.spyOn(prodLog, 'warn').mockImplementation(() => {})
const brain = new Brainy({ storage: { type: 'memory' }, dimensions: 384, requireSubtype: false })
await brain.init()
await brain.add({ id: UUID('a1'), data: 'x', type: NounType.Document })
;(brain as any)._indexRebuildFailed = new Error('rebuild boom')
await brain.find({ type: NounType.Document })
await brain.get(UUID('a1')) // second read: must NOT double-warn
const degradedWarns = warn.mock.calls.filter((c) =>
String(c[0]).includes('derived index is INCOMPLETE')
)
expect(degradedWarns.length).toBe(1)
await brain.repairIndex()
expect((brain as any)._indexRebuildFailed).toBeNull()
expect((brain as any)._indexDegradedIds.size).toBe(0)
warn.mockClear()
await brain.find({ type: NounType.Document })
expect(warn.mock.calls.filter((c) => String(c[0]).includes('INCOMPLETE')).length).toBe(0)
})
it('persistSingleOp records receipt.degraded (widened return type, not dropped)', async () => {
const brain = new Brainy({ storage: { type: 'memory' }, dimensions: 384, requireSubtype: false })
await brain.init()
// Simulate a degraded receipt by wrapping the generation store's commitSingleOp.
const gs: any = (brain as any).generationStore
const realCommit = gs.commitSingleOp.bind(gs)
vi.spyOn(gs, 'commitSingleOp').mockImplementation(async (args: any) => {
const r = await realCommit(args)
return { ...r, degraded: [...(args.touched.nouns ?? [])] }
})
await brain.add({ id: UUID('b2'), data: 'y', type: NounType.Document })
expect((brain as any)._indexDegradedIds.size).toBeGreaterThan(0)
expect((brain as any)._indexDegradedIds.has(UUID('b2'))).toBe(true)
const health = await brain.checkHealth()
expect(health.healthy).toBe(false)
expect(health.recommendation).toMatch(/repairIndex\(\)/)
})
})