From f08c761716f65d20a5c19923a48ab7b7caee54d8 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Wed, 2 Sep 2026 12:26:53 -0700 Subject: [PATCH 1/2] Update indexes around the node table write based on index needs (#896) Indexes have conflicting needs when a column they are built on is updated: FTS delete_ re-tokenizes the OLD document from the node table (must run before the write), while the HNSW index re-scans the updated node's embedding during re-insertion (must run after the write). Previously all indexes ran before the write, so HNSW observed a stale embedding: when the previous value was NULL it dereferenced a null EmbeddingHandle and segfaulted in the distance function, potentially leaving the WAL unreplayable. Run "before" indexes first (all indexes except HNSW, preserving existing behavior), then apply the row update to the node table or local storage, then run the HNSW index update. The ordering is keyed on the serialized index type name to avoid extending the extension-facing Index API. The isLoaded() orphaned-holder guard is preserved in both loops. Fixes #896. --- src/storage/table/node_table.cpp | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/storage/table/node_table.cpp b/src/storage/table/node_table.cpp index 581ea3436..64e6d8a90 100644 --- a/src/storage/table/node_table.cpp +++ b/src/storage/table/node_table.cpp @@ -26,6 +26,17 @@ using namespace lbug::evaluator; namespace lbug { namespace storage { +namespace { +// Whether index maintenance for an update must run AFTER the new value has been written to +// the node table. The HNSW index re-scans the updated node's embedding from the node table +// during re-insertion (see OnDiskHNSWIndex), so it must observe the NEW value. All other +// index kinds (e.g. FTS, which re-tokenizes the OLD document from the node table while +// deleting its entries) must run before the write. We key on the serialized index type +// name to avoid extending the extension-facing Index API. +bool updatesAfterTableWrite(const Index& index) { + return index.getIndexInfo().indexType == "HNSW"; +} +} // namespace NodeTableVersionRecordHandler::NodeTableVersionRecordHandler(NodeTable* table) : table(table) {} @@ -527,18 +538,22 @@ void NodeTable::update(Transaction* transaction, TableUpdateState& updateState) throw RuntimeException("Cannot update pk."); } const auto nodeOffset = nodeUpdateState.nodeIDVector.readNodeOffset(pos); + // Indexes that need to read the OLD value from the node table (e.g. FTS re-tokenizing the + // document being deleted) must be updated before the row is written. for (auto i = 0u; i < indexes.size(); i++) { // Skip unloaded (orphaned) index holders; see initUpdateState(). if (!indexes[i].isLoaded()) { continue; } auto index = indexes[i].getIndex(); - if (!nodeUpdateState.needToUpdateIndex(i)) { + if (!nodeUpdateState.needToUpdateIndex(i) || updatesAfterTableWrite(*index)) { continue; } index->update(transaction, nodeUpdateState.nodeIDVector, nodeUpdateState.propertyVector, *nodeUpdateState.indexUpdateState[i]); } + // Indexes that re-scan the node table to observe the NEW value during update (e.g. HNSW) + // must be updated after the row is written. if (transaction->isUnCommitted(tableID, nodeOffset)) { const auto localTable = transaction->getLocalStorage()->getLocalTable(tableID); DASSERT(localTable); @@ -551,6 +566,18 @@ void NodeTable::update(Transaction* transaction, TableUpdateState& updateState) ->update(transaction, rowIdxInGroup, nodeUpdateState.columnID, nodeUpdateState.propertyVector); } + for (auto i = 0u; i < indexes.size(); i++) { + // Skip unloaded (orphaned) index holders; see initUpdateState(). + if (!indexes[i].isLoaded()) { + continue; + } + auto index = indexes[i].getIndex(); + if (!nodeUpdateState.needToUpdateIndex(i) || !updatesAfterTableWrite(*index)) { + continue; + } + index->update(transaction, nodeUpdateState.nodeIDVector, nodeUpdateState.propertyVector, + *nodeUpdateState.indexUpdateState[i]); + } if (updateState.logToWAL && transaction->shouldLogToWAL()) { DASSERT(transaction->isWriteTransaction()); auto& wal = transaction->getLocalWAL(); From b01b1da4e8ab89d560a3a7614a2c68a4afc521e7 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Wed, 2 Sep 2026 12:26:53 -0700 Subject: [PATCH 2/2] Bump extension submodule: HNSW SET-on-NULL crash fix (#896) Points ladybugdb/extensions at 0b4f40d7f (merged PR #78), which guards shrinkForNode() against a NULL embedding scanned from the node table and adds regression tests for SET on NULL-embedding rows. Fixes #896 (together with the NodeTable::update two-phase ordering). --- extension | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension b/extension index 988456458..0b4f40d7f 160000 --- a/extension +++ b/extension @@ -1 +1 @@ -Subproject commit 988456458e64b26cc3c4bb3663fe2e2f2f21d347 +Subproject commit 0b4f40d7f5bb035b1e6c7becf68394e35b13203a