From 67d50fa7347ef6906c73c0242d893859b693cf9f Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sat, 2 May 2026 15:42:40 -0600 Subject: [PATCH 1/4] add .gitattributes for npm-merge-driver Co-Authored-By: Claude Sonnet 4.6 --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..20afa5237 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +package-lock.json merge=npm-merge-driver From 047744bf81bcad8c73f00a76217b06cbc8ea3cae Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sat, 2 May 2026 16:24:37 -0600 Subject: [PATCH 2/4] fix(hnsw): skip corrupt nodes instead of crashing on decode errors When an HNSW index node's data is corrupt (e.g., from an interrupted write or format mismatch after a reclone), getSync throws a decode error that previously propagated uncaught through searchLayer and index(), crashing the worker via uncaughtException, which then triggered a RocksDB SIGSEGV via napi_call_threadsafe_function on the dead thread. Wrap all getSync calls that traverse the HNSW graph (entry point, neighbor nodes in searchLayer, fromId/toId nodes during connection updates, and neighbor nodes during old-node removal) in try-catch so corrupt nodes are skipped rather than crashing the process. Co-Authored-By: Claude Sonnet 4.6 --- .../HierarchicalNavigableSmallWorld.ts | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/resources/indexes/HierarchicalNavigableSmallWorld.ts b/resources/indexes/HierarchicalNavigableSmallWorld.ts index 8d4713402..7af175f42 100644 --- a/resources/indexes/HierarchicalNavigableSmallWorld.ts +++ b/resources/indexes/HierarchicalNavigableSmallWorld.ts @@ -112,7 +112,12 @@ export class HierarchicalNavigableSmallWorld { oldNode = { ...this.indexStore.getSync(nodeId, options) }; } else oldNode = {} as Node; if (vector) { - let entryPoint = entryPointId && this.indexStore.getSync(entryPointId, options); + let entryPoint: Node | null | false; + try { + entryPoint = entryPointId && this.indexStore.getSync(entryPointId, options); + } catch { + entryPoint = null; + } if (entryPoint == null) { const level = Math.floor(-Math.log(Math.random()) * this.mL); const node = { @@ -218,8 +223,19 @@ export class HierarchicalNavigableSmallWorld { for (const { fromId, toId } of connectionsToBeReplaced) { let from = updateNode(fromId); - if (!from) from = updateNode(fromId, this.indexStore.getSync(fromId, options)); - for (let i = 0; i < from[l].length; i++) { + if (!from) { + let fromNode: Node; + try { + fromNode = this.indexStore.getSync(fromId, options); + } catch { + continue; + } + from = updateNode(fromId, fromNode); + } + if (!from) continue; + const fromAtLevel = from[l]; + if (!fromAtLevel) continue; + for (let i = 0; i < fromAtLevel.length; i++) { if (from[l][i].id === toId) { if (Object.isFrozen(from[l])) { from[l] = from[l].slice(); @@ -319,7 +335,13 @@ export class HierarchicalNavigableSmallWorld { const oldConnections = oldNode[l]; for (const { id: neighborId } of oldConnections) { // get and copy the neighbor node so we can modify it - const neighborNode = updateNode(neighborId, this.indexStore.getSync(neighborId, options)); + let _neighborRaw: Node; + try { + _neighborRaw = this.indexStore.getSync(neighborId, options); + } catch { + continue; + } + const neighborNode = updateNode(neighborId, _neighborRaw); if (!neighborNode) continue; for (let l2 = 0; l2 <= l; l2++) { // remove the connection to this node from the neighbor node @@ -410,7 +432,12 @@ export class HierarchicalNavigableSmallWorld { if (visited.has(neighborId) || neighborId === undefined) continue; visited.add(neighborId); - const neighbor = this.indexStore.getSync(neighborId, options); + let neighbor: Node; + try { + neighbor = this.indexStore.getSync(neighborId, options); + } catch { + continue; + } if (!neighbor) continue; this.nodesVisitedCount++; const distance = distanceFunction(queryVector, neighbor.vector); @@ -567,7 +594,13 @@ export class HierarchicalNavigableSmallWorld { node[level] = keptConnections; // For removed connections, ensure there's still a path to them for (const removed of removedConnections) { - let removedNode = updateNode(removed.id) ?? this.indexStore.getSync(removed.id, options); + let removedNodeRaw: Node; + try { + removedNodeRaw = this.indexStore.getSync(removed.id, options); + } catch { + continue; + } + let removedNode = updateNode(removed.id) ?? removedNodeRaw; if (removedNode) { // Remove the reverse connection if it exists if (removedNode[level]) { From 4b9e22bab3d840a0b652784c1b23c87bbe4a7497 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sat, 2 May 2026 16:41:12 -0600 Subject: [PATCH 3/4] fix(hnsw): use safeGetSync helper to skip corrupt nodes across all read paths Adds a private `safeGetSync` helper that wraps `indexStore.getSync` in a try-catch, returning `undefined` instead of throwing when a node's stored data is corrupt. Applies it to all 7 call sites that read node data by numeric key (including `getEntryPoint`, `searchLayer`, `checkSymmetry`, and `addConnection`), so a single corrupt node causes a logged warning rather than an uncaught exception. Also fixes a pre-existing bug in `checkSymmetry` where the undefined value (not the key) was being logged. Adds a unit test using a minimal mock store that simulates corrupt reads after the third neighbor access, verifying `doesNotThrow` on subsequent inserts. Co-Authored-By: Claude Sonnet 4.6 --- .../HierarchicalNavigableSmallWorld.ts | 58 ++++++------------- unitTests/resources/vectorIndex.test.js | 52 +++++++++++++++++ 2 files changed, 71 insertions(+), 39 deletions(-) diff --git a/resources/indexes/HierarchicalNavigableSmallWorld.ts b/resources/indexes/HierarchicalNavigableSmallWorld.ts index 7af175f42..bb1753144 100644 --- a/resources/indexes/HierarchicalNavigableSmallWorld.ts +++ b/resources/indexes/HierarchicalNavigableSmallWorld.ts @@ -112,12 +112,7 @@ export class HierarchicalNavigableSmallWorld { oldNode = { ...this.indexStore.getSync(nodeId, options) }; } else oldNode = {} as Node; if (vector) { - let entryPoint: Node | null | false; - try { - entryPoint = entryPointId && this.indexStore.getSync(entryPointId, options); - } catch { - entryPoint = null; - } + let entryPoint = entryPointId ? this.safeGetSync(entryPointId, options) : undefined; if (entryPoint == null) { const level = Math.floor(-Math.log(Math.random()) * this.mL); const node = { @@ -223,15 +218,7 @@ export class HierarchicalNavigableSmallWorld { for (const { fromId, toId } of connectionsToBeReplaced) { let from = updateNode(fromId); - if (!from) { - let fromNode: Node; - try { - fromNode = this.indexStore.getSync(fromId, options); - } catch { - continue; - } - from = updateNode(fromId, fromNode); - } + if (!from) from = updateNode(fromId, this.safeGetSync(fromId, options)); if (!from) continue; const fromAtLevel = from[l]; if (!fromAtLevel) continue; @@ -335,13 +322,7 @@ export class HierarchicalNavigableSmallWorld { const oldConnections = oldNode[l]; for (const { id: neighborId } of oldConnections) { // get and copy the neighbor node so we can modify it - let _neighborRaw: Node; - try { - _neighborRaw = this.indexStore.getSync(neighborId, options); - } catch { - continue; - } - const neighborNode = updateNode(neighborId, _neighborRaw); + const neighborNode = updateNode(neighborId, this.safeGetSync(neighborId, options)); if (!neighborNode) continue; for (let l2 = 0; l2 <= l; l2++) { // remove the connection to this node from the neighbor node @@ -372,14 +353,24 @@ export class HierarchicalNavigableSmallWorld { for (const [key, vector] of needsReindexing) { this.index(key, vector, vector, options); } - this.checkSymmetry(nodeId, this.indexStore.getSync(nodeId, options), options); + this.checkSymmetry(nodeId, this.safeGetSync(nodeId, options), options); + } + + private safeGetSync(key: any, options?: any): any { + try { + return this.indexStore.getSync(key, options); + } catch { + logger.warn?.('Failed to decode HNSW node, skipping', key); + return undefined; + } } private getEntryPoint(options: { transaction?: any } = {}) { // Get entry point const entryPointId = this.indexStore.getSync(ENTRY_POINT, options); if (entryPointId === undefined) return; - const node = this.indexStore.getSync(entryPointId, options); + const node = this.safeGetSync(entryPointId, options); + if (!node) return; return { id: entryPointId, ...node }; } @@ -432,12 +423,7 @@ export class HierarchicalNavigableSmallWorld { if (visited.has(neighborId) || neighborId === undefined) continue; visited.add(neighborId); - let neighbor: Node; - try { - neighbor = this.indexStore.getSync(neighborId, options); - } catch { - continue; - } + const neighbor = this.safeGetSync(neighborId, options); if (!neighbor) continue; this.nodesVisitedCount++; const distance = distanceFunction(queryVector, neighbor.vector); @@ -546,9 +532,9 @@ export class HierarchicalNavigableSmallWorld { // verify that the level is not empty, otherwise this means we have an orphaned node if (connections.length === 0) break; for (const { id: neighbor } of connections) { - const neighborNode = this.indexStore.getSync(neighbor, options); + const neighborNode = this.safeGetSync(neighbor, options); if (!neighborNode) { - logger.info?.('could not find neighbor node', neighborNode); + logger.info?.('could not find neighbor node', neighbor); continue; } // verify that the connection is symmetrical @@ -594,13 +580,7 @@ export class HierarchicalNavigableSmallWorld { node[level] = keptConnections; // For removed connections, ensure there's still a path to them for (const removed of removedConnections) { - let removedNodeRaw: Node; - try { - removedNodeRaw = this.indexStore.getSync(removed.id, options); - } catch { - continue; - } - let removedNode = updateNode(removed.id) ?? removedNodeRaw; + let removedNode = updateNode(removed.id) ?? this.safeGetSync(removed.id, options); if (removedNode) { // Remove the reverse connection if it exists if (removedNode[level]) { diff --git a/unitTests/resources/vectorIndex.test.js b/unitTests/resources/vectorIndex.test.js index 64369d921..6d7d419d0 100644 --- a/unitTests/resources/vectorIndex.test.js +++ b/unitTests/resources/vectorIndex.test.js @@ -198,6 +198,58 @@ describe('HierarchicalNavigableSmallWorld indexing', () => { assert.equal(euclidean[0].id, 1); assert.equal(dot[0].id, 2); }); + it('does not crash when an index node decodes as corrupt', () => { + const nodes = new Map(); + let entryPoint; + let neighborReadCount = 0; + + // Minimal mock indexStore: corrupt reads on numeric neighbor-node keys after the first few + const mockStore = { + encoder: { useFloat32: false }, + getSync(key) { + if (key === Symbol.for('entryPoint')) return entryPoint; + if (typeof key === 'number') { + neighborReadCount++; + // After the graph has a few nodes, simulate a corrupt node read + if (neighborReadCount > 3) { + throw new Error('Data read, but end of buffer not reached 0'); + } + return nodes.get(key); + } + return nodes.get(JSON.stringify(key)); + }, + put(key, value) { + if (key === Symbol.for('entryPoint')) { + entryPoint = value; + } else if (typeof key === 'number') { + nodes.set(key, value); + } else { + nodes.set(JSON.stringify(key), value); + } + }, + remove(key) { + nodes.delete(typeof key === 'number' ? key : JSON.stringify(key)); + }, + getKeys() { + return []; + }, + getUserSharedBuffer(_name, buffer) { + return buffer; + }, + }; + + const hnsw = new HierarchicalNavigableSmallWorld(mockStore, {}); + + // Build a small graph (neighbor reads stay under threshold here) + for (let i = 0; i < 5; i++) { + hnsw.index(i, [i, i + 1, i + 2], null, {}); + } + neighborReadCount = 0; // reset so subsequent inserts hit the corrupt path + + // Inserting new nodes must not throw even though neighbor reads now corrupt + assert.doesNotThrow(() => hnsw.index(100, [1, 2, 3], null, {})); + assert.doesNotThrow(() => hnsw.index(101, [4, 5, 6], null, {})); + }); after(() => { HNSWTest.dropTable(); }); From d8b0846f60904ff8c68b2a80d52d0af6b8c0d11b Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 13 May 2026 11:53:39 -0600 Subject: [PATCH 4/4] fix(hnsw): apply safeGetSync to update path missed in previous fix Co-Authored-By: Claude Sonnet 4.6 --- resources/indexes/HierarchicalNavigableSmallWorld.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/indexes/HierarchicalNavigableSmallWorld.ts b/resources/indexes/HierarchicalNavigableSmallWorld.ts index bb1753144..7e2f50628 100644 --- a/resources/indexes/HierarchicalNavigableSmallWorld.ts +++ b/resources/indexes/HierarchicalNavigableSmallWorld.ts @@ -109,7 +109,7 @@ export class HierarchicalNavigableSmallWorld { if (existingVector) { // If we are updating an existing entry, we need to update the entry point // if the new entry is closer to the entry point than the old one - oldNode = { ...this.indexStore.getSync(nodeId, options) }; + oldNode = { ...this.safeGetSync(nodeId, options) }; } else oldNode = {} as Node; if (vector) { let entryPoint = entryPointId ? this.safeGetSync(entryPointId, options) : undefined;