An HNSW graph has levels. When a merge reuses a graph that lost vectors to deletes, Lucene repairs it and then rebalances its levels. For maxConn = 1 that rebalance never stops.
- HNSW levels: level 0 holds every vector, and each higher level a random, shrinking subset. With
maxConn = Mthe builder puts a vector on level 1 or higher with probability 1/M; for M = 1 it uses e^-1, about 0.37. - Reusing a graph that has deletions (since 10.4.0): a merge may start from an existing segment's graph whose deleted share, as an integer percentage, is at most 40. It copies the graph without the deleted vectors, repairs vectors that lost links, then rebalances.
- Rebalance: for level 1, 2, 3 and so on it wants
size * (1/M)^levelvectors on the level, promotes vectors from the level below to reach that, and stops at the first level whose target is 0.
maxConn = 1, why every other value reaches 0, and the one-line fix. Select the still to open it at full size. Generated with AI, checked against the code.- You use
new Lucene99HnswVectorsFormat(1, 100, 0)(maxConn 1; the 0 builds a graph even for tiny segments), index 3 one-dimensional vectors {0}, {1}, {2}, commit, delete document 0 and callforceMerge(1). - You expect the merge to finish, as it does after 13 merge abort checks with
maxConn = 2, 10 withmaxConn = 16, and 17 withmaxConn = 1when nothing was deleted. - Instead the merge reuses the old graph, drops the deleted vector and rebalances the levels: it wants
size * (1/M)^levelvectors on each level and stops when that is 0. With 2 survivors and M = 1 the target is 2 on every level, so both vectors are copied into level 2, then 3, then 4, without end. - The test's guard stops it after 1,000 abort checks; 989 of the 1,001 were rebalance promotions, about 495 new levels. Unguarded,
forceMergeandclose()block while the heap fills: with 1,000 vectors and a 128 MB heap,OutOfMemoryErrorcloses the writer andforceMergefails after about 13 s. - On 10.6.0 and main
rollback()can still stop the merge; on 10.5.1 and 10.5.2 nothing can. A plaincommit()after the delete starts the same endless merge in the background.
Only fields whose HNSW format uses maxConn = 1 (default 16), an unusual, low-recall setting every HNSW format accepts. Then, once a delete or update removes a vector from a built graph, any merge that reuses that graph hangs: forceMerge, a background merge, or one a commit() starts. No scale needed. Released in 10.5.1, 10.5.2 and 10.6.0.
Mechanism, terms and numbers639 words
When a merge reuses a segment's HNSW graph that lost at least one vector to a delete, Lucene copies the graph, repairs it and calls InitializedHnswGraphBuilder.rebalanceGraph(). That method wants size * (1/M)^level vectors on each level and stops at the first level whose target is 0. For maxConn = 1, a value every HNSW format accepts, 1/M is 1.0: the target is the whole graph on every level, the exit is never taken, and every vector is promoted into one more level per pass, with links. forceMerge and close() cannot complete normally, and the heap grows until OutOfMemoryError closes the IndexWriter as a tragic event (on 10.6.0 and main rollback() can stop the merge first). HnswGraphBuilder itself gives M = 1 the level probability e^-1, and using that probability in rebalanceGraph is a one-line fix. Released in 10.5.1, 10.5.2 and 10.6.0 with the default codec.
InitializedHnswGraphBuilder.initializeFromGraph runs repair and rebalance only when the copy dropped a deleted vector (if (hasDeletes), InitializedHnswGraphBuilder.java:309-315 at 645d4fe14a20). rebalanceGraph() (:435-485):
double invMaxConn = 1.0 / M; // :438
for (int level = 1; ; level++) { // :441
int maxNodesAtLevel = (int) (size * Math.pow(invMaxConn, level)); // :444
if (maxNodesAtLevel <= 0) break; // the only exit // :445
...
if (random.nextDouble() < invMaxConn && !hnsw.nodeExistAtLevel(level, node)) { // :467
maybeAbort(); // :468
hnsw.addNode(level, node); // :470
... addConnections(node, level, scorer); // :477For M = 1, invMaxConn is 1.0 and Math.pow(1.0, level) is 1.0, so maxNodesAtLevel == size on every level and the break is never reached. nextDouble() is always below 1.0, so every vector of level L-1 is promoted into level L, which is new each time (levelToNodes grows by one, :451). OnHeapHnswGraph.addNode grows the vector's level array and allocates a neighbour array per level with no cap. Once the method runs with M = 1 on a non-empty graph, the non-termination is exact: it does not depend on the seed or the vector values.
The repro's flushed graph has 2 levels; after the delete both survivors are on level 1. Level 1 already has its target of 2, so the loop adds level 2 with both, then level 3, and so on. Of the 1,001 merge abort checks before the test's guard fires, 989 come from rebalanceGraph promotions (:468), about 495 new levels. A unit-level probe of the same graph counted 133,541 levels after 10 s, with the heap after GC grown from 4.9 MB to 43.3 MB. With maxConn = 2 the target is (int) (2 * 0.5) = 1 on level 1 and 0 on level 2, so the loop stops; the merge takes 13 abort checks.
The builder assigns levels with ml = M == 1 ? 1 : 1 / Math.log(1.0 * M) (HnswGraphBuilder.java:184) and level = (int) (-log(U) * ml) (:573-578), so P(level >= 1) = exp(-1/ml): exactly 1/M for M >= 2, but e^-1 for M = 1. rebalanceGraph uses 1/M and says it is "matching HNSW's level assignment distribution" (:466). A bounded-exhaustive check of the loop's expressions over every accepted M (1 to 512) finds M = 1 the only value without an exit; every other M exits by level 31 even at the maximum document count.
On 10.6.0 and main the loop polls the merge abort check before each promotion (#16620), so rollback() ends the merge; forceMerge keeps waiting, and close() waits too, because IndexWriter.shutdown calls waitForMerges() (IndexWriter.java:1326). On 10.5.1 and 10.5.2 there is no check in the loop: the merge thread runs until OutOfMemoryError, then forceMerge throws "this writer hit an unrecoverable error; cannot complete forceMerge". The concurrent merger (numMergeWorkers > 1) runs the same rebalanceGraph and hangs the same way.
Exposure and likelihood323 words
- Who. Fields whose HNSW format is built with
maxConn = 1, e.g.new Lucene99HnswVectorsFormat(1, beamWidth)(executed).Lucene104HnswScalarQuantizedVectorsFormat,HnswBitVectorsFormatand the two sandbox formatsDedupHnswVectorsFormatandDedupHnswScalarQuantizedVectorsFormataccept the same 1 to 512 and build their graphs with the same writer code and builders (code reading, not executed). The default is 16.maxConn = 1is a low-recall setting (reasoned and called degenerate by a maintainer; recall not measured here), and Lucene's random test codecs never pick it (5 to 50). - Trigger. A segment with a built graph, one delete or update (an update is a delete plus an add) that removes one of its vectors, and a merge that selects that graph as its base (deleted share, as an integer percentage, at most 40) and reuses it. With the default
IndexWriterConfig, a plaincommit()after the delete returns after about 0.55 s (its 500 ms merge-on-commit wait runs out) and leaves a merge thread running insiderebalanceGraph3 s later; withmaxConn = 2the commit takes about 20 ms and no merge is left running. That is this input: whether a commit starts such a merge depends on the merge policy and the segments. - Scale. None: 3 vectors with the tiny-segment threshold set to 0. With the 2-argument constructor (threshold 100) a merged segment needs about 650 live vectors before Lucene builds any graph; the hunt's 1,000-vector test hangs.
- Effect. A merge thread at full CPU,
forceMergeandclose()that do not return normally, and a heap that grows untilOutOfMemoryErrorcloses the writer: after about 13 s at 128 MB on main, 25 to 57 s at 512 MB on 10.5.x (1,000 vectors; times from one machine, they depend on heap size and hardware). Silent: no exception names the setting. By code reading, every later merge that reuses that segment's graph repeats it. - How likely. Rare: it needs a non-default, degenerate setting; nobody has reported it since 10.4.0.
One JUnit test (production APIs plus the test framework): 3 vectors, maxConn = 1, one delete, forceMerge(1). The merge's own abort check stops the endless merge after 1,000 checks, so the test fails instead of hanging. Deterministic, no concurrency in the test; it fails with assertions on and off on main and 10.6.0, and passes unchanged with the fix.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.codecs.lucene99;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.MergePolicy;
import org.apache.lucene.index.OneMergeWrappingMergePolicy;
import org.apache.lucene.index.Term;
import org.apache.lucene.index.TieredMergePolicy;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestHnswMaxConnOneMerge extends LuceneTestCase {
public void testForceMergeWithDeletionFinishes() throws Exception {
AtomicInteger abortChecks = new AtomicInteger();
IndexWriterConfig iwc =
new IndexWriterConfig()
.setCodec(TestUtil.alwaysKnnVectorsFormat(new Lucene99HnswVectorsFormat(1, 100, 0)))
.setMergePolicy(
new OneMergeWrappingMergePolicy(
new TieredMergePolicy(),
merge ->
new MergePolicy.OneMerge(merge) {
@Override
public void checkAborted() throws MergePolicy.MergeAbortedException {
if (abortChecks.incrementAndGet() > 1_000) {
setAborted();
}
super.checkAborted();
}
}));
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
for (int i = 0; i < 3; i++) {
Document doc = new Document();
doc.add(new StringField("id", Integer.toString(i), Field.Store.NO));
doc.add(new KnnFloatVectorField("vector", new float[] {i}));
w.addDocument(doc);
}
w.commit();
w.deleteDocuments(new Term("id", "0"));
// Expected: forceMerge finishes; actual: rebalanceGraph adds levels forever until aborted.
w.forceMerge(1);
assertTrue(
"forceMerge(1) still running after 1,000 abort checks", abortChecks.get() <= 1_000);
}
}
}
cp $REPRO/TestHnswMaxConnOneMerge.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene99/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene99.TestHnswMaxConnOneMerge -Ptests.seed=DEADBEEF
# add -Ptests.asserts=true or -Ptests.asserts=false to choose the assertion mode; it fails both ways
- Expected
forceMerge(1)finishes, as withmaxConn = 2(13 abort checks), and the test passes.- Observed
AssertionError: forceMerge(1) still running after 1,000 abort checks; 989 of the checks arerebalanceGraphpromotions.
More files, environment, full output355 words
A finishing merge of this input needs 10 to 13 abort checks (maxConn 16 and 2), so the 1,000-check guard has a wide margin. The guard is an anonymous subclass of MergePolicy.OneMerge through its protected copy constructor (MergePolicy.java:260), and the codec comes from the test framework's TestUtil.alwaysKnnVectorsFormat. The test uses no randomness: for M = 1 the loop has no exit whatever the random draws.
Keep the test as it is. A commit() or an NRT reader open after forceMerge(1) starts a further merge of the same segment, which IndexWriter wraps in its own OneMerge (IndexWriter.java:3959-3964), so the test's guard does not apply and close() then waits for the endless merge (a diagnostic variant hung for 300 s that way). On 10.5.x the loop has no abort check, so the guard cannot work and the test hangs there.
This copy differs from the hunt's minimal test only in one wrapped line, so that it passes :lucene:core:checkGoogleJavaFormat.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0 release jars; hangs on 10.5.2 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (the only platform it ran on) |
cp $REPRO/TestHnswMaxConnOneMerge.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene99/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene99.TestHnswMaxConnOneMerge -Ptests.seed=DEADBEEF
# add -Ptests.asserts=true or -Ptests.asserts=false to choose the assertion mode; it fails both ways
# gradle, fresh worktree at main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=false
TestHnswMaxConnOneMerge > testForceMergeWithDeletionFinishes FAILED
java.lang.AssertionError: forceMerge(1) still running after 1,000 abort checks
at __randomizedtesting.SeedInfo.seed([DEADBEEF:ACBD97A663D25037]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.assertTrue(Assert.java:42)
at org.apache.lucene.codecs.lucene99.TestHnswMaxConnOneMerge.testForceMergeWithDeletionFinishes(TestHnswMaxConnOneMerge.java:67)
1 test completed, 1 failed
# the same with -Ptests.asserts=true
# where the 1,001st abort check comes from (diagnostic copy of the same input, main, -ea and -da)
at org.apache.lucene.index.MergeState.checkAborted(MergeState.java:249)
at org.apache.lucene.util.hnsw.HnswGraphBuilder.maybeAbort(HnswGraphBuilder.java:230)
at org.apache.lucene.util.hnsw.InitializedHnswGraphBuilder.rebalanceGraph(InitializedHnswGraphBuilder.java:468)
at org.apache.lucene.util.hnsw.InitializedHnswGraphBuilder.initializeFromGraph(InitializedHnswGraphBuilder.java:314)
abort checks by caller, maxConn = 1: copyAndPruneGraphStructure 5, fixDisconnectedNodes 1, rebalanceGraph 989, merge plumbing 6
maxConn = 2: 13 checks (rebalanceGraph 0), finished; maxConn = 16: 10, finished; maxConn = 1, no delete: 17, finished
# standalone runner, the minimal test (tests_run=1 failures=1 each)
main 645d4fe14a20, 10.6.0 -ea and -da, seed DEADBEEF the same AssertionError
10.5.2 -da no exit after 60 s; merge thread in rebalanceGraph:405- Every HNSW format accepts 1 to 512, and the graph builder handles
M = 1on purpose. rebalanceGraphpromises "the expected exponential decay in level sizes"; forM = 1there is none.- A maintainer called
maxConn = 1degenerate for recall and connectivity; that explains poor results, not a merge that never ends.
The full argument202 words
- Accepted and tested as valid.
Lucene99HnswVectorsFormatrejects onlymaxConn <= 0or> 512(Lucene99HnswVectorsFormat.java:271-276; the same check in the other four writable HNSW formats, two of them in the sandbox), and itstestLimitsasserts that -1 and 0 are rejected, not 1 (TestLucene99HnswVectorsFormat.java:64-70). No javadoc,CHANGES.txtorMIGRATE.mdentry restrictsmaxConnto 2 or more. - The builder supports M = 1 on purpose. The
ml = M == 1 ? 1 : ...branch came with #11781 (07af358f90d, 2022, Lucene 9.4), and upstream tests buildM = 1graphs (HnswGraphTestCase.testDiversityFallbackandtestDiversity3d,HnswGraphBuilder.create(scorerSupplier, 1, 10, ...)). This shows that M = 1 is handled rather than rejected; it does not by itself promise useful recall. - The method states the opposite intent. Javadoc: "maintain the expected exponential decay in level sizes according to the HNSW probabilistic model" (
InitializedHnswGraphBuilder.java:420-421); comment: "Promote with probability 1/M, matching HNSW's level assignment distribution" (:466). - History. The loop came with #15003 (2025); its 48 comments and 19 review comments never mention M = 1 or termination, and its benchmarks used
maxConn8 to 64. #16620 later added the abort poll for long but finite phases.
The devil's-advocate check201 words
The strongest case for "intended or unsupported": maxConn = 1 is degenerate. Michael Sokolov excluded it from a search test as a "degenerate edge case" (d84925a201a, 2021) and called M=1 "a degenerate case" for connecting graph components in a 2024 review on #13566; Lucene's random codecs use 5 to 50; the classes are @lucene.experimental; and the javadoc formula totalNodes * (1/M)^level, read literally, promises no decay for M = 1.
Why it fails: those remarks are about recall and connectivity in tests, not a restriction of the format. The same test later excluded maxConn = 2 for connectivity too (LUCENE-9819, 9e8207a5587, 2021), and maxConn = 2 merges fine here. A quality trade-off does not explain a merge that never ends and exhausts the heap, and the literal reading of the formula contradicts the method's stated intent. If M = 1 should go, the answer is a clear IllegalArgumentException, not an endless merge. So the case is meaningful but insufficient: reviewers who argued the "intended" side, one from another model family (OpenAI GPT-5.6), rated it weak, and a later cross-model fact check rated it moderate while agreeing that a silent merge that never completes is not acceptable.
Recommended Use the builder's level probability in rebalanceGraph (one line)
In rebalanceGraph, use the builder's own level probability: invMaxConn = M == 1 ? Math.exp(-1) : 1.0 / M. Nothing changes for maxConn 2 to 512; for 1 the targets reach 0 and the 3-vector merge finishes after 12 abort checks. The test and 19 HNSW and codec suites (352 tests) pass.
void rebalanceGraph() throws IOException {
SplittableRandom random = new SplittableRandom(seed);
int size = hnsw.size();
// P(level >= 1) of HnswGraphBuilder's level distribution: exp(-1 / ml), which is 1/M for
// M >= 2 and exp(-1) for M == 1 (where ml = 1)
double invMaxConn = M == 1 ? Math.exp(-1) : 1.0 / M; // was: 1.0 / M
for (int level = 1; ; level++) {
int maxNodesAtLevel = (int) (size * Math.pow(invMaxConn, level));
if (maxNodesAtLevel <= 0) break; // now reached for every M
...All options and trade-offs3 options ยท 569 words
Three options; A was validated with gradle in a fresh scratch worktree at 645d4fe14a20, B and C were not executed. Maintainers who want M = 1 gone may prefer C; A and C combine well.
Use the builder's level probability in rebalanceGraph (one line) (validated by the checks) (recommended)
InitializedHnswGraphBuilder.java:438 becomes double invMaxConn = M == 1 ? Math.exp(-1) : 1.0 / M; (in general exp(-1/ml), the builder's P(level >= 1)), plus a two-line comment.
void rebalanceGraph() throws IOException {
SplittableRandom random = new SplittableRandom(seed);
int size = hnsw.size();
// P(level >= 1) of HnswGraphBuilder's level distribution: exp(-1 / ml), which is 1/M for
// M >= 2 and exp(-1) for M == 1 (where ml = 1)
double invMaxConn = M == 1 ? Math.exp(-1) : 1.0 / M; // was: 1.0 / M
for (int level = 1; ; level++) {
int maxNodesAtLevel = (int) (size * Math.pow(invMaxConn, level));
if (maxNodesAtLevel <= 0) break; // now reached for every M
...Trade-offs
- No change for M >= 2. The expression is the same
1.0 / Mthere. - M = 1 terminates for any input. The targets become
size * e^-level, which reach 0 by level 22 even at the maximum document count; termination follows from the target, not from the random draws. In the 3-vector repro the level-1 target is already 0, and the merge finishes after 12 abort checks with no promotion. - Javadoc. The formula
totalNodes * (1/M)^level(:425) should then name the builder's level probability. - It fixes the loop, not the poor recall of
M = 1; that remains a configuration choice. - Not measured: the recall, graph quality or merge time of a merged
M = 1graph with the fix; the validation covers termination and the existing tests.
What the checks validated
- Gradle, fresh scratch worktree at
645d4fe14a20, JDK 25.0.3,--no-daemon -Ptests.seed=DEADBEEF: without the fix the minimal test fails with-Ptests.asserts=trueandfalse;git applysucceeds; with the fix:lucene:core:checkGoogleJavaFormatand the minimal test pass (assertions on and off). --tests 'org.apache.lucene.util.hnsw.*' --tests 'org.apache.lucene.codecs.lucene99.*' --tests TestKnnGraph --tests TestHnswMergeAbort: 19 suites, 352 tests, 12 skipped, 0 failures (TestHnswFloatVectorGraph32,TestLucene99HnswVectorsFormat60,TestKnnGraph6,TestHnswMergeAbort4).- Earlier: the hunt's 1,000-vector test, an independent reproducer's test and the cross-model reviewers' tests passed with the same change; on 10.4.0, shadowing only this line makes the failing run pass.
Stop rebalancing at the source graph's top level, or at a fixed maximum level.
Trade-offs
Also guards against any future ratio mistake, but rebalanceGraph may create levels above the source graph's top today (:450-452) for M >= 2, so a bound changes that behaviour and needs a benchmark; it leaves the probability mismatch with the builder for M = 1. Not executed.
Lucene99HnswVectorsFormat, Lucene104HnswScalarQuantizedVectorsFormat, HnswBitVectorsFormat and the two sandbox formats DedupHnswVectorsFormat and DedupHnswScalarQuantizedVectorsFormat throw an IllegalArgumentException that names the setting.
Trade-offs
A clear error instead of a hang, but it breaks applications that configure 1 today, testLimits would need a new boundary case (it tests only -1 and 0 at the lower end), and it leaves HnswGraphBuilder's M == 1 branch and the two upstream M = 1 tests to decide on; the public @lucene.experimental builder classes would still loop for M = 1 unless option A is applied too. Not executed.
Products that let index creators choose the HNSW connection count and pass 1 through to Lucene inherit the hang; validating that setting is theirs to add.
Code reading of rebalanceGraph while building a bounded-exhaustive harness of merge-time graph reuse. Its sweep over maxConn, beamWidth and one or no deletion, capped by merge abort checks, flagged every maxConn = 1 case with a deletion and beamWidth >= 2, and no other (beamWidth = 1 fails earlier, LFV-1056). An SMT (Z3) obligation found the same in parallel.
The method in full225 words
- First signal (2026-10-25). While writing a bounded-exhaustive translation-validation harness for the HNSW merge-reuse path, the finder read
rebalanceGraph's bound. The harness swept M in {1, 2, 3, 4, 8, 16} x beamWidth in {1, 2, 3, 4, 8, 16, 100} x {one deletion, none}, with a bound on abort-check polls as the termination oracle, and reported non-termination for every M = 1 case with a deletion and beamWidth >= 2, with the serial and the concurrent merger, and for no other M. Every beamWidth = 1 case, M = 1 included, failed earlier withIllegalArgumentException: initialCapacity must be > 0 ... got: 0(the sibling LFV-1056) and never reached the rebalance. It was confirmed end to end throughIndexWriter.forceMergeon the stock main jars. - Twin. In the same minute, a second finder reading the HNSW graph format found it with an SMT (Z3) obligation: for M == 1 no level satisfies
(int) (size * (1/M)^level) <= 0, while for M >= 2 level 32 always does. - Verification. An adjudication unit re-ran the earliest repro on
645d4fe14a20, reduced it to one public-API test, checked the loop's expressions for every accepted M (1 to 512), and ran controls; an independent reproducer, reviewers from another model family (OpenAI GPT-5.6, executed) and an intended-behaviour devil's advocate confirmed it; the no-noise gate reduced the test to 3 vectors.
No earlier report found (searched 2026-10-30, re-checked the same evening). Closest: #16726 (open), which asks only whether the same loop's random generator should be seeded.
Searches4 searches
Context, not reports: a 2021 commit and a 2024 review remark call maxConn = 1 degenerate for search quality and connectivity.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene; Elasticsearch, OpenSearch, k-NN, Solr), the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, the web | 2026-10-30 | 199 queries in four runs of the hunt's search tool plus 35 GitHub search-API queries: identifiers (rebalanceGraph, invMaxConn, maxNodesAtLevel, class names), symptoms (hang, never terminates, infinite loop, OutOfMemoryError, forceMerge hangs), "degenerate maxConn" | no report of this defect |
| Full-text reads and open PRs | 2026-10-30 | body, comments and reviews of 102 upstream items; the diffs of all 13 open PRs touching the HNSW merge files | none changes or discusses the loop bound for M = 1 |
| GitHub recheck | 2026-10-30 (twice) | PRs and issues updated since 2026-10-28 / 2026-10-29; searches for rebalanceGraph, invMaxConn, maxNodesAtLevel, InitializedHnswGraphBuilder and maxConn; the file's commit history on upstream main 2f424fd428da | nothing new: rebalanceGraph matches #16726, #16618, #16620 and #16794, none about the bound or M = 1; #16806 (open PR) does not change the file; newest commit on the file is #16627 (2026-10-08) |
| Closest items (read) | 2026-10-30 | #16726, #15003, #16620, #16618, #16627, #16794, #16552, #16555, #16367, elastic/elasticsearch#158407, #13566 | other aspects of the same code (seed, repair and merge speed, degree decay, abort polling, long finite merges); none is this bug |
Introduced in 10.4.0 by #15003 (merged 2025-12-01), which added merge-time reuse of graphs with deletions and the rebalance loop; with the default codec since 10.5.1 (#16403). Affects 10.5.1, 10.5.2, 10.6.0 and main; 10.3.2 and older are clean.
Versions tested5 rows
Introduced by #15003 "Avoid reconstructing HNSW graphs during segment merging." (Pulkitg64; main ef10476d98a, branch_10x backport 3e024479d35, CHANGES.txt under 10.4.0); blame attributes the loop to it, except the abort poll at :468 (#16620) and the seeded random generator at :436 (#16627). The 10.x backport skipped graph reuse under the per-field wrapper, so the default codec in 10.4.0 and 10.5.0 never reached the loop; #16403 (issue #16400) restored reuse in 10.5.1. #16620 (10.6.0) added the abort poll inside the loop, which makes the merge abortable but not finite; #16627 seeded its random generator. On 10.4.0, shadowing only the invMaxConn line makes the failing run pass.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20, 10.6.0 | affected | never finishes; rollback() can abort it; the minimal test fails (-ea, -da) |
| 10.5.1, 10.5.2 | affected | default codec: OutOfMemoryError in rebalanceGraph after 25 to 57 s at 512 MB, then the writer is closed; the minimal test hangs (10.5.2) |
| 10.4.0, 10.5.0 | custom codec only | default codec passes (no reuse); through a codec without the per-field wrapper OutOfMemoryError after 24 to 43 s at 512 MB |
| 9.12.3, 10.3.2 | not affected | no rebalanceGraph; the merge finishes |
| fix A on main | not affected | the minimal test and 19 suites (352 tests) pass |
An adjudication, an independent reproducer, an intended-behaviour devil's advocate, two executed reviews by another model family, a credibility audit and the no-noise gate confirm it. This doc re-ran the test on main, 10.6.0 and 10.5.2 and validated the fix with gradle.
Checks and verdicts446 words
Every check below executed code except the read-only cross-model fact check; the cross-model reviews ran on the earlier main ea57360912ed, where the three files involved (InitializedHnswGraphBuilder, HnswGraphBuilder, Lucene99HnswVectorsFormat) are identical to 645d4fe14a20.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | 1,000-vector JUnit fails -ea/-da; controls finish (maxConn 2: 2,081 polls, 16: 1,081, no deletion: 2,131); concurrent merger hangs too; termination check over M in 1 to 512; one-line fix passes. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own 700-vector test fails at 3 seeds; maxConn = 2 control passes; unguarded, the merge thread stays in rebalanceGraph until a 12 s timeout; the fix passes. |
| Independent reproducer | real | Own wall-clock-bounded test fails on main, 10.6.0 (hang) and 10.5.2 (OutOfMemoryError); 10.4.0 only without the per-field wrapper; 9.12.3 passes; shadow fix passes. |
| Cross-model review, intended lens (OpenAI GPT-5.6, executed) | real (high); intended case weak | Own test fails -ea/-da; with the fix its tests and selected upstream M = 1 and merge-reuse tests pass. |
| Intended-behaviour devil's advocate | real (high; medium on impact) | A production codec without the test framework is still merging after 20 s on main and 10.6.0; OutOfMemoryError at 128 MB; the "degenerate" argument weighed and rejected. |
| adjudication | confirmed (high), hang | Re-ran the finder test (-ea, -da) and a maxConn = 2 control. |
| credibility audit and no-noise gate | keep; claim | Re-ran the test on 645d4fe14a20; reduced it to the 3-vector test, which fails and passes with the fix. |
| this doc | reproduced, fix validated | Minimal test on main and 10.6.0 (fail) and 10.5.2 (hang); abort checks by caller and controls; the commit()-only path; gradle without and with fix A, format check, 19 suites. |
| independent fact check of this doc | holds; wording fixed | Re-ran the minimal test on main (-ea, -da), on 10.6.0 (fail) and with the fix (pass); its own control test reproduces every count; recomputed the loop bound for M in 1 to 512. Its five wording corrections are applied. |
| cross-model fact check (OpenAI GPT-5.6, read-only) | real; intended case moderate | Read the code, history and saved runs. Its scope and wording corrections (the two sandbox formats, testLimits, the search result, the test's use of the test framework and the merge thread, the recheck record) are applied. |
| diagram check | matches code and runs | Checked every on-screen code line, number and graph against the code at 645d4fe14a20 and the probe runs; its two caption corrections (989 of the 1,001 checks; about 13 s) are applied and the visuals re-rendered. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 429 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,548 words
- Registry title
- With maxConn = 1, a merge that reuses an HNSW graph with a deleted vector never finishes: InitializedHnswGraphBuilder.rebalanceGraph adds one full graph level per pass until OutOfMemoryError
- Area
- core / HNSW merge-time graph reuse (util/hnsw InitializedHnswGraphBuilder.rebalanceGraph, codecs/lucene99 Lucene99HnswVectorsFormat)
- Priority
- Low, rank 685: The first merge that reuses a graph after a delete is certain to hang until OutOfMemoryError closes the writer, and on 10.5.x nothing can stop it, which is worse than a clear exception.
- Impact class
- hang
- Impact note
- Hits only indexes whose vector field uses an HNSW format built with maxConn = 1 (Lucene99HnswVectorsFormat(1, beamWidth) [exec]; by code reading also the other HNSW formats, which accept the same 1..512 range and build the same writer): the first merge or forceMerge that reuses a segment's graph after one ordinary delete or update never finishes, forceMerge and close() block, and the heap grows until OutOfMemoryError closes the IndexWriter as a tragic event (on main and 10.6.0 rollback() can still stop the loop through the merge abort check, on 10.5.1 and 10.5.2 nothing can); otherwise default IndexWriterConfig and merge policy, serial or concurrent merger, no scale needed (about 650 vectors, enough to get a graph), released in 10.5.1, 10.5.2 and 10.6.0 with the default per-field codec and in 10.4.0 and 10.5.0 through a codec that returns the HNSW format directly.
- Summary
- You index vectors with an HNSW format configured with maxConn = 1, a value the format accepts (1 to 512), delete or update one document and call forceMerge(1) (or let a natural merge run), and you expect the merge to finish, as it does in about a second with maxConn = 2 or 16. Instead the merge reuses the old graph, repairs it and calls rebalanceGraph, whose level loop stops only when size * (1/M)^level drops to 0: for M = 1 that target stays at the full size on every level and every node is promoted, so each pass adds one more complete graph level, without end. forceMerge never returns and the heap grows until OutOfMemoryError, which closes the IndexWriter (10.5.x has no abort check in the loop; main and 10.6.0 poll it, so rollback() can stop it).
- Introduced
- ef10476d98ab37b92fe48a1f71a1a06dad009ae3 (apache/lucene#15003 'Avoid reconstructing HNSW graphs during segment merging.', author Pulkitg64; branch_10x backport 3e024479d358, first released in 10.4.0) [read: blame of every line of the level loop (invMaxConn = 1.0 / M, the open for loop, the maxNodesAtLevel <= 0 exit, promotion iff random.nextDouble() < invMaxConn) and of the hasDeletes -> repair -> rebalance call site; later touches #16627 (RNG seed) and #16620 (abort poll) change no bound; release-level exec: 10.3.2 passes, 10.4.0 fails through a codec without the per-field wrapper, and shadowing only the invMaxConn line on 10.4.0 makes it pass; no build at the parent commit] (2025-12-01 (author date 2025-12-02T03:44:21+05:30 = 2025-12-01T22:14:21Z, merged to main the same minute; branch_10x backport 2025-12-01T22:49:47Z))
- First affected version
- 10.4.0 (through a codec whose knnVectorsFormat() returns the HNSW format directly); with the default per-field codec 10.5.1, because 10.4.0 and 10.5.0 did not reuse graphs under per-field formats (#16400, fixed by #16403 in 10.5.1); 10.3.2 and earlier have no rebalanceGraph
- Affected versions
- [exec] main 645d4fe14a20 (-ea and -da; re-run at the registration check 2026-10-30: 1 of 1 fails each, seed DEADBEEF, abort raised from rebalanceGraph:468), 10.6.0 (4ae946e5a9b9, -ea and -da: 'forceMerge(1) still running after 100000 abort checks'; a production-codec probe without the test framework is still merging after 20 s), 10.5.2 and 10.5.1 (-da, -Xmx512m: OutOfMemoryError in rebalanceGraph after 25-42 s, then 'this writer hit an unrecoverable error; cannot complete forceMerge'), 10.5.0 and 10.4.0 pass with the default per-field codec (no graph reuse under per-field formats before #16403) but fail with OutOfMemoryError in rebalanceGraph through a codec that returns the HNSW format directly; 10.3.2 and 9.12.3 pass (no rebalanceGraph; grep count 0 from 9.12.3 to 10.3.2). [read] the loop lines are identical in 10.4.0, 10.5.x, 10.6.0 and main; not fixed upstream: main head 716d6f216f5 (2026-10-29) is unchanged at the fix site (the last commit to InitializedHnswGraphBuilder.java is d8468fd9375, #16627, 2026-10-08) and no open PR changes the loop [read, 2026-10-30]. Not new in 10.6.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: code reading; also reached by exhaustive check, Z3
- Upstream and prior reports
- none found (2026-10-30, the prior-report record, verdict new: 4 scripted prior-report searches with 199 queries over GitHub issues and PRs, commits, downstream projects (elasticsearch, OpenSearch, k-NN, solr), the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, plus 35 own GitHub search-API queries, full-text reads of 102 upstream items and the diffs of all 13 open PRs that touch the HNSW merge files; refresh 2026-10-30: no new item, and none of the 30 open PRs updated since 2026-10-28 changes invMaxConn, maxNodesAtLevel or rebalanceGraph). Closest items, none the same bug: #16726 (open; same method, asks only whether its RNG should be seeded), #15003 (introduced rebalanceGraph; its benchmarks used maxConn 8-64), #16620 (added the abort poll inside the loop, aimed at long but finite phases), #16618, #16627 and #16794 (repair speed), #16552 and #16555 (degree decay), #16367 and elastic/elasticsearch#158407 (a rollback or shard close blocked by a long, finite HNSW merge phase). Context, not reports: d84925a201a (2021, msokolov, 'Exclude degenerate edge case (maxConn=1) from TestKnnGraph.testSearch') and a 2024 review remark on #13566 call maxConn = 1 degenerate for connectivity and search quality. Not fixed upstream (main head 716d6f216f5, 2026-10-29).
- Lucene commit tested
- main
645d4fe14a20(11.0.0-SNAPSHOT), 2026-10-30: the minimal test with gradle in a fresh scratch worktree (fails without the fix with assertions on and off; with the fix it passes, together with 19 HNSW and lucene99 codec suites, 352 tests, and the core format check) and with the standalone runner (-ea,-da); a diagnostic copy that counts abort checks by caller, withmaxConn2 and 16 and no-delete controls; acommit()-only probe; the released 10.6.0 jars (fails) and 10.5.2 jars (hangs). The hunt's 1,000-vector test ran on main, 10.6.0, 10.5.2, 10.5.1, 10.5.0, 10.4.0 and 10.3.2 (it does not compile on 9.12.3), and its variant through a codec without the per-field wrapper on main, 10.6.0, 10.5.0, 10.4.0, 10.3.2 and 9.12.3. Upstream main2f424fd428da(2026-10-30) has no change toInitializedHnswGraphBuilder.javasinced8468fd93757(#16627, 2026-10-08).
- Finding record
lucene/core/src/java/org/apache/lucene/util/hnsw/InitializedHnswGraphBuilder.java:303-316 (at 645d4fe14a20) (initializeFromGraph: repairDisconnectedNodes and rebalanceGraph run only when hasDeletes), :420-434 (rebalanceGraph javadoc: 'maintain the expected exponential decay in level sizes', target 'maxNodesAtLevel = totalNodes * (1/M)^level', promotion 'with probability 1/M until the target count is reached'), :438 (double invMaxConn = 1.0 / M), :441 (for (int level = 1; level++)), :444-445 (maxNodesAtLevel = (int) (size * Math.pow(invMaxConn, level)); if (maxNodesAtLevel <= 0) break; the only exit), :466-468 ('matching HNSW's level assignment distribution'; if (random.nextDouble() < invMaxConn && ...); maybeAbort()), :54 (@lucene.experimental)lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:184 (at 645d4fe14a20) (ml = M == 1 ? 1 : 1 / Math.log(1.0 * M)), :573-578 (getRandomGraphLevel: (int) (-log(U) * ml), so P(level >= 1) = exp(-1/ml) = 1/M for M >= 2 and exp(-1) for M = 1)lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:271-276 (at 645d4fe14a20) ('maxConn must be positive and less than or equal to 512'), :83 (@lucene.experimental); lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104HnswScalarQuantizedVectorsFormat.java:164 and lucene/codecs/src/java/org/apache/lucene/codecs/bitvectors/HnswBitVectorsFormat.java:109 (same check)lucene/core/src/java/org/apache/lucene/util/hnsw/IncrementalHnswGraphMerger.java:65 (at 645d4fe14a20) (DELETE_PCT_THRESHOLD = 40) and :128-139 (a graph with at most 40% deleted vectors becomes the merge base); lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:191-201 (the concurrent merger runs the same rebalanceGraph)lucene/core/src/test/org/apache/lucene/util/hnsw/HnswGraphTestCase.java:975-1034 (at 645d4fe14a20) (testDiversityFallback and testDiversity3d build M = 1 graphs with HnswGraphBuilder.create(scorerSupplier, 1, 10, ...))https://github.com/apache/lucene/pull/15003; https://github.com/apache/lucene/issues/16726; https://github.com/apache/lucene/pull/16620; https://github.com/apache/lucene/pull/16403
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/hnsw/InitializedHnswGraphBuilder.java:303-316 (at 645d4fe14a20) (initializeFromGraph; hasDeletes block :309-315), :420-428 (javadoc), :435-485 (rebalanceGraph: :438 invMaxConn, :441 open loop, :444-445 the only exit, :451 levelToNodes growth, :466-470 promotion, maybeAbort, addNode, :477 addConnections)lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:184 (at 645d4fe14a20) (ml = M == 1 ? 1 : 1 / Math.log(1.0 * M)), :573-578 (getRandomGraphLevel)lucene/core/src/java/org/apache/lucene/util/hnsw/OnHeapHnswGraph.java:145-172 (at 645d4fe14a20) (addNode, no level cap)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1325-1327 (at 645d4fe14a20) (shutdown: flush, waitForMerges)lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:192-201 (at 645d4fe14a20) (concurrent merger runs rebalanceGraph)
- Who is affected
lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:137 (at 645d4fe14a20) (HNSW_GRAPH_THRESHOLD = 100), :271-276 (1 <= maxConn <= 512); lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:716-722 (at 645d4fe14a20) (threshold <= 0 always builds a graph)lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104HnswScalarQuantizedVectorsFormat.java:164; (at 645d4fe14a20) lucene/codecs/src/java/org/apache/lucene/codecs/bitvectors/HnswBitVectorsFormat.java:109 (same range)lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/dedup/DedupHnswVectorsFormat.java:168 (at 645d4fe14a20) (same range), :201 (new Lucene99HnswVectorsWriter); lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/dedup/DedupHnswScalarQuantizedVectorsFormat.java:182, :215 (the same)lucene/core/src/java/org/apache/lucene/util/hnsw/IncrementalHnswGraphMerger.java:65 (at 645d4fe14a20) (DELETE_PCT_THRESHOLD = 40), :128-139 (merge base selection)lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomCodec.java:272-293 (at 645d4fe14a20) (maxConn 5..50)
- Minimal repro
lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:260 (at 645d4fe14a20) (protected OneMerge copy constructor used by the guard)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:83 (at 645d4fe14a20) (@lucene.experimental), :271-276 (range check); lucene/core/src/test/org/apache/lucene/codecs/lucene99/TestLucene99HnswVectorsFormat.java:64-70 (at 645d4fe14a20) (testLimits)lucene/core/src/test/org/apache/lucene/util/hnsw/HnswGraphTestCase.java:991, (at 645d4fe14a20) :1021 (M = 1 graphs)lucene/core/src/java/org/apache/lucene/util/hnsw/InitializedHnswGraphBuilder.java:420-428 (at 645d4fe14a20) (javadoc), :466 (comment), :54 (@lucene.experimental)
- Suggested fix
lucene/core/src/java/org/apache/lucene/util/hnsw/InitializedHnswGraphBuilder.java:425 (at 645d4fe14a20) (javadoc formula), :450-452 (new levels above the source graph)
- History
lucene/CHANGES.txt:1444 (at 645d4fe14a20) (GITHUB#15003 under 10.4.0)
- TestHnswMaxConnOneMerge.java (lucene/core, package org.apache.lucene.codecs.lucene99, 1 test):
repro/LFV-1055/TestHnswMaxConnOneMerge.java(71 lines)
Code lines and numbers on screen are checked against the Lucene checkout at 645d4fe14a20, the minimal repro, the fix diff and the diagram's own executed probe.
assets/LFV-1055/bug.png(429 KB)
