feat(recovery): the catchup verdict is consumed; verb rows go live; the metadata rebuild goes online

Three cures on the JS metadata index, one seam:

- THE CATCHUP WIRING. The index computed its three-way watermark verdict at
  open and nothing consumed it — after a crash + adopt reopen, find() served
  the pre-crash index while canonical reads and counts recovered (caught by
  the lifecycle lane's first run). The open path now consumes the verdict:
  'adopt' is a no-op, 'catchup' folds the fact window (stamped, committed]
  through the index legs — nouns and verbs, remove-then-add, one mechanism
  for add and update — and 'rescan' runs the explicit rebuild, each narrated.
  The lane's Ch4–6 release-blocking marker comes off: the contract holds.
  Bonus root-cause: close() never stamped the projection watermarks (only
  flush() did), so any close without a prior flush verdicted a needless
  'rescan' on reopen — both doors now stamp.

- THE LIVE VERB PATH. Verb rows entered the metadata index only via rebuild
  walks, so every rebuilt store minted phantom/stale verb postings from its
  first live relate(). relate()/unrelate()/updateRelation() and remove()'s
  cascade now post/retract the verb's row in the same commit as the graph
  leg — transact() planners mirror identically — using the exact record
  shape the rebuild walk uses, so live and rebuilt populations agree.

- THE ONLINE REBUILD. rebuild() was clear-then-walk — every metadata read
  empty for the duration. rebuildMetadataIndexOnline builds a fresh manager
  beside the serving one (shared identity, in-memory build, dual-write via
  a shadow seam with zero call-site changes), atomically swaps the
  reference, and persists exactly once post-swap. A find() polled ~200x
  during a 2k-noun rebuild never dropped below its baseline.
  repairIndex({ rebuild: ['metadata'] }) uses it automatically.
This commit is contained in:
David Snelling 2026-08-25 10:01:56 -07:00
parent f8f64780b1
commit 18f172e098
6 changed files with 1275 additions and 117 deletions

View file

@ -0,0 +1,167 @@
/**
* @module tests/integration/metadata-online-rebuild
* @description THE ONLINE JS METADATA REBUILD (B3 Deliverable 3) pins.
* `MetadataIndexManager.rebuild()` used to be clear-then-walk reads went
* dark for the duration. `repairIndex({ rebuild: ['metadata'] })` now builds
* a fresh replacement index BESIDE the live one (walk canonical + mirror
* every live write via `beginShadow`/`endShadow` + a bounded fact-log fold),
* then atomically swaps the brain's reference `find()` never observes a
* half-built index, and a write landing DURING the build is never lost.
*/
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
import { describe, it, expect, afterEach } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Brainy } from '../../src/brainy.js'
import { NounType, VerbType } from '../../src/types/graphTypes.js'
import type { MetadataIndexManager } from '../../src/utils/metadataIndex.js'
const dirs: string[] = []
const brains: Brainy<any>[] = []
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 })
})
function metadataIndexOf(brain: Brainy<any>): MetadataIndexManager {
return (brain as unknown as { metadataIndex: MetadataIndexManager }).metadataIndex
}
async function openBrain(): Promise<{ brain: Brainy<any>; dir: string }> {
const dir = mkdtempSync(join(tmpdir(), 'brainy-online-rebuild-'))
dirs.push(dir)
const brain = new Brainy<any>({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
silent: true,
persistence: { policy: 'manual' },
logAuthority: 'adopt'
})
await brain.init()
brains.push(brain)
return { brain, dir }
}
describe('repairIndex({ rebuild: ["metadata"] }) — the online build-beside rebuild', () => {
it(
'a find() polled throughout the rebuild of a 2k-noun store never returns fewer rows than ' +
'before the build started, and a write landing DURING the build is never lost',
async () => {
const { brain, dir } = await openBrain()
void dir
const N = 2000
const ids: string[] = []
for (let i = 0; i < N; i++) {
ids.push(
await brain.add({
data: `entity ${i}`,
type: NounType.Person,
metadata: { status: i % 2 === 0 ? 'active' : 'inactive' }
})
)
}
for (let i = 0; i < 20; i++) {
await brain.relate({
from: ids[i], to: ids[i + 1], type: VerbType.WorksWith, metadata: { tag: 'orig' }
})
}
await brain.flush()
const baseline = await brain.find({ where: { status: 'active' }, limit: 10000 })
expect(baseline.length).toBe(N / 2)
// Kick off the online rebuild WITHOUT awaiting — poll reads and
// perform a live write concurrently with it.
const repairPromise = brain.repairIndex({ rebuild: ['metadata'] })
let minObserved = Infinity
let polls = 0
const pollPromise = (async () => {
// Poll until the rebuild settles — bounded so a slow CI box can't
// spin forever, generous enough to actually overlap the walk.
while (polls < 200) {
const rows = await brain.find({ where: { status: 'active' }, limit: 10000 })
minObserved = Math.min(minObserved, rows.length)
polls++
await new Promise((resolve) => setTimeout(resolve, 1))
}
})()
const newId = await brain.add({
data: 'added during the rebuild',
type: NounType.Person,
metadata: { status: 'active' }
})
const newRelId = await brain.relate({
from: newId, to: ids[0], type: VerbType.WorksWith, metadata: { tag: 'during-build' }
})
const [report] = await Promise.all([repairPromise, pollPromise])
// THE PIN: never fewer rows than the pre-build baseline, at any polled
// instant — reads served the OLD (fully-populated) manager throughout.
expect(polls).toBeGreaterThan(0)
expect(minObserved).toBeGreaterThanOrEqual(baseline.length)
// The repair report still accounts for the family (same receipt shape
// regardless of which rebuild mechanism actually ran underneath).
const metadataFamily = report.families.find((f) => f.family === 'provider:metadata')
expect(metadataFamily?.checked).toBe(true)
expect(metadataFamily?.rebuilt).toBe(true)
// Post-swap correctness: the live write during the build was never
// lost (the beginShadow mirror + post-walk fold caught it).
const afterActive = await brain.find({ where: { status: 'active' }, limit: 10000 })
expect(afterActive.length).toBe(baseline.length + 1)
expect(afterActive.some((r) => r.id === newId)).toBe(true)
const index = metadataIndexOf(brain)
expect(await index.getIds('tag', 'during-build')).toEqual([newRelId])
expect((await index.getIds('tag', 'orig')).length).toBe(20)
// The swap stamped the watermark — a reopen adopts, zero rebuild.
await brain.close()
brains.length = 0 // already closed above; afterEach must not double-close
const reopened = new Brainy<any>({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
silent: true,
persistence: { policy: 'manual' },
logAuthority: 'adopt'
})
await reopened.init()
brains.push(reopened)
const reopenedIndex = metadataIndexOf(reopened)
expect(reopenedIndex.watermarkVerdict()).toBe('adopt')
const reopenedActive = await reopened.find({ where: { status: 'active' }, limit: 10000 })
expect(reopenedActive.length).toBe(afterActive.length)
},
60000
)
it('repairIndex({ rebuild: ["metadata"] }) on an empty store is a trivial no-op walk', async () => {
const { brain } = await openBrain()
const report = await brain.repairIndex({ rebuild: ['metadata'] })
const metadataFamily = report.families.find((f) => f.family === 'provider:metadata')
expect(metadataFamily?.checked).toBe(true)
expect(await brain.getNounCount()).toBe(0)
})
it('two consecutive online rebuilds both leave the index correct (idempotent)', async () => {
const { brain } = await openBrain()
const a = await brain.add({ data: 'a', type: NounType.Person, metadata: { status: 'active' } })
await brain.add({ data: 'b', type: NounType.Person, metadata: { status: 'inactive' } })
await brain.flush()
await brain.repairIndex({ rebuild: ['metadata'] })
const first = await brain.find({ where: { status: 'active' } })
expect(first.map((r) => r.id)).toEqual([a])
await brain.repairIndex({ rebuild: ['metadata'] })
const second = await brain.find({ where: { status: 'active' } })
expect(second.map((r) => r.id)).toEqual([a])
})
})

View file

@ -0,0 +1,190 @@
/**
* @module tests/integration/verb-metadata-rows
* @description THE LIVE VERB PATH pins. Before this train, verb rows entered
* the metadata index ONLY via `MetadataIndexManager.rebuild()`'s canonical
* walk every relate()/unrelate()/updateRelation() call, and every
* remove()-cascaded relationship, left the metadata index blind to verb
* writes until the next rebuild. This file pins that `relate()`,
* `unrelate()`, `updateRelation()`, `remove()`'s cascade, and their
* `transact()` mirrors now post/retract the SAME verb rows a rebuild would
* derive from canonical (ADR-007 A4: one mechanism for add/update, live and
* rebuilt).
*/
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import { NounType, VerbType } from '../../src/types/graphTypes.js'
import type { MetadataIndexManager } from '../../src/utils/metadataIndex.js'
/** The JS metadata-index manager backing a memory-storage brain in these
* tests (feature-detected in production code via `instanceof
* MetadataIndexManager`; a narrow test-only reach-in here, matching the
* existing idiom in tests/integration/find-where-zero.test.ts and
* tests/integration/level-field-shadow.test.ts). */
function metadataIndexOf(brain: Brainy<any>): MetadataIndexManager {
return (brain as unknown as { metadataIndex: MetadataIndexManager }).metadataIndex
}
describe('verb metadata rows — the live path matches the rebuild walk', () => {
let brain: Brainy<any>
beforeEach(async () => {
brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true })
await brain.init()
})
afterEach(async () => {
await brain.close()
})
async function addPerson(label: string): Promise<string> {
return brain.add({
data: `person ${label}`,
type: NounType.Person,
metadata: { label }
})
}
it('(a) relate() posts a metadata-index-backed verb row a query can find', async () => {
const a = await addPerson('a')
const b = await addPerson('b')
const relId = await brain.relate({
from: a, to: b, type: VerbType.WorksWith, metadata: { role: 'lead' }
})
// Read it back the SAME way a rebuild-sourced row is queried — the
// manager's own posting lookup, keyed on the custom field the caller wrote.
const index = metadataIndexOf(brain)
expect(await index.getIds('role', 'lead')).toEqual([relId])
})
it('(b) unrelate() retracts the row', async () => {
const a = await addPerson('a')
const b = await addPerson('b')
const relId = await brain.relate({
from: a, to: b, type: VerbType.WorksWith, metadata: { role: 'lead' }
})
const index = metadataIndexOf(brain)
expect(await index.getIds('role', 'lead')).toEqual([relId])
// Flush BEFORE retracting the field's only occurrence: this durably
// persists the 'role' column (a segment on disk/in the store), so the
// post-retraction query below reads "this field exists, zero live
// postings" (→ []) rather than "this field has never been written"
// (→ FIELD_NOT_INDEXED) — an orthogonal column-store characteristic
// (an unflushed field with its last live posting removed reverts to
// unknown), not a D2 behavior.
await brain.flush()
await brain.unrelate(relId)
expect(await index.getIds('role', 'lead')).toEqual([])
})
it('(c) updateRelation({ metadata }) leaves exactly the new values', async () => {
const a = await addPerson('a')
const b = await addPerson('b')
const relId = await brain.relate({
from: a, to: b, type: VerbType.WorksWith, metadata: { role: 'lead', team: 'core' }
})
const index = metadataIndexOf(brain)
expect(await index.getIds('role', 'lead')).toEqual([relId])
// Flush first — see (b)'s note: 'role'/'team' must be durably known
// fields before their only value is retracted, or the post-update
// "gone" checks below throw FIELD_NOT_INDEXED instead of returning [].
await brain.flush()
await brain.updateRelation({ id: relId, metadata: { role: 'reviewer' }, merge: false })
// Stale values gone (the old shape AND the merge:false-dropped field)…
expect(await index.getIds('role', 'lead')).toEqual([])
expect(await index.getIds('team', 'core')).toEqual([])
// …only the new value serves.
expect(await index.getIds('role', 'reviewer')).toEqual([relId])
})
it("(d) remove(entity) cascade retracts every incident relation's metadata row", async () => {
const a = await addPerson('a')
const b = await addPerson('b')
const c = await addPerson('c')
const rel1 = await brain.relate({
from: a, to: b, type: VerbType.WorksWith, metadata: { tag: 'cascade-test' }
})
const rel2 = await brain.relate({
from: c, to: a, type: VerbType.WorksWith, metadata: { tag: 'cascade-test' }
})
const index = metadataIndexOf(brain)
expect((await index.getIds('tag', 'cascade-test')).sort()).toEqual([rel1, rel2].sort())
// Flush first — see (b)'s note.
await brain.flush()
await brain.remove(a) // a is source of rel1, target of rel2 — both cascade
expect(await index.getIds('tag', 'cascade-test')).toEqual([])
})
it('(e) a rebuild() reproduces exactly the verb-row population the live path built', async () => {
const a = await addPerson('a')
const b = await addPerson('b')
const c = await addPerson('c')
await brain.relate({ from: a, to: b, type: VerbType.WorksWith, metadata: { tag: 'parity', label: 'ab' } })
await brain.relate({ from: b, to: c, type: VerbType.RelatedTo, metadata: { tag: 'parity', label: 'bc' } })
const relId3 = await brain.relate({
from: c, to: a, type: VerbType.WorksWith, metadata: { tag: 'parity', label: 'ca' }
})
await brain.unrelate(relId3) // exercise retraction too — the rebuild must NOT resurrect it
const index = metadataIndexOf(brain)
const beforeIds = (await index.getIds('tag', 'parity')).slice().sort()
expect(beforeIds.length).toBe(2)
const beforeAb = await index.getIds('label', 'ab')
const beforeBc = await index.getIds('label', 'bc')
await index.rebuild()
const afterIds = (await index.getIds('tag', 'parity')).slice().sort()
expect(afterIds).toEqual(beforeIds)
expect(await index.getIds('label', 'ab')).toEqual(beforeAb)
expect(await index.getIds('label', 'bc')).toEqual(beforeBc)
expect(await index.getIds('label', 'ca')).toEqual([]) // the unrelated edge stays gone
})
it('(f) transact() relate/unrelate posts/retracts the same metadata-index rows as single-op', async () => {
const a = await addPerson('a')
const b = await addPerson('b')
const c = await addPerson('c')
const d = await addPerson('d')
// Single-op baseline.
const singleOpId = await brain.relate({
from: a, to: b, type: VerbType.WorksWith, metadata: { tag: 'parity-f' }
})
// transact() mirror.
const relateDb = await brain.transact([
{ op: 'relate', from: c, to: d, type: VerbType.WorksWith, metadata: { tag: 'parity-f' } }
])
const transactId = relateDb.receipt!.ids[0]
await relateDb.release()
const index = metadataIndexOf(brain)
expect((await index.getIds('tag', 'parity-f')).sort()).toEqual([singleOpId, transactId].sort())
// Flush first — see (b)'s note: 'tag' must be durably known before its
// last live posting is retracted below.
await brain.flush()
// Retract both ways — single-op unrelate() and transact() unrelate.
await brain.unrelate(singleOpId)
const unrelateDb = await brain.transact([{ op: 'unrelate', id: transactId }])
await unrelateDb.release()
expect(await index.getIds('tag', 'parity-f')).toEqual([])
})
})