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
167
tests/integration/metadata-online-rebuild.test.ts
Normal file
167
tests/integration/metadata-online-rebuild.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
190
tests/integration/verb-metadata-rows.test.ts
Normal file
190
tests/integration/verb-metadata-rows.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -286,32 +286,7 @@ describe.sequential('lifecycle — the working store', () => {
|
|||
300000
|
||||
)
|
||||
|
||||
/**
|
||||
* Ch4 CRASH is a LIVE ENGINE FINDING, not a defect in this lane (see
|
||||
* README.md and the project report this lane's build produced): after a
|
||||
* crash (writes acked at commit but never flushed, the process abandoned
|
||||
* exactly as `abandonAsCrashed` models, then reopened), canonical storage
|
||||
* (`get()`), the vector index, and `getNounCount()`/`getCanonicalCounts()`
|
||||
* all correctly recover every acked write — but the METADATA INDEX behind
|
||||
* `find({ where })` recovers NONE of the crash-window's acked writes
|
||||
* (neither new adds nor metadata updates to pre-existing entities), even
|
||||
* though `getIndexStatus()` reports `projections.metadata.synchronous:
|
||||
* true`. `repairIndex()` cannot close the gap either: its own report names
|
||||
* `provider:metadata` as `checked: false, skipped: "no
|
||||
* validateInvariants/rebuild contract"`. The assertion below states the
|
||||
* TRUE contract (find() must agree with get()) and is expected to fail
|
||||
* against the current engine — it must never be loosened to paper over
|
||||
* this. Ch5/Ch6 are written in full below it and will start running the
|
||||
* moment this gap is closed; they are not dead code, they are blocked code.
|
||||
*/
|
||||
// RELEASE-BLOCKING FINDING (the kill-matrix convention: assert the CONTRACT,
|
||||
// mark `.fails`, never weaken): after a crash + adopt reopen, the JS metadata
|
||||
// index computes its watermark verdict but nothing consumes 'catchup'
|
||||
// (metadataIndex.ts loadWatermarkVerdict) — find() serves the pre-crash
|
||||
// index while get()/counts recover. The catchup wiring is the cure; when it
|
||||
// lands this `.fails` marker MUST be removed (vitest will force it: a
|
||||
// passing `.fails` test is itself a failure).
|
||||
it.fails(
|
||||
it(
|
||||
'Ch4 CRASH -> Ch5 REPAIR -> Ch6 SECOND LIFE: continues the Ch3 store',
|
||||
async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Reference in a new issue