Three pieces addressing the cold-restart-write incident where a production deployment's first writes after every restart (33-35s each on a cold page cache) blew the op-count-scaled transact budget mid-batch: every write is itself a multi-op transaction, so one cold operation consumed the whole budget, the gate before the next operation tripped, and the write rolled back atomically - refused, retried, and refused again until the page cache warmed passively. - The budget's start-gating contract is now explicit and pinned: it gates STARTING the next operation, never rolling back completed work for elapsed time (the shipped schedule since 8.7.0, now stated in contract JSDoc, guarded by code for operation 0, and enforced by regression tests). The 30s floor is configurable via transactionBudgetFloorMs for stores whose cold operations legitimately run long. - New brain.warm() eagerly loads the vector index, metadata index, and graph adjacency so first operations after a cold restart run at steady-state cost. Returns a WarmReport with an honest per-surface outcome (warmed / probed / unavailable) - never reports a probe as a warm. warmOnOpen: true runs it during init(). New optional provider hook warm() on the vector and graph plugin contracts. - Vector-index transaction op classes renamed from the backend-specific AddToHNSWOperation / RemoveFromHNSWOperation to backend-neutral AddToVectorIndexOperation / RemoveFromVectorIndexOperation, stamping the active backend into the emitted op-name string (AddToVectorIndex(js-hnsw) vs a native provider's own identity) so journals never misdirect an operator toward an index that isn't running.
152 lines
6.3 KiB
TypeScript
152 lines
6.3 KiB
TypeScript
/**
|
|
* @module tests/unit/transaction/vectorIndexOperations-rename
|
|
* @description Coverage for the backend-neutral vector-index transaction
|
|
* operations (formerly `AddToHNSWOperation` / `RemoveFromHNSWOperation`).
|
|
* These op-name strings surface directly in consumer-visible transaction
|
|
* journals and timings — a fossil "HNSW" name misdirects an operator running
|
|
* a native (non-HNSW) vector provider. Verifies:
|
|
* 1. rollback wiring stayed byte-identical under the rename,
|
|
* 2. the emitted `name` stamps the active backend — `'js-hnsw'` for
|
|
* Brainy's own JS index, a provider's own `providerId` when it
|
|
* self-identifies, and the honest `'unknown-provider'` literal when
|
|
* neither applies (never a silently-wrong guess).
|
|
*/
|
|
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
AddToVectorIndexOperation,
|
|
RemoveFromVectorIndexOperation,
|
|
BatchAddToVectorIndexOperation
|
|
} from '../../../src/transaction/operations/IndexOperations.js'
|
|
import type { VectorIndexProvider } from '../../../src/plugin.js'
|
|
import type { VectorDocument, Vector } from '../../../src/coreTypes.js'
|
|
import { JsHnswVectorIndex } from '../../../src/hnsw/hnswIndex.js'
|
|
|
|
/**
|
|
* A minimal, real (not mocked) VectorIndexProvider implementation backed by
|
|
* a plain Map — exercises the exact contract the operations call through,
|
|
* without any Brainy internals.
|
|
*/
|
|
class FakeVectorProvider implements VectorIndexProvider {
|
|
readonly items = new Map<string, Vector>()
|
|
constructor(readonly providerId?: string) {}
|
|
|
|
async addItem(item: VectorDocument): Promise<string> {
|
|
this.items.set(item.id, item.vector)
|
|
return item.id
|
|
}
|
|
async removeItem(id: string): Promise<boolean> {
|
|
return this.items.delete(id)
|
|
}
|
|
async getItem(id: string): Promise<VectorDocument | undefined> {
|
|
const vector = this.items.get(id)
|
|
return vector ? { id, vector } : undefined
|
|
}
|
|
async search(): Promise<Array<[string, number]>> {
|
|
return []
|
|
}
|
|
size(): number {
|
|
return this.items.size
|
|
}
|
|
clear(): void {
|
|
this.items.clear()
|
|
}
|
|
async rebuild(): Promise<void> {}
|
|
async flush(): Promise<number> {
|
|
return 0
|
|
}
|
|
getPersistMode(): 'immediate' | 'deferred' {
|
|
return 'immediate'
|
|
}
|
|
}
|
|
|
|
describe('Vector index transaction operations — backend-neutral rename', () => {
|
|
describe('rollback wiring (byte-identical behavior under the rename)', () => {
|
|
it('AddToVectorIndexOperation rollback removes a newly-added item', async () => {
|
|
const provider = new FakeVectorProvider('fake-provider')
|
|
const op = new AddToVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
|
|
|
const rollback = await op.execute()
|
|
expect(provider.items.has('id-1')).toBe(true)
|
|
|
|
await rollback()
|
|
expect(provider.items.has('id-1')).toBe(false)
|
|
})
|
|
|
|
it('AddToVectorIndexOperation rollback is a no-op when the item pre-existed (update semantics)', async () => {
|
|
const provider = new FakeVectorProvider('fake-provider')
|
|
await provider.addItem({ id: 'id-1', vector: [9, 9, 9] })
|
|
|
|
const op = new AddToVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
|
const rollback = await op.execute()
|
|
expect(provider.items.get('id-1')).toEqual([1, 2, 3])
|
|
|
|
await rollback()
|
|
// Pre-existing item is NOT removed by rollback — it stays (the update
|
|
// itself is not undone by this op; that's RemoveFromVectorIndexOperation's job).
|
|
expect(provider.items.has('id-1')).toBe(true)
|
|
})
|
|
|
|
it('RemoveFromVectorIndexOperation rollback re-adds the item with its original vector', async () => {
|
|
const provider = new FakeVectorProvider('fake-provider')
|
|
await provider.addItem({ id: 'id-1', vector: [4, 5, 6] })
|
|
|
|
const op = new RemoveFromVectorIndexOperation(provider, 'id-1', [4, 5, 6])
|
|
const rollback = await op.execute()
|
|
expect(provider.items.has('id-1')).toBe(false)
|
|
|
|
await rollback()
|
|
expect(provider.items.get('id-1')).toEqual([4, 5, 6])
|
|
})
|
|
|
|
it('BatchAddToVectorIndexOperation rolls back every item in reverse order on undo', async () => {
|
|
const provider = new FakeVectorProvider('fake-provider')
|
|
const op = new BatchAddToVectorIndexOperation(provider, [
|
|
{ id: 'a', vector: [1] },
|
|
{ id: 'b', vector: [2] },
|
|
{ id: 'c', vector: [3] }
|
|
])
|
|
|
|
const rollback = await op.execute()
|
|
expect([...provider.items.keys()].sort()).toEqual(['a', 'b', 'c'])
|
|
|
|
await rollback()
|
|
expect(provider.items.size).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('backend stamping in the emitted op name', () => {
|
|
it('stamps the real JS HNSW index as js-hnsw (self-identifying providerId)', () => {
|
|
const index = new JsHnswVectorIndex()
|
|
const addOp = new AddToVectorIndexOperation(index, 'id-1', [1, 2, 3])
|
|
const removeOp = new RemoveFromVectorIndexOperation(index, 'id-1', [1, 2, 3])
|
|
|
|
expect(addOp.name).toBe('AddToVectorIndex(js-hnsw)')
|
|
expect(removeOp.name).toBe('RemoveFromVectorIndex(js-hnsw)')
|
|
})
|
|
|
|
it('stamps a self-identifying provider\'s own providerId, never "HNSW"', () => {
|
|
const provider = new FakeVectorProvider('acme-diskann')
|
|
const addOp = new AddToVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
|
const removeOp = new RemoveFromVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
|
const batchOp = new BatchAddToVectorIndexOperation(provider, [{ id: 'a', vector: [1] }])
|
|
|
|
expect(addOp.name).toBe('AddToVectorIndex(acme-diskann)')
|
|
expect(removeOp.name).toBe('RemoveFromVectorIndex(acme-diskann)')
|
|
expect(batchOp.name).toBe('BatchAddToVectorIndex(acme-diskann)')
|
|
expect(addOp.name).not.toContain('HNSW')
|
|
expect(removeOp.name).not.toContain('HNSW')
|
|
})
|
|
|
|
it('honestly reports "unknown-provider" rather than guessing when a provider omits providerId', () => {
|
|
const provider = new FakeVectorProvider(undefined)
|
|
const addOp = new AddToVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
|
const removeOp = new RemoveFromVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
|
|
|
expect(addOp.name).toBe('AddToVectorIndex(unknown-provider)')
|
|
expect(removeOp.name).toBe('RemoveFromVectorIndex(unknown-provider)')
|
|
// Never silently falls back to the JS engine's name for a provider it
|
|
// knows nothing about — that's the exact fossil-naming bug being fixed.
|
|
expect(addOp.name).not.toContain('js-hnsw')
|
|
})
|
|
})
|
|
})
|