158 lines
6.2 KiB
TypeScript
158 lines
6.2 KiB
TypeScript
|
|
/**
|
||
|
|
* HNSW deferred-flush durability tests (Finding 6 — spine-plan Part B,
|
||
|
|
* "blind catch" audit).
|
||
|
|
*
|
||
|
|
* `JsHnswVectorIndex.flush()` used to swallow a per-node
|
||
|
|
* `persistNodeConnections` failure (and a system-record `saveHNSWSystem`
|
||
|
|
* failure) behind `console.error`, then unconditionally clear the dirty set
|
||
|
|
* and report the pre-flush node count as "success". A transient write fault
|
||
|
|
* therefore silently dropped that node's connections from durable storage
|
||
|
|
* forever, with `flush()` having lied about it. These tests pin the fix:
|
||
|
|
* a node (or the system record) that fails to persist stays in the
|
||
|
|
* dirty/retry set, and `flush()` throws {@link HnswFlushError} instead of
|
||
|
|
* returning a count. The immediate-mode first-noun `saveHNSWSystem` swallow
|
||
|
|
* (while `addItem()` still returned the id) is covered too.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect, vi } from 'vitest'
|
||
|
|
import { v4 as uuidv4 } from 'uuid'
|
||
|
|
import { JsHnswVectorIndex, HnswFlushError } from '../../../src/hnsw/hnswIndex.js'
|
||
|
|
import { euclideanDistance } from '../../../src/utils/index.js'
|
||
|
|
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
|
||
|
|
|
||
|
|
// Helper: generate a random vector of given dimension (mirrors lazy-vectors.test.ts).
|
||
|
|
function randomVector(dim: number): number[] {
|
||
|
|
return Array.from({ length: dim }, () => Math.random() * 2 - 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('HNSW deferred-flush durability (Finding 6)', () => {
|
||
|
|
const dim = 8
|
||
|
|
|
||
|
|
it('a clean flush persists every dirty node and clears the dirty set', async () => {
|
||
|
|
const storage = new MemoryStorage()
|
||
|
|
const index = new JsHnswVectorIndex(
|
||
|
|
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||
|
|
euclideanDistance,
|
||
|
|
{ useParallelization: false, storage, persistMode: 'deferred' }
|
||
|
|
)
|
||
|
|
|
||
|
|
for (let i = 0; i < 5; i++) {
|
||
|
|
await index.addItem({ id: uuidv4(), vector: randomVector(dim) })
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sanity: inserting several items in deferred mode does dirty something.
|
||
|
|
expect((index as any).dirtyNodes.size).toBeGreaterThan(0)
|
||
|
|
|
||
|
|
const flushed = await index.flush()
|
||
|
|
expect(typeof flushed).toBe('number')
|
||
|
|
expect((index as any).dirtyNodes.size).toBe(0)
|
||
|
|
expect((index as any).dirtySystem).toBe(false)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('retains a node whose connections failed to persist and surfaces HnswFlushError instead of reporting success', async () => {
|
||
|
|
const storage = new MemoryStorage()
|
||
|
|
const index = new JsHnswVectorIndex(
|
||
|
|
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||
|
|
euclideanDistance,
|
||
|
|
{ useParallelization: false, storage, persistMode: 'deferred' }
|
||
|
|
)
|
||
|
|
|
||
|
|
const ids: string[] = []
|
||
|
|
for (let i = 0; i < 5; i++) {
|
||
|
|
const id = uuidv4()
|
||
|
|
ids.push(id)
|
||
|
|
await index.addItem({ id, vector: randomVector(dim) })
|
||
|
|
}
|
||
|
|
|
||
|
|
// The very first node is guaranteed to become a neighbor of the second
|
||
|
|
// insert (it is the only existing node in the graph at that point), so it
|
||
|
|
// is deterministically present in the dirty set before any fault.
|
||
|
|
const failId = ids[0]
|
||
|
|
const dirtyBefore = (index as any).dirtyNodes as Set<string>
|
||
|
|
expect(dirtyBefore.has(failId)).toBe(true)
|
||
|
|
|
||
|
|
const originalSave = storage.saveVectorIndexData.bind(storage)
|
||
|
|
const spy = vi
|
||
|
|
.spyOn(storage, 'saveVectorIndexData')
|
||
|
|
.mockImplementation(async (nounId, hnswData) => {
|
||
|
|
if (nounId === failId) {
|
||
|
|
throw Object.assign(new Error('simulated write fault'), { code: 'EIO' })
|
||
|
|
}
|
||
|
|
return originalSave(nounId, hnswData)
|
||
|
|
})
|
||
|
|
|
||
|
|
await expect(index.flush()).rejects.toBeInstanceOf(HnswFlushError)
|
||
|
|
|
||
|
|
const dirtyAfter = (index as any).dirtyNodes as Set<string>
|
||
|
|
// The failed node stays dirty for the next retry ...
|
||
|
|
expect(dirtyAfter.has(failId)).toBe(true)
|
||
|
|
// ... and every node that DID persist successfully leaves the dirty set —
|
||
|
|
// the failure of one node must not re-dirty (or fail to clear) the rest.
|
||
|
|
expect(dirtyAfter.size).toBe(1)
|
||
|
|
|
||
|
|
// Recovery: once the fault clears, the retained node persists and the
|
||
|
|
// flush reports success again (the intended retry path).
|
||
|
|
spy.mockRestore()
|
||
|
|
const flushed = await index.flush()
|
||
|
|
expect(typeof flushed).toBe('number')
|
||
|
|
expect((index as any).dirtyNodes.size).toBe(0)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('surfaces a system-record persist failure as HnswFlushError and keeps dirtySystem set for retry', async () => {
|
||
|
|
const storage = new MemoryStorage()
|
||
|
|
const index = new JsHnswVectorIndex(
|
||
|
|
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||
|
|
euclideanDistance,
|
||
|
|
{ useParallelization: false, storage, persistMode: 'deferred' }
|
||
|
|
)
|
||
|
|
|
||
|
|
// First noun in deferred mode marks dirtySystem (entryPoint/maxLevel).
|
||
|
|
await index.addItem({ id: uuidv4(), vector: randomVector(dim) })
|
||
|
|
expect((index as any).dirtySystem).toBe(true)
|
||
|
|
|
||
|
|
const spy = vi
|
||
|
|
.spyOn(storage, 'saveHNSWSystem')
|
||
|
|
.mockRejectedValue(
|
||
|
|
Object.assign(new Error('simulated system write fault'), { code: 'EIO' })
|
||
|
|
)
|
||
|
|
|
||
|
|
let caught: unknown
|
||
|
|
try {
|
||
|
|
await index.flush()
|
||
|
|
} catch (error) {
|
||
|
|
caught = error
|
||
|
|
}
|
||
|
|
|
||
|
|
expect(caught).toBeInstanceOf(HnswFlushError)
|
||
|
|
expect((caught as HnswFlushError).systemFailed).toBe(true)
|
||
|
|
// The system record must stay dirty — a lost entry point/maxLevel update
|
||
|
|
// must be retried, not silently dropped.
|
||
|
|
expect((index as any).dirtySystem).toBe(true)
|
||
|
|
|
||
|
|
spy.mockRestore()
|
||
|
|
const flushed = await index.flush()
|
||
|
|
expect(typeof flushed).toBe('number')
|
||
|
|
expect((index as any).dirtySystem).toBe(false)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('surfaces the immediate-mode first-noun system persist failure via a rejecting addItem()', async () => {
|
||
|
|
const storage = new MemoryStorage()
|
||
|
|
const index = new JsHnswVectorIndex(
|
||
|
|
{ M: 4, efConstruction: 50, efSearch: 20 },
|
||
|
|
euclideanDistance,
|
||
|
|
{ useParallelization: false, storage } // default persistMode: 'immediate'
|
||
|
|
)
|
||
|
|
|
||
|
|
vi.spyOn(storage, 'saveHNSWSystem').mockRejectedValue(
|
||
|
|
Object.assign(new Error('simulated system write fault'), { code: 'EIO' })
|
||
|
|
)
|
||
|
|
|
||
|
|
// Previously this swallowed the error (console.error) and addItem()
|
||
|
|
// still resolved with the id — stranding a brand-new index whose root
|
||
|
|
// (entry point) was never actually persisted. Now it must reject.
|
||
|
|
await expect(
|
||
|
|
index.addItem({ id: uuidv4(), vector: randomVector(dim) })
|
||
|
|
).rejects.toThrow('simulated system write fault')
|
||
|
|
})
|
||
|
|
})
|