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:
parent
f8f64780b1
commit
18f172e098
6 changed files with 1275 additions and 117 deletions
|
|
@ -11,8 +11,11 @@
|
|||
* Same rule, same verdict names as the shipped aggregation machinery
|
||||
* (AggregationIndex.stateAdoptionVerdict).
|
||||
*
|
||||
* The verdict is COMPUTED AND EXPOSED only — these pins assert no rebuild
|
||||
* trigger changed; acting on 'catchup' lands with the coordinator's wiring.
|
||||
* The verdict is computed at init and consumed via
|
||||
* {@link MetadataIndexManager.applyWatermarkCatchup} — the coordinator
|
||||
* (`Brainy.performInit`) calls it right after `init()`, with an open fact
|
||||
* scan when the verdict is `'catchup'`. This file pins both halves: the
|
||||
* verdict computation (above) and the fold/no-op/demotion behavior below.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
|
@ -22,6 +25,46 @@ import {
|
|||
} from '../../../src/utils/metadataIndex.js'
|
||||
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
|
||||
import { prodLog } from '../../../src/utils/logger.js'
|
||||
import type { CommitFact, FactScanBatch, FactScanHandle } from '../../../src/db/factLog.js'
|
||||
|
||||
/** A fact scan handle over an in-memory list of facts — batches them one
|
||||
* fact at a time (batch size is irrelevant to the fold, which reads
|
||||
* `batch.facts` only). */
|
||||
function fakeScan(facts: CommitFact[]): FactScanHandle {
|
||||
return {
|
||||
headGeneration: facts.length > 0 ? facts[facts.length - 1].generation : 0,
|
||||
segmentCount: 1,
|
||||
approxFactCount: facts.length,
|
||||
async *batches(): AsyncGenerator<FactScanBatch> {
|
||||
for (const fact of facts) {
|
||||
yield {
|
||||
facts: [fact],
|
||||
firstGeneration: fact.generation,
|
||||
lastGeneration: fact.generation,
|
||||
factCount: 1,
|
||||
byteSize: 0,
|
||||
segmentId: 'fake'
|
||||
}
|
||||
}
|
||||
},
|
||||
summary: () => ({ factsYielded: facts.length, segmentsRead: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
/** One noun after-image fact — the flat-record shape (no nested `metadata`
|
||||
* key), matching this file's existing `writeArtifact` convention. */
|
||||
function nounAdd(generation: number, id: string, metadata: Record<string, unknown>): CommitFact {
|
||||
return {
|
||||
generation,
|
||||
timestamp: Date.now(),
|
||||
ops: [{ kind: 'noun', id, record: { metadata, vector: null } }]
|
||||
}
|
||||
}
|
||||
|
||||
/** One noun tombstone fact. */
|
||||
function nounDelete(generation: number, id: string): CommitFact {
|
||||
return { generation, timestamp: Date.now(), ops: [{ kind: 'noun', id, record: null }] }
|
||||
}
|
||||
|
||||
/** Fresh storage with a controllable committed generation. */
|
||||
async function makeStorage(committed: number | null): Promise<MemoryStorage> {
|
||||
|
|
@ -169,3 +212,106 @@ describe('metadata index — watermark stamp + three-way load verdict', () => {
|
|||
expect(await storage.getMetadata(METADATA_INDEX_STAMP_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('metadata index — applyWatermarkCatchup (the coordinator door)', () => {
|
||||
it("an 'adopt' verdict performs zero index writes", async () => {
|
||||
const storage = await makeStorage(5)
|
||||
await writeArtifact(storage, 5)
|
||||
const index = await reopen(storage)
|
||||
expect(index.watermarkVerdict()).toBe('adopt')
|
||||
|
||||
const addSpy = vi.spyOn(index, 'addToIndex')
|
||||
const removeSpy = vi.spyOn(index, 'removeFromIndex')
|
||||
|
||||
const result = await index.applyWatermarkCatchup(null)
|
||||
|
||||
expect(result).toEqual({ action: 'noop' })
|
||||
expect(addSpy).not.toHaveBeenCalled()
|
||||
expect(removeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a catchup window folding an add, an update (same id twice), and a delete → the index serves exactly the final state', async () => {
|
||||
const storage = await makeStorage(5)
|
||||
|
||||
// Session 1: two pre-existing entities, stamped at generation 5.
|
||||
const survivorId = uuidv4()
|
||||
const deletedId = uuidv4()
|
||||
{
|
||||
const index = new MetadataIndexManager(storage)
|
||||
await index.init()
|
||||
await index.addToIndex(survivorId, { status: 'active' })
|
||||
await index.addToIndex(deletedId, { status: 'active' })
|
||||
index.stampWatermark(5)
|
||||
await index.flush()
|
||||
}
|
||||
|
||||
// The store advanced to generation 8 without another metadata flush —
|
||||
// the exact shape a crash-then-adopt-reopen leaves behind.
|
||||
setCommitted(storage, 8)
|
||||
|
||||
const index = await reopen(storage)
|
||||
expect(index.watermarkVerdict()).toBe('catchup')
|
||||
expect(index.watermarkGap()).toEqual({ from: 5, to: 8 })
|
||||
|
||||
const addedId = uuidv4()
|
||||
const scan = fakeScan([
|
||||
nounAdd(6, addedId, { status: 'new' }), // add
|
||||
nounAdd(7, addedId, { status: 'updated' }), // update — same id twice
|
||||
nounDelete(8, deletedId) // delete
|
||||
])
|
||||
|
||||
const result = await index.applyWatermarkCatchup(scan)
|
||||
|
||||
expect(result.action).toBe('caught-up')
|
||||
expect(result.window).toEqual({ from: 5, to: 8 })
|
||||
expect(result.factsApplied).toBe(3)
|
||||
expect(result.nounsApplied).toBe(3)
|
||||
expect(result.verbsApplied).toBe(0)
|
||||
|
||||
// Final state: the added/updated id serves ONLY its final value...
|
||||
expect(await index.getIds('status', 'updated')).toEqual([addedId])
|
||||
expect(await index.getIds('status', 'new')).toEqual([]) // stale value gone
|
||||
// ...the deleted id is gone...
|
||||
expect(await index.getIds('status', 'active')).toEqual([survivorId])
|
||||
// ...and the untouched survivor is unaffected.
|
||||
expect(await index.getIds('status', 'active')).toContain(survivorId)
|
||||
|
||||
// The window is certified: watermark stamped at `to`, and a fresh
|
||||
// reopen now verdicts 'adopt'.
|
||||
expect(index.watermark()).toBe(8)
|
||||
const reopened = await reopen(storage)
|
||||
expect(reopened.watermarkVerdict()).toBe('adopt')
|
||||
})
|
||||
|
||||
it("a 'rescan' verdict runs the existing rebuild path instead of folding", async () => {
|
||||
const storage = await makeStorage(9)
|
||||
await writeArtifact(storage, 9)
|
||||
setCommitted(storage, 4) // a truncated log pulled the watermark back — stamp ABOVE committed → rescan
|
||||
|
||||
const index = await reopen(storage)
|
||||
expect(index.watermarkVerdict()).toBe('rescan')
|
||||
|
||||
const rebuildSpy = vi.spyOn(index, 'rebuild')
|
||||
const result = await index.applyWatermarkCatchup(null)
|
||||
|
||||
expect(result.action).toBe('rescan')
|
||||
expect(result.reason).toBeTruthy()
|
||||
expect(rebuildSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("a 'catchup' verdict with no fact log available demotes to rebuild, narrated", async () => {
|
||||
const storage = await makeStorage(5)
|
||||
await writeArtifact(storage, 5)
|
||||
setCommitted(storage, 8)
|
||||
|
||||
const index = await reopen(storage)
|
||||
expect(index.watermarkVerdict()).toBe('catchup')
|
||||
|
||||
const rebuildSpy = vi.spyOn(index, 'rebuild')
|
||||
const result = await index.applyWatermarkCatchup(null) // no scan — no fact log
|
||||
|
||||
expect(result.action).toBe('rescan')
|
||||
expect(result.reason).toContain('no fact log')
|
||||
expect(rebuildSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue