Repository navigation
fix(hnsw): keep the graph reachable after DELETE then INSERT (#30660); upgrade usearch to 2.26.5 - #30823
fix(hnsw): keep the graph reachable after DELETE then INSERT (#30660); upgrade usearch to 2.26.5#30823cpegeric wants to merge 6 commits into
Conversation
…rigin#30660) A CDC DELETE followed by INSERT calls usearch Remove then Add. Add reuses the removed node's slot and rewrites the node in place at the new vector's position, dropping its neighbor links without repairing them. Everything reachable only through the removed node became unreachable: on 1000 points on a line, deleting id 100 and inserting a far row left the index returning 901 rows (ids 1..99 missing) even after the new model was published and after a restart. Vendor the usearch fix (cpegeric/usearch matrixone_20261022 = concurrent_add + fix_delete_20261022): before reusing a removed slot, relink the removed node's old neighbors to each other on every level, and serialize slot-reusing adds. Add TestRemoveAddKeepsGraphReachable through HnswModel (remove 100, remove 500, and save/load then remove); it fails on the previous tarball and passes now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
Mirrors the issue: 1000 rows on a line, single-threaded build + FORCE_SYNC, then DELETE id 100 + INSERT a far row (and later id 500 + another far row). Waits for the published model checksum to change before querying, then asserts the index returns all 1000 rows, every id below the deleted one, and the true nearest row. On the previous usearch tarball the same case returns 901 / 502 rows, no ids below the deleted one, and id 101 as the nearest row to [1,1,2]. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
and matrixorigin#30660 fixes Replace thirdparties/usearch-2.25.3.tar.gz with usearch-2.26.5.tar.gz, built (git archive, same layout) from cpegeric/usearch matrixone_20261022 = upstream v2.26.5 + concurrent_add (matrixorigin#735 concurrent-add reachability) + fix_delete_20261022 (remove()+add() slot-reuse relink, matrixorigin#30660). v2.26.5 also brings upstream matrixorigin#769 (slot_lookup_ tombstone rehash; v2.25.3 could spin in remove() under heavy churn). Upstream matrixorigin#777 makes Reserve(n) allocate exactly n members instead of rounding up to the key table's power-of-two size. MO always reserves its real capacity (MaxCapacity on build, IndexCapacity on sync load), so no production path relied on that slack; TestModel loaded with IndexCapacity 0 and is given a real capacity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
mo-bot review · Commit: Review completed. Read the assessment. |
XuPeng-SH
left a comment
There was a problem hiding this comment.
mo-bot review · mo-bot-local · review
Commit: 489e38cd3f8da31af3d745bc35f3464b706b3b90
Assessment: request_changes
Reviewed all seven changed repository files and 48 changed files inside the vendored archive. Repairing reachability in the native graph owner is a coherent design, and focused probes confirm the reported fix. However, the upgrade introduces an allocation-failure path during reserved-capacity churn that aborts the process.
Model: gpt-6-astra / xhigh
Session: 01a13dcf-1517-7f90-b3f9-c5f9f9e7031b · slot 0 · generation 1
- P2
thirdparties/Makefile:32: Repeated DELETE/new-key INSERT operations trigger the new tombstone rehash in usearch-2.26.5; allocation of the replacement hash table fails.
Impact: std::allocator throws through noexcept try_reserve, causing SIGABRT instead of returning an index-update error. Because HnswModel.Add invokes this native path, allocator rejection can terminate the CN. Handle rehash failure before committing the insertion and propagate it through Add; merely returning false is insufficient because the insertion callback currently ignores try_emplace's result.
Evidence: Inside the selected archive, include/usearch/index_plugins.hpp:4303 introduces the churn-triggered rehash; allocation at line 3992 occurs inside noexcept try_reserve. A public-C-API probe reserves 128 entries, fills them, then performs remove/new-key-add rounds while rejecting the 4160-byte hash allocation. Candidate receipt checks/1792673191665326807-3.json records SIGABRT (-6); base control checks/1792673205892858231-3.json completes 100 rounds without that allocation. oom-trace.log and receipt checks/1792674039073797676-3.json confirm usearch_add → index_dense::add_ → flat_hash_multi_set::try_reserve. This finding concerns the newly introduced churn allocation, not preexisting initial-reserve behavior.
Validation:
-
Artifacts are retained under /home/xupeng/.local/state/mo-bot/jobs/56d8cf9310231b17c5b9a596/attempt-1. review-audit.json correlates 23 terminal receipts with commands, source fingerprints, exit status and logs. Final audit receipt 1792674238133858201-4.json passed; source remains unchanged.
-
Isolation preflight passed. Candidate and base native libraries were built through their exact repository Makefiles; archive and build-input hashes were independently reconciled in native-inputs.json.
-
The original reachability probe returned 901 keys on base and all 1000 on candidate. Candidate also passed 40 deterministic graphs × 100 reuse operations and a four-writer mixed fresh/reused-slot probe.
-
Cross-version persistence passed: base save, candidate load/repair/save, and reload by both versions retained all expected keys.
-
Five paired native microbenchmarks measured median candidate/base elapsed ratios of 1.20× for build, 1.14× for search and 2.76× for reuse updates. Repair adds work and serialization to reuse; these measurements do not establish SQL workload latency.
-
Frozen evidence reports 20 successful CI checks, including UT, native builds and pessimistic multi-CN BVT. local_validation is empty; author test-count claims were not treated as independently reproduced results.
Limitations:
-
Local behavioral checks exercised native APIs, not a running SQL server. No local Go suite, SQL BVT or sanitizer run was performed.
-
Allocation failure was injected; no SQL-level OOM or CN crash was induced. Functional concurrency probes do not establish race freedom.
-
Raw CI/author execution logs and the standalone linked issue body were not supplied; issue behavior was assessed from the frozen PR description.
aptend
left a comment
There was a problem hiding this comment.
Deep-reviewed exact head 489e38cd3f8da31af3d745bc35f3464b706b3b90 against merge-base 6cbe375b49696021d72778baa292a66287f080bd, including all seven repository files, the 48 changed files inside the archive, historical review/comments, and the native Go/CDC/save/load consumers. One independently reproduced P2 blocker remains (inline).
The reachability fix works: the same base-generated 1,000-point model returns 901 keys after remove/add on base and all 1,000 on this head. Candidate repair/save and reload by both versions preserve the exact live key set. Local Go regression passed all three cases. Selected native regressions passed for 32/40-bit slots, churn, one/four-writer reuse, compact, load/view, truncated input, and RNG reserve continuity.
Reused verified exact-head CI 39761813910: HNSW owning package -race checkpoint exited 0; non-race coverage producer exited 0 with no failed events; SCA passed. The new SQL case actually ran in the PROXY compose job: 44/44, no failures/ignored/abnormal cases. Native builds used each revision's repository Makefile in separate detached worktrees.
| Unhappy-path check | Result |
|---|---|
| Q1 ownership | Model/context/native-buffer destruction traced; normal failures have owners. |
| Q2 termination | No new lock-order cycle found; allocation failure instead reaches process termination (blocker). |
| Q3 boundedness | Repair buffers are degree/context-bounded; rehash retains old/new tables only during replacement. |
Limits: the crash proof is deterministic allocator rejection through the public C API, not a real SQL OOM or live CN crash. Native concurrency checks are functional, not TSAN proof. An initial whole-vendored-test harness hit an unrelated StringZilla API mismatch; only the successfully compiled selected-test harness is counted. Evidence retained locally under /tmp/review30823-m5l6DB.
…cannot allocate Rebuilds usearch-2.26.5.tar.gz from cpegeric/usearch matrixone_20261023 (732fc64), which adds fix_rehash_oom_20261023 to the matrixorigin#735 and matrixorigin#30660 fixes. The tombstone rehash in usearch 2.26.x allocated inside a noexcept function, so std::bad_alloc during DELETE/INSERT churn terminated the CN. try_reserve now returns false and leaves the table intact, try_emplace still inserts into a free or deleted slot when the rehash fails, and add reports a failed key mapping instead of ignoring it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@XuPeng-SH: the P2 from your review (rehash allocation failure aborting the CN) is the same finding as @aptend's inline comment. Details in the reply there: #30823 (comment) Fixed in
The two cases are covered separately, as you asked:
Results:
|
|
mo-bot review · Commit: "interrupted" |
XuPeng-SH
left a comment
There was a problem hiding this comment.
Re-reviewed the updated exact head 54d80ef534ab54ed4d4a69639f1f9402261bd832 against the prior allocation-failure finding. The follow-up change closes that path: try_reserve catches allocator exceptions and preserves the old table; try_emplace can still consume the freed/tombstoned slot when rehash allocation fails; index_dense_gt::add_ now propagates a failed key mapping instead of silently succeeding. The new upstream test exercises repeated erase/emplace under throwing allocation and checks live/deleted lookups, with later successful rehash; the separate churn test remains. Exact-head GitHub checks report UT, coverage, shared build, native build, Dockerfile, and pessimistic/PROXY BVT successful. I did not rerun them locally; this workspace has no MatrixOne checkout or Go/native harness. I found no remaining actionable blocker in the updated source.
What type of PR is this?
Which issue(s) this PR fixes:
Fixes #30660
What this PR does / why we need it:
After an HNSW index is built, a DELETE followed by an INSERT can make other live rows unreachable through the index. In the issue's repro (1000 rows on a line, delete id 100, insert a far row) the index returns 901 rows, with ids 1–99 missing. This persists after the new model is published and after a restart; only
FORCE_SYNC(a full rebuild) restores it.Root cause (usearch, not ISCP). The CDC path is correct:
HnswSync.sequentialUpdateappliesRemove(100)thenAdd(2001). The same two calls on bare usearch, with no SQL or ISCP involved, give the same 901 result. In usearch,remove()only tombstones the node and queues its slot. The nextadd()reuses that slot, andindex_gt::update()rewrites the node in place at the new vector's position, clearing its neighbour lists without repairing anything (upstream leaves a TODO there). Whatever was reachable only through the removed node is cut off. Points on a line make the removed node the only bridge, so the loss is a whole range; on realistic data the same mechanism causes partial recall loss. Full trace: #30660 (comment)Fix. The vendored usearch is upgraded from
usearch-2.25.3.tar.gztousearch-2.26.5.tar.gz, built withgit archive(same layout as before) from cpegeric/usearchmatrixone_20261022. That branch is upstream v2.26.5 plus the existingconcurrent_add(#735) fix plusfix_delete_20261022, which cherry-picks cleanly onto upstream for its own upstream PR. Upstream v2.26.5 still has this bug: the new line-reachability test fails on unmodified v2.26.5.Upgrading to 2.26.5 also brings upstream #769, which rehashes tombstones in the key-lookup table. In v2.25.3,
remove()could spin at 100% CPU under heavy delete/insert churn.Upstream #777 changes
Reserve(n): it now allocates exactlynslots, where it used to round up to the key-lookup table's power-of-two size (about +37%, e.g.Reserve(1000)gave 1365). MO always reserves its real capacity:MaxCapacityon build, which the build path never exceeds, andIndexCapacityon a sync load, which defaults to 1,000,000. Updates reuse the slot their ownRemovefreed. So no production path depended on that extra room. OnlyTestModel, which loaded withIndexCapacity0, relied on it, and it now sets a real capacity.update()relinks the node's old neighbours on every level. Each neighbour drops its link to the reused slot and re-picks its neighbours, using the existing heuristic, from its own list plus the removed node's other neighbours. Nodes are locked one at a time, as inform_reverse_links_. The snapshot uses a reused per-thread buffer, and out-of-memory is reported as an error.index_dense_gt::add_()serializes adds that reuse a slot, because the in-place rewrite is not safe against concurrent rewrites of neighbouring nodes. Fresh adds and searches stay concurrent. MO's CDC update path is already sequential.No memory growth: removed slots are still reused. Apart from the usearch upgrade, the only MO change is the
TestModelcapacity in its test setup.Tests
Validated on 2.26.5:
pkg/vectorindex/...packages pass.vector_hnsw_delete_insertpasses 44/44.pessimistic_transaction/vectorhas 0 failures,vectorpasses 3301/3301, andbranch_hnsw_async_refresh,fulltext2_hnsw_clone,index_cache_size_bounds,cloneandbranch_multi_index_restore_inithave 0 failures.The results below were first measured on the 2.25.3-based tarball:
TestRemoveAddKeepsGraphReachable(pkg/vectorindex/hnsw) goes throughHnswModel: remove 100, remove 500, and save→load then remove, each followed by a far add, asserting every live key is returned. All three fail on the previous tarball (901 / 501 rows) and pass with this one. Allpkg/vectorindex/hnsw/...andpkg/vectorindex/usearchexpackages pass.cpp/test.cppin the fork): the remove + far add on a line test forslot32_tanduint40_tfails without the fix. Remove/add churn rounds, single- and multi-threaded, assert every live key finds itself in an exhaustive search. On the merged branch, these and the Data lost after restart server #735 concurrent-add reachability test pass 10/10 runs with internal assertions enabled.pessimistic_transaction/vector/vector_hnsw_delete_insert. It mirrors the issue (single-threaded build +FORCE_SYNC, DELETE id 100 + far INSERT, then DELETE id 500 + another far INSERT) and waits for the published model's checksum to change before querying. With this tarball: 44/44, and the index returns all 1000 rows, every id below the deleted one, and the true nearest row. On the previous tarball the same case returns 901 / 502 rows, none of the ids below the deleted one, and id 101 as the nearest row to[1,1,2]. Thepessimistic_transaction/vector(703 pass, 0 failed) andvectorsuites stay green. The one suite-only failure, in IVFvector_filtered_search_knobs, passes 51/51 when run alone and doesn't involve HNSW.[1,1,2],[777.2,1,2]and[1000,1,2], and 1000/1000 in the delete-id-500 variant (was 501).🤖 Generated with Claude Code