fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an invariant the surrounding code already intended; every one has a fail-before/pass-after test. - Pattern C, finding 5 (baseStorage): delete now decrements the user-facing scalar total symmetrically — deleteNounMetadata was decrementing only the per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so getNounCount()/getVerbCount() inflated permanently (the stale scalar wins pagination via Math.max and is persisted). Invariant now holds: scalar total === Σ per-type across add/update/delete and reopen. - Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no longer publishes the manifest's full relationship count as healthy. Any per-SSTable load failure throws after the batch, which resets to honest-empty and lets the existing size()===0 self-heal rebuild run — size()/isHealthy() can no longer lie about a partial load. - Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears dirty nodes whose connections failed to persist — failed nodes stay in the retry set, and flush() throws HnswFlushError instead of returning a lying node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so addItem() rejects rather than returning an id for a rootless index. - Pattern B, finding 11 (part — storage reads): new shared isAbsentError() helper (utils/errorClassification, ENOENT-only absence) applied to loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE) now propagates loudly instead of masquerading as "absent", which had driven needless rebuilds / empty reads (loadBinaryBlob feeds the native provider). Regression: 78 green across the 3 new suites + db-mvcc, generationStore, temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded), finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
This commit is contained in:
parent
eb9c4eb963
commit
119087a75c
8 changed files with 518 additions and 30 deletions
|
|
@ -25,6 +25,29 @@ const DEFAULT_CONFIG: HNSWConfig = {
|
|||
ml: 16 // Max level
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Thrown by {@link JsHnswVectorIndex.flush} when one or more dirty
|
||||
* nodes (or the system record) could not be persisted. The failed nodes remain
|
||||
* in the dirty set for the next flush; this error tells the caller the flush did
|
||||
* NOT achieve durability instead of a node-count that lies. Mandate: loud
|
||||
* errors, never quiet losses.
|
||||
*/
|
||||
export class HnswFlushError extends Error {
|
||||
constructor(
|
||||
public readonly failedNodeCount: number,
|
||||
public readonly systemFailed: boolean,
|
||||
public override readonly cause?: Error
|
||||
) {
|
||||
super(
|
||||
`HNSW flush did not achieve durability: ${failedNodeCount} node(s) failed to ` +
|
||||
`persist${systemFailed ? ' and the system record (entryPoint/maxLevel) failed' : ''}. ` +
|
||||
`Failed nodes remain dirty for retry.` +
|
||||
(cause ? ` First error: ${cause.message}` : '')
|
||||
)
|
||||
this.name = 'HnswFlushError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@link VectorIndexProvider}: the vector-index surface Brainy calls
|
||||
* on whatever the `'vector'` factory returns (its own `JsHnswVectorIndex`, or a native
|
||||
|
|
@ -150,41 +173,69 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
const startTime = Date.now()
|
||||
const nodeCount = this.dirtyNodes.size
|
||||
|
||||
// Batch persist all dirty nodes concurrently
|
||||
// Batch persist all dirty nodes concurrently. A node whose connections FAIL
|
||||
// to persist must stay dirty (retried on the next flush) — clearing it would
|
||||
// silently drop the write forever. Track failures; only successfully-
|
||||
// persisted (or deleted) nodes leave the dirty set, so nodes added to it
|
||||
// during this flush are preserved.
|
||||
const failedNodes = new Set<string>()
|
||||
let firstError: Error | null = null
|
||||
|
||||
if (this.dirtyNodes.size > 0) {
|
||||
const batchSize = 50 // Reasonable batch size for cloud storage
|
||||
const nodeIds = Array.from(this.dirtyNodes)
|
||||
|
||||
for (let i = 0; i < nodeIds.length; i += batchSize) {
|
||||
const batch = nodeIds.slice(i, i + batchSize)
|
||||
const promises = batch.map(nodeId => {
|
||||
const promises = batch.map(async nodeId => {
|
||||
const noun = this.nouns.get(nodeId)
|
||||
if (!noun) return Promise.resolve() // Node was deleted
|
||||
|
||||
return this.persistNodeConnections(nodeId, noun).catch(error => {
|
||||
console.error(`[HNSW flush] Failed to persist node ${nodeId}:`, error)
|
||||
})
|
||||
if (!noun) return // Node was deleted — drop it from the dirty set.
|
||||
try {
|
||||
await this.persistNodeConnections(nodeId, noun)
|
||||
} catch (error) {
|
||||
failedNodes.add(nodeId)
|
||||
if (firstError === null) firstError = error as Error
|
||||
prodLog.error(`[HNSW flush] Failed to persist node ${nodeId}: ${(error as Error).message}`)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
this.dirtyNodes.clear()
|
||||
// Remove only nodes that were persisted (or deleted mid-flush); keep the
|
||||
// failed ones dirty for the next attempt.
|
||||
for (const nodeId of nodeIds) {
|
||||
if (!failedNodes.has(nodeId)) this.dirtyNodes.delete(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist system data if dirty
|
||||
// Persist system data if dirty — keep it dirty on failure so the next flush
|
||||
// retries rather than losing the entry-point/maxLevel update.
|
||||
let systemFailed = false
|
||||
if (this.dirtySystem) {
|
||||
await this.storage.saveHNSWSystem({
|
||||
entryPointId: this.entryPointId,
|
||||
maxLevel: this.maxLevel
|
||||
}).catch(error => {
|
||||
console.error('[HNSW flush] Failed to persist system data:', error)
|
||||
})
|
||||
|
||||
this.dirtySystem = false
|
||||
try {
|
||||
await this.storage.saveHNSWSystem({
|
||||
entryPointId: this.entryPointId,
|
||||
maxLevel: this.maxLevel
|
||||
})
|
||||
this.dirtySystem = false
|
||||
} catch (error) {
|
||||
systemFailed = true
|
||||
if (firstError === null) firstError = error as Error
|
||||
prodLog.error(`[HNSW flush] Failed to persist system data: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// Loud failure: if ANY node or the system record could not be persisted the
|
||||
// flush did not achieve durability. Throw so callers (close(), explicit
|
||||
// flush(), the flush-request watcher) see the failure instead of a success
|
||||
// count that lies. The failed nodes/system stay dirty for retry.
|
||||
if (failedNodes.size > 0 || systemFailed) {
|
||||
throw new HnswFlushError(failedNodes.size, systemFailed, firstError ?? undefined)
|
||||
}
|
||||
|
||||
if (nodeCount > 0) {
|
||||
prodLog.info(`[HNSW] Flushed ${nodeCount} dirty nodes in ${duration}ms`)
|
||||
}
|
||||
|
|
@ -396,13 +447,14 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
this.maxLevel = nounLevel
|
||||
this.nouns.set(id, noun)
|
||||
|
||||
// Persist system data for first noun (previously skipped)
|
||||
// Persist system data for first noun (previously skipped). Surface a
|
||||
// persist failure loudly — the entry point is the root of the whole index;
|
||||
// silently dropping it while addItem() returns the id would strand every
|
||||
// future search on a rootless index. Mandate: never a quiet loss.
|
||||
if (this.storage && this.persistMode === 'immediate') {
|
||||
await this.storage.saveHNSWSystem({
|
||||
entryPointId: this.entryPointId,
|
||||
maxLevel: this.maxLevel
|
||||
}).catch(error => {
|
||||
console.error('Failed to persist initial HNSW system data:', error)
|
||||
})
|
||||
} else if (this.persistMode === 'deferred') {
|
||||
this.dirtySystem = true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue