From 7234b5649a880c7c068301a26fb4dfb7c590a17f Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Wed, 2 Sep 2026 09:17:02 -0700 Subject: [PATCH] Fix crash on SET of indexed embedding while HNSW index is live (#896) - Guard shrinkForNode() against a null EmbeddingHandle when the node's embedding scanned from the node table is NULL (e.g. its embedding was SET to NULL while still referenced in the graph). Previously this dereferenced a null handle and segfaulted in the distance function. - Document that OnDiskHNSWIndex::update() runs after the node table has been updated, so scanned embeddings are the new value. - Add regression tests: SET on a row that was NULL at index build, SET to NULL and back, and NULL-row SET surviving checkpoint + reopen. Fixes ladybugdb/ladybug#896 (together with the NodeTable::update ordering fix in the main repo). --- vector/src/index/hnsw_index.cpp | 9 +++- vector/test/test_files/update.test | 73 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/vector/src/index/hnsw_index.cpp b/vector/src/index/hnsw_index.cpp index cd5d2300..100a7ad1 100644 --- a/vector/src/index/hnsw_index.cpp +++ b/vector/src/index/hnsw_index.cpp @@ -804,6 +804,9 @@ std::unique_ptr OnDiskHNSWIndex::initUpdateState(main::Clien void OnDiskHNSWIndex::update(Transaction* transaction, const common::ValueVector& nodeIDVector, common::ValueVector& propertyVector, UpdateState& updateState) { auto& hnswUpdateState = updateState.cast(); + // NOTE: The node table has already been updated with the new value when we get here + // (NodeTable::update applies the table update before updating indexes), so any embedding + // scanned from the node table during re-insertion is the new value. commitInsert(transaction, nodeIDVector, {&propertyVector}, *hnswUpdateState.insertState); } @@ -1427,7 +1430,11 @@ void OnDiskHNSWIndex::shrinkForNode(Transaction* transaction, common::offset_t o const auto approxMetricFunc = embeddings.getMetricFunction(config.metric); auto& embeddingScanState = *insertState.searchState.embeddingScanState; const auto vector = embeddings.getEmbedding(offset, embeddingScanState); - DASSERT(!vector.isNull()); + if (vector.isNull()) { + // The node has no embedding (e.g. its embedding was updated to NULL while it is still + // referenced in the graph). We cannot compute distances, so we skip shrinking. + return; + } const auto& searchState = insertState.searchState; const auto& graph = isUpperLayer ? searchState.upperGraph : searchState.lowerGraph; const auto relTableID = isUpperLayer ? storageInfo->cast().upperRelTableID : diff --git a/vector/test/test_files/update.test b/vector/test/test_files/update.test index d6b1e04d..c963cd66 100644 --- a/vector/test/test_files/update.test +++ b/vector/test/test_files/update.test @@ -188,3 +188,76 @@ Binder exception: Cannot set property id in table embeddings because it is used 1 2 3 + +-CASE UpdateNullEmbeddingRowAtIndexBuild +-LOAD_DYNAMIC_EXTENSION vector +-STATEMENT CREATE NODE TABLE embeddings (id int64, vec FLOAT[8], PRIMARY KEY (id)); +---- ok +-STATEMENT CALL threads=1; +---- ok +-STATEMENT UNWIND range(0, 499) AS i CREATE (:embeddings {id: i, vec: [i * 0.001, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]}); +---- ok +-STATEMENT UNWIND range(500, 549) AS i CREATE (:embeddings {id: i}); +---- ok +-STATEMENT CALL CREATE_VECTOR_INDEX('embeddings', 'e_hnsw_index', 'vec', metric := 'l2'); +---- ok +-STATEMENT MATCH (t:embeddings) WHERE t.id = 500 SET t.vec = [0.35, 0.25, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]; +---- ok +-STATEMENT MATCH (t:embeddings) WHERE t.id = 550 SET t.vec = [0.9, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]; +---- ok +-STATEMENT CALL QUERY_VECTOR_INDEX('embeddings', 'e_hnsw_index', [0.35, 0.25, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], 1, efs := 50) RETURN node.id ORDER BY distance; +---- 1 +500 + +-CASE UpdateNullEmbeddingToNullAndBack +-LOAD_DYNAMIC_EXTENSION vector +-STATEMENT CREATE NODE TABLE embeddings (id int64, vec FLOAT[8], PRIMARY KEY (id)); +---- ok +-STATEMENT CALL threads=1; +---- ok +-STATEMENT UNWIND range(0, 499) AS i CREATE (:embeddings {id: i, vec: [i * 0.001, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]}); +---- ok +-STATEMENT UNWIND range(500, 549) AS i CREATE (:embeddings {id: i, vec: [i * 0.002, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]}); +---- ok +-STATEMENT CALL CREATE_VECTOR_INDEX('embeddings', 'e_hnsw_index', 'vec', metric := 'l2'); +---- ok +-STATEMENT MATCH (t:embeddings) WHERE t.id = 500 SET t.vec = NULL; +---- ok +-STATEMENT MATCH (t:embeddings) WHERE t.id = 501 SET t.vec = [1.5, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]; +---- ok +-STATEMENT MATCH (t:embeddings) WHERE t.id = 500 SET t.vec = [1.5, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]; +---- ok +-STATEMENT CALL QUERY_VECTOR_INDEX('embeddings', 'e_hnsw_index', [1.5, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], 2, efs := 50) RETURN node.id ORDER BY distance; +-CHECK_ORDER +---- 2 +500 +501 + +-CASE UpdateNullEmbeddingSurvivesCheckpointReopen +-SKIP_IN_MEM +-LOAD_DYNAMIC_EXTENSION vector +-STATEMENT CREATE NODE TABLE embeddings (id int64, vec FLOAT[8], PRIMARY KEY (id)); +---- ok +-STATEMENT CALL threads=1; +---- ok +-STATEMENT UNWIND range(0, 499) AS i CREATE (:embeddings {id: i, vec: [i * 0.001, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]}); +---- ok +-STATEMENT UNWIND range(500, 549) AS i CREATE (:embeddings {id: i}); +---- ok +-STATEMENT CALL CREATE_VECTOR_INDEX('embeddings', 'e_hnsw_index', 'vec', metric := 'l2'); +---- ok +-STATEMENT CHECKPOINT; +---- ok +-RELOADDB +-LOAD_DYNAMIC_EXTENSION vector +-STATEMENT MATCH (t:embeddings) WHERE t.id = 500 SET t.vec = [0.35, 0.25, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]; +---- ok +-STATEMENT MATCH (t:embeddings) WHERE t.id = 510 SET t.vec = [0.51, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]; +---- ok +-STATEMENT CHECKPOINT; +---- ok +-RELOADDB +-LOAD_DYNAMIC_EXTENSION vector +-STATEMENT CALL QUERY_VECTOR_INDEX('embeddings', 'e_hnsw_index', [0.35, 0.25, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], 1, efs := 50) RETURN node.id ORDER BY distance; +---- 1 +500